diff --git a/.gitattributes b/.gitattributes index f6f78cf6..7f4524b7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -74,6 +74,9 @@ tests/SelectedSaclFixtureProtection.cs text eol=lf /scripts/FileAccessProbe* text eol=lf /tests/FileAccessProbe* text eol=lf +# Disposable native provider configuration fixture +tests/NativeProviderConfigure.Windows.Tests.ps1 text eol=lf + # Disposable public registry lifecycle fixture bytes are retained in evidence. /tests/RegistrySacl* text eol=lf /scripts/WecAuthorization* text eol=lf @@ -88,3 +91,8 @@ tests/SelectedSaclFixtureProtection.cs text eol=lf /scripts/SelectedSaclDescendants.ps1 text eol=lf /scripts/AuditRecovery.ps1 text eol=lf /scripts/EvtxRecovery.ps1 text eol=lf + +# Collector inventory reads native UTF16 names and bounded Unicode XML. +/modules/WecSubscriptionInventory.cs text eol=lf +/tests/WecCollectorObservation* text eol=lf +/tests/WecSubscriptionInventory* text eol=lf diff --git a/.github/workflows/native-provider-configure.yml b/.github/workflows/native-provider-configure.yml new file mode 100644 index 00000000..0112f9ef --- /dev/null +++ b/.github/workflows/native-provider-configure.yml @@ -0,0 +1,43 @@ +name: Native provider configuration acceptance +on: + push: + branches: ['**'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + native-provider-configure: + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + engine: [powershell, pwsh] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - name: Focused provider regressions in Windows PowerShell5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: ./tests/NativeProviderPacks.Tests.ps1 + - name: Public native provider configuration in Windows PowerShell5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: ./tests/NativeProviderConfigure.Windows.Tests.ps1 -AllowDisposableProviderWrite + - name: Focused provider regressions in PowerShell7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: ./tests/NativeProviderPacks.Tests.ps1 + - name: Public native provider configuration in PowerShell7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: ./tests/NativeProviderConfigure.Windows.Tests.ps1 -AllowDisposableProviderWrite + - name: Retain owned fixture evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: native-provider-configure-${{ matrix.os }}-${{ matrix.engine }} + path: ${{ runner.temp }}/wela-provider-configure-*/ + if-no-files-found: warn + retention-days: 7 diff --git a/.github/workflows/native-smb-policy.yml b/.github/workflows/native-smb-policy.yml new file mode 100644 index 00000000..7221014a --- /dev/null +++ b/.github/workflows/native-smb-policy.yml @@ -0,0 +1,47 @@ +name: Native public SMB policy configuration +on: + push: + branches: ['**'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + native-smb-policy: + 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/SmbAuditing.Tests.ps1 + ./tests/SmbAuditing.Windows.Tests.ps1 + - name: Native public SMB policy configuration in Windows PowerShell5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: ./tests/SmbPolicyConfigure.Windows.Tests.ps1 -AllowDisposablePolicyWrite + - name: Fixtures and public guards in PowerShell7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: | + ./tests/SmbAuditing.Tests.ps1 + ./tests/SmbAuditing.Windows.Tests.ps1 + - name: Native public SMB policy configuration in PowerShell7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: ./tests/SmbPolicyConfigure.Windows.Tests.ps1 -AllowDisposablePolicyWrite + - name: Retain owned fixture evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: native-smb-policy-${{ matrix.os }}-${{ matrix.engine }} + path: ${{ runner.temp }}/wela-smb-policy-*/ + if-no-files-found: warn + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3a175d2a..52989c99 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,7 +41,7 @@ jobs: Copy-Item -Recurse -Path ./scripts -Destination release-binaries/ Copy-Item -Recurse -Path ./modules -Destination release-binaries/ New-Item -ItemType Directory -Path release-binaries/docs -Force | Out-Null - Copy-Item -Path ./docs/gpo-audit-packages.md, ./docs/gpo-package-deployment.md, ./docs/gpo-creation.md, ./docs/wmi-probe.md, ./docs/firewall-logging.md, ./docs/firewall-logging-recovery.md, ./docs/ipsec-prerequisites.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-ingress.md, ./docs/capi2-probe.md, ./docs/powershell-transcription.md, ./docs/transcription-recovery.md, ./docs/eventlog-recovery.md, ./docs/failed-logon-probe.md, ./docs/wec-listener.md, ./docs/file-access-probe.md, ./docs/applocker-script-probe.md, ./docs/applocker-probe.md, ./docs/selected-sacl-configuration.md, ./docs/registry-sacl-recovery.md, ./docs/targeted-sacl-planning.md, ./docs/native-registry-sacl-validation.md, ./docs/wec-authorization.md, ./docs/channel-recovery.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/registry-sacl-recovery.md, ./docs/targeted-sacl-planning.md, ./docs/native-registry-sacl-validation.md, ./docs/wec-authorization.md, ./docs/channel-recovery.md, ./docs/outgoing-ntlm.md, ./docs/wec-collector-observation.md, ./docs/native-provider-packs.md, ./docs/native-provider-acceptance.md -Destination release-binaries/docs/ - name: Set Artifact Name if: contains(matrix.info.os, 'windows') == true diff --git a/.github/workflows/scoped-outgoing-ntlm.yml b/.github/workflows/scoped-outgoing-ntlm.yml new file mode 100644 index 00000000..2353c399 --- /dev/null +++ b/.github/workflows/scoped-outgoing-ntlm.yml @@ -0,0 +1,47 @@ +name: Scoped outgoing NTLM auditing +on: + push: + branches: ['**'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + scoped-outgoing-ntlm: + 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/OutgoingNtlmAudit.Tests.ps1 + ./tests/OutgoingNtlmAudit.Cli.Tests.ps1 + - name: Scoped outgoing NTLM auditing in Windows PowerShell5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: ./tests/OutgoingNtlmAudit.Windows.Tests.ps1 -AllowDisposableAuditWrite + - name: Fixtures and public guards in PowerShell7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: | + ./tests/OutgoingNtlmAudit.Tests.ps1 + ./tests/OutgoingNtlmAudit.Cli.Tests.ps1 + - name: Scoped outgoing NTLM auditing in PowerShell7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: ./tests/OutgoingNtlmAudit.Windows.Tests.ps1 -AllowDisposableAuditWrite + - name: Retain owned fixture evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: scoped-outgoing-ntlm-${{ matrix.os }}-${{ matrix.engine }} + path: ${{ runner.temp }}/wela-outgoing-audit-*/ + if-no-files-found: warn + retention-days: 7 diff --git a/.github/workflows/wec-collector-observation.yml b/.github/workflows/wec-collector-observation.yml new file mode 100644 index 00000000..dc1b3aa4 --- /dev/null +++ b/.github/workflows/wec-collector-observation.yml @@ -0,0 +1,59 @@ +name: Native collector subscription observation +on: + push: + paths: ['WELA.ps1', 'modules/WefSubscriptions.psm1', 'modules/WecSubscription*', 'scripts/WefDeployment.ps1', 'tests/WecCollectorObservation*', 'tests/WecSubscriptionInventory*', '.github/workflows/wec-collector-observation.yml'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + collector-observation: + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + engine: [powershell, pwsh] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - name: Native observation regressions in Windows PowerShell 5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: | + ./tests/WecSubscriptionInventory.Tests.ps1 + ./tests/WecSubscriptionXml.Tests.ps1 + ./tests/WefDeployment.Tests.ps1 + ./tests/WefDeployment.Cli.Tests.ps1 + - name: Native observation regressions in PowerShell 7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: | + ./tests/WecSubscriptionInventory.Tests.ps1 + ./tests/WecSubscriptionXml.Tests.ps1 + ./tests/WefDeployment.Tests.ps1 + ./tests/WefDeployment.Cli.Tests.ps1 + - name: Existing read-only service preservation in Windows PowerShell 5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: ./tests/WefDeployment.Windows.Tests.ps1 + - name: Existing read-only service preservation in PowerShell 7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: ./tests/WefDeployment.Windows.Tests.ps1 + - name: Actual public collector observations in Windows PowerShell 5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: ./tests/WecCollectorObservation.Windows.Tests.ps1 -AllowDisposableSubscription + - name: Actual public collector observations in PowerShell 7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: ./tests/WecCollectorObservation.Windows.Tests.ps1 -AllowDisposableSubscription + - name: Retain native observations and exact cleanup evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: collector-observation-${{ matrix.os }}-${{ matrix.engine }} + path: ${{ runner.temp }}/wela-wec-observation-* + if-no-files-found: warn + retention-days: 7 diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index a06ac27b..3b6874eb 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -6,6 +6,14 @@ - 明示的な `registry-sacl-recovery` を追加しました。整合する4つの元記録、レビュー済み計画のハッシュ、過去・現在ともに空の子キー観測、監査縮小と継承への個別同意を必須とし、追加が証明されたレジストリルートの明示的な監査ACEを1つだけ削除します。SACLのみのネイティブ書き込みで他の記述子フィールドとACEの順序を保持し、部分的な書き込みの証跡と元の記述子バイトとの一致を別々に報告します。過去のキー・操作者の同一性認証、ツリー全体の原子性、イベント生成、Sigmaの準備完了は保証しません。 (Related #373) (@Shirofune-Security) +- Server 2022/2025とPowerShell 5.1/7でSMBポリシー設定の公開CLIを検証します。対応ホストで6項目の適用・再読取・再実行、非対応ホストのスキップ、元の型付き記録、無関係な設定の維持と完全な復元を確認します。イベント生成と実行時の有効化は別途検証します。(関連 #377) (@Shirofune-Security) + +- `outgoing-ntlm` のAudit/Plan/Configureを追加し、送信NTLM監査DWORDを個別に設定できます。既存の拒否設定は既定で維持し、置換には明示的なAuditを要求します。不明な型・値や書込直前の変化を拒否し、元の型付き記録と再読取を保持します。Server 2022/2025のテストで範囲と復元を確認し、認証・イベント生成は未検証として報告します。(関連 #362) (@Shirofune-Security) + +- コレクターのサブスクリプション観測で、コンソール経由の文字変換を、上限付きの完全なネイティブ名前列挙と厳密なUnicode XML読取に置き換えました。空の一覧・読取失敗・実際の無効状態を区別し、Unicodeの説明とXPathを保持します。Server 2022/2025と両PowerShellで公開Audit/Planおよび正確な後処理を検証し、ドメイン展開・転送・Sigma対応は主張しません。(関連 #368) (@Shirofune-Security) + +- Server 2022/2025 の Windows PowerShell 5.1/PowerShell 7 で、公開 provider-packs コマンドの Plan、DryRun、Configure、冪等性、前提不足・手動対象の拒否、部分適用を実機検証する使い捨て CI を追加。DNS Client、CAPI2、WinRM、RDP Client の設定と復元記録・ハッシュを確認し、完全な ACL、保持モード、大きい既存バッファ、他チャネル、サービス、全監査マスクの保持とテスト後の正確な復元を検証。イベント生成や Sigma 対応の証明は含みません。 WinRM のマニフェスト ID をネイティブの Int64 として比較し、対象外の大きい ID により必要なイベントの確認が失敗する不具合も修正。 (@Shirofune-Security) + - Server 2022/2025 と Windows PowerShell 5.1/PowerShell 7 の破棄可能な環境で、Securityログ警告設定の公開CLIを検証します。未設定・0・高いしきい値、早い警告値の維持、DryRun、再実行、不正型の拒否と完全な復元を確認し、無関係な設定は保持します。ログ枯渇や警告イベント生成は検証範囲外です。 (@Shirofune-Security) - Server 2022/2025 と両 PowerShell エンジンで、公開 `targeted-sacl` のレジストリ操作を検証する使い捨てテストを追加しました。テスト専用の新規ハイブをマウントし、対象選択、DryRun、監査 ACE の追加、古い計画・前提条件不足の拒否、冪等性と厳密に対応付けた Security4657 を確認します。無関係な ACE・型付き値の保持、監査ポリシー・トークンの復元、所有ハイブのアンロードと削除の証跡を保存します。製品側のハイブ読み込みや Sigma 準備完了の判定は追加しません。(関連 #373) (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb3c4649..f7129758 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ - Added opt-in `registry-sacl-recovery` for one proven explicit registry-root audit ACE, requiring four matching original records, a reviewed plan hash, empty historical/current descendants and separate audit-reduction/inheritance consent. Native SACL-only removal preserves unrelated descriptor fields and ACE order, retains partial-write evidence and reports original-byte equality separately; no authenticated historical key/operator identity, atomic-tree, event or Sigma claim. (Related #373) (@Shirofune-Security) +- Add native public SMB policy configuration acceptance on Server 2022/2025 and PowerShell 5.1/7: six-policy apply/readback and idempotence on supported hosts, unsupported-host skips, typed original journals, unrelated-state preservation and exact cleanup. Event generation and runtime activation remain separate. (Related #377) (@Shirofune-Security) + +- Add `outgoing-ntlm` Audit/Plan/Configure to manage the outgoing audit DWORD independently of broad configuration. Preserve existing deny by default, require explicit Audit to replace it, refuse unknown types/values and pre-write drift, and retain typed original journals plus native readback. Native Server 2022/2025 tests verify narrow scope and exact cleanup; authentication/event generation remain unverified. (Related #362) (@Shirofune-Security) + +- Fixed collector subscription observations to use complete bounded native name enumeration and strict Unicode XML reads instead of console decoding. Empty inventories, failed reads and actual disabled state remain distinct; Unicode descriptions and XPath are preserved. Disposable public Audit/Plan tests cover both Server 2022/2025 and PowerShell engines with exact cleanup, without domain deployment, forwarding or Sigma claims. (Related #368) (@Shirofune-Security) + +- Added disposable native acceptance for public provider-pack Plan, DryRun, Configure, idempotence, missing/manual refusal and partial outcomes on Server 2022/2025 under Windows PowerShell 5.1/PowerShell 7. Actual DNS Client, CAPI2, WinRM and RDP Client configuration preserves descriptors, retention, larger buffers, unrelated channels, services and all audit masks, with journal/hash evidence and exact fixture cleanup; no event or Sigma credit. Fixed WinRM manifest inspection to preserve native Int64 event IDs, so unrelated large IDs no longer block the selected event schema. (@Shirofune-Security) + - Verify public Security-log warning configuration on disposable Server 2022/2025 hosts under Windows PowerShell 5.1/PowerShell 7: absent/zero/higher thresholds, earlier-threshold preservation, dry run, idempotence, wrong-type refusal and exact cleanup. Preserve unrelated registry values, channel configuration, audit masks and CrashOnAuditFail; no log-exhaustion or warning-event claim. (@Shirofune-Security) - Added disposable public `targeted-sacl` registry lifecycle validation on Server 2022/2025 and both PowerShell engines. A fixture-owned mounted hive exercises reviewed selection, DryRun, additive configuration, stale/prerequisite refusal and idempotence, followed by one precisely attributed Security4657. Retained native receipts verify unrelated ACE/value preservation and exact audit-policy, token and owned-hive cleanup; production does not load hives or gain Sigma credit. (Related #373) (@Shirofune-Security) diff --git a/WELA.ps1 b/WELA.ps1 index c24c20d4..9aad3df0 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -16,6 +16,7 @@ [switch]$Auto, [ValidateSet("PreserveOrAudit", "Audit", "Deny")] [string]$OutgoingNtlmMode = "PreserveOrAudit", + [ValidateSet("Audit","Plan","Configure")][string]$NtlmAction = "Audit", [switch]$DryRun, [string]$BackupPath, [string]$ResultsPath, @@ -235,6 +236,7 @@ $EidMappingPath = Join-Path $ScriptRoot "config/eid_subcategory_mapping.csv" $AuditpolTxtPath = Join-Path $ScriptRoot "auditpol.txt" $SaclTargetsPath = Join-Path $ScriptRoot "config/audit_sacl_targets.json" . (Join-Path $ScriptRoot "scripts/Configuration.ps1") +. (Join-Path $ScriptRoot "scripts/OutgoingNtlmAudit.ps1") . (Join-Path $ScriptRoot "scripts/AdcsAuditing.ps1") . (Join-Path $ScriptRoot "scripts/AdcsRestartResume.ps1") . (Join-Path $ScriptRoot "scripts/AuditIntegrity.ps1") @@ -2076,6 +2078,7 @@ Usage: ./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 outgoing-ntlm -Help # Configure outgoing NTLM auditing independently ./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 @@ -2179,6 +2182,12 @@ if ($Cmd -ne 'wec-listener' -and @($PSBoundParameters.Keys | Where-Object {$_ -l 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 'outgoing-ntlm' -and $PSBoundParameters.ContainsKey('NtlmAction')) {throw 'NtlmAction requires outgoing-ntlm.'} +if ($Cmd -eq 'outgoing-ntlm') { + if (@($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','NtlmAction','OutgoingNtlmMode','Auto','DryRun','BackupPath','ResultsPath','Help')}).Count) {throw 'outgoing-ntlm accepts only its dedicated options.'} + if ($OutgoingNtlmMode -eq 'Deny') {throw 'outgoing-ntlm configures auditing only; Deny enforcement is not accepted.'} + if ($NtlmAction -ne 'Configure' -and ($Auto -or $DryRun -or $BackupPath)) {throw 'Consent, dry-run and backup options require NtlmAction Configure.'} +} if ($Cmd -ne '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.'} @@ -2267,7 +2276,7 @@ if ($Cmd -ne 'ad-object-sacl' -and @($PSBoundParameters.Keys | Where-Object { }).Count) { throw 'AD object SACL options require the dedicated ad-object-sacl command. No command was run.' } -if ($DryRun -and -not ($Cmd -eq 'transcription-recovery' -and $TranscriptRecoveryAction -eq 'Restore') -and -not ($Cmd -eq 'adcs-auditing' -and $AdcsAction -eq 'Configure') -and -not ($Cmd -eq 'adcs-resume' -and $AdcsResumeAction -eq 'Resume') -and -not ($Cmd -eq 'gpo-create' -and $GpoCreateAction -eq 'Create') -and -not ($Cmd -eq 'dns-analytical' -and $DnsAction -eq 'Configure') -and -not ($Cmd -eq 'targeted-sacl' -and $TargetSaclAction -eq 'Configure') -and -not ($Cmd -eq 'audit-recovery' -and $RecoveryAction -eq 'Restore') -and -not ($Cmd -eq 'gpo-package' -and $GpoAction -eq 'Export') -and -not ($Cmd -eq 'audit-integrity' -and $IntegrityAction -eq 'Configure') -and -not ($Cmd -eq 'audit-notifications' -and $NotificationAction -eq 'Configure') -and -not ($Cmd -eq 'ldap-diagnostics' -and $LdapAction -eq 'Configure') -and -not ($Cmd -eq 'applocker-readiness' -and $AppLockerAction -eq 'Import') -and $Cmd -notin @('configure', 'configure-eventlogs') -and +if ($DryRun -and -not ($Cmd -eq 'outgoing-ntlm' -and $NtlmAction -eq 'Configure') -and -not ($Cmd -eq 'transcription-recovery' -and $TranscriptRecoveryAction -eq 'Restore') -and -not ($Cmd -eq 'adcs-auditing' -and $AdcsAction -eq 'Configure') -and -not ($Cmd -eq 'adcs-resume' -and $AdcsResumeAction -eq 'Resume') -and -not ($Cmd -eq 'gpo-create' -and $GpoCreateAction -eq 'Create') -and -not ($Cmd -eq 'dns-analytical' -and $DnsAction -eq 'Configure') -and -not ($Cmd -eq 'targeted-sacl' -and $TargetSaclAction -eq 'Configure') -and -not ($Cmd -eq 'audit-recovery' -and $RecoveryAction -eq 'Restore') -and -not ($Cmd -eq 'gpo-package' -and $GpoAction -eq 'Export') -and -not ($Cmd -eq 'audit-integrity' -and $IntegrityAction -eq 'Configure') -and -not ($Cmd -eq 'audit-notifications' -and $NotificationAction -eq 'Configure') -and -not ($Cmd -eq 'ldap-diagnostics' -and $LdapAction -eq 'Configure') -and -not ($Cmd -eq 'applocker-readiness' -and $AppLockerAction -eq 'Import') -and $Cmd -notin @('configure', 'configure-eventlogs') -and -not ($Cmd -eq 'provider-packs' -and $ProviderAction -eq 'Configure') -and -not ($Cmd -eq 'firewall-logging' -and $FirewallAction -eq 'Configure') -and -not ($Cmd -eq 'firewall-recovery' -and $FirewallRecoveryAction -eq 'Restore') -and @@ -2453,6 +2462,13 @@ switch ($Cmd.ToLower()) { $report=Invoke-WelaWecIngress @arguments;$report if($report.ExitCode){exit $report.ExitCode} } + 'outgoing-ntlm' { + if ($Help) {Write-Host 'Usage: outgoing-ntlm [-NtlmAction Audit|Plan|Configure] [-OutgoingNtlmMode PreserveOrAudit|Audit] [-Auto] [-DryRun] [-BackupPath new-directory] [-ResultsPath report.json]. Changes only the outgoing audit DWORD. Existing deny is preserved by default; explicit Audit authorizes replacing it. See docs/outgoing-ntlm.md.';return} + if ($NtlmAction -eq 'Configure' -and -not (TestAdministrator)) {throw 'Outgoing NTLM configuration requires Administrator privileges.'} + $report=Invoke-WelaOutgoingAuditCommand -Action $NtlmAction -Mode $OutgoingNtlmMode -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath -ResultsPath $ResultsPath + $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} @@ -2586,7 +2602,7 @@ switch ($Cmd.ToLower()) { { $_ -in @('wef-source','wec-collector') } { if ($Help) { Write-Host 'Usage: ./WELA.ps1 wef-source|wec-collector -WefConfigPath operator.json [-WefAction Audit|Plan|Configure] [-Auto] [-DryRun] [-BackupPath new-directory] [-ResultsPath file.json]' - Write-Host 'Native domain/Kerberos HTTP source configuration and create-only collector subscriptions. Existing collector listener and explicit scoped ingress are prerequisites. Optional ASD hardening is explicit in JSON. See docs/wef-deployment.md; forwarding/event arrival remain unverified.' + Write-Host 'Native domain/Kerberos HTTP source configuration and create-only collector subscriptions. Existing collector listener and explicit scoped ingress are prerequisites. Optional ASD hardening is explicit in JSON. See docs/wef-deployment.md and docs/wec-collector-observation.md; native inventory/XML read failures stay unknown, and forwarding/event arrival remain unverified.' return } if ($Profile -or $Baseline -or $HtmlPath) { throw 'WEF commands require their own explicit JSON config and use -ResultsPath; -Profile, -Baseline and -HtmlPath are unsupported.' } diff --git a/docs/native-provider-acceptance.md b/docs/native-provider-acceptance.md new file mode 100644 index 00000000..53441d85 --- /dev/null +++ b/docs/native-provider-acceptance.md @@ -0,0 +1,28 @@ +# Native provider configuration acceptance + +The dedicated `Native provider configuration acceptance` workflow tests the public `provider-packs` command on disposable GitHub-hosted Windows Server 2022 and 2025, separately under Windows PowerShell 5.1 and PowerShell 7. It complements the read-only manifest inventory and mocked failure tests described in [the provider-pack guide](native-provider-packs.md). + +This fixture is destructive to the selected channels' temporary configuration and can discard records when restoring smaller buffers. It requires `-AllowDisposableProviderWrite`, `GITHUB_ACTIONS=true` and `RUNNER_ENVIRONMENT=github-hosted`; do not run it on ordinary machines. Production behavior is unchanged; only this opted-in disposable fixture prepares and restores the temporary test settings. + +## Actual public behavior checked + +- The real provider/channel registrations and expected event schemas must permit all four explicitly selected client-side packs: `dns-client`, `capi2`, `winrm` and `rdp-client`. Missing or incompatible metadata fails the fixture; it is never replaced with a mock or skipped success. +- Plan and Configure with `-DryRun` preserve prepared native settings. Unsupported preview and reader-grant options are refused before a recovery directory is created. +- Configure actually enables the four channels and applies their exact minimum buffers. A prepared 2 GiB WinRM buffer stays larger, and a prepared CAPI2 `Retain` mode stays intact. The complete descriptor is preserved; provider packs never request an Event Log Readers grant. +- Every Applied result and its original journal entry are compared with independent native before/after observations. Repeated Configure is idempotent and creates no write journal. +- Both manual DNS packs refuse configuration. The hosted image must genuinely lack the DNS Server service, and `dns-server-audit` must refuse that missing prerequisite. No DNS role is installed or removed to manufacture the result. +- A mixed CAPI2/manual-DNS invocation performs one real selected change and reports the other failure with a nonzero overall exit and exactly one journal entry. Partial application is explicit. + +The fixture does not issue DNS queries, RDP connections or WinRM sessions, change service configuration, or intentionally generate test events. Ordinary background Windows events may occur while the channels are enabled. All rules retain zero Ready credit; enabling a source does not establish event fields, effective reader access, ingestion or matching backend queries. + +## Preservation, cleanup and evidence + +Before preparation, the fixture captures native settings and complete `wevtutil gl /f:xml` configuration for registered catalog channels and additional unselected Security, System, Application, AppLocker and DriverFrameworks controls. During public configuration it compares every selected XML field except the permitted enabled flag and maximum size; unselected registered channels must remain byte-for-byte equivalent at the XML level. It also compares the state/start type of EventLog, Winmgmt, WinRM, TermService and DNS, and all 59 effective audit masks. + +Each selected channel has independent cleanup that restores original enablement, exact byte limit, descriptor and retention/backup mode. A failure restoring one channel does not skip the remaining channels. Final observations compare original full XML, service state and audit masks. Cleanup failure prevents a passing result. Owned child commands have bounded execution and output, and termination failures remain in the cleanup receipt. + +`original.json`, public JSON reports, command output, actual journals, `completed.json`, `cleanup.json` and a SHA256 manifest are retained for seven days by the workflow. The manifest binds the fixture, product helpers, catalog, corpus and full reviewed rule-source bytes. Event records are not restored, and no retention-duration, Windows 11, domain/DC/ADCS, positive installed-DNS, forwarding or Sigma acceptance is implied. This advances issues #386 and #366 without closing their broader acceptance work. + +The underlying enablement, size, retention and backup options follow Microsoft's [wevtutil command reference](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/wevtutil). The product's existing channel floors and schema gates remain unchanged. + +The first native run exposed a WinRM manifest bug: an unrelated event ID `3221734403` overflowed the reader's signed 32-bit cast and made the whole provider schema unknown. The reader now compares [EventMetadata.Id in its native Int64 domain](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.eventing.reader.eventmetadata.id), then parses only the exact reviewed event/channel templates. Focused tests also require refusal when only unrelated large IDs exist; schema gates are unchanged. diff --git a/docs/native-provider-packs.md b/docs/native-provider-packs.md index 1061ba35..0af10746 100644 --- a/docs/native-provider-packs.md +++ b/docs/native-provider-packs.md @@ -47,7 +47,7 @@ Successful channel configuration says nothing about benign operation generation, Every attempted native write first records the original enabled flag, exact buffer size, retention and complete descriptor in `before.jsonl`. Restore only the recorded selected channel values using an elevated `wevtutil sl` after reviewing concurrent GPO/administrator changes; do not replace an entire descriptor with an example. No automatic rollback overwrites later changes. Event loss/volume and long-term storage requirements require a measured deployment plan. -The mocked regression suite exercises missing fields/providers, unsupported types/builds, role/service gates, journal-before-write, dry-run, decline, idempotence, preserved ACL/retention/larger buffers, native failure/false success, prompt races and final schema drift. Windows Server 2022/2025 CI on PowerShell 5.1/7 reads real provider manifests and the public CLI plan and checks that channel settings stay unchanged. It creates no DNS queries, log entries, services or subscriptions. Windows 11/DC/CA event-generation and actual backend/collector validation remain pending acceptance work for issue #386. +The mocked regression suite exercises missing fields/providers, unsupported types/builds, role/service gates, journal-before-write, dry-run, decline, idempotence, preserved ACL/retention/larger buffers, native failure/false success, prompt races and final schema drift. Windows Server 2022/2025 CI on PowerShell 5.1/7 reads real provider manifests and the public CLI plan and checks that channel settings stay unchanged. It creates no DNS queries, log entries, services or subscriptions. Windows 11/DC/CA event-generation and actual backend/collector validation remain pending acceptance work for issue #386. A separate [disposable native configuration acceptance suite](native-provider-acceptance.md) now exercises actual public Configure, dry-run, idempotence, refusal, partial outcomes, journals and exact cleanup for the four client-side packs. It supplies configuration proof only. Primary references: [Microsoft WEF Appendix C/F](https://learn.microsoft.com/en-us/windows/security/operating-system-security/device-management/use-windows-event-forwarding-to-assist-in-intrusion-detection), [DNS logging and diagnostics](https://learn.microsoft.com/en-us/windows-server/networking/dns/dns-logging-and-diagnostics), [EventMetadata](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.eventing.reader.eventmetadata?view=windowsdesktop-10.0), [EventLogLink](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.eventing.reader.eventloglink?view=windowsdesktop-10.0), [Windows 11 release families](https://learn.microsoft.com/en-us/windows/release-health/windows11-release-information), and [Windows Server release families](https://learn.microsoft.com/en-us/windows/release-health/windows-server-release-info). The WEF sample identifies event/channel candidates; it does not validate these rule definitions or this implementation on every build. diff --git a/docs/outgoing-ntlm.md b/docs/outgoing-ntlm.md new file mode 100644 index 00000000..8ba701c7 --- /dev/null +++ b/docs/outgoing-ntlm.md @@ -0,0 +1,22 @@ +# Scoped outgoing NTLM auditing + +`outgoing-ntlm` audits or configures only `HKLM\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0\RestrictSendingNTLMTraffic`. The separate broad `configure` workflow retains its existing behavior. Use elevated 64-bit PowerShell for Configure on reviewed Windows 11 builds (22000/22621/22631/26100/26200) or Server 2022/2025 (20348/26100); observed role/build and the existing key are required. Role overrides are refused. + +```powershell +./WELA.ps1 outgoing-ntlm -NtlmAction Audit -ResultsPath audit.json +./WELA.ps1 outgoing-ntlm -NtlmAction Plan -ResultsPath plan.json +./WELA.ps1 outgoing-ntlm -NtlmAction Configure -DryRun -ResultsPath preview.json +./WELA.ps1 outgoing-ntlm -NtlmAction Configure -Auto -BackupPath ./before -ResultsPath result.json +# Explicitly replace a previously reviewed Deny all value with auditing: +./WELA.ps1 outgoing-ntlm -NtlmAction Configure -OutgoingNtlmMode Audit -BackupPath ./before-reviewed -ResultsPath reviewed.json +``` + +The default `PreserveOrAudit` mode sets only DWORD **1 (Audit all)** when absent or DWORD0. Existing DWORD1 is already compliant. Existing DWORD **2 (Deny all)** is reported as `PreservedEnforcement` and skipped; exit0 for this preserved case does not mean auditing was enabled. Explicit `Audit` authorizes replacing a known DWORD2 with1. Unknown types/values fail without writes in either mode. `Deny` is refused by this scoped command. It never changes incoming/domain NTLM policy, exceptions, audit subcategories, channel settings, services, or authentication restrictions other than the explicit conversion of a known outgoing deny to audit. + +Plan is a live read-only assessment, not an importable authorization file. Audit/Plan reject mutation options. Configure re-reads the actual host and typed value, journals before mutation, refuses pre-write drift, and verifies immediate/final readback. A race after the final pre-write read remains possible; these observations are not atomic with GPO or another administrator. RSoP is explicitly last-applied and potentially stale, never proof of the current registry writer. Skipped, Failed and Overridden results remain distinct. No automatic rollback occurs. + +For manual recovery, inspect the selected successful result and its original `before.jsonl` entry. The original typed registry state is `Before.Policy`; preserve current policy ownership and review drift before restoring that one value/type or removing that value if it was originally absent. Never remove the parent MSV1_0 key or replay another journal kind. Failed/partial attempts require individual inspection. Keep the original journal and result together. + +Native acceptance uses disposable unjoined Server2022/2025 hosts under PowerShell5.1/7, exercises actual absence/allow→audit, original journals, dry run, repeat, readback and exact cleanup. Existing enforcement and malformed values are never installed on a native runner merely for testing; portable regressions verify those preservation/refusal paths, prompt-time drift and failures. Native tests preserve incoming/domain policy, siblings/access descriptor, channels, service and all59 audit masks. Windows11/DC/ADCS acceptance, authentication behavior, representative NTLM events, GPO persistence and collector delivery remain separate work for #362. No Sigma credit is inferred. Built-in Windows only; Sysmon is excluded. + +Microsoft distinguishes outgoing audit from deny, describes GPO precedence and identifies the NTLM Operational log for validation: [outgoing NTLM policy](https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-10/security/threat-protection/security-policy-settings/network-security-restrict-ntlm-outgoing-ntlm-traffic-to-remote-servers). diff --git a/docs/smb-auditing.md b/docs/smb-auditing.md index 153fa76f..dc726dca 100644 --- a/docs/smb-auditing.md +++ b/docs/smb-auditing.md @@ -72,3 +72,11 @@ On isolated supported client/server snapshots, retain OS build/revision, PowerSh Microsoft documents the policy-to-registry mappings and the SMB configuration cmdlets, but the cited pages do not establish synchronous propagation of a direct policy-registry write into the getter or promise that refreshing Group Policy resolves any discrepancy. WELA makes neither assumption. Sources: [LanmanServer Policy CSP mappings](https://learn.microsoft.com/en-us/windows/client-management/mdm/policy-csp-lanmanserver), [LanmanWorkstation Policy CSP mappings](https://learn.microsoft.com/en-us/windows/client-management/mdm/policy-csp-lanmanworkstation), [SMB signing and encryption auditing](https://learn.microsoft.com/en-us/windows-server/storage/file-server/smb-signing-overview), [SMB feature availability](https://learn.microsoft.com/en-us/windows-server/storage/file-server/file-server-smb-overview), [SMB configuration getter](https://learn.microsoft.com/en-us/powershell/module/smbshare/get-smbclientconfiguration?view=windowsserver2025-ps), [SMB server audit parameters](https://learn.microsoft.com/en-us/powershell/module/smbshare/set-smbserverconfiguration?view=windowsserver2025-ps), and [issue #377](https://github.com/Yamato-Security/WELA/issues/377). + +## Native public configuration acceptance + +The separately opted-in `SmbPolicyConfigure.Windows.Tests.ps1` fixture runs public Plan, DryRun and Configure on disposable, unjoined Server 2022/2025 hosts with PowerShell 5.1/7. Server 2022 must skip all six unsupported controls without policy writes. On Server 2025, exact local ADMX and runtime observations must qualify before preparing six DWORD 0 values. Public Configure then writes six DWORD 1 values, preserves every unrelated native SMB configuration property, siblings, access descriptors, service state and all 59 audit masks, records exact typed original journals, and repeats without writes. Cleanup restores the original values and removes only fixture-created empty policy keys. Native results retain the actual build/UBR and PowerShell version. + +This acceptance establishes policy registry behavior only. It generates no SMB traffic, performs no runtime activation or policy refresh, and does not establish Windows client/DC/AD CS, event, forwarding or Sigma readiness. + +On the measured Server2025 CI images, the six getter audit Booleans changed from False to True after registry configuration and returned to their original values after cleanup. The fixture records these separately and compares them with the public report; it preserves all other runtime properties. This observed result does not establish synchronous activation on other builds or after future policy refresh, and no SMB setter/restart or traffic is invoked. diff --git a/docs/wec-collector-observation.md b/docs/wec-collector-observation.md new file mode 100644 index 00000000..281a2253 --- /dev/null +++ b/docs/wec-collector-observation.md @@ -0,0 +1,22 @@ +# Native collector subscription observations + +The existing `wec-collector` Audit and Plan commands now enumerate subscription names through the local Windows Event Collector API and read selected XML through the shared bounded Unicode reader. This avoids treating PowerShell console output, including a BOM-only empty result, as subscription identity. Non-ASCII descriptions and XPath literals remain intact in the report. + +```powershell +.\WELA.ps1 wec-collector -WefAction Audit ` + -WefConfigPath C:\Reviewed\collector.json -ResultsPath C:\Evidence\collector-audit.json +.\WELA.ps1 wec-collector -WefAction Plan ` + -WefConfigPath C:\Reviewed\collector.json -ResultsPath C:\Evidence\collector-plan.json +``` + +Use the explicit collector configuration described in [WEF deployment](wef-deployment.md). These commands observe the selected local subscriptions and prerequisites. They do not create, save, enable or delete subscriptions. Existing Configure remains create-only and retains its domain, listener, ingress and hardening prerequisites. + +A successful complete enumeration can establish `ObservedSubscription.Exists: false`; its `ObservedEnabled` remains null. An enumeration error, cap, duplicate/invalid native name, vanished or unreadable selected definition, mismatched XML identity or unsupported authorization remains unknown, with `ObservationError` and an `Unknown` control. Failed observations never authorize creation. A valid disabled definition is reported as disabled even when the requested XML says enabled. A readable difference requires manual review rather than a replacement. + +Enumeration preserves exact native UTF-16 names, including Unicode and whitespace; it does not trim names or parse localized command output. It is limited to 4,096 names, 1,023 UTF-16 characters per name and 1,048,576 total characters including terminators. Exceeding a bound fails the observation instead of returning a partial list. Selected subscription IDs continue to use the existing supported ASCII ID syntax, and native XML reads retain their ten-MiB and thirty-second bounds. Native API errors are preserved as failures. The loaded enumeration helper is bound to its implementation bytes. + +Enumeration and XML readback are sequential observations, not a transaction or protection against another administrator. A disappearing subscription is unknown for that observation; retry with a fresh audit. A complete configuration match still does not establish source identity, effective source access, runtime health, event arrival, bookmark continuity or Sigma coverage. Collector-local channel observations describe only the collector. + +The disposable native suite exercises the actual public commands on Server 2022/2025 with Windows PowerShell 5.1 and PowerShell 7. It uses one uniquely owned disabled subscription with Unicode description and XPath, verifies absence, exact observation, requested/observed state separation, changed-description review and authorization-mismatch uncertainty, then removes only the owned subscription and restores original service startup/state. It preserves the destination channel and original subscription inventory. The real standalone fixture remains `Incomplete` with exit 1 for domain deployment prerequisites; those checks are neither mocked nor counted as domain or forwarding proof. Native name-buffer, cap, duplicate and read-failure regressions supplement that Windows acceptance. + +Microsoft references: [subscription enumeration](https://learn.microsoft.com/en-us/windows/win32/api/evcoll/nf-evcoll-ecenumnextsubscription), [enumeration handles](https://learn.microsoft.com/en-us/windows/win32/api/evcoll/nf-evcoll-ecopensubscriptionenum), and [wecutil XML/read-only commands](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/wecutil). diff --git a/docs/wef-deployment.md b/docs/wef-deployment.md index 3614569b..108558e7 100644 --- a/docs/wef-deployment.md +++ b/docs/wef-deployment.md @@ -50,6 +50,8 @@ ForwardedEvents enablement preserves its size, retention mode and security descr ## Subscription XML and evidence +[Native collector observations](wec-collector-observation.md) use complete bounded WEC name enumeration and strict Unicode XML reads. Failed or partial observations remain unknown; they never become permission to create a subscription. Raw Unicode descriptions/filters and actual disabled state are retained independently of the requested settings. + The supported input is the native Subscription namespace, SourceInitiated type, native EventLog URI, HTTP transport, ForwardedEvents destination and Normal/MinLatency/MinBandwidth delivery preset. Enabled, ReadExistingEvents, content format and locale must be explicit. QueryList uses unique numeric Query IDs, exact native channel paths and nonempty Select/Suppress XPath expressions. Wildcard/provider channel names, external-provider channels, Sysmon/EMET, DTDs, unknown settings and custom delivery are rejected. This checks supported structure, not Windows XPath execution; native `cs` remains the final syntax validator. An empty `AllowedSourceDomainComputers` input is filled from the explicit `SourceSids`; a nonempty value must match that authorization exactly. No empty authorization reaches `wecutil`, avoiding Windows' broader default authorization. Non-domain/certificate authorization is not supported. The example Security 4740 filter is illustrative and is not a complete baseline or a recommendation to lock an account for testing. @@ -64,7 +66,7 @@ Use the separate [reviewed authorization update](wec-authorization.md) to change `before.jsonl` is written before each mutation. Review its exact Target/Before/Desired and the results before recovery. For a newly created subscription, it records absence and stores the prepared XML; remove that exact ID only after verifying its current definition still belongs to this run. Existing subscriptions are never edited. For the new SubscriptionManager value, compare the current value with Desired before removing only that value; keep other list entries and parent keys. Restore WSMan values and service start/running states only after verifying their present state and current policy authority. Remove only the newly added group SID after comparing the full membership snapshot; DC membership is never changed by this workflow. For channel restoration, use the channel journal and descriptor-preservation guidance. Recovery is deliberately manual so a newer operator/GPO change is not overwritten. -Safe fixture tests exercise the public command/report, journals, readback failures, occupied slots, explicit authorization, native create failures, configuration drift, DC group protection and blocked prerequisites. Windows PowerShell 5.1/PowerShell 7 CI adds real **read-only** channel, service, WSMan, firewall and ADMX assessment. These tests do not deploy subscriptions or prove forwarding. +Safe fixture tests exercise the public command/report, journals, readback failures, occupied slots, explicit authorization, native create failures, configuration drift, DC group protection and blocked prerequisites. Windows PowerShell 5.1/PowerShell 7 CI adds real **read-only** channel, service, WSMan, firewall and ADMX assessment. Those read-only smoke tests do not deploy subscriptions or prove forwarding. The separate [native observation fixture](wec-collector-observation.md) now exercises public Audit/Plan against one owned disabled subscription on standalone Server 2022/2025 runners, preserving real unmet domain prerequisites and exact fixture cleanup; it does not test domain deployment or delivery. Before closing issue #368, an isolated domain lab must configure a dedicated collector and Windows 11/member-server/DC/AD CS sources, verify source identity/token read access (including any required token/service refresh), preserve runtime status, and demonstrate native events matching each selected query arriving with the expected source identity/timestamps. Include disabled-query, denied-source, absent-channel, GPO refresh, idempotence, drift and recovery cases. Use a deliberately chosen benign native Application/System event or a controlled test account/object relevant to the query; record actual events, not merely a successful command or ACE. Forwarded Sigma coverage remains unassessed until those events and the processing pipeline are validated. diff --git a/modules/WecSubscriptionInventory.cs b/modules/WecSubscriptionInventory.cs new file mode 100644 index 00000000..7c1654a0 --- /dev/null +++ b/modules/WecSubscriptionInventory.cs @@ -0,0 +1,52 @@ +// Read-only WEC names, returned only after complete bounded native enumeration. +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +namespace Wela.WecInventory { + public static class Reader { + public const string SourceSha256="__WELA_SOURCE_SHA256__"; + const uint Capacity=1024; + [DllImport("wecapi.dll",SetLastError=true)] static extern IntPtr EcOpenSubscriptionEnum(uint flags); + [DllImport("wecapi.dll",SetLastError=true)] [return:MarshalAs(UnmanagedType.Bool)] static extern bool EcEnumNextSubscription(IntPtr enumeration,uint size,IntPtr name,out uint used); + [DllImport("wecapi.dll",SetLastError=true)] [return:MarshalAs(UnmanagedType.Bool)] static extern bool EcClose(IntPtr handle); + // Public only for safe allocated-buffer ABI and boundary regression tests. + public static string DecodeName(IntPtr buffer,uint used,uint capacity) { + if(buffer==IntPtr.Zero||capacity<2||capacity>Capacity||used<2||used>capacity)throw new InvalidDataException("Invalid native subscription-name buffer."); + if(Marshal.ReadInt16(buffer,checked((int)(used-1)*2))!=0)throw new InvalidDataException("Native subscription name is not terminated."); + byte[] bytes=new byte[checked((int)(used-1)*2)];Marshal.Copy(buffer,bytes,0,bytes.Length); + string name=new UnicodeEncoding(false,false,true).GetString(bytes); + if(name.IndexOf('\0')>=0)throw new InvalidDataException("Native subscription name contains an embedded terminator."); + return name; + } + // Public so duplicate, count and aggregate bounds can be tested without Windows. + public static string[] ValidateNames(string[] names) { + if(names==null||names.Length>4096)throw new InvalidDataException("Native subscription inventory exceeds 4096 entries."); + var unique=new HashSet(StringComparer.OrdinalIgnoreCase);long characters=0; + foreach(string name in names) { + if(String.IsNullOrEmpty(name)||name.Length>=Capacity||name.IndexOf('\0')>=0||!unique.Add(name))throw new InvalidDataException("Native subscription inventory contains an invalid or duplicate name."); + new UnicodeEncoding(false,false,true).GetBytes(name); + characters+=name.Length+1;if(characters>1048576)throw new InvalidDataException("Native subscription inventory exceeds its total character bound."); + } + string[] result=(string[])names.Clone();Array.Sort(result,StringComparer.Ordinal);return result; + } + public static string[] ReadNames() { + IntPtr handle=EcOpenSubscriptionEnum(0);if(handle==IntPtr.Zero)throw new Win32Exception(Marshal.GetLastWin32Error()); + try { + IntPtr buffer=Marshal.AllocHGlobal(checked((int)Capacity*2)); + try { + var names=new List();long characters=0; + while(true) { + uint used; + if(!EcEnumNextSubscription(handle,Capacity,buffer,out used)) {int error=Marshal.GetLastWin32Error();if(error==259)return ValidateNames(names.ToArray());throw new Win32Exception(error);} + string name=DecodeName(buffer,used,Capacity);characters+=name.Length+1; + if(names.Count>=4096||characters>1048576)throw new InvalidDataException("Native subscription inventory exceeded its bounds; no absence is established."); + names.Add(name); + } + }finally {Marshal.FreeHGlobal(buffer);} + }finally {EcClose(handle);} + } + } +} diff --git a/modules/WefSubscriptions.psm1 b/modules/WefSubscriptions.psm1 index 7f40d9d6..46ab3492 100644 --- a/modules/WefSubscriptions.psm1 +++ b/modules/WefSubscriptions.psm1 @@ -194,6 +194,27 @@ function Import-WelaWefConfig { [pscustomobject]@{ Config=$config; Path=$full; Subscriptions=$subscriptions } } +function Initialize-WelaWecSubscriptionInventory { + $path=Join-Path $PSScriptRoot 'WecSubscriptionInventory.cs' + $bytes=[IO.File]::ReadAllBytes($path);if($bytes.Length -gt 65536){throw 'Native inventory source exceeds its bound.'} + $sha=[Security.Cryptography.SHA256]::Create();try{$hash=([BitConverter]::ToString($sha.ComputeHash($bytes))).Replace('-','').ToLowerInvariant()}finally{$sha.Dispose()} + if(-not ('Wela.WecInventory.Reader' -as [type])){ + $source=[Text.UTF8Encoding]::new($false,$true).GetString($bytes).TrimStart([char]0xfeff) + if([regex]::Matches($source,'__WELA_SOURCE_SHA256__').Count -ne 1){throw 'Native inventory source binding marker is missing or ambiguous.'} + $compile=@{TypeDefinition=$source.Replace('__WELA_SOURCE_SHA256__',$hash);ErrorAction='Stop'} + if($PSVersionTable.PSEdition -eq 'Desktop'){$compile.ReferencedAssemblies=@('System.dll','System.Core.dll')} + Add-Type @compile + } + if([Wela.WecInventory.Reader]::SourceSha256 -cne $hash){throw 'Loaded native inventory differs from its source; start a fresh process.'} +} + +function Get-WelaWecSubscriptionIds { + if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'Native WEC inventory requires 64-bit Windows.'} + Initialize-WelaWecSubscriptionInventory + # The native method returns nothing until enumeration has completed successfully. + [Wela.WecInventory.Reader]::ReadNames() +} + function Read-WelaWecSubscriptionXml { param([Parameter(Mandatory)][string]$Id) if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'Native WEC XML reads require 64-bit Windows.'} @@ -207,4 +228,4 @@ function Read-WelaWecSubscriptionXml { [Wela.WecXml.Reader]::ReadXml($Id) } -Export-ModuleMember -Function ConvertTo-WelaWefFirewallAddressKey, Test-WelaWefFirewallAddressSet, Read-WelaWecSubscriptionXml, Read-WelaWefXml, Get-WelaWefXmlKey, ConvertFrom-WelaWefQuery, Get-WelaWefAuthorization, ConvertFrom-WelaWefSubscription, Import-WelaWefConfig +Export-ModuleMember -Function Get-WelaWecSubscriptionIds, ConvertTo-WelaWefFirewallAddressKey, Test-WelaWefFirewallAddressSet, Read-WelaWecSubscriptionXml, Read-WelaWefXml, Get-WelaWefXmlKey, ConvertFrom-WelaWefQuery, Get-WelaWefAuthorization, ConvertFrom-WelaWefSubscription, Import-WelaWefConfig diff --git a/scripts/Configuration.ps1 b/scripts/Configuration.ps1 index 6e5297fa..c44d2de1 100644 --- a/scripts/Configuration.ps1 +++ b/scripts/Configuration.ps1 @@ -108,7 +108,7 @@ function Invoke-WelaConfigurationControl { function Complete-WelaConfiguration { param($Context, [string]$ResultsPath, $Plan, - [ValidateSet("native-windows-configuration", "advanced-audit-policy-only", "advanced-audit-policy-and-precedence", "firewall-text-logging-only", "event-log-size-and-mode-only", "smb-audit-policies-only", "native-channel-settings-only", "wmi-namespace-sacl-only", "ad-object-sacl-only", "windows-powershell-transcription-policy-only", "wef-source-configuration-only", "wec-collector-subscriptions-only", "audit-integrity-local-policy-only", "adcs-audit-settings-only", "disabled-unlinked-gpo-creation-only")] + [ValidateSet("native-windows-configuration", "outgoing-ntlm-audit-policy-only", "advanced-audit-policy-only", "advanced-audit-policy-and-precedence", "firewall-text-logging-only", "event-log-size-and-mode-only", "smb-audit-policies-only", "native-channel-settings-only", "wmi-namespace-sacl-only", "ad-object-sacl-only", "windows-powershell-transcription-policy-only", "wef-source-configuration-only", "wec-collector-subscriptions-only", "audit-integrity-local-policy-only", "adcs-audit-settings-only", "disabled-unlinked-gpo-creation-only")] [string]$Scope = "native-windows-configuration", [string]$SuccessMessage = 'Configuration completed; all requested controls verified.') if ($Context.PSObject.Properties['CustomProfileGuard']) { diff --git a/scripts/NativeProviderPacks.ps1 b/scripts/NativeProviderPacks.ps1 index 1ba0a64d..744d7d26 100644 --- a/scripts/NativeProviderPacks.ps1 +++ b/scripts/NativeProviderPacks.ps1 @@ -53,12 +53,15 @@ function Get-WelaProviderPackSchema { if ([guid]$provider.Id -eq [guid]::Empty) { throw 'Provider GUID is unknown.' } if (@($logs[0].ProviderNames) -notcontains $Pack.provider -or @($provider.LogLinks.LogName) -notcontains $Pack.channel) { throw 'Provider/channel links disagree.' } $events = @() + # EventMetadata.Id is Int64; WinRM includes unrelated IDs above Int32.MaxValue. + # Compare before parsing selected templates, without narrowing the native ID. + $expectedIds = @($Pack.events | ForEach-Object { [long]$_.id }) foreach ($event in $provider.Events) { - if (@($Pack.events.id) -contains [int]$event.Id -and $event.LogLink.LogName -eq $Pack.channel) { + if ($expectedIds -contains [long]$event.Id -and $event.LogLink.LogName -eq $Pack.channel) { $fields = @(Get-WelaProviderTemplateFields -Template $event.Template) $sha = [Security.Cryptography.SHA256]::Create() try { $templateHash = ([BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes([string]$event.Template)))).Replace('-','').ToLowerInvariant() } finally { $sha.Dispose() } - $events += [pscustomobject]@{ Id=[int]$event.Id; Version=[int]$event.Version; Channel=[string]$event.LogLink.LogName; Fields=$fields; TemplateSha256=$templateHash } + $events += [pscustomobject]@{ Id=[long]$event.Id; Version=[int]$event.Version; Channel=[string]$event.LogLink.LogName; Fields=$fields; TemplateSha256=$templateHash } } } [pscustomobject]@{ State='Observed'; Provider=[string]$provider.Name; ProviderGuid=[string]$provider.Id; ChannelType=[string]$logs[0].LogType; Events=$events; Diagnostic=$null } diff --git a/scripts/OutgoingNtlmAudit.ps1 b/scripts/OutgoingNtlmAudit.ps1 new file mode 100644 index 00000000..4bbe8e67 --- /dev/null +++ b/scripts/OutgoingNtlmAudit.ps1 @@ -0,0 +1,77 @@ +# Explicit outgoing audit policy; does not invoke the broad configure workflow. +function Get-WelaOutgoingAuditSnapshot { + if ($env:OS -ne 'Windows_NT' -or -not [Environment]::Is64BitProcess) { throw 'Use 64-bit PowerShell on Windows.' } + $os=Get-CimInstance Win32_OperatingSystem -Property BuildNumber,ProductType -ErrorAction Stop + $computer=Get-CimInstance Win32_ComputerSystem -Property DomainRole,PartOfDomain -ErrorAction Stop + $build=[int]$os.BuildNumber;$product=[int]$os.ProductType + if (-not (($product -eq 1 -and $build -in @(22000,22621,22631,26100,26200)) -or ($product -in @(2,3) -and $build -in @(20348,26100)))) { throw 'This Windows role/build has not been reviewed for the scoped command.' } + if ($computer.DomainRole -notin @(0,1,2,3,4,5) -or $computer.PartOfDomain -isnot [bool]) {throw 'Computer role/join context is unavailable.'} + $policy=Get-WelaRegistryState 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0' RestrictSendingNTLMTraffic + if (-not $policy.KeyExists) {throw 'The existing MSV1_0 policy key is required; no parent key will be created.'} + [pscustomobject][ordered]@{Host=[pscustomobject][ordered]@{Build=$build;ProductType=$product;DomainRole=[int]$computer.DomainRole;PartOfDomain=$computer.PartOfDomain};Policy=$policy} +} + +function Get-WelaOutgoingAuditDisposition { + param($Snapshot,[ValidateSet('PreserveOrAudit','Audit')][string]$Mode) + $p=$Snapshot.Policy + if ($p.ValueExists -and ($p.Type -cne 'DWord' -or $p.Value -notin @(0,1,2))) {return 'Unknown'} + if ($p.ValueExists -and $p.Value -eq 1) {return 'AlreadyCompliant'} + if ($p.ValueExists -and $p.Value -eq 2 -and $Mode -eq 'PreserveOrAudit') {return 'PreservedEnforcement'} + return 'ChangeRequired' +} + +function Get-WelaOutgoingAuditPlan { + param([ValidateSet('PreserveOrAudit','Audit')][string]$Mode='PreserveOrAudit') + try { + $snapshot=Get-WelaOutgoingAuditSnapshot + $status=Get-WelaOutgoingAuditDisposition $snapshot $Mode + $diagnostic=switch($status){ + Unknown {'Unknown registry type/value is preserved; investigate it before configuration.'} + PreservedEnforcement {'Deny all (2) is authentication enforcement, preserved by default. Explicit -OutgoingNtlmMode Audit authorizes replacing it with Audit all (1).'} + AlreadyCompliant {'Audit all (1) is configured; authentication, events and policy persistence are unverified.'} + default {'Set only outgoing NTLM Audit all (DWORD 1).'} + } + [pscustomobject]@{Status=$status;Mode=$Mode;Desired=1;Before=$snapshot;Diagnostic=$diagnostic;PolicySource=Get-WelaOutgoingNtlmPolicySource} + }catch{[pscustomobject]@{Status='Unknown';Mode=$Mode;Desired=1;Before=$null;Diagnostic=$_.ToString();PolicySource='Unknown'}} +} + +function Invoke-WelaOutgoingAuditCommand { + param([ValidateSet('Audit','Plan','Configure')][string]$Action='Audit',[ValidateSet('PreserveOrAudit','Audit')][string]$Mode='PreserveOrAudit',[switch]$Auto,[switch]$DryRun,[string]$BackupPath,[string]$ResultsPath) + if ($Action -ne 'Configure' -and ($Auto -or $DryRun -or $BackupPath)) {throw 'Consent, dry-run and backup options require Configure.'} + $plan=Get-WelaOutgoingAuditPlan $Mode + if ($Action -eq 'Configure') { + $context=New-WelaConfigurationContext -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath + $path='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0';$name='RestrictSendingNTLMTraffic' + if ($plan.Status -eq 'Unknown') { + $context.Results.Add([pscustomobject]@{Id="Registry/$path/$name";Kind='Registry';Target=@{Path=$path;Name=$name};Desired=@{Value=1;Type='DWord'};Before=$plan.Before;After=$null;Status='Failed';Diagnostic=$plan.Diagnostic}) + }else{ + $state=@{Observed=$null;PlannedHost=($plan.Before.Host|ConvertTo-Json -Compress);Mode=$Mode;Path=$path;Name=$name} + $read={param($s) + $snapshot=Get-WelaOutgoingAuditSnapshot + if (($snapshot.Host|ConvertTo-Json -Compress) -cne $s.PlannedHost) {throw 'Observed host context changed; review a new plan.'} + if ((Get-WelaOutgoingAuditDisposition $snapshot $s.Mode) -eq 'Unknown') {throw 'Unknown registry type/value is preserved.'} + $s.Observed=$snapshot + return $snapshot + } + $test={param($snapshot) $snapshot.Policy.ValueExists -and $snapshot.Policy.Type -ceq 'DWord' -and $snapshot.Policy.Value -eq 1} + $preserve=if($Mode -eq 'PreserveOrAudit'){{param($snapshot) if($snapshot.Policy.ValueExists -and $snapshot.Policy.Type -ceq 'DWord' -and $snapshot.Policy.Value -eq 2){'Preserved Deny all enforcement; explicit Audit mode is required to replace it.'}}}else{$null} + $apply={param($s) + $fresh=Get-WelaOutgoingAuditSnapshot + if (($fresh|ConvertTo-Json -Depth 8 -Compress) -cne ($s.Observed|ConvertTo-Json -Depth 8 -Compress)) {throw 'Outgoing NTLM state changed after the original journal snapshot; no write was attempted.'} + if ((Get-WelaOutgoingAuditDisposition $fresh $s.Mode) -ne 'ChangeRequired') {throw 'The current state no longer authorizes this write.'} + Set-ItemProperty -LiteralPath $s.Path -Name $s.Name -Value 1 -Type DWord -ErrorAction Stop + 'Only outgoing NTLM Audit all (1) was requested; no authentication or event-generation test was performed.' + } + Invoke-WelaConfigurationControl -Context $context -Id "Registry/$path/$name" -Kind Registry -Target @{Path=$path;Name=$name} -Desired @{Value=1;Type='DWord'} -Read $read -Compliant $test -PreserveWhen $preserve -Apply $apply -CallbackState $state -Description $plan.Diagnostic + } + $report=Complete-WelaConfiguration -Context $context -Scope 'outgoing-ntlm-audit-policy-only' -SuccessMessage 'Outgoing NTLM configuration results recorded; inspect preserved/skipped controls separately.' + $report|Add-Member NoteProperty Plan $plan + }else{$report=[pscustomobject]@{ExitCode=$(if($plan.Status -eq 'Unknown'){1}else{0});Scope='outgoing-ntlm-audit-policy-only';Action=$Action;Plan=$plan}} + $report|Add-Member NoteProperty EventGeneration 'Not verified; registry compliance does not establish authentication, NTLM events, forwarding, GPO persistence or Sigma readiness.' + $report|Add-Member NoteProperty ReadyRuleCredit 0 + if ($ResultsPath) { + try {$report|ConvertTo-Json -Depth 16|Set-Content -LiteralPath $ResultsPath -Encoding UTF8 -ErrorAction Stop} + catch {$report.ExitCode=1;Write-Host "[Failed] Writing outgoing NTLM results: $_" -ForegroundColor Red} + } + return $report +} diff --git a/scripts/WefDeployment.ps1 b/scripts/WefDeployment.ps1 index 39d9597b..bb0f78cc 100644 --- a/scripts/WefDeployment.ps1 +++ b/scripts/WefDeployment.ps1 @@ -30,13 +30,14 @@ function Get-WelaWefControlState { 'SubscriptionManager' { return Get-WelaRegistryState -Path $Target.Path -Name $Target.Name } 'ForwardedEvents' { return Get-WelaNativeChannel -Name 'ForwardedEvents' } 'Subscription' { - $ids = @((Invoke-WelaNative -FilePath 'wecutil.exe' -Arguments @('es')).Output | ForEach-Object { $_.ToString().Trim() } | Where-Object { $_ }) + $ids = @(Get-WelaWecSubscriptionIds) if ($ids -notcontains $Target.Id) { return [pscustomobject]@{ Exists=$false; Xml=$null; Key=$null; Definition=$null } } # Keep evidence as a plain string. Windows PowerShell 5.1's JSON # serializer expands ETS properties on strings (for example a test # reader's PSDrive/PSProvider graph), unlike modern PowerShell. - $xml = [string]::Concat((Invoke-WelaNative -FilePath 'wecutil.exe' -Arguments @('gs',$Target.Id,'/f:xml')).Diagnostic) + $xml = [string]::Concat((Read-WelaWecSubscriptionXml -Id $Target.Id)) $model = ConvertFrom-WelaWefSubscription -Xml $xml -SourceSids $Target.SourceSids -Observed + if($model.Id -cne $Target.Id){throw 'Native subscription identity differs from the selected ID.'} return [pscustomobject]@{ Exists=$true; Xml=$xml; Key=$model.Key; Definition=$model.Definition } } default { throw "Unsupported WEF control kind: $Kind" } diff --git a/tests/NativeProviderConfigure.Windows.Tests.ps1 b/tests/NativeProviderConfigure.Windows.Tests.ps1 new file mode 100644 index 00000000..52019079 --- /dev/null +++ b/tests/NativeProviderConfigure.Windows.Tests.ps1 @@ -0,0 +1,174 @@ +param([switch]$AllowDisposableProviderWrite) +$ErrorActionPreference='Stop' +if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not $AllowDisposableProviderWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit disposable GitHub-hosted Windows provider-write opt-in required.'} +$repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo +Import-Module "$repo/modules/AuditProfiles.psm1" -Force +Import-Module "$repo/modules/EventLogSettings.psm1" -Force +Import-Module "$repo/modules/NativeProviders.psm1" -Force +Import-Module "$repo/modules/NativeChannelAccess.psm1" -Force +. "$repo/scripts/Configuration.ps1" +. "$repo/scripts/NativeChannelConfiguration.ps1" +. "$repo/scripts/NativeProviderPacks.ps1" +$count=0;$errors=@();$primary=$null;$mutated=@() +function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} +function Save($Name,$Value){ConvertTo-Json -InputObject $Value -Depth 30|Set-Content -LiteralPath (Join-Path $root $Name) -Encoding UTF8} +function Read-Raw([string]$Name){$r=Invoke-WelaNative wevtutil.exe @('gl',$Name,'/f:xml');$doc=[Xml.XmlDocument]::new();$doc.XmlResolver=$null;$doc.LoadXml(($r.Output -join "`n"));return ,$doc} +function Guard-Raw($Xml){$copy=$Xml.CloneNode($true);$copy.DocumentElement.RemoveAttribute('enabled');foreach($node in @($copy.SelectNodes("/*/*[local-name()='logging']/*[local-name()='maxSize']"))){$null=$node.ParentNode.RemoveChild($node)};$copy.OuterXml} +function Services { + foreach($name in @('EventLog','Winmgmt','WinRM','TermService','DNS')){ + $state=Get-WelaNativeService $name + if($state.State -eq 'Unknown'){throw "Service $name is unreadable"} + [pscustomobject][ordered]@{Name=$name;State=$state.State;Start=$(if($state.State -ne 'Not installed'){[string](Get-Service -Name $name -ErrorAction Stop).StartType}else{$null})} + } +} +function Key($Value){ConvertTo-Json -InputObject $Value -Depth 20 -Compress} +Add-Type -TypeDefinition @' +using System; using System.IO; using System.Text; using System.Threading.Tasks; +public static class WelaProviderConfigureFixturePipe { + public static async Task Read(TextReader reader) { + var text=new StringBuilder(); var buffer=new char[1024]; + while(true) { int n=await reader.ReadAsync(buffer,0,buffer.Length).ConfigureAwait(false); if(n==0)return text.ToString(); + if(n>1048576-text.Length)throw new InvalidDataException("Fixture output exceeded 1Mi characters.");text.Append(buffer,0,n); } + } +} +'@ +$engine=(Get-Process -Id $PID).Path +$root=Join-Path $env:RUNNER_TEMP ('wela-provider-configure-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root +$wrapper=Join-Path $root 'public.ps1' +@' +param([string]$InputPath) +$ErrorActionPreference='Stop';$global:LASTEXITCODE=0 +$p=Get-Content -LiteralPath $InputPath -Raw|ConvertFrom-Json +$a=@{ProviderAction=[string]$p.Action;ProviderPack=[string[]]$p.Names;ResultsPath=[string]$p.ResultsPath} +if($p.Action -ceq 'Configure'){$a.Auto=$true;$a.BackupPath=[string]$p.BackupPath} +if($p.DryRun){$a.DryRun=$true} +# Array-splatted strings are positional values, not named PowerShell switches. +# Fixed literal branches exercise the public parameter parser exactly. +if(@($p.Extra).Count -eq 0){& ([string]$p.Script) provider-packs @a} +elseif(@($p.Extra).Count -eq 1 -and $p.Extra[0] -ceq '-WhatIf'){& ([string]$p.Script) provider-packs @a -WhatIf} +elseif(@($p.Extra).Count -eq 1 -and $p.Extra[0] -ceq '-GrantEventLogReaders'){& ([string]$p.Script) provider-packs @a -GrantEventLogReaders} +else{throw 'Unreviewed fixture option.'} +exit $global:LASTEXITCODE +'@ | Set-Content -LiteralPath $wrapper -Encoding UTF8 +function Public([string]$Name,[string]$Action,[string[]]$Names,[switch]$DryRun,[int]$Expected=0,[string[]]$Extra=@()){ + $inputPath=Join-Path $root ($Name+'-input.json');$resultPath=Join-Path $root ($Name+'.json');$backup=Join-Path $root ($Name+'-journal') + Save ($Name+'-input.json') @{Script="$repo/WELA.ps1";Action=$Action;Names=$Names;DryRun=[bool]$DryRun;ResultsPath=$resultPath;BackupPath=$backup;Extra=$Extra} + $all=@('-NoLogo','-NoProfile','-NonInteractive','-File',$wrapper,'-InputPath',$inputPath) + foreach($a in $all){if($a.Contains('"') -or $a.EndsWith('\') -or $a -match '[\x00-\x1f]'){throw 'Unsupported fixture argument.'}} + $info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$engine;$info.Arguments=(@($all|ForEach-Object {'"'+$_+'"'}) -join ' ');$info.UseShellExecute=$false;$info.CreateNoWindow=$true;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true + $process=[Diagnostics.Process]::new();$process.StartInfo=$info;$started=$false + try { + if(-not $process.Start()){throw 'Public process did not start'};$started=$true + $stdout=[WelaProviderConfigureFixturePipe]::Read($process.StandardOutput);$stderr=[WelaProviderConfigureFixturePipe]::Read($process.StandardError) + if(-not $process.WaitForExit(180000)){throw 'Public command exceeded three minutes.'} + if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),5000)){throw 'Public command output drain timed out.'} + $text=$stdout.Result+"`n"+$stderr.Result;[IO.File]::WriteAllText((Join-Path $root ($Name+'.txt')),$text) + Assert ($process.ExitCode -eq $Expected) "Public $Name exit $($process.ExitCode) expected $Expected : $text" + }finally{ + if($started){$exited=$false;try{$exited=$process.HasExited}catch{$script:errors+=$_.Exception.Message};if(-not $exited){try{$process.Kill()}catch{$script:errors+=$_.Exception.Message};try{$exited=$process.WaitForExit(5000)}catch{$script:errors+=$_.Exception.Message}};if(-not $exited){$script:errors+='Owned public process termination unconfirmed'}} + try{$process.Dispose()}catch{$script:errors+=$_.Exception.Message} + } + if(Test-Path $resultPath){$r=Get-Content $resultPath -Raw|ConvertFrom-Json;Assert ($r.ExitCode -eq $Expected -and $r.ReadyRules -eq 0 -and $r.UnverifiedEvidence.Count -eq 4) 'Public result agrees with process exit and grants no readiness credit.';return $r} + Assert ($Expected -eq 1 -and -not(Test-Path $backup)) 'Invalid CLI refuses before report or recovery directory creation.' +} +$catalog=Get-WelaProviderPackCatalog +$names=@('dns-client','capi2','winrm','rdp-client');$selected=@($catalog.packs|Where-Object {$names -contains $_.id}) +$channels=@(@($catalog.packs.channel)+@('Security','System','Application','Microsoft-Windows-AppLocker/EXE and DLL','Microsoft-Windows-DriverFrameworks-UserMode/Operational')|Sort-Object -Unique) +$before=@{};$raw=@{};$prepared=@{};$preparedRaw=@{};$services=@(Services);$policies=Get-WelaEffectiveAuditPolicy +foreach($channel in $channels){$before[$channel]=Get-WelaNativeChannel $channel;if(Test-WelaNativeChannelSnapshot $before[$channel]){$raw[$channel]=Read-Raw $channel}} +$rawText=@{};foreach($channel in $raw.Keys){$rawText[$channel]=$raw[$channel].OuterXml} +Save 'original.json' @{Channels=$before;RawXml=$rawText;Services=$services;AuditMasks=$policies;Engine=$PSVersionTable.PSVersion.ToString()} +function Stable-Selected {foreach($channel in $selected.channel){Assert (Test-WelaNativeChannelSnapshotEqual $configured[$channel] (Get-WelaNativeChannel $channel)) 'Idempotent/refused/partial invocation preserves the expected complete selected-channel tuple.'}} +function Preserved { + foreach($channel in $channels){ + $now=Get-WelaNativeChannel $channel + if($selected.channel -contains $channel){Assert ((Guard-Raw (Read-Raw $channel)) -ceq (Guard-Raw $preparedRaw[$channel])) 'Selected channel preserves complete descriptor, retention, path and publisher settings.'} + elseif($raw.ContainsKey($channel)){Assert ((Read-Raw $channel).OuterXml -ceq $raw[$channel].OuterXml) 'Unselected registered channel retains every configuration field.'} + else{Assert ((Key $now) -ceq (Key $before[$channel])) 'Uninstalled/unreadable nonselected channel observation remains unchanged.'} + } + Assert ((Key @(Services)) -ceq (Key $services)) 'EventLog, Winmgmt, WinRM, RDP and DNS service state/start types remain unchanged.' + $nowMasks=Get-WelaEffectiveAuditPolicy;Assert ($nowMasks.Count -eq 59 -and $policies.Count -eq 59) 'All59 native audit masks are present.' + foreach($guid in $policies.Keys){if($nowMasks[$guid] -ne $policies[$guid]){throw "Audit mask changed: $guid"}};$script:count++ +} +try { + Assert (@($services|Where-Object {$_.Name -in @('Winmgmt','EventLog') -and $_.State -ne 'Running'}).Count -eq 0) 'Metadata dependencies must already be running; fixture never starts services.' + $os=Get-CimInstance Win32_OperatingSystem + Assert ($os.ProductType -eq 3 -and $os.BuildNumber -in @('20348','26100')) 'Reviewed disposable Server2022/2025 required.' + Assert (@($services|Where-Object {$_.Name -eq 'DNS' -and $_.State -eq 'Not installed'}).Count -eq 1) 'Fixture requires genuine DNS Server absence; no role is installed/removed for acceptance.' + foreach($pack in $selected){Assert ($raw.ContainsKey($pack.channel)) "Actual selected channel required: $($pack.id)"} + # First real public observation must support all four reviewed manifest gates. + $initial=Public 'initial' 'Plan' $names + foreach($entry in $initial.ControlsPlan){Assert ($entry.ProviderEvidence.CanConfigure -and $entry.ProviderEvidence.Schema.State -ceq 'Observed' -and $entry.ProviderEvidence.Schema.Provider -ceq $entry.Pack.provider) 'Exact actual provider/schema permits the selected pack.'} + Assert ($initial.ControlsPlan.Count -eq 4) 'Exactly four explicit packs are observed.' + foreach($channel in $raw.Keys){Assert ((Read-Raw $channel).OuterXml -ceq $raw[$channel].OuterXml) 'Initial public Plan preserves every registered channel configuration.'} + foreach($pack in $selected){ + $channel=$pack.channel;$mutated+=,$channel + $size=if($pack.id -ceq 'winrm'){2147483648L}else{1048576L} + $nativeArguments=@('sl',$channel,'/e:false',('/ms:'+$size));if($pack.id -ceq 'capi2'){$nativeArguments+=@('/rt:true','/ab:false')} + $null=Invoke-WelaNative wevtutil.exe $nativeArguments + $prepared[$channel]=Get-WelaNativeChannel $channel;$preparedRaw[$channel]=Read-Raw $channel + } + $preparedText=@{};foreach($channel in $preparedRaw.Keys){$preparedText[$channel]=$preparedRaw[$channel].OuterXml} + Save 'prepared.json' $prepared;Save 'prepared-xml.json' $preparedText + $planned=Public 'plan' 'Plan' $names + Assert (@($planned.ControlsPlan|Where-Object Status -cne 'ChangeRequired').Count -eq 0) 'Actual disabled/small prepared channels require change.' + $dry=Public 'dry' 'Configure' $names -DryRun + Assert ($dry.DryRun -and $dry.Results.Count -eq 4 -and @($dry.Results|Where-Object Status -cne 'Skipped').Count -eq 0 -and -not(Test-Path "$root/dry-journal")) 'Public DryRun skips all selected writes and creates no journal.' + $null=Public 'whatif' 'Configure' $names -Expected 1 -Extra @('-WhatIf') + $null=Public 'grant-option' 'Configure' $names -Expected 1 -Extra @('-GrantEventLogReaders') + foreach($channel in $selected.channel){Assert (Test-WelaNativeChannelSnapshotEqual $prepared[$channel] (Get-WelaNativeChannel $channel)) 'Plan, DryRun and invalid options preserve prepared actual state.'} + Preserved + $configured=@{} + $applied=Public 'configure' 'Configure' $names + Assert ($applied.Action -ceq 'Configure' -and $applied.Scope -ceq 'native-channel-settings-only' -and $applied.Results.Count -eq 4 -and @($applied.Results|Where-Object Status -cne 'Applied').Count -eq 0) 'All four explicit configurations are actually Applied.' + $journal=@(Get-Content "$root/configure-journal/before.jsonl"|ForEach-Object {$_|ConvertFrom-Json}) + Assert ($journal.Count -eq 4 -and @($journal|Where-Object {$selected.channel -notcontains $_.Target.Channel}).Count -eq 0) 'Exactly four selected changes have durable original journals.' + foreach($pack in $selected){ + $channel=$pack.channel;$entry=@($applied.Results|Where-Object {$_.Target.Channel -ceq $channel});$j=@($journal|Where-Object {$_.Target.Channel -ceq $channel});$now=Get-WelaNativeChannel $channel;$configured[$channel]=$now + $minimum=if($pack.id -ceq 'capi2'){102432768L}elseif($pack.id -ceq 'winrm'){2147483648L}else{33554432L} + Assert ($entry.Count -eq 1 -and $j.Count -eq 1 -and (Test-WelaNativeChannelSnapshotEqual $j[0].Before $prepared[$channel]) -and (Test-WelaNativeChannelSnapshotEqual $entry[0].Before $prepared[$channel])) 'Native journal and result retain exact prepared before-state.' + Assert ($now.IsEnabled -and $now.MaximumSizeInBytes -eq $minimum -and (Test-WelaNativeChannelSnapshotEqual $entry[0].After $now)) 'Exact native enable/floor/larger-buffer readback matches Applied after-state.' + Assert ((Test-WelaChannelDescriptorEqual $now.SecurityDescriptor $prepared[$channel].SecurityDescriptor) -and $now.LogMode -ceq $prepared[$channel].LogMode -and -not $entry[0].Desired.AccessChangeRequested) 'Every descriptor byte and retention mode is preserved without a read grant.' + } + $configuredText=@{};foreach($channel in $selected.channel){$configuredText[$channel]=(Read-Raw $channel).OuterXml};Save 'configured-xml.json' $configuredText + Assert ((Get-WelaNativeChannel 'Microsoft-Windows-CAPI2/Operational').LogMode -ceq 'Retain') 'An actual nondefault Retain setting survives provider configuration.' + Preserved + $repeat=Public 'repeat' 'Configure' $names + Assert (@($repeat.Results|Where-Object Status -cne 'AlreadyCompliant').Count -eq 0 -and -not(Test-Path "$root/repeat-journal/before.jsonl")) 'Native repeat is idempotent and journals no write.' + Stable-Selected + $manual=Public 'manual' 'Configure' @('dns-server-analytical','dns-server-classic') -Expected 1 + Assert ($manual.Results.Count -eq 2 -and @($manual.Results|Where-Object Status -cne 'Failed').Count -eq 0 -and -not(Test-Path "$root/manual-journal/before.jsonl")) 'Both actual manual-only selections fail without channel mutation or journal.' + Stable-Selected + $missing=Public 'missing-dns' 'Configure' @('dns-server-audit') -Expected 1 + Assert ($missing.Results[0].Status -ceq 'Failed' -and $missing.ControlsPlan[0].ProviderEvidence.Service.State -ceq 'Not installed' -and -not(Test-Path "$root/missing-dns-journal/before.jsonl")) 'Missing actual DNS service cannot be replaced by an assumed server role.' + Stable-Selected + # A genuine partial public run must retain one success and one manual refusal. + $capi='Microsoft-Windows-CAPI2/Operational';$null=Invoke-WelaNative wevtutil.exe @('sl',$capi,'/e:false');$partialBefore=Get-WelaNativeChannel $capi + $partial=Public 'partial' 'Configure' @('capi2','dns-server-analytical') -Expected 1 + Assert (@($partial.Results|Where-Object Status -ceq 'Applied').Count -eq 1 -and @($partial.Results|Where-Object Status -ceq 'Failed').Count -eq 1 -and (Get-WelaNativeChannel $capi).IsEnabled) 'Actual partial configuration retains one verified change and explicit nonzero failure.' + $partialJournal=@(Get-Content "$root/partial-journal/before.jsonl"|ForEach-Object {$_|ConvertFrom-Json}) + Assert ($partialJournal.Count -eq 1 -and $partialJournal[0].Target.Channel -ceq $capi -and (Test-WelaNativeChannelSnapshotEqual $partialJournal[0].Before $partialBefore)) 'Partial run journals only its actual selected write.' + Stable-Selected + Preserved + Save 'completed.json' @{Status='Passed';Assertions=$count;ActualAppliedControls=5;IdempotentControls=4;ManualRefusals=3;MissingServiceRefusals=1;ReadyRuleCredit=0} +}catch{$primary=$_} +finally { + foreach($channel in $mutated){ + try { + $s=$before[$channel];$retention=if($s.LogMode -ceq 'Circular'){'false'}else{'true'};$backup=if($s.LogMode -ceq 'AutoBackup'){'true'}else{'false'} + $null=Invoke-WelaNative wevtutil.exe @('sl',$channel,('/e:'+$s.IsEnabled.ToString().ToLowerInvariant()),('/ms:'+$s.MaximumSizeInBytes),('/ca:'+$s.SecurityDescriptor),('/rt:'+$retention),('/ab:'+$backup)) + if(-not(Test-WelaNativeChannelSnapshotEqual $s (Get-WelaNativeChannel $channel)) -or (Read-Raw $channel).OuterXml -cne $raw[$channel].OuterXml){throw 'Exact original channel configuration differs after cleanup.'} + }catch{$errors+="$channel : $($_.Exception.Message)"} + } + $after=@{};$afterRaw=@{};foreach($channel in $channels){try{$after[$channel]=Get-WelaNativeChannel $channel;if($raw.ContainsKey($channel)){$afterRaw[$channel]=(Read-Raw $channel).OuterXml;if($afterRaw[$channel] -cne $raw[$channel].OuterXml){throw 'Original channel XML differs'}}elseif((Key $after[$channel]) -cne (Key $before[$channel])){throw 'Original unavailable observation differs'}}catch{$errors+="$channel : $($_.Exception.Message)"}} + $serviceAfter=$null;try{$serviceAfter=@(Services);if((Key $serviceAfter) -cne (Key $services)){throw 'Service state/start type differs'}}catch{$errors+=$_.Exception.Message} + $maskAfter=$null;try{$maskAfter=Get-WelaEffectiveAuditPolicy;if($maskAfter.Count -ne $policies.Count){throw 'Audit mask count differs'};foreach($guid in $policies.Keys){if($maskAfter[$guid] -ne $policies[$guid]){throw "Audit mask differs: $guid"}}}catch{$errors+=$_.Exception.Message} + Save 'cleanup.json' @{CleanupVerified=($errors.Count -eq 0);Original=$before;After=$after;AfterRawXml=$afterRaw;ServicesBefore=$services;ServicesAfter=$serviceAfter;AuditMasksCompared=$policies.Count;AuditMasksAfter=$maskAfter;Errors=$errors;PrimaryError=[string]$primary;Assertions=$count} +} +$artifacts=@(Get-ChildItem -LiteralPath $root -File -Recurse|ForEach-Object {[ordered]@{Path=$_.FullName.Substring($root.Length+1);Sha256=(Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash}}) +$sourcePaths=@('WELA.ps1','scripts/Configuration.ps1','scripts/NativeChannelConfiguration.ps1','scripts/NativeProviderPacks.ps1','modules/AuditProfiles.psm1','modules/EventLogSettings.psm1','modules/NativeProviders.psm1','modules/NativeChannelAccess.psm1','config/native_channel_profile.json','config/native_provider_packs.json','config/security_rules.json','tests/NativeProviderConfigure.Windows.Tests.ps1')+@($catalog.ruleReviews|ForEach-Object {'config/'+$_.localPath}) +$sources=@($sourcePaths|ForEach-Object {[ordered]@{Path=$_;Sha256=(Get-FileHash -LiteralPath (Join-Path $repo $_) -Algorithm SHA256).Hash}}) +Save 'manifest.json' @{Status=$(if($primary -or $errors.Count){'Failed'}else{'Passed'});Commit=$env:GITHUB_SHA;Engine=$PSVersionTable.PSVersion.ToString();Assertions=$count;Artifacts=$artifacts;Sources=$sources;EventGenerationVerified=$false;ForwardingVerified=$false;ReadyRuleCredit=0} +if($errors.Count){throw "Fixture cleanup failed: $($errors -join '; '); primary=$primary"};if($primary){throw $primary} +Write-Host "PASS: $count native public provider-pack assertions and exact channel/service/audit cleanup. No event generation or Sigma proof." +exit 0 diff --git a/tests/NativeProviderPacks.Tests.ps1 b/tests/NativeProviderPacks.Tests.ps1 index 84cc23c7..6e51e5fd 100644 --- a/tests/NativeProviderPacks.Tests.ps1 +++ b/tests/NativeProviderPacks.Tests.ps1 @@ -43,6 +43,10 @@ function Get-WinEvent { $channel=if($f.TemplateMode -eq 'wrongchannel'){'Other/Operational'}else{$p.channel} $events+= [pscustomobject]@{Id=$e.id;Version=0;LogLink=[pscustomobject]@{LogName=$channel};Template=$template} } + if($f.LargeIds){ + if($f.LargeOnly){$events=@()} + foreach($large in @([long]3221734403,[long]4294967295)){$events+=[pscustomobject]@{Id=$large;Version=0;LogLink=[pscustomobject]@{LogName=$p.channel};Template='unselected template is never parsed'}} + } [pscustomobject]@{Name=$ListProvider;Id='11111111-1111-1111-1111-111111111111';LogLinks=@([pscustomobject]@{LogName=$p.channel});Events=$events} } function Read-Host {param($Prompt) if($f.PromptSchemaDrift){$f.TemplateMode='missing'};$f.Prompt} @@ -95,6 +99,11 @@ try { Assert (@($entry.RuleReviews|Where-Object Eligibility -ne 'Conditional').Count -eq 0 -and $report.ReadyRules -eq 0) 'Provider settings never convert incomplete rule evidence into Ready.' Assert ($entry.ProviderEvidence.Schema.Events[0].Fields[0].InType -eq 'win:UnicodeString' -and $entry.ProviderEvidence.Schema.Events[0].TemplateSha256.Length -eq 64) 'Report retains runtime version, native field types and template fingerprint.' Assert ($f.Writes.Count -eq 0 -and -not(Test-Path $backup)) 'Read-only plan creates no journal and makes no channel changes.' + Reset;$f.LargeIds=$true;$r=Invoke-WelaProviderPackCommand -Action Plan -Names winrm + Assert ($r.ExitCode -eq 0 -and $r.ControlsPlan[0].ProviderEvidence.CanConfigure) 'Actual WinRM Int64 event IDs above Int32 do not invalidate unrelated selected event6.' + Assert ($r.ControlsPlan[0].ProviderEvidence.Schema.Events.Count -eq 1 -and $r.ControlsPlan[0].ProviderEvidence.Schema.Events[0].Id -eq 6) 'Only the exact reviewed event6 enters schema evidence; large unselected IDs/templates are excluded.' + Reset;$f.LargeIds=$true;$f.LargeOnly=$true;$r=Invoke-WelaProviderPackCommand -Action Configure -Names winrm -Auto -BackupPath $backup + Assert ($r.ExitCode -eq 1 -and -not $r.ControlsPlan[0].ProviderEvidence.CanConfigure -and $f.Writes.Count -eq 0) 'Unrelated large native IDs cannot substitute for a missing selected event6.' Reset;$f.States['Microsoft-Windows-DNS-Client/Operational'].State='Not installed';$f.States['Microsoft-Windows-DNS-Client/Operational'].IsEnabled=$null $r=Invoke-WelaProviderPackCommand -Action Configure -Names dns-client -Auto -BackupPath $backup Assert ($r.ExitCode -eq 1 -and $f.Writes.Count -eq 0) 'Missing actual channel metadata cannot be replaced by provider-manifest availability.' diff --git a/tests/OutgoingNtlmAudit.Cli.Tests.ps1 b/tests/OutgoingNtlmAudit.Cli.Tests.ps1 new file mode 100644 index 00000000..c8195578 --- /dev/null +++ b/tests/OutgoingNtlmAudit.Cli.Tests.ps1 @@ -0,0 +1,18 @@ +$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent;$engine=(Get-Process -Id $PID).Path;$count=0 +$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-ntlm-cli-'+[guid]::NewGuid().ToString('N')) +$cases=@( + @{Args=@('outgoing-ntlm','-Help');Code=0;Pattern='Changes only'}, + @{Args=@('configure','-NtlmAction','Configure');Code=1;Pattern='requires outgoing-ntlm'}, + @{Args=@('outgoing-ntlm','-OutgoingNtlmMode','Deny');Code=1;Pattern='enforcement is not accepted'}, + @{Args=@('outgoing-ntlm','-Role','Client');Code=1;Pattern='dedicated options'}, + @{Args=@('outgoing-ntlm','-Profile','wela-2.2.0');Code=1;Pattern='dedicated options'}, + @{Args=@('outgoing-ntlm','-Auto');Code=1;Pattern='require NtlmAction Configure'}, + @{Args=@('outgoing-ntlm','-DryRun');Code=1;Pattern='require NtlmAction Configure'}, + @{Args=@('outgoing-ntlm','-BackupPath',$root);Code=1;Pattern='require NtlmAction Configure'}, + @{Args=@('outgoing-ntlm','-Help','-ProviderAction','Configure');Code=1;Pattern='dedicated options'}, + @{Args=@('outgoing-ntlm','-NtlmAction','Configure','-Typo');Code=1;Pattern='Unsupported trailing arguments'} +) +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 created unexpected output.'} +Write-Host "PASS: $count scoped outgoing NTLM CLI guards." +exit 0 diff --git a/tests/OutgoingNtlmAudit.Tests.ps1 b/tests/OutgoingNtlmAudit.Tests.ps1 new file mode 100644 index 00000000..95c5c577 --- /dev/null +++ b/tests/OutgoingNtlmAudit.Tests.ps1 @@ -0,0 +1,58 @@ +$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent +. (Join-Path $repo 'scripts/Configuration.ps1') +. (Join-Path $repo 'scripts/OutgoingNtlmAudit.ps1') +$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-ntlm-test-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root +$count=0;$sequence=0 +function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} +function Key($Value){ConvertTo-Json -InputObject $Value -Depth 12 -Compress} +function Reset($Value,$Type='DWord'){ + $script:policy=[pscustomobject][ordered]@{KeyExists=$true;ValueExists=($null -ne $Value);Value=$Value;Type=$(if($null -ne $Value){$Type}else{$null})} + $script:writes=0;$script:reads=0;$script:failRead=$false;$script:failWrite=$false;$script:ignoreWrite=$false;$script:promptChange=$null;$script:onRead=$null +} +function Get-WelaOutgoingAuditSnapshot { + $script:reads++;if($script:onRead){& $script:onRead};if($script:failRead){throw 'Access denied'} + [pscustomobject][ordered]@{Host=[pscustomobject][ordered]@{Build=26100;ProductType=3;DomainRole=2;PartOfDomain=$false};Policy=($script:policy|ConvertTo-Json|ConvertFrom-Json)} +} +function Get-WelaOutgoingNtlmPolicySource {'Unknown (fixture has no RSoP ownership evidence)'} +function Set-ItemProperty {param($LiteralPath,$Name,$Value,$Type,$ErrorAction) + Assert ($LiteralPath -ceq 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0' -and $Name -ceq 'RestrictSendingNTLMTraffic' -and $Value -eq 1 -and $Type -ceq 'DWord') 'Only the one exact audit-only target may be written.' + $script:writes++;if($script:failWrite){throw 'Write denied'};if(-not $script:ignoreWrite){$script:policy.ValueExists=$true;$script:policy.Value=1;$script:policy.Type='DWord'} +} +function Read-Host {param($Prompt) if($script:promptChange){& $script:promptChange};return 'Y'} +function Configure([string]$Mode='PreserveOrAudit',[switch]$DryRun,[switch]$Prompt){ + $script:sequence++;$script:backup=Join-Path $root ('case-'+$script:sequence) + Invoke-WelaOutgoingAuditCommand -Action Configure -Mode $Mode -Auto:(-not $Prompt) -DryRun:$DryRun -BackupPath $script:backup +} +try{ + foreach($initial in @($null,0,1)){ + Reset $initial;$old=Key $script:policy;$r=Configure + Assert ($r.ExitCode -eq 0 -and $r.Scope -ceq 'outgoing-ntlm-audit-policy-only' -and $r.ReadyRuleCredit -eq 0) 'Public report scopes success to one policy, without detection credit.' + Assert ($script:policy.Value -eq 1 -and $script:writes -eq $(if($initial -eq 1){0}else{1})) 'Absent/allow are audited; existing audit is idempotent.' + if($initial -ne 1){$j=@(Get-Content (Join-Path $backup 'before.jsonl')|ConvertFrom-Json);Assert ($j.Count -eq 1 -and (Key $j[0].Before.Policy) -ceq $old) 'Typed original snapshot is durable before the one write.'} + else{Assert (-not(Test-Path (Join-Path $backup 'before.jsonl'))) 'Already configured mode does not journal a write.'} + } + Reset 2;$r=Configure;Assert ($script:writes -eq 0 -and $r.Results[0].Status -ceq 'Skipped' -and $r.Plan.Status -ceq 'PreservedEnforcement' -and $script:policy.Value -eq 2) 'Default mode preserves and identifies authentication enforcement.' + Reset 2;$r=Configure Audit;Assert ($script:writes -eq 1 -and $script:policy.Value -eq 1 -and $r.Results[0].Status -ceq 'Applied') 'Explicit audit mode authorizes replacing deny with auditing.' + foreach($value in @(42,'1')){foreach($mode in @('PreserveOrAudit','Audit')){ + Reset $value $(if($value -is [string]){'String'}else{'DWord'});$r=Configure $mode + Assert ($r.ExitCode -eq 1 -and $script:writes -eq 0 -and $r.Results[0].Status -ceq 'Failed') 'Unknown values/types remain untouched even in explicit Audit mode.' + }} + Reset 0;$r=Configure -DryRun;Assert ($script:writes -eq 0 -and $r.DryRun -and -not(Test-Path $backup)) 'Dry run has no policy or journal-directory mutation.' + Reset 0;$script:failRead=$true;$r=Configure;Assert ($r.ExitCode -eq 1 -and $script:writes -eq 0) 'An unreadable policy fails closed.' + foreach($kind in @('failWrite','ignoreWrite')){ + Reset 0;Set-Variable -Scope Script -Name $kind -Value $true;$r=Configure + Assert ($r.ExitCode -eq 1 -and $r.Results[0].Status -ceq 'Failed') 'Native failure and ignored-write readback cannot report success.' + } + foreach($changed in @(1,2,42)){ + Reset 0;$script:changed=$changed;$script:promptChange={$script:policy.Value=$script:changed};$r=Configure -Prompt + Assert ($r.ExitCode -eq 1 -and $script:writes -eq 0 -and $script:policy.Value -eq $changed) 'Prompt-time drift refuses writes after preserving the exact original receipt.' + } + Reset 0;$script:onRead={if($script:reads -eq 5){$script:policy.Value=0}};$r=Configure + Assert ($script:writes -eq 1 -and $r.ExitCode -eq 1 -and $r.Results[0].Status -ceq 'Overridden') 'A later policy change fails final verification.' + Reset 0;$r=Invoke-WelaOutgoingAuditCommand -Action Plan;Assert ($r.Plan.Status -ceq 'ChangeRequired' -and $script:writes -eq 0) 'Plan is current-host assessment and does not mutate policy.' + foreach($action in @('Audit','Plan')){foreach($option in @('Auto','DryRun','BackupPath')){ + $a=@{Action=$action};$a[$option]=$(if($option -eq 'BackupPath'){'unused'}else{$true});$threw=$false;try{Invoke-WelaOutgoingAuditCommand @a}catch{$threw=$true};Assert $threw 'Read-only actions reject mutation-only options.' + }} +}finally{Remove-Item -LiteralPath $root -Recurse -Force} +Write-Host "PASS: $count scoped outgoing NTLM assertions." +exit 0 diff --git a/tests/OutgoingNtlmAudit.Windows.Tests.ps1 b/tests/OutgoingNtlmAudit.Windows.Tests.ps1 new file mode 100644 index 00000000..f544ce04 --- /dev/null +++ b/tests/OutgoingNtlmAudit.Windows.Tests.ps1 @@ -0,0 +1,76 @@ +param([switch]$AllowDisposableAuditWrite) +$ErrorActionPreference='Stop' +if(-not $AllowDisposableAuditWrite -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 +. (Join-Path $repo 'scripts/Configuration.ps1') +. (Join-Path $repo 'scripts/OutgoingNtlmAudit.ps1') +Import-Module (Join-Path $repo 'modules/AuditProfiles.psm1') -Force +Import-Module (Join-Path $repo 'modules/NativeProviders.psm1') -Force +$engine=(Get-Process -Id $PID).Path;$count=0;$failure=$null;$errors=@() +$root=Join-Path $env:RUNNER_TEMP ('wela-outgoing-audit-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root +$path='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0';$name='RestrictSendingNTLMTraffic' +function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} +function Key($Value){ConvertTo-Json -InputObject $Value -Depth 24 -Compress} +function Save($Name,$Value){ConvertTo-Json -InputObject $Value -Depth 24|Set-Content -LiteralPath (Join-Path $root $Name) -Encoding UTF8} +function Masks {$m=Get-WelaEffectiveAuditPolicy;@($m.Keys|Sort-Object|ForEach-Object{"$_=$($m[$_])"}) -join ';'} +function Other { + $base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64);$k=$null + try{ + $k=$base.OpenSubKey('SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0');if(-not $k){throw 'Existing MSV1_0 key required.'} + $values=@($k.GetValueNames()|Sort-Object|Where-Object {$_ -ine $name}|ForEach-Object{[pscustomobject][ordered]@{Name=$_;Type=$k.GetValueKind($_).ToString();Value=$k.GetValue($_,$null,[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)}}) + $children=@($k.GetSubKeyNames()|Sort-Object) + $security=if($PSVersionTable.PSVersion.Major -ge 6){[Microsoft.Win32.RegistryAclExtensions]::GetAccessControl($k)}else{$k.GetAccessControl()} + $acl=$security.GetSecurityDescriptorSddlForm([Security.AccessControl.AccessControlSections]::Access -bor [Security.AccessControl.AccessControlSections]::Owner -bor [Security.AccessControl.AccessControlSections]::Group) + }finally{if($k){$k.Dispose()};$base.Dispose()} + [pscustomobject][ordered]@{Values=$values;Children=$children;Access=$acl;DomainPolicy=Get-WelaRegistryState 'HKLM:\SYSTEM\CurrentControlSet\Services\Netlogon\Parameters' AuditNTLMInDomain;NtlmChannel=Get-WelaNativeChannel 'Microsoft-Windows-NTLM/Operational';SecurityChannel=Get-WelaNativeChannel Security;NetlogonService=[string](Get-Service Netlogon).Status} +} +function Public([string]$Label,[string[]]$Arguments){ + $prior=$ErrorActionPreference + try{$ErrorActionPreference='Continue';$output=& $engine -NoLogo -NoProfile -NonInteractive -File (Join-Path $repo 'WELA.ps1') outgoing-ntlm @Arguments 2>&1|Out-String;$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior} + $output|Set-Content -LiteralPath (Join-Path $root ($Label+'.txt')) -Encoding UTF8 + Assert ($code -eq 0) "Public $Label exited $code : $output" + Get-Content -Raw -LiteralPath (Join-Path $root ($Label+'.json'))|ConvertFrom-Json +} +$original=Get-WelaOutgoingAuditSnapshot +Assert ($original.Host.ProductType -eq 3 -and $original.Host.DomainRole -eq 2 -and -not $original.Host.PartOfDomain) 'Actual unjoined disposable Server is required.' +Assert (-not $original.Policy.ValueExists -or ($original.Policy.Type -ceq 'DWord' -and $original.Policy.Value -in @(0,1))) 'Fixture never replaces pre-existing enforcement or an unknown policy.' +$other=Other;$masks=Masks +Save 'original.json' @{Snapshot=$original;Unselected=$other;Masks=$masks;UBR=(Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion').UBR;Engine=$PSVersionTable.PSVersion.ToString();Commit=$env:GITHUB_SHA} +try{ + foreach($case in @('absent','allow')){ + if((Get-WelaRegistryState $path $name).ValueExists){Remove-ItemProperty -LiteralPath $path -Name $name -ErrorAction Stop} + if($case -eq 'allow'){$null=New-ItemProperty -LiteralPath $path -Name $name -PropertyType DWord -Value 0} + $prepared=Get-WelaOutgoingAuditSnapshot + $plan=Public ($case+'-plan') @('-NtlmAction','Plan','-ResultsPath',(Join-Path $root ($case+'-plan.json'))) + Assert ($plan.Plan.Status -ceq 'ChangeRequired' -and (Key $plan.Plan.Before) -ceq (Key $prepared)) 'Public plan retains the exact native absence/allow state and actual host.' + $dryBackup=Join-Path $root ($case+'-dry-backup') + $dry=Public ($case+'-dry') @('-NtlmAction','Configure','-Auto','-DryRun','-BackupPath',$dryBackup,'-ResultsPath',(Join-Path $root ($case+'-dry.json'))) + Assert ($dry.DryRun -and $dry.Results[0].Status -ceq 'Skipped' -and -not(Test-Path $dryBackup) -and (Key (Get-WelaOutgoingAuditSnapshot)) -ceq (Key $prepared)) 'Dry run preserves policy and creates no journal directory.' + $backup=Join-Path $root ($case+'-backup') + $report=Public $case @('-NtlmAction','Configure','-Auto','-BackupPath',$backup,'-ResultsPath',(Join-Path $root ($case+'.json'))) + $after=Get-WelaOutgoingAuditSnapshot + Assert ($report.Scope -ceq 'outgoing-ntlm-audit-policy-only' -and $report.Results.Count -eq 1 -and $report.Results[0].Status -ceq 'Applied') 'Exactly one native outgoing policy is applied through public CLI.' + Assert ($after.Policy.Type -ceq 'DWord' -and $after.Policy.Value -eq 1 -and (Key $report.Results[0].After) -ceq (Key $after)) 'Native audit-only readback matches the public result.' + $journal=@(Get-Content (Join-Path $backup 'before.jsonl')|ConvertFrom-Json) + Assert ($journal.Count -eq 1 -and (Key $journal[0].Before) -ceq (Key $prepared) -and $journal[0].Target.Path -ceq $path -and $journal[0].Target.Name -ceq $name) 'One original journal retains the actual typed policy and native context.' + $repeatBackup=Join-Path $root ($case+'-repeat-backup') + $repeat=Public ($case+'-repeat') @('-NtlmAction','Configure','-Auto','-BackupPath',$repeatBackup,'-ResultsPath',(Join-Path $root ($case+'-repeat.json'))) + Assert ($repeat.Results[0].Status -ceq 'AlreadyCompliant' -and -not(Test-Path (Join-Path $repeatBackup 'before.jsonl'))) 'Repeated configuration is idempotent without another original journal.' + $audit=Public ($case+'-audit') @('-NtlmAction','Audit','-ResultsPath',(Join-Path $root ($case+'-audit.json'))) + Assert ($audit.Plan.Status -ceq 'AlreadyCompliant' -and $audit.ReadyRuleCredit -eq 0 -and $audit.EventGeneration -like 'Not verified*') 'Audit distinguishes registry compliance from event or authentication proof.' + Assert ((Key (Other)) -ceq (Key $other) -and (Masks) -ceq $masks) 'Incoming/domain policies, siblings, access descriptor, channels, service and all59 masks remain unchanged.' + } + Save 'completed.json' @{Status='Passed';Assertions=$count;NativeWrites=2;Scope='Only outgoing audit DWORD1. No network authentication attempt, enforcement, event generation, GPO refresh or Sigma proof.'} +}catch{$failure=$_.ToString();throw}finally{ + try{ + if((Get-WelaRegistryState $path $name).ValueExists){Remove-ItemProperty -LiteralPath $path -Name $name -ErrorAction Stop} + if($original.Policy.ValueExists){$null=New-ItemProperty -LiteralPath $path -Name $name -Value $original.Policy.Value -PropertyType $original.Policy.Type} + }catch{$errors+=$_.ToString()} + $checks=[ordered]@{} + foreach($pair in @(@('Policy',{(Key (Get-WelaOutgoingAuditSnapshot)) -ceq (Key $original)}),@('Unselected',{(Key (Other)) -ceq (Key $other)}),@('All59Masks',{(Masks) -ceq $masks}))){try{$checks[$pair[0]]=& $pair[1]}catch{$checks[$pair[0]]=$false;$errors+=$_.ToString()}} + $complete=$errors.Count -eq 0 -and @($checks.Values|Where-Object {-not $_}).Count -eq 0 + Save 'cleanup.json' @{Complete=$complete;Checks=$checks;Errors=$errors;Failure=$failure;Assertions=$count} + if(-not $complete){throw 'Outgoing NTLM native fixture cleanup failed.'} +} +Write-Host "PASS: $count native public outgoing NTLM assertions and exact cleanup." +exit 0 diff --git a/tests/SmbPolicyConfigure.Windows.Tests.ps1 b/tests/SmbPolicyConfigure.Windows.Tests.ps1 new file mode 100644 index 00000000..60c4cae7 --- /dev/null +++ b/tests/SmbPolicyConfigure.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 +. (Join-Path $repo 'scripts/Configuration.ps1') +. (Join-Path $repo 'scripts/SmbAuditing.ps1') +Import-Module (Join-Path $repo 'modules/AuditProfiles.psm1') -Force +$os=Get-CimInstance Win32_OperatingSystem;$computer=Get-CimInstance Win32_ComputerSystem +if($os.ProductType -ne 3 -or [int]$os.BuildNumber -notin @(20348,26100) -or $computer.DomainRole -ne 2 -or $computer.PartOfDomain){throw 'An unjoined disposable Server 2022/2025 is required.'} +$root=Join-Path $env:RUNNER_TEMP ('wela-smb-policy-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root +$engine=(Get-Process -Id $PID).Path;$definitions=@(Get-WelaSmbAuditDefinitions);$count=0;$failure=$null;$errors=@() +function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} +function Key($Value){ConvertTo-Json -InputObject $Value -Depth 24 -Compress} +function Save($Name,$Value){ConvertTo-Json -InputObject $Value -Depth 24|Set-Content -LiteralPath (Join-Path $root $Name) -Encoding UTF8} +function Masks { $m=Get-WelaEffectiveAuditPolicy;@($m.Keys|Sort-Object|ForEach-Object{"$_=$($m[$_])"}) -join ';' } +function Runtime { + foreach($side in @('Server','Client')){ + $cmd="Get-Smb${side}Configuration";$c=& $cmd -ErrorAction Stop + [pscustomobject][ordered]@{Side=$side;Properties=@($c.CimInstanceProperties|Sort-Object Name|ForEach-Object{[pscustomobject][ordered]@{Name=$_.Name;Type=$_.CimType.ToString();Value=$_.Value}})} + } +} +function UnselectedRuntime($Snapshot) { + foreach($side in $Snapshot){ + $component=if($side.Side -eq 'Server'){'LanmanServer'}else{'LanmanWorkstation'} + $selected=@($definitions|Where-Object Component -eq $component|ForEach-Object Name) + [pscustomobject][ordered]@{Side=$side.Side;Properties=@($side.Properties|Where-Object Name -NotIn $selected)} + } +} +function Policies {foreach($d in $definitions){[pscustomobject]@{Definition=$d;Policy=Get-WelaRegistryState $d.Path $d.Name}}} +function Keys { + foreach($component in @('LanmanServer','LanmanWorkstation')){ + $base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64);$k=$null + try{ + $k=$base.OpenSubKey("SOFTWARE\Policies\Microsoft\Windows\$component") + if(-not $k){[pscustomobject][ordered]@{Component=$component;Exists=$false;Values=@();Children=@();Access=$null};continue} + $acl=if($PSVersionTable.PSVersion.Major -ge 6){[Microsoft.Win32.RegistryAclExtensions]::GetAccessControl($k)}else{$k.GetAccessControl()} + [pscustomobject][ordered]@{Component=$component;Exists=$true;Values=@($k.GetValueNames()|Sort-Object|ForEach-Object{[pscustomobject][ordered]@{Name=$_;Type=$k.GetValueKind($_).ToString();Value=$k.GetValue($_,$null,[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)}});Children=@($k.GetSubKeyNames()|Sort-Object);Access=$acl.GetSecurityDescriptorSddlForm([Security.AccessControl.AccessControlSections]::Access -bor [Security.AccessControl.AccessControlSections]::Owner -bor [Security.AccessControl.AccessControlSections]::Group)} + }finally{if($k){$k.Dispose()};$base.Dispose()} + } +} +function OtherKeys { + $all=@(Keys) + foreach($k in $all){$names=@($definitions|Where-Object Component -eq $k.Component|ForEach-Object Name);$k.Values=@($k.Values|Where-Object Name -NotIn $names)} + return $all +} +function Public([string]$Name,[string[]]$Arguments,[int]$Expected=0){ + $prior=$ErrorActionPreference + try{$ErrorActionPreference='Continue';$output=& $engine -NoLogo -NoProfile -NonInteractive -File (Join-Path $repo 'WELA.ps1') smb-auditing @Arguments 2>&1|Out-String;$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior} + $output|Set-Content -LiteralPath (Join-Path $root ($Name+'.txt')) -Encoding UTF8 + Assert ($code -eq $Expected) "Public $Name exited $code : $output" + Get-Content -Raw -LiteralPath (Join-Path $root ($Name+'.json'))|ConvertFrom-Json +} +$before=@(Policies);$keys=@(Keys);$runtime=@(Runtime);$masks=Masks +$services=@(Get-Service LanmanServer,LanmanWorkstation|Sort-Object Name|Select-Object Name,Status) +Save 'original.json' @{Policies=$before;Keys=$keys;Runtime=$runtime;Masks=$masks;Services=$services;Build=[int]$os.BuildNumber;UBR=(Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion').UBR;Engine=$PSVersionTable.PSVersion.ToString();PartOfDomain=$computer.PartOfDomain;DomainRole=$computer.DomainRole} +try{ + $initial=@(Get-WelaSmbAuditPlan) + if([int]$os.BuildNumber -eq 20348){Assert (@($initial|Where-Object Status -ne NotApplicable).Count -eq 0) 'All six policies are genuinely not applicable on Server 2022.'} + else{ + Assert (@($initial|Where-Object {$_.Status -notin @('ChangeRequired','PolicyConfigured')}).Count -eq 0) 'All six policies require exact local ADMX and readable native runtime before fixture writes.' + foreach($d in $definitions){New-WelaRegistryKey $d.Path;$null=New-ItemProperty -LiteralPath $d.Path -Name $d.Name -Value 0 -PropertyType DWord -Force} + } + $prepared=@(Policies);$other=@(OtherKeys);$preparedRuntime=@(Runtime);Save 'prepared.json' $prepared;Save 'prepared-runtime.json' $preparedRuntime + $plan=Public plan @('-SmbAction','Plan','-ResultsPath',(Join-Path $root 'plan.json')) + Assert ($plan.Controls.Count -eq 6) 'Public Plan accounts for exactly six controls.' + $dry=Public dry @('-SmbAction','Configure','-DryRun','-BackupPath',(Join-Path $root 'dry-backup'),'-ResultsPath',(Join-Path $root 'dry.json')) + Assert ($dry.DryRun -and @($dry.Results|Where-Object Status -eq Applied).Count -eq 0 -and -not(Test-Path (Join-Path $root 'dry-backup'))) 'Dry run does not change policy or create original journals.' + Assert ((Key @(Policies)) -ceq (Key $prepared) -and (Key @(Runtime)) -ceq (Key $preparedRuntime)) 'Plan and DryRun preserve exact typed policy and full native runtime.' + $applied=Public apply @('-SmbAction','Configure','-Auto','-BackupPath',(Join-Path $root 'apply-backup'),'-ResultsPath',(Join-Path $root 'apply.json')) + Assert ($applied.Scope -ceq 'smb-audit-policies-only' -and $applied.Results.Count -eq 6) 'Public Configure retains narrow scope and all six outcomes.' + if([int]$os.BuildNumber -eq 20348){ + Assert (@($applied.Results|Where-Object Status -ne Skipped).Count -eq 0 -and -not(Test-Path (Join-Path $root 'apply-backup/before.jsonl'))) 'Unsupported Server 2022 has six skipped controls and no policy writes.' + }else{ + Assert (@($applied.Results|Where-Object Status -ne Applied).Count -eq 0) 'Server 2025 actually applied all six policy DWORDs.' + $journal=@(Get-Content (Join-Path $root 'apply-backup/before.jsonl')|ConvertFrom-Json);Assert ($journal.Count -eq 6) 'Every actual write has an original journal entry.' + foreach($row in $applied.Results){ + $j=@($journal|Where-Object Id -eq $row.Id);$p=@($prepared|Where-Object {$_.Definition.Path -ceq $row.Target.Path -and $_.Definition.Name -ceq $row.Target.Name}) + Assert ($j.Count -eq 1 -and $p.Count -eq 1 -and (Key $j[0].Before.Policy) -ceq (Key $p[0].Policy)) 'Each journal matches the actual typed original policy.' + Assert ($row.After.Policy.Type -ceq 'DWord' -and $row.After.Policy.Value -eq 1 -and $row.After.PolicyRegistryConfigured) 'Actual native readback verifies each DWORD without inferring runtime state.' + } + $repeat=Public repeat @('-SmbAction','Configure','-Auto','-BackupPath',(Join-Path $root 'repeat-backup'),'-ResultsPath',(Join-Path $root 'repeat.json')) + Assert (@($repeat.Results|Where-Object Status -ne AlreadyCompliant).Count -eq 0 -and -not(Test-Path (Join-Path $root 'repeat-backup/before.jsonl'))) 'Repeated public Configure is idempotent without another journal.' + } + $afterRuntime=@(Runtime);$afterOther=@(OtherKeys);Save 'after-runtime.json' $afterRuntime;Save 'after-other-keys.json' $afterOther;Save 'prepared-other-keys.json' $other + Assert ((Key $afterOther) -ceq (Key $other)) 'Sibling values, access descriptors and child keys are preserved.' + Assert ((Key @(UnselectedRuntime $afterRuntime)) -ceq (Key @(UnselectedRuntime $runtime))) 'Every unrelated native SMB runtime property is preserved.' + Assert ((Masks) -ceq $masks) 'All59 audit masks are preserved.' + if([int]$os.BuildNumber -eq 26100){ + foreach($row in $applied.Results){ + $side=if($row.Target.Path -like '*LanmanServer'){'Server'}else{'Client'} + $observed=@(($afterRuntime|Where-Object Side -eq $side).Properties|Where-Object Name -eq $row.Target.Name) + Assert ($observed.Count -eq 1 -and $observed[0].Type -ceq 'Boolean' -and $row.After.Runtime.Value -ceq $observed[0].Value) 'Reported audit runtime observation matches a separate native getter; activation is observed, not assumed.' + } + } + Save 'completed.json' @{Status='Passed';Assertions=$count;ActualPolicyWrites=$(if([int]$os.BuildNumber -eq 26100){6}else{0});Scope='Policy registry only; no SMB traffic, activation, GPO refresh, event generation or Sigma proof.'} +}catch{$failure=$_.ToString();throw}finally{ + foreach($row in $before){try{ + $d=$row.Definition;$old=$row.Policy;$now=Get-WelaRegistryState $d.Path $d.Name + if($old.ValueExists){$null=New-ItemProperty -LiteralPath $d.Path -Name $d.Name -Value $old.Value -PropertyType $old.Type -Force} + elseif($now.ValueExists){Remove-ItemProperty -LiteralPath $d.Path -Name $d.Name -ErrorAction Stop} + }catch{$errors+=$_.ToString()}} + foreach($k in $keys|Where-Object {-not $_.Exists}){try{ + $path="HKLM:\SOFTWARE\Policies\Microsoft\Windows\$($k.Component)" + if(Test-Path -LiteralPath $path){$item=Get-Item -LiteralPath $path;if($item.ValueCount -ne 0 -or $item.SubKeyCount -ne 0){throw 'A fixture-created key is not empty; it was preserved.'};Remove-Item -LiteralPath $path -ErrorAction Stop} + }catch{$errors+=$_.ToString()}} + $checks=[ordered]@{} + foreach($pair in @(@('Policies',{(Key @(Policies)) -ceq (Key $before)}),@('Keys',{(Key @(Keys)) -ceq (Key $keys)}),@('Runtime',{(Key @(Runtime)) -ceq (Key $runtime)}),@('AuditMasks',{(Masks) -ceq $masks}),@('Services',{(Key @(Get-Service LanmanServer,LanmanWorkstation|Sort-Object Name|Select-Object Name,Status)) -ceq (Key $services)}))){try{$checks[$pair[0]]=& $pair[1]}catch{$checks[$pair[0]]=$false;$errors+=$_.ToString()}} + $complete=$errors.Count -eq 0 -and @($checks.Values|Where-Object {-not $_}).Count -eq 0 + Save 'cleanup.json' @{Complete=$complete;Checks=$checks;Errors=$errors;Failure=$failure;Assertions=$count} + if(-not $complete){throw 'SMB native policy fixture cleanup failed; inspect retained receipts.'} +} +Write-Host "PASS: $count native public SMB policy assertions and exact cleanup." +exit 0 diff --git a/tests/WecCollectorObservation.Windows.Tests.ps1 b/tests/WecCollectorObservation.Windows.Tests.ps1 new file mode 100644 index 00000000..21ab6ff8 --- /dev/null +++ b/tests/WecCollectorObservation.Windows.Tests.ps1 @@ -0,0 +1,101 @@ +param([switch]$AllowDisposableSubscription) +$ErrorActionPreference='Stop' +if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not $AllowDisposableSubscription -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit disposable GitHub-hosted Windows subscription opt-in required.'} +$repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo +Import-Module "$repo/modules/WefSubscriptions.psm1" -Force +. "$repo/scripts/Configuration.ps1" +. "$repo/scripts/ChannelRead.ps1" +$count=0;$engine=(Get-Process -Id $PID).Path +function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} +function Key($Value){ConvertTo-Json -InputObject $Value -Depth 24 -Compress} +function Services {@(Get-CimInstance Win32_Service -Filter "Name='Wecsvc' OR Name='Winmgmt' OR Name='EventLog' OR Name='WinRM'"|Sort-Object Name|Select-Object Name,State,StartMode)} +function Channel {$c=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('ForwardedEvents');try{[pscustomobject]@{Name=$c.LogName;Enabled=$c.IsEnabled;Mode=[string]$c.LogMode;MaximumBytes=$c.MaximumSizeInBytes;Path=$c.LogFilePath;SecurityDescriptor=$c.SecurityDescriptor}}finally{$c.Dispose()}} +function Inventory {$ids=@(Get-WelaWecSubscriptionIds);if($ids.Count -gt 64){throw 'Disposable fixture inventory exceeds 64 entries.'};@($ids|ForEach-Object {[pscustomobject]@{Id=$_;Xml=Read-WelaWecSubscriptionXml $_}})} +$hostState=Get-WelaChannelReadHost +Assert ($hostState.Build -in @(20348,26100) -and $hostState.UBR -gt 0 -and $hostState.ProductType -eq 3 -and $hostState.DomainRole -eq 2 -and -not $hostState.DomainJoined) 'Actual patched standalone Server2022/2025 fixture; no invented domain identity' +$nonce=[guid]::NewGuid().ToString('N');$id='WELA-Observe-'+$nonce;$unicode=([string][char]0x65e5)+([string][char]0x672c) +$description='Owned observation '+$nonce+' '+$unicode;$sid='S-1-5-21-111111111-222222222-333333333-1234' +$root=Join-Path $env:RUNNER_TEMP ('wela-wec-observation-'+$nonce);$null=New-Item -ItemType Directory $root +function Save($Name,$Value){$text=ConvertTo-Json -InputObject $Value -Depth 30;[IO.File]::WriteAllText((Join-Path $root $Name),$text,[Text.UTF8Encoding]::new($false))} +$beforeServices=Services;$beforeChannel=Channel;$serviceKey='HKLM:\SYSTEM\CurrentControlSet\Services\Wecsvc';$beforeDelayed=Get-WelaRegistryState $serviceKey DelayedAutoStart +$original=$null;$created=$false;$failure=$null;$errors=@();$inventoryOk=$false;$servicesOk=$false;$channelOk=$false;$reports=@();$afterServices=$null;$afterChannel=$null;$afterDelayed=$null +$sources=[ordered]@{};foreach($p in @('WELA.ps1','scripts/WefDeployment.ps1','modules/WefSubscriptions.psm1','modules/WecSubscriptionInventory.cs','modules/WecSubscriptionXml.cs')){$sources[$p]=(Get-FileHash (Join-Path $repo $p)).Hash.ToLowerInvariant()} +Save 'before-fixture.json' @{Host=$hostState;Services=$beforeServices;Channel=$beforeChannel;DelayedAutoStart=$beforeDelayed;Sources=$sources} +$config=Get-Content "$repo/config/wef-examples/collector.json" -Raw|ConvertFrom-Json +# Existing Audit/Plan deliberately remain incomplete on this real standalone runner. +# The example collector identity is never resolved, contacted or asserted as local. +$config.SourceSids=@($sid);$config.SubscriptionFiles=@('requested.xml');$config.IngressRuleName='WELA-Absent-'+$nonce +$path=Join-Path $root 'requested.xml';$configPath=Join-Path $root 'collector.json';Save 'collector.json' $config +$query='' +$xml=@" +$idSourceInitiated$descriptionfalsehttp://schemas.microsoft.com/wbem/wsman/1/windows/EventLogNormalfalseHTTPEventsForwardedEvents +"@ +[IO.File]::WriteAllText($path,$xml,[Text.UTF8Encoding]::new($false)) +function Public([string]$Action,[string]$Name){ + $out=Join-Path $root ($Name+'.json');$prior=$ErrorActionPreference + try{$ErrorActionPreference='Continue';$text=@(&$engine -NoLogo -NoProfile -NonInteractive -File "$repo/WELA.ps1" wec-collector -WefAction $Action -WefConfigPath $configPath -ResultsPath $out 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior} + [IO.File]::WriteAllText((Join-Path $root ($Name+'.log')),($text -join "`n"),[Text.UTF8Encoding]::new($false)) + Assert ($code -eq 1 -and (Test-Path $out)) 'Public collector reports incomplete real standalone prerequisites with exit1' + $r=Get-Content -LiteralPath $out -Raw -Encoding UTF8|ConvertFrom-Json + Assert ($r.Action -ceq $Action -and $r.Role -ceq 'Collector' -and $r.LocalConfigurationStatus -ceq 'Incomplete' -and -not $r.HostIdentity.DomainJoined) 'Public report preserves actual role and incomplete domain prerequisites' + Assert ($r.Subscriptions.Count -eq 1 -and $r.Subscriptions[0].Id -ceq $id -and $r.Subscriptions[0].EventArrival -ceq 'Not tested' -and $r.Subscriptions[0].ForwardedSigmaCoverage -ceq 'Not assessed' -and $r.Subscriptions[0].ChannelObservationLocation -like 'Collector only*') 'No source authentication, remote channel or forwarded coverage claim' + $script:reports+=($Name+'.json');$r +} +function SubscriptionControl($Report){@($Report.Controls|Where-Object Kind -eq Subscription)[0]} +try { + $wec=@($beforeServices|Where-Object Name -eq Wecsvc);Assert ($wec.Count -eq 1 -and $wec[0].State -in @('Running','Stopped') -and $wec[0].StartMode -in @('Auto','Manual','Disabled')) 'Stable original collector service state required' + if($wec[0].StartMode -eq 'Disabled'){Set-Service Wecsvc -StartupType Manual};if($wec[0].State -eq 'Stopped'){Start-Service Wecsvc} + $original=@(Inventory);Save 'original-inventory.json' $original;Assert (@(Get-WelaWecSubscriptionIds) -notcontains $id) 'Unique owned subscription initially absent' + Save 'console-enumeration-before.json' (Invoke-WelaNative 'wecutil.exe' @('es')) + $duringServices=Services + $absent=Public Audit 'absent-before' + Assert ($absent.Subscriptions[0].ObservedSubscription.Exists -eq $false -and $null -eq $absent.Subscriptions[0].ObservedEnabled -and -not $absent.Subscriptions[0].ObservationError -and (SubscriptionControl $absent).Status -ceq 'ChangeRequired') 'Complete native enumeration establishes selected absence' + $model=Import-WelaWefConfig $configPath Collector;$ownedPath=Join-Path $root 'owned.xml';[IO.File]::WriteAllText($ownedPath,$model.Subscriptions[0].Xml,[Text.UTF8Encoding]::new($false)) + $created=$true;$null=Invoke-WelaNative 'wecutil.exe' @('cs',$ownedPath) + $before=Read-WelaWecSubscriptionXml $id;[IO.File]::WriteAllText((Join-Path $root 'original-owned.xml'),$before,[Text.UTF8Encoding]::new($false)) + Assert (@(Get-WelaWecSubscriptionIds) -ccontains $id) 'Actual native enumeration returns exact owned ID' + foreach($action in @('Audit','Plan')){ + $r=Public $action ('present-'+$action.ToLowerInvariant());$o=$r.Subscriptions[0] + Assert ($o.ObservedSubscription.Exists -and $o.ObservedEnabled -eq $false -and -not $o.ObservationError -and (SubscriptionControl $r).Status -ceq 'RequestedSettingsMatch') 'Existing disabled native definition is observed and matched' + Assert ($o.ObservedSubscription.Xml -ceq $before -and $o.ObservedSubscription.Definition.Description -ceq $description -and $o.Filters[0].XPath -ceq ('*[System[(EventID=1)] and EventData[Data='''+$unicode+''']]')) 'Public JSON preserves exact Unicode native XML, description and selected XPath' + Assert ((Read-WelaWecSubscriptionXml $id) -ceq $before) 'Public Audit/Plan does not save or alter existing subscription' + } + [IO.File]::WriteAllText($path,$xml.Replace('false','true'),[Text.UTF8Encoding]::new($false)) + $r=Public Plan 'requested-enabled' + Assert ($r.Subscriptions[0].RequestedEnabled -and $r.Subscriptions[0].ObservedEnabled -eq $false -and (SubscriptionControl $r).Status -ceq 'ManualReview') 'Actual disabled state is not replaced with requested enabled state' + [IO.File]::WriteAllText($path,$xml,[Text.UTF8Encoding]::new($false)) + try { + $null=Invoke-WelaNative 'wecutil.exe' @('ss',$id,('/d:'+($description+' drift'))) + $r=Public Audit 'description-drift' + Assert ((SubscriptionControl $r).Status -ceq 'ManualReview' -and $r.Subscriptions[0].ObservedSubscription.Definition.Description -ceq ($description+' drift')) 'Native Unicode drift is retained and does not become a match' + }finally{$null=Invoke-WelaNative 'wecutil.exe' @('ss',$id,('/d:'+$description))} + $config.SourceSids=@($sid.Replace('-1234','-1235'));Save 'collector.json' $config + $r=Public Audit 'authorization-mismatch' + Assert ((SubscriptionControl $r).Status -ceq 'Unknown' -and $null -eq $r.Subscriptions[0].ObservedSubscription -and $null -eq $r.Subscriptions[0].ObservedEnabled -and $r.Subscriptions[0].ObservationError) 'Unsupported observed authorization remains unknown, never absent' + $config.SourceSids=@($sid);Save 'collector.json' $config + Assert ((Read-WelaWecSubscriptionXml $id) -ceq $before) 'All public observations and fixture drift restoration preserve original raw XML' + [IO.File]::WriteAllText((Join-Path $root 'restored-owned.xml'),(Read-WelaWecSubscriptionXml $id),[Text.UTF8Encoding]::new($false)) + $null=Invoke-WelaNative 'wecutil.exe' @('ds',$id);$created=$false + $r=Public Audit 'absent-after';Assert ($r.Subscriptions[0].ObservedSubscription.Exists -eq $false -and -not $r.Subscriptions[0].ObservationError) 'Actual removed owned subscription returns confirmed absence' + Assert ((Key (Services)) -ceq (Key $duringServices) -and (Key (Channel)) -ceq (Key $beforeChannel)) 'Read-only public commands preserve services and complete destination configuration' + Write-Host "PASS: $count actual collector observation assertions on $($PSVersionTable.PSVersion). No domain/forwarding proof." +}catch{$failure=$_.ToString();Write-Host $failure}finally{ + try { + if($created -and @(Get-WelaWecSubscriptionIds) -contains $id){$raw=Read-WelaWecSubscriptionXml $id;$doc=Read-WelaWefXml $raw;if($doc.Subscription.Description -cne $description -and $doc.Subscription.Description -cne ($description+' drift')){throw 'Fixture ownership differs; do not delete subscription.'};$null=Invoke-WelaNative 'wecutil.exe' @('ds',$id)} + $restored=@(Inventory);Save 'restored-inventory.json' $restored;$inventoryOk=$null -ne $original -and (Key $restored) -ceq (Key $original) + }catch{$errors+=$_.ToString()} + try{$afterChannel=Channel;$channelOk=(Key $afterChannel) -ceq (Key $beforeChannel)}catch{$errors+=$_.ToString()} + try { + $wec=@($beforeServices|Where-Object Name -eq Wecsvc)[0] + if($wec.State -eq 'Stopped' -and (Get-Service Wecsvc).Status -ne 'Stopped'){Stop-Service Wecsvc} + if($wec.StartMode -eq 'Disabled'){Set-Service Wecsvc -StartupType Disabled} + if((Key (Get-WelaRegistryState $serviceKey DelayedAutoStart)) -cne (Key $beforeDelayed)){if($beforeDelayed.ValueExists){$null=New-ItemProperty -LiteralPath $serviceKey -Name DelayedAutoStart -Value $beforeDelayed.Value -PropertyType $beforeDelayed.Type -Force}else{Remove-ItemProperty -LiteralPath $serviceKey -Name DelayedAutoStart -ErrorAction Stop}} + $afterServices=Services;$afterDelayed=Get-WelaRegistryState $serviceKey DelayedAutoStart + $servicesOk=(Key $afterServices) -ceq (Key $beforeServices) -and (Key $afterDelayed) -ceq (Key $beforeDelayed) + }catch{$errors+=$_.ToString()} + Save 'after-fixture.json' @{Services=$afterServices;Channel=$afterChannel;DelayedAutoStart=$afterDelayed} + $artifacts=@(Get-ChildItem $root -File|ForEach-Object {[pscustomobject]@{Name=$_.Name;Bytes=$_.Length;Sha256=(Get-FileHash $_.FullName).Hash.ToLowerInvariant()}}) + Save 'cleanup.json' @{Failure=$failure;CleanupErrors=$errors;SubscriptionsRestored=$inventoryOk;ServicesRestored=$servicesOk;ChannelPreserved=$channelOk;Complete=($inventoryOk -and $servicesOk -and $channelOk -and -not $errors.Count);Assertions=$count;Engine=$PSVersionTable.PSVersion.ToString();Host=$hostState;Sources=$sources;Artifacts=$artifacts;PublicReports=$reports;Scope='Native local collector observation only; real standalone prerequisites remain incomplete.'} +} +if($failure -or -not $inventoryOk -or -not $servicesOk -or -not $channelOk -or $errors.Count){throw "Native observation or fixture cleanup failed; inspect $root"} +exit 0 diff --git a/tests/WecSubscriptionInventory.Tests.ps1 b/tests/WecSubscriptionInventory.Tests.ps1 new file mode 100644 index 00000000..87e04847 --- /dev/null +++ b/tests/WecSubscriptionInventory.Tests.ps1 @@ -0,0 +1,34 @@ +$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent +Import-Module "$repo/modules/WefSubscriptions.psm1" -Force +& (Get-Module WefSubscriptions) {Initialize-WelaWecSubscriptionInventory} +$count=0 +function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} +function Reject([scriptblock]$Action){$failed=$false;try{&$Action|Out-Null}catch{$failed=$true};Assert $failed 'Malformed, duplicate, partial or oversized native inventory must be rejected'} +Assert ([Wela.WecInventory.Reader]::SourceSha256 -ceq (Get-FileHash "$repo/modules/WecSubscriptionInventory.cs").Hash.ToLowerInvariant()) 'Loaded inventory binds exact source bytes' +Assert ([Wela.WecInventory.Reader]::ValidateNames([string[]]@()).Length -eq 0) 'Completed empty inventory is distinct from an error' +$unicode='Name '+[char]0x65e5+[char]0x672c +$names=[string[]]@('z',$unicode,'A',' leading ',([string][char]0xfeff)) +$observed=[Wela.WecInventory.Reader]::ValidateNames($names) +Assert ($observed.Length -eq 5 -and $observed -ccontains $unicode -and $observed -ccontains ' leading ' -and $observed -ccontains ([string][char]0xfeff)) 'Actual Unicode and whitespace names are retained, never console-trimmed' +Assert ($names[0] -ceq 'z') 'Validation does not mutate caller inventory' +Reject {[Wela.WecInventory.Reader]::ValidateNames($null)} +Reject {[Wela.WecInventory.Reader]::ValidateNames([string[]]@('same','SAME'))} +Reject {[Wela.WecInventory.Reader]::ValidateNames([string[]]@(''))} +Reject {[Wela.WecInventory.Reader]::ValidateNames([string[]]@("ab`0cd"))} +Reject {[Wela.WecInventory.Reader]::ValidateNames([string[]]@('x'*1024))} +Reject {[Wela.WecInventory.Reader]::ValidateNames([string[]]@([string][char]0xd800))} +Reject {[Wela.WecInventory.Reader]::ValidateNames([string[]]@(1..4097|ForEach-Object {"id-$_"}))} +Reject {[Wela.WecInventory.Reader]::ValidateNames([string[]]@(1..1025|ForEach-Object {$prefix=[string]$_;$prefix+('x'*(1023-$prefix.Length))}))} +$buffer=[Runtime.InteropServices.Marshal]::AllocHGlobal(64) +try { + for($i=0;$i -lt 64;$i++){[Runtime.InteropServices.Marshal]::WriteByte($buffer,$i,0)} + $bytes=[Text.Encoding]::Unicode.GetBytes($unicode+[char]0);[Runtime.InteropServices.Marshal]::Copy($bytes,0,$buffer,$bytes.Length) + Assert ([Wela.WecInventory.Reader]::DecodeName($buffer,($unicode.Length+1),32) -ceq $unicode) 'Native used length counts UTF16 characters including terminator' + foreach($used in @(0,1,33)){Reject {[Wela.WecInventory.Reader]::DecodeName($buffer,$used,32)}} + Reject {[Wela.WecInventory.Reader]::DecodeName([IntPtr]::Zero,2,32)} + Reject {[Wela.WecInventory.Reader]::DecodeName($buffer,2,1025)} + Reject {[Wela.WecInventory.Reader]::DecodeName($buffer,$unicode.Length,32)} + [Runtime.InteropServices.Marshal]::WriteInt16($buffer,2,0);Reject {[Wela.WecInventory.Reader]::DecodeName($buffer,($unicode.Length+1),32)} + [Runtime.InteropServices.Marshal]::WriteInt16($buffer,0,[int16]-10240);Reject {[Wela.WecInventory.Reader]::DecodeName($buffer,2,32)} +}finally{[Runtime.InteropServices.Marshal]::FreeHGlobal($buffer)} +Write-Host "PASS: $count native subscription inventory buffer/boundary assertions." diff --git a/tests/WefDeployment.Tests.ps1 b/tests/WefDeployment.Tests.ps1 index fba71b03..95b8509b 100644 --- a/tests/WefDeployment.Tests.ps1 +++ b/tests/WefDeployment.Tests.ps1 @@ -80,6 +80,15 @@ function Get-NetFirewallRule { param($Name,$PolicyStore) Assert ($PolicyStore -e function Get-NetFirewallPortFilter { [CmdletBinding()]param([Parameter(ValueFromPipeline)]$Rule) process { [pscustomobject]@{ Protocol='TCP'; LocalPort='5985'; RemotePort='Any' } } } function Get-NetFirewallAddressFilter { [CmdletBinding()]param([Parameter(ValueFromPipeline)]$Rule) process { [pscustomobject]@{ LocalAddress=@('192.0.2.10/255.255.255.255'); RemoteAddress=@('192.0.2.0/255.255.255.0') } } } function Read-Host { param($Prompt) return $global:WelaWefFixture.Prompt } +function Get-WelaWecSubscriptionIds { + if($global:WelaWefFixture.Fail -eq 'Inventory'){throw 'Incomplete native inventory'} + @($global:WelaWefFixture.Subs.Keys) +} +function Read-WelaWecSubscriptionXml { + param($Id) + if($global:WelaWefFixture.Fail -eq 'ReadXml' -or -not $global:WelaWefFixture.Subs.ContainsKey($Id)){throw 'Native definition is no longer readable'} + $global:WelaWefFixture.Subs[$Id] +} function Invoke-WelaNative { param($FilePath,$Arguments) $f=$global:WelaWefFixture @@ -90,8 +99,7 @@ function Invoke-WelaNative { } Assert ($FilePath -eq 'wecutil.exe') 'Only native wecutil subscription API is called' switch ($Arguments[0]) { - 'es' { return [pscustomobject]@{ ExitCode=0; Output=@($f.Subs.Keys); Diagnostic=(@($f.Subs.Keys) -join "`n") } } - 'gs' { if (-not $f.Subs.ContainsKey($Arguments[1])) { throw 'No fixture subscription' }; return [pscustomobject]@{ ExitCode=0; Output=@($f.Subs[$Arguments[1]]); Diagnostic=$f.Subs[$Arguments[1]] } } + {$_ -in @('es','gs')} {throw 'Subscription inventory and XML must bypass console decoding.'} 'gr' { return [pscustomobject]@{ ExitCode=0; Output=@('Localized runtime fixture'); Diagnostic='Localized runtime fixture' } } 'cs' { Record-Write Subscription $Arguments @@ -215,6 +223,22 @@ try { Assert ($report.ExitCode -eq 1 -and $global:WelaWefFixture.Writes.Count -eq 0) 'An existing disabled/different subscription is never silently updated' Assert ($report.Subscriptions[0].RequestedEnabled -and $report.Subscriptions[0].ObservedEnabled -eq $false) 'Inventory distinguishes an observed disabled subscription from the requested enabled definition' Reset-Fixture + foreach($failure in @('Inventory','ReadXml')) { + Reset-Fixture + $model=Import-WelaWefConfig (Join-Path $temp 'collector.json') Collector + $global:WelaWefFixture.Subs[$model.Subscriptions[0].Id]=$model.Subscriptions[0].Xml + $global:WelaWefFixture.Fail=$failure + $report=Invoke-Collector + Assert ($report.ExitCode -eq 1 -and $global:WelaWefFixture.Writes.Count -eq 0) 'Incomplete enumeration or disappearing/unreadable XML cannot authorize creation' + Assert ($null -eq $report.Subscriptions[0].ObservedSubscription -and $null -eq $report.Subscriptions[0].ObservedEnabled -and $report.Subscriptions[0].ObservationError) 'Read failure stays unknown rather than absent or disabled' + Assert (@($report.Controls|Where-Object {$_.Kind -eq 'Subscription' -and $_.Status -eq 'Unknown'}).Count -eq 1) 'Partial observation remains an unknown control' + } + Reset-Fixture + $model=Import-WelaWefConfig (Join-Path $temp 'collector.json') Collector + $global:WelaWefFixture.Subs[$model.Subscriptions[0].Id]=$model.Subscriptions[0].Xml.Replace($model.Subscriptions[0].Id,'Different native ID') + $report=Invoke-Collector + Assert ($report.ExitCode -eq 1 -and $report.Subscriptions[0].ObservationError -match 'identity differs' -and $global:WelaWefFixture.Writes.Count -eq 0) 'Mismatched native XML identity cannot become selected subscription evidence' + Reset-Fixture $global:WelaWefFixture.Fail='false-subscription' $report=Invoke-Collector Assert ($report.ExitCode -eq 1) 'A successful native exit without matching subscription readback fails' diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index 885098c2..854679ee 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -9,6 +9,14 @@ - 明示的な `registry-sacl-recovery` を追加しました。整合する4つの元記録、レビュー済み計画のハッシュ、過去・現在ともに空の子キー観測、監査縮小と継承への個別同意を必須とし、追加が証明されたレジストリルートの明示的な監査ACEを1つだけ削除します。SACLのみのネイティブ書き込みで他の記述子フィールドとACEの順序を保持し、部分的な書き込みの証跡と元の記述子バイトとの一致を別々に報告します。過去のキー・操作者の同一性認証、ツリー全体の原子性、イベント生成、Sigmaの準備完了は保証しません。 (Related #373) (@Shirofune-Security) +- Server 2022/2025とPowerShell 5.1/7でSMBポリシー設定の公開CLIを検証します。対応ホストで6項目の適用・再読取・再実行、非対応ホストのスキップ、元の型付き記録、無関係な設定の維持と完全な復元を確認します。イベント生成と実行時の有効化は別途検証します。(関連 #377) (@Shirofune-Security) + +- `outgoing-ntlm` のAudit/Plan/Configureを追加し、送信NTLM監査DWORDを個別に設定できます。既存の拒否設定は既定で維持し、置換には明示的なAuditを要求します。不明な型・値や書込直前の変化を拒否し、元の型付き記録と再読取を保持します。Server 2022/2025のテストで範囲と復元を確認し、認証・イベント生成は未検証として報告します。(関連 #362) (@Shirofune-Security) + +- コレクターのサブスクリプション観測で、コンソール経由の文字変換を、上限付きの完全なネイティブ名前列挙と厳密なUnicode XML読取に置き換えました。空の一覧・読取失敗・実際の無効状態を区別し、Unicodeの説明とXPathを保持します。Server 2022/2025と両PowerShellで公開Audit/Planおよび正確な後処理を検証し、ドメイン展開・転送・Sigma対応は主張しません。(関連 #368) (@Shirofune-Security) + +- Server 2022/2025 の Windows PowerShell 5.1/PowerShell 7 で、公開 provider-packs コマンドの Plan、DryRun、Configure、冪等性、前提不足・手動対象の拒否、部分適用を実機検証する使い捨て CI を追加。DNS Client、CAPI2、WinRM、RDP Client の設定と復元記録・ハッシュを確認し、完全な ACL、保持モード、大きい既存バッファ、他チャネル、サービス、全監査マスクの保持とテスト後の正確な復元を検証。イベント生成や Sigma 対応の証明は含みません。 WinRM のマニフェスト ID をネイティブの Int64 として比較し、対象外の大きい ID により必要なイベントの確認が失敗する不具合も修正。 (@Shirofune-Security) + - Server 2022/2025 と Windows PowerShell 5.1/PowerShell 7 の破棄可能な環境で、Securityログ警告設定の公開CLIを検証します。未設定・0・高いしきい値、早い警告値の維持、DryRun、再実行、不正型の拒否と完全な復元を確認し、無関係な設定は保持します。ログ枯渇や警告イベント生成は検証範囲外です。 (@Shirofune-Security) - Server 2022/2025 と両 PowerShell エンジンで、公開 `targeted-sacl` のレジストリ操作を検証する使い捨てテストを追加しました。テスト専用の新規ハイブをマウントし、対象選択、DryRun、監査 ACE の追加、古い計画・前提条件不足の拒否、冪等性と厳密に対応付けた Security4657 を確認します。無関係な ACE・型付き値の保持、監査ポリシー・トークンの復元、所有ハイブのアンロードと削除の証跡を保存します。製品側のハイブ読み込みや Sigma 準備完了の判定は追加しません。(関連 #373) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index 570f32e5..6b533916 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -9,6 +9,14 @@ - Added opt-in `registry-sacl-recovery` for one proven explicit registry-root audit ACE, requiring four matching original records, a reviewed plan hash, empty historical/current descendants and separate audit-reduction/inheritance consent. Native SACL-only removal preserves unrelated descriptor fields and ACE order, retains partial-write evidence and reports original-byte equality separately; no authenticated historical key/operator identity, atomic-tree, event or Sigma claim. (Related #373) (@Shirofune-Security) +- Add native public SMB policy configuration acceptance on Server 2022/2025 and PowerShell 5.1/7: six-policy apply/readback and idempotence on supported hosts, unsupported-host skips, typed original journals, unrelated-state preservation and exact cleanup. Event generation and runtime activation remain separate. (Related #377) (@Shirofune-Security) + +- Add `outgoing-ntlm` Audit/Plan/Configure to manage the outgoing audit DWORD independently of broad configuration. Preserve existing deny by default, require explicit Audit to replace it, refuse unknown types/values and pre-write drift, and retain typed original journals plus native readback. Native Server 2022/2025 tests verify narrow scope and exact cleanup; authentication/event generation remain unverified. (Related #362) (@Shirofune-Security) + +- Fixed collector subscription observations to use complete bounded native name enumeration and strict Unicode XML reads instead of console decoding. Empty inventories, failed reads and actual disabled state remain distinct; Unicode descriptions and XPath are preserved. Disposable public Audit/Plan tests cover both Server 2022/2025 and PowerShell engines with exact cleanup, without domain deployment, forwarding or Sigma claims. (Related #368) (@Shirofune-Security) + +- Added disposable native acceptance for public provider-pack Plan, DryRun, Configure, idempotence, missing/manual refusal and partial outcomes on Server 2022/2025 under Windows PowerShell 5.1/PowerShell 7. Actual DNS Client, CAPI2, WinRM and RDP Client configuration preserves descriptors, retention, larger buffers, unrelated channels, services and all audit masks, with journal/hash evidence and exact fixture cleanup; no event or Sigma credit. Fixed WinRM manifest inspection to preserve native Int64 event IDs, so unrelated large IDs no longer block the selected event schema. (@Shirofune-Security) + - Verify public Security-log warning configuration on disposable Server 2022/2025 hosts under Windows PowerShell 5.1/PowerShell 7: absent/zero/higher thresholds, earlier-threshold preservation, dry run, idempotence, wrong-type refusal and exact cleanup. Preserve unrelated registry values, channel configuration, audit masks and CrashOnAuditFail; no log-exhaustion or warning-event claim. (@Shirofune-Security) - Added disposable public `targeted-sacl` registry lifecycle validation on Server 2022/2025 and both PowerShell engines. A fixture-owned mounted hive exercises reviewed selection, DryRun, additive configuration, stale/prerequisite refusal and idempotence, followed by one precisely attributed Security4657. Retained native receipts verify unrelated ACE/value preservation and exact audit-policy, token and owned-hive cleanup; production does not load hives or gain Sigma credit. (Related #373) (@Shirofune-Security)