diff --git a/.github/workflows/audit-catalog-mappings.yml b/.github/workflows/audit-catalog-mappings.yml new file mode 100644 index 00000000..e5bf0fb6 --- /dev/null +++ b/.github/workflows/audit-catalog-mappings.yml @@ -0,0 +1,25 @@ +name: Audit catalog mapping regressions +on: + push: + branches: ['**'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + audit-catalog-mappings: + runs-on: windows-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Catalog, ambiguity and renderer fixtures on Windows PowerShell 5.1 + shell: powershell + run: ./tests/AuditCatalogMappings.Tests.ps1 + - name: Native identifier observations on Windows PowerShell 5.1 + shell: powershell + run: ./tests/AuditCatalogMappings.Windows.Tests.ps1 + - name: Catalog, ambiguity and renderer fixtures on PowerShell 7 + shell: pwsh + run: ./tests/AuditCatalogMappings.Tests.ps1 + - name: Native identifier observations on PowerShell 7 + shell: pwsh + run: ./tests/AuditCatalogMappings.Windows.Tests.ps1 diff --git a/.github/workflows/ldap-diagnostics.yml b/.github/workflows/ldap-diagnostics.yml new file mode 100644 index 00000000..22dac232 --- /dev/null +++ b/.github/workflows/ldap-diagnostics.yml @@ -0,0 +1,25 @@ +name: LDAP diagnostic regressions +on: + push: + branches: ['**'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + ldap-diagnostics: + runs-on: windows-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: LDAP fixtures on Windows PowerShell 5.1 + shell: powershell + run: ./tests/LdapDiagnostics.Tests.ps1 + - name: Native applicability on Windows PowerShell 5.1 + shell: powershell + run: ./tests/LdapDiagnostics.Windows.Tests.ps1 + - name: LDAP fixtures on PowerShell 7 + shell: pwsh + run: ./tests/LdapDiagnostics.Tests.ps1 + - name: Native applicability on PowerShell 7 + shell: pwsh + run: ./tests/LdapDiagnostics.Windows.Tests.ps1 diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index 20c30fae..bed33de3 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -6,6 +6,9 @@ - Windows標準のドメイン/Kerberos環境向けに、送信元設定と明示的に選択した収集サーバーのサブスクリプションを監査・計画・設定する任意実行の`wef-source`と`wec-collector`を追加しました。実際の収集先ID、既存リスナーと範囲を限定した受信規則、送信元SID、メンバーホストでの読み取り権限の追加、明示的なASDのWSMan強化設定と共通チャネル設定を、復旧記録・変更検出・読み戻しで確認します。DCのグループ管理権限と異なる既存サブスクリプションは変更しません。JSONにはクエリ・チャネル・実行状態の証拠を保持し、実効読み取り権限・イベント到着・転送後のSigma検知範囲は未検証と表示します。隔離Windows環境での配備検証は別途必要です。 (#406) (@Shirofune-Security) - CIS v4.0.0 Level 2向けに、Windows PowerShell 5.1トランスクリプトの明示的な監査・計画・設定機能を追加しました。管理者が選択した既存の出力ディレクトリを確認し、ポリシーとは分けて報告します。型を含むレジストリ変更前の状態を保存し、共有される32/64ビットのビューと変更後の状態を検証します。呼び出しヘッダーの設定は保持し、ACL・共有・保存期間は変更せず、Sigma EVTX検知範囲の向上も自動加算しません。使い捨て環境の実トランスクリプトテストでは元のポリシーを復元します。中央保存先の権限と収集は別途検証が必要です。 (#405) (@Shirofune-Security) +- 通常の設定でDCのField Engineeringを自動的にレベル5へ変更せず、既存のLDAP 1644診断設定を保持するようにしました。明示的な`ldap-diagnostics`の監査・計画・設定を追加し、保持、しきい値を指定した診断、MDIの旧設定削除を選択できます。役割・ビルド確認、型付き復旧記録、競合検出、順序付きの読み戻しと最終状態確認に対応します。LDAP専用オプションを他のプロファイルコマンドへ指定すると、実行前に拒否します。イベント生成・量・転送は隔離DCでの検証が必要です。 (#404) (@Shirofune-Security) +- 従来のToken Right Adjusted EventsのGUIDを修正し、すべてのベースラインでRPCとトークン監査の状態を個別に評価するようにしました。実行時のカタログID・明示的な名前別表記の検証と、ハッシュ付きの読み取り専用EventID対応表レビューを追加しました。曖昧な対応、カテゴリだけの対応、不明な対応から検知可能とは判断しません。 (#403) (@Shirofune-Security) + - MDIのドメイン/Exchange Configuration監査と、明示的に選択した証明書テンプレート/登録サービスオブジェクト向けに、任意実行の`ad-object-sacl`監査・計画・設定・保守的なロールバックを追加しました。接続先DCとスキーマGUIDを検証し、既存のセキュリティ設定を保持したまま不足する監査ACEだけをSACLに追加します。変更前のSDDLと追加ACEを保存し、書き込み後と最終状態を確認します。任意のdMSA前提条件が不明な場合は未確認のスキップ項目として報告し、独立した他の5種類のドメインクラスの監査ACEは引き続き設定します。実効監査ポリシー、継承・レプリケーション、4662/5136イベントの証拠は隔離DCで別途検証が必要です。Sigma検知範囲の向上は未検証です。 (#402) (@Shirofune-Security) - Microsoft WEF Appendix Cのチャネルを監査・計画・設定する任意実行の`channel-settings`を追加しました。CAPI2の有効化、原典の正確なバイト数、明示的に指定したEvent Log Readersの読み取りACEに対応します。既存のセキュリティ記述子・ACEと大きいバッファを保持し、安全に扱えないACLは変更しません。共通の復旧記録、書き込み直前の状態確認と読み戻しで失敗を報告します。Appendix E/Fの標準チャネル一覧からSysmonとEMETを除外し、実際のIDによるアクセス・イベント生成・収集は未検証と表示します。Windowsラボでの検証は別途必要です。 (#401) (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2802c62..0ee80f7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ - Added separate opt-in `wef-source` and `wec-collector` audit, plan and configure commands for native domain/Kerberos source settings and explicitly selected collector subscriptions. Actual collector identity, scoped existing ingress/listener prerequisites, explicit source SIDs, additive member-host read permissions, optional ASD WSMan hardening and shared channel controls are checked with journals, drift guards and readback. DC group authority and different existing subscriptions are preserved. JSON retains query/channel/runtime evidence without claiming effective read access, event arrival or forwarded Sigma coverage; isolated Windows deployment validation remains pending. (#406) (@Shirofune-Security) - Added explicit CIS v4.0.0 Level 2 Windows PowerShell 5.1 transcription audit, plan and configure actions. An operator-selected existing output directory is checked and reported separately from policy; typed canonical registry writes are journaled, verified through shared 32/64-bit views and checked for drift while preserving invocation-header preferences. No ACL/share/retention changes or automatic Sigma EVTX credit are introduced; disposable native transcript tests restore original policy, and central authorization/collection remains a deployment check. (#405) (@Shirofune-Security) +- Preserved LDAP 1644 diagnostics during normal configuration instead of automatically enabling Field Engineering level 5 on DCs. Added explicit `ldap-diagnostics` audit/plan/configure modes for preservation, tunable diagnostics and MDI legacy cleanup, with role/build checks, typed recovery snapshots, race guards, ordered readback and final drift checks. LDAP-only options are rejected before unrelated profile commands can run. Generated events, volume and forwarding remain isolated-DC validation. (#404) (@Shirofune-Security) +- Corrected the legacy Token Right Adjusted Events GUID so RPC and token auditing are assessed independently in every baseline. Added runtime catalog identity/alias checks and a read-only, fingerprinted EventID mapping review that preserves ambiguous, category-only and unknown candidates without detection credit. (#403) (@Shirofune-Security) + - Added opt-in `ad-object-sacl` audit, plan, configure and conservative rollback actions for MDI domain/Exchange Configuration auditing and explicitly selected certificate template/enrollment service objects. Exact DC binding, schema GUID checks, additive SACL-only changes, pre-write SDDL/ACE receipts and read-back preserve existing security entries. Unknown optional dMSA prerequisites are reported as a separate skipped gap while the five independent domain class ACEs continue. Effective audit policy, inheritance/replication and 4662/5136 event evidence remain separate isolated-DC checks; no Sigma uplift is claimed. (#402) (@Shirofune-Security) - Added opt-in `channel-settings` audit, plan and configure actions for Microsoft WEF Appendix C, including CAPI2 enablement, exact source byte sizes and an explicitly requested Event Log Readers read ACE. Existing descriptor components/ACEs and larger buffers are preserved or unsafe ACL edits are refused; shared journaling, fresh-state guards and readback report failures. Native Appendix E/F channel inventories exclude Sysmon/EMET and retain unverified identity access, generation and ingestion prerequisites. Windows lab evidence remains pending. (#401) (@Shirofune-Security) diff --git a/WELA.ps1 b/WELA.ps1 index a168b8d1..4893a00a 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -41,6 +41,11 @@ [switch]$WmiIncludeChildren, [ValidateSet('Audit', 'Plan', 'Configure')][string]$TranscriptionAction = 'Audit', [string]$TranscriptDirectory, + [ValidateSet('Audit','Plan','Configure')][string]$LdapAction = 'Audit', + [ValidateSet('Preserve','Diagnostic','MdiCleanup')][string]$LdapMode = 'Preserve', + [ValidateRange(1,2147483647)][int]$LdapSearchTimeMs, + [ValidateRange(1,2147483647)][int]$LdapExpensiveThreshold, + [ValidateRange(1,2147483647)][int]$LdapInefficientThreshold, [switch]$Help ) @@ -57,11 +62,13 @@ $SaclTargetsPath = Join-Path $ScriptRoot "config/audit_sacl_targets.json" . (Join-Path $ScriptRoot "scripts/Configuration.ps1") . (Join-Path $ScriptRoot "scripts/FirewallLogging.ps1") . (Join-Path $ScriptRoot "scripts/SmbAuditing.ps1") +. (Join-Path $ScriptRoot "scripts/LdapDiagnostics.ps1") . (Join-Path $ScriptRoot "scripts/AdObjectSacl.ps1") . (Join-Path $ScriptRoot "scripts/AppLockerReadiness.ps1") . (Join-Path $ScriptRoot "scripts/WmiNamespaceAuditing.ps1") . (Join-Path $ScriptRoot "scripts/PowerShellTranscription.ps1") Import-Module (Join-Path $ScriptRoot "modules/AuditProfiles.psm1") -ErrorAction Stop +Import-Module (Join-Path $ScriptRoot "modules/AuditCatalog.psm1") -ErrorAction Stop Import-Module (Join-Path $ScriptRoot "modules/NativeProviders.psm1") -ErrorAction Stop Import-Module (Join-Path $ScriptRoot "modules/EventLogSettings.psm1") -ErrorAction Stop . (Join-Path $ScriptRoot "scripts/EventLogConfiguration.ps1") @@ -334,7 +341,9 @@ function GetBaselineConfig { if (-not (Test-Path -Path $script:BaselineConfigPath)) { throw "Baseline config not found: $script:BaselineConfigPath" } - return Get-Content -Path $script:BaselineConfigPath -Raw | ConvertFrom-Json + $data = Get-Content -Path $script:BaselineConfigPath -Raw | ConvertFrom-Json + Assert-WelaAuditCatalog -Catalog $data.catalog -CanonicalCatalog (Import-WelaAuditProfiles).catalog + return $data } function GetBaselineNames { @@ -1433,9 +1442,7 @@ function ConfigureAuditSettings { Set-RegistryConfig -RegPaths $regPaths -Auto:$Auto -Context $context Set-WelaDomainNtlmAudit -Auto:$Auto -Context $context if ($hostContext.Role -eq 'DomainController') { - Set-RegistryConfig -RegPaths @( - @{Path = 'HKLM:\SYSTEM\CurrentControlSet\Services\NTDS\Diagnostics'; Name = '15 Field Engineering'; Value = 5} - ) -Auto:$Auto -Context $context + Write-Host 'LDAP 1644 diagnostics are preserved. MDI no longer requires them; use ldap-diagnostics for explicit Diagnostic or MdiCleanup changes.' -ForegroundColor Yellow } # Both audit display and mutation use the versioned role-aware profile. @@ -1701,6 +1708,9 @@ function Get-WelaUserProfiles { $usage = @" Usage: + ./WELA.ps1 ldap-diagnostics -LdapAction Audit + ./WELA.ps1 ldap-diagnostics -LdapAction Plan -LdapMode Diagnostic -LdapSearchTimeMs 100 + ./WELA.ps1 ldap-diagnostics -LdapAction Configure -LdapMode Diagnostic -LdapSearchTimeMs 100 -DryRun ./WELA.ps1 channel-settings -ChannelAction Audit -WefQuerySet Both -ResultsPath channels.json ./WELA.ps1 channel-settings -ChannelAction Plan -GrantEventLogReaders ./WELA.ps1 channel-settings -ChannelAction Configure -GrantEventLogReaders -DryRun @@ -1779,7 +1789,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 'applocker-readiness' -and $AppLockerAction -eq 'Import') -and $Cmd -notin @('configure', 'configure-eventlogs') -and +if ($DryRun -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 'firewall-logging' -and $FirewallAction -eq 'Configure') -and -not ($Cmd -eq 'smb-auditing' -and $SmbAction -eq 'Configure') -and -not ($Cmd -eq 'powershell-transcription' -and $TranscriptionAction -eq 'Configure') -and @@ -1787,7 +1797,7 @@ if ($DryRun -and -not ($Cmd -eq 'applocker-readiness' -and $AppLockerAction -eq -not ($Cmd -in @('wef-source','wec-collector') -and $WefAction -eq 'Configure') -and -not ($Cmd -eq 'ad-object-sacl' -and $AdSaclAction -in @('Configure', 'Rollback')) -and -not ($Cmd -eq 'wmi-auditing' -and $WmiAction -eq 'Configure')) { - throw "-DryRun is supported only by configure (including configure -Profile), configure-eventlogs, firewall-logging -FirewallAction Configure, smb-auditing -SmbAction Configure, powershell-transcription -TranscriptionAction Configure, wmi-auditing -WmiAction Configure, channel-settings -ChannelAction Configure, wef-source/wec-collector -WefAction Configure, applocker-readiness -AppLockerAction Import, and ad-object-sacl -AdSaclAction Configure|Rollback. No command was run." + throw "-DryRun is supported only by configure (including configure -Profile), configure-eventlogs, firewall-logging -FirewallAction Configure, smb-auditing -SmbAction Configure, powershell-transcription -TranscriptionAction Configure, wmi-auditing -WmiAction Configure, channel-settings -ChannelAction Configure, wef-source/wec-collector -WefAction Configure, applocker-readiness -AppLockerAction Import, ad-object-sacl -AdSaclAction Configure|Rollback, and ldap-diagnostics -LdapAction Configure. No command was run." } if (($WmiNamespace -or $WmiIncludeChildren -or $PSBoundParameters.ContainsKey('WmiAction')) -and $Cmd -ne 'wmi-auditing') { throw '-WmiAction, -WmiNamespace and -WmiIncludeChildren require wmi-auditing. No command was run.' @@ -1807,6 +1817,10 @@ if (($PSBoundParameters.ContainsKey('ChannelAction') -or $PSBoundParameters.Cont throw 'Channel options require channel-settings. No command was run.' } +if ($Cmd -ne 'ldap-diagnostics' -and @($PSBoundParameters.Keys | Where-Object { $_ -in @('LdapAction','LdapMode','LdapSearchTimeMs','LdapExpensiveThreshold','LdapInefficientThreshold') }).Count) { + throw 'LDAP options require the dedicated ldap-diagnostics command. No command was run.' +} + if ($Profile -and $Cmd.ToLower() -in @('plan', 'audit', 'audit-settings', 'configure') -and -not $Help) { Invoke-WelaProfileCommand -Command $Cmd.ToLower() return @@ -1828,6 +1842,18 @@ switch ($Cmd.ToLower()) { if ($report.ExitCode) { exit $report.ExitCode } } catch { Write-Host "[Failed] WEF configuration: $_" -ForegroundColor Red; exit 1 } } + 'ldap-diagnostics' { + if ($Help) { Write-Host 'Usage: ./WELA.ps1 ldap-diagnostics [-LdapAction Audit|Plan|Configure] [-LdapMode Preserve|Diagnostic|MdiCleanup] [-LdapSearchTimeMs positive-ms] [-LdapExpensiveThreshold positive-count] [-LdapInefficientThreshold positive-count] [-Auto] [-DryRun] [-BackupPath new-directory] [-ResultsPath report.json]. See docs/ldap-diagnostics.md.'; return } + if ($Profile -or $Baseline -or $Role -or $Build -or $HtmlPath) { throw 'ldap-diagnostics observes the actual local DC and uses -LdapMode/-ResultsPath; audit profiles, role overrides and HTML output do not apply.' } + if ($LdapAction -eq 'Configure' -and $LdapMode -ne 'Preserve' -and -not (TestAdministrator)) { throw 'LDAP configuration requires Administrator privileges.' } + $thresholds=@{} + if ($PSBoundParameters.ContainsKey('LdapSearchTimeMs')) { $thresholds.SearchTime=$LdapSearchTimeMs } + if ($PSBoundParameters.ContainsKey('LdapExpensiveThreshold')) { $thresholds.Expensive=$LdapExpensiveThreshold } + if ($PSBoundParameters.ContainsKey('LdapInefficientThreshold')) { $thresholds.Inefficient=$LdapInefficientThreshold } + $report=Invoke-WelaLdapCommand -Action $LdapAction -Mode $LdapMode -Thresholds $thresholds -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath -ResultsPath $ResultsPath + $report + if ($report.ExitCode) { exit $report.ExitCode } + } 'channel-settings' { if ($Help) { Write-Host 'Usage: ./WELA.ps1 channel-settings [-ChannelAction Audit|Plan|Configure] [-ChannelProfile microsoft-wef-appendix-c] [-WefQuerySet Baseline|Suspect|Both] [-GrantEventLogReaders] [-Auto] [-DryRun] [-BackupPath new-directory] [-ResultsPath file.json]' diff --git a/config/baselines.json b/config/baselines.json index 368ad7db..708405ec 100644 --- a/config/baselines.json +++ b/config/baselines.json @@ -456,7 +456,7 @@ "subCategory": "Token Right Adjusted Events", "select": { "type": "guid", - "guid": "0CCE922E-69AE-11D9-BED3-505054503030" + "guid": "0CCE924A-69AE-11D9-BED3-505054503030" }, "currentSetting": { "type": "auditpol" diff --git a/docs/audit-catalog-mappings.md b/docs/audit-catalog-mappings.md new file mode 100644 index 00000000..1ee7d66d --- /dev/null +++ b/docs/audit-catalog-mappings.md @@ -0,0 +1,17 @@ +# Audit identifiers and EventID mapping review + +`Token Right Adjusted Events` now uses `0CCE924A-69AE-11D9-BED3-505054503030` in the legacy baseline catalog, matching the versioned audit catalog. RPC keeps `0CCE922E-69AE-11D9-BED3-505054503030`. This fixes legacy ASD/Microsoft assessments that previously displayed RPC state for token auditing. Recommendations and enablement masks are unchanged. + +Every baseline load checks canonical name/GUID pairs and rejects duplicate legacy identifiers. Only three explicit spelling aliases are accepted: `Non-Sensitive Privilege Use`, `User / Device Claims`, and `Central Policy Staging`. They map to the existing canonical names; no fuzzy matching or arbitrary GUID aliases are allowed. + +Review the bundled EventID candidates without reading or changing Windows: + +```powershell +./scripts/Review-AuditCatalog.ps1 -ResultsPath mapping-review.json +``` + +The export fingerprints the mapping file, lists candidates and reasons per EventID, and separates blank category headings. Missing mappings or candidates with only a category GUID are unknown. Multiple subcategories, unresolved object types, outcomes and role context remain conditional. `DetectionReady` is always false: an identifier review cannot verify a detection. This helper does not replace full rule eligibility or establish the complete Boolean/field requirements of a Sigma rule. + +The bundled CSV remains a historical candidate map, not a universally valid event-generation contract. For example, 4703 appears against both Token Right Adjusted and Authorization Policy Change. Microsoft's [Token Right Adjusted page](https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-10/security/threat-protection/auditing/audit-token-right-adjusted) lists it, while the [4703 event page](https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-10/security/threat-protection/auditing/event-4703) names Authorization Policy Change. WELA retains the conflict rather than inventing a build-independent resolution. Microsoft also states that Token Right Adjusted has no Failure events; setting a Failure mask is not proof of Failure records. + +Fixtures check malformed/duplicate identifiers, unknown events, ambiguous 4703/object mappings, and independent RPC/token state through all four legacy baseline renderers. Windows CI runs [`auditpol /list /subcategory:* /v`](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/auditpol-list) and native policy queries under Windows PowerShell 5.1/PowerShell 7. These checks validate identifiers/readback, not generated event XML. Build-specific client, member-server, DC and CA event/outcome validation remains separate acceptance work for issue #380. Native Windows functionality only; Sysmon is out of scope. diff --git a/docs/ldap-diagnostics.md b/docs/ldap-diagnostics.md new file mode 100644 index 00000000..3919aab7 --- /dev/null +++ b/docs/ldap-diagnostics.md @@ -0,0 +1,37 @@ +# Explicit LDAP 1644 diagnostics + +Normal `configure` preserves existing LDAP diagnostics and explains that 1644 is not selected. It no longer sets `15 Field Engineering=5` automatically on domain controllers. Microsoft's [current MDI guidance](https://learn.microsoft.com/en-us/defender-for-identity/deploy/configure-windows-event-collection#update-legacy-configurations) no longer requires these diagnostic settings. Security auditing and the other MDI prerequisites remain separate. + +The dedicated local workflow supports Server 2022/2025 DC builds 20348/26100 in 64-bit Windows PowerShell 5.1 or PowerShell 7. Client, member server and member-server CA hosts are not applicable. Unknown or conflicting role/build evidence blocks writes; no NTDS settings are created on a non-DC. + +LDAP options require the dedicated `ldap-diagnostics` command. Supplying them to another command, including `configure -Profile`, stops before that command runs. + +```powershell +./WELA.ps1 ldap-diagnostics -LdapAction Audit -ResultsPath ldap-before.json +./WELA.ps1 ldap-diagnostics -LdapAction Plan -LdapMode Diagnostic -LdapSearchTimeMs 100 +./WELA.ps1 ldap-diagnostics -LdapAction Configure -LdapMode Diagnostic -LdapSearchTimeMs 100 -DryRun +./WELA.ps1 ldap-diagnostics -LdapAction Configure -LdapMode Diagnostic -LdapSearchTimeMs 100 -Auto -BackupPath C:\WelaRecovery\ldap-01 -ResultsPath ldap-result.json +``` + +`Preserve` is the default mode, including when Configure is selected. It changes no settings. `Diagnostic` explicitly requests Field Engineering level 5 and only the thresholds supplied by the operator. Omitted thresholds retain their existing typed values; they are not silently lowered to 1. Thresholds must be positive integers through 2147483647. Windows treats an absent/zero time threshold as its documented default; use cleanup to remove an override instead of supplying zero. + +| Option | NTDS Parameters value | Unit | Microsoft documented default, not a host observation | +|---|---|---|---:| +| `-LdapSearchTimeMs` | Search Time Threshold (msecs) | milliseconds | 30000 | +| `-LdapExpensiveThreshold` | Expensive Search Results Threshold | entry threshold | 10000 | +| `-LdapInefficientThreshold` | Inefficient Search Results Threshold | entry threshold | 1000 | + +These values are described in Microsoft's [1644 diagnostics procedure](https://learn.microsoft.com/en-us/troubleshoot/windows-server/active-directory/event1644reader-analyze-ldap-query-performance). The example's 100 ms is a starting point to evaluate, not a baseline requirement. Level 5 also generates other Directory Service events. Measure event rate, log rollover and DC workload over a bounded interval before wider rollout. WELA neither issues LDAP searches nor measures that volume automatically. + +`MdiCleanup` is an explicit removal choice. It removes **all four named values** shown in the plan, including customized values: Field Engineering first, followed by the three thresholds. It leaves their registry keys and unrelated diagnostic values intact. Select it only after reviewing existing operator diagnostics; normal configure and Preserve never perform cleanup. + +```powershell +./WELA.ps1 ldap-diagnostics -LdapAction Plan -LdapMode MdiCleanup +./WELA.ps1 ldap-diagnostics -LdapAction Configure -LdapMode MdiCleanup -DryRun +``` + +Writes require a fresh complete snapshot and a saved `before.jsonl` recovery record containing original existence, type and value for every control. Unknown types and concurrent changes block writes. Each selected threshold is read back before verbose logging is enabled; errors stop the remaining changes and produce a nonzero result. Repeated configuration is idempotent, and the final check detects drift in selected and preserved values. Dry-run does not create a backup directory or modify Windows. A partial failure retains its journal; it does not trigger automatic restoration over newer changes. + +For recovery, compare fresh values with both the journal's original state and this run's desired values. Restore original values/types only where this run's changes still remain; remove only a value that was originally absent. Preserve any newer operator changes and keep the recovery journal. MDI cleanup is not a general rollback command. + +Tests cover default preservation, explicit setup/cleanup, positive bounds, type/read/write errors, journal ordering, stale plans, races, partial failures, repeated application and final drift. Windows CI queries native role applicability without configuring a DC. Before closing #383, use an isolated DC snapshot to verify a benign query against the selected thresholds, retain matching 1644 XML and before/after policy, measure volume, and verify forwarding. Registry readback is not event-generation or Sigma detection evidence. Sysmon is out of scope. diff --git a/modules/AuditCatalog.psm1 b/modules/AuditCatalog.psm1 new file mode 100644 index 00000000..e8563fee --- /dev/null +++ b/modules/AuditCatalog.psm1 @@ -0,0 +1,72 @@ +# Identifier validation is separate from recommendations and detection readiness. +Set-StrictMode -Version 2.0 + +function ConvertTo-WelaCanonicalAuditName { + param([string]$Name) + # Only reviewed spelling aliases; do not accept arbitrary fuzzy matches. + switch -CaseSensitive ($Name) { + 'Non-Sensitive Privilege Use' { return 'Non Sensitive Privilege Use' } + 'User / Device Claims' { return 'User/Device Claims' } + 'Central Policy Staging' { return 'Central Access Policy Staging' } + default { return $Name } + } +} + +function Assert-WelaAuditCatalog { + [CmdletBinding()] + param([Parameter(Mandatory)]$Catalog, [Parameter(Mandatory)]$CanonicalCatalog) + $canonical = @{}; $canonicalGuids = @{}; $seen = @{}; $ids = @{} + foreach ($row in $CanonicalCatalog) { + if (-not $row.id -or $canonical.ContainsKey($row.id) -or + $row.guid -notmatch '^[0-9A-Fa-f]{8}(-[0-9A-Fa-f]{4}){3}-[0-9A-Fa-f]{12}$' -or $canonicalGuids.ContainsKey($row.guid)) { + throw 'Invalid or duplicate canonical audit identifier.' + } + $canonical[$row.id] = $row.guid; $canonicalGuids[$row.guid] = $true + } + foreach ($row in @($Catalog | Where-Object { $_.currentSetting.type -eq 'auditpol' })) { + $name = ConvertTo-WelaCanonicalAuditName $row.subCategory + if (-not $row.id -or $ids.ContainsKey($row.id)) { throw "Duplicate or empty legacy audit id: $($row.id)" } + if ($row.select.type -ne 'guid' -or -not $canonical.ContainsKey($name) -or $canonical[$name] -ine $row.select.guid) { + throw "Audit catalog name/GUID mismatch: $($row.subCategory) / $($row.select.guid)" + } + if ($seen.ContainsKey($row.select.guid)) { throw "Duplicate legacy audit GUID: $($row.select.guid)" } + $seen[$row.select.guid] = $true; $ids[$row.id] = $true + } +} + +function Get-WelaEventMappingReview { + [CmdletBinding()] + param( + [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Mappings, + [Parameter(Mandatory)]$CanonicalCatalog, + [Parameter(Mandatory)][ValidateRange(0,65535)][int]$EventId + ) + $byGuid = @{} + foreach ($row in $CanonicalCatalog) { $byGuid[$row.guid] = $row.id } + # Empty category-heading rows must never become EventID 0 through a cast. + $matches = @($Mappings | Where-Object { $_.'Event ID' -match '^\d+$' -and [int]$_.'Event ID' -eq $EventId }) + $candidates = @(); $uncertain = @() + foreach ($row in $matches) { + if (-not $row.Subcategory -or -not $byGuid.ContainsKey($row.GUID)) { + $uncertain += 'CategoryOnlyOrUnknownGuid'; continue + } + if ((ConvertTo-WelaCanonicalAuditName $row.Subcategory) -cne $byGuid[$row.GUID]) { + $uncertain += 'NameGuidMismatch'; continue + } + $candidates += [pscustomobject]@{ Name=$byGuid[$row.GUID]; Guid=$row.GUID } + } + $candidates = @($candidates | Sort-Object Guid -Unique) + $reasons = @($uncertain | Select-Object -Unique) + if (-not $matches.Count) { $reasons += 'NoMapping' } + if ($candidates.Count -gt 1) { $reasons += 'MultipleSubcategories' } + # Even an unambiguous mapping does not prove outcome, object, fields or source role. + $reasons += 'OutcomeObjectAndRoleUnverified' + [pscustomobject]@{ + EventId=$EventId + State=$(if (-not $matches.Count -or -not $candidates.Count) { 'Unknown' } else { 'Conditional' }) + MappingCount=$matches.Count; Candidates=$candidates; Reasons=$reasons + DetectionReady=$false + } +} + +Export-ModuleMember -Function Assert-WelaAuditCatalog, Get-WelaEventMappingReview diff --git a/scripts/LdapDiagnostics.ps1 b/scripts/LdapDiagnostics.ps1 new file mode 100644 index 00000000..3ba08ba2 --- /dev/null +++ b/scripts/LdapDiagnostics.ps1 @@ -0,0 +1,147 @@ +# Opt-in local DC diagnostics; separate from MDI-required Security auditing. +function Get-WelaLdapDefinitions { + $root = 'HKLM:\SYSTEM\CurrentControlSet\Services\NTDS' + @( + [pscustomobject]@{Id='FieldEngineering';Path="$root\Diagnostics";Name='15 Field Engineering';Unit='verbosity';DocumentedDefault=0} + [pscustomobject]@{Id='SearchTime';Path="$root\Parameters";Name='Search Time Threshold (msecs)';Unit='milliseconds';DocumentedDefault=30000} + [pscustomobject]@{Id='Expensive';Path="$root\Parameters";Name='Expensive Search Results Threshold';Unit='entry threshold';DocumentedDefault=10000} + [pscustomobject]@{Id='Inefficient';Path="$root\Parameters";Name='Inefficient Search Results Threshold';Unit='entry threshold';DocumentedDefault=1000} + ) +} + +function Get-WelaLdapHost { + if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { return [pscustomobject]@{Status='NotApplicable';Diagnostic='Windows domain controller required.'} } + $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop + $system = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop + if ($os.ProductType -notin @(1,2,3) -or $system.DomainRole -notin @(0,1,2,3,4,5)) { throw 'Cannot classify the Windows role.' } + if ($os.ProductType -ne 2 -and $system.DomainRole -notin @(4,5)) { return [pscustomobject]@{Status='NotApplicable';Diagnostic='Client/member/CA without DC role: no NTDS diagnostics changes.'} } + if ($os.ProductType -ne 2 -or $system.DomainRole -notin @(4,5)) { throw 'Conflicting domain-controller role observations.' } + if ([int]$os.BuildNumber -notin @(20348,26100)) { return [pscustomobject]@{Status='Unknown';Diagnostic='This workflow supports Server 2022/2025 DC builds 20348/26100.'} } + if (-not [Environment]::Is64BitProcess) { throw 'Run the LDAP workflow in 64-bit PowerShell.' } + [pscustomobject]@{Status='Applicable';ComputerName=$env:COMPUTERNAME;Build=[int]$os.BuildNumber;Diagnostic='Local domain controller; event generation remains unverified.'} +} + +function Get-WelaLdapSnapshot { + $hostState = Get-WelaLdapHost + $values = @() + if ($hostState.Status -eq 'Applicable') { + foreach ($definition in Get-WelaLdapDefinitions) { + $values += [pscustomobject]@{Definition=$definition;State=(Get-WelaRegistryState -Path $definition.Path -Name $definition.Name)} + } + } + [pscustomobject]@{Host=$hostState;Values=$values} +} + +function Get-WelaLdapPlan { + param($Snapshot, [ValidateSet('Preserve','Diagnostic','MdiCleanup')][string]$Mode='Preserve', [hashtable]$Thresholds=@{}) + if ($Mode -ne 'Diagnostic' -and $Thresholds.Count) { throw 'Thresholds require Diagnostic mode.' } + foreach ($id in $Thresholds.Keys) { + if ($id -notin @('SearchTime','Expensive','Inefficient') -or $Thresholds[$id] -is [bool] -or + $Thresholds[$id] -isnot [ValueType] -or [double]$Thresholds[$id] -lt 1 -or [double]$Thresholds[$id] -gt 2147483647 -or + [double]$Thresholds[$id] -ne [int]$Thresholds[$id]) { throw "Invalid positive DWORD threshold: $id" } + } + $rows = foreach ($entry in $Snapshot.Values) { + $desired = $null; $operation = 'Preserve' + if ($Mode -eq 'MdiCleanup') { $operation='Remove' } + elseif ($Mode -eq 'Diagnostic') { + if ($entry.Definition.Id -eq 'FieldEngineering') { $operation='Set'; $desired=5 } + elseif ($Thresholds.ContainsKey($entry.Definition.Id)) { $operation='Set'; $desired=[int]$Thresholds[$entry.Definition.Id] } + } + [pscustomobject]@{Definition=$entry.Definition;Before=$entry.State;Operation=$operation;Desired=$desired} + } + [pscustomobject]@{ + Mode=$Mode;Host=$Snapshot.Host;Controls=@($rows) + Guidance='MDI no longer requires 1644. Diagnostic mode is an explicit troubleshooting/detection choice; MdiCleanup explicitly removes the four listed legacy values. Preserve makes no changes.' + Volume='Field Engineering level 5 can also generate other Directory Service events. Measure volume in a bounded window before wider rollout.' + VerificationScope='Registry policy only; 1644 generation, thresholds in practice, volume and forwarding are not established.' + } +} + +function Test-WelaLdapDesired { + param($Snapshot,$Plan) + if ($Snapshot.Host.Status -ne 'Applicable') { return $false } + foreach ($row in $Plan.Controls) { + $actual = @($Snapshot.Values | Where-Object { $_.Definition.Id -eq $row.Definition.Id }) + if ($actual.Count -ne 1) { return $false } + $state=$actual[0].State + if ($row.Operation -eq 'Set' -and (-not $state.ValueExists -or $state.Type -ne 'DWord' -or $state.Value -ne $row.Desired)) { return $false } + if ($row.Operation -eq 'Remove' -and $state.ValueExists) { return $false } + if ($row.Operation -eq 'Preserve' -and -not (Test-WelaLdapValueEqual $state $row.Before)) { return $false } + } + return $true +} + +function Test-WelaLdapValueEqual { + param($Left,$Right) + # Creating an absent parent for another selected value must not appear as value drift. + return $Left.ValueExists -eq $Right.ValueExists -and $Left.Type -ceq $Right.Type -and + (ConvertTo-Json $Left.Value -Compress) -ceq (ConvertTo-Json $Right.Value -Compress) +} + +function Set-WelaLdapDiagnostics { + param($Context,$Plan) + $state = @{Plan=$Plan;Expected=$null} + $read = { + param($state) + $current = Get-WelaLdapSnapshot + if ($current.Host.Status -ne 'Applicable') { throw "LDAP diagnostics unavailable: $($current.Host.Diagnostic)" } + $current + } + $test = { + param($snapshot,$state) + if (-not $state.Expected) { $state.Expected=$snapshot } + Test-WelaLdapDesired $snapshot $state.Plan + } + $apply = { + param($state) + $fresh=Get-WelaLdapSnapshot + if ((ConvertTo-Json $fresh -Depth 12 -Compress) -cne (ConvertTo-Json $state.Expected -Depth 12 -Compress)) { throw 'LDAP state changed after the pre-change journal; no write performed.' } + # Plan is independently checked against the current pre-change state too. + foreach ($row in $state.Plan.Controls) { + $before=@($fresh.Values | Where-Object { $_.Definition.Id -eq $row.Definition.Id })[0].State + if (-not (Test-WelaLdapValueEqual $before $row.Before)) { throw 'LDAP plan is stale; review a fresh plan.' } + if ($before.ValueExists -and $before.Type -ne 'DWord') { throw "Unknown registry type for $($row.Definition.Name); preserved without mutation." } + } + # Configure thresholds before enabling verbose logging. Cleanup disables it first. + $ordered = if ($state.Plan.Mode -eq 'Diagnostic') { @($state.Plan.Controls | Sort-Object { $_.Definition.Id -eq 'FieldEngineering' }) } else { @($state.Plan.Controls) } + foreach ($row in $ordered) { + if ($row.Operation -eq 'Preserve') { continue } + $before=Get-WelaRegistryState -Path $row.Definition.Path -Name $row.Definition.Name + if (-not (Test-WelaLdapValueEqual $before $row.Before)) { throw "LDAP value changed before writing $($row.Definition.Name); remaining changes stopped." } + if ($row.Operation -eq 'Remove') { + if ($before.ValueExists) { Remove-ItemProperty -LiteralPath $row.Definition.Path -Name $row.Definition.Name -ErrorAction Stop } + } elseif (-not $before.ValueExists -or $before.Type -ne 'DWord' -or $before.Value -ne $row.Desired) { + New-WelaRegistryKey -Path $row.Definition.Path + Set-ItemProperty -LiteralPath $row.Definition.Path -Name $row.Definition.Name -Value $row.Desired -Type DWord -ErrorAction Stop + } + $after=Get-WelaRegistryState -Path $row.Definition.Path -Name $row.Definition.Name + if (($row.Operation -eq 'Remove' -and $after.ValueExists) -or + ($row.Operation -eq 'Set' -and (-not $after.ValueExists -or $after.Type -ne 'DWord' -or $after.Value -ne $row.Desired))) { + throw "LDAP value readback failed for $($row.Definition.Name); remaining changes stopped." + } + } + } + Invoke-WelaConfigurationControl -Context $Context -Id 'LdapDiagnostics/LocalDC' -Kind RegistrySet -Target 'NTDS diagnostics/parameters (four named values only)' -Desired $Plan ` + -Read $read -Compliant $test -Apply $apply -CallbackState $state -Description 'Apply the explicitly selected LDAP diagnostic mode and listed values.' +} + +function Invoke-WelaLdapCommand { + param([ValidateSet('Audit','Plan','Configure')][string]$Action='Audit', + [ValidateSet('Preserve','Diagnostic','MdiCleanup')][string]$Mode='Preserve', [hashtable]$Thresholds=@{}, + [switch]$Auto,[switch]$DryRun,[string]$BackupPath,[string]$ResultsPath) + $snapshot=Get-WelaLdapSnapshot + $plan=Get-WelaLdapPlan -Snapshot $snapshot -Mode $Mode -Thresholds $Thresholds + $report=[pscustomobject]@{ExitCode=$(if ($snapshot.Host.Status -eq 'Unknown') {1} else {0});Scope='ldap-1644-diagnostics';Plan=$plan;Snapshot=$snapshot} + if ($Action -eq 'Configure' -and $Mode -ne 'Preserve') { + if ($snapshot.Host.Status -ne 'Applicable') { throw "LDAP configuration blocked: $($snapshot.Host.Diagnostic)" } + $context=New-WelaConfigurationContext -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath + Set-WelaLdapDiagnostics -Context $context -Plan $plan + $report=Complete-WelaConfiguration -Context $context -SuccessMessage 'Selected LDAP registry changes verified; event generation and forwarding remain unverified.' + $report.Scope='ldap-1644-diagnostics' + $report | Add-Member NoteProperty Plan $plan + } + if ($ResultsPath) { $report | ConvertTo-Json -Depth 16 | Set-Content -LiteralPath $ResultsPath -Encoding UTF8 -ErrorAction Stop } + Write-Host $plan.Guidance + Write-Host $plan.Volume + $report +} diff --git a/scripts/Review-AuditCatalog.ps1 b/scripts/Review-AuditCatalog.ps1 new file mode 100644 index 00000000..c2ae1ede --- /dev/null +++ b/scripts/Review-AuditCatalog.ps1 @@ -0,0 +1,24 @@ +# Developer-facing, read-only catalog review. No Windows query or policy changes. +param([string]$ResultsPath) +$ErrorActionPreference = 'Stop' +Import-Module (Join-Path $PSScriptRoot '../modules/AuditProfiles.psm1') -Force +Import-Module (Join-Path $PSScriptRoot '../modules/AuditCatalog.psm1') -Force +$root = Split-Path $PSScriptRoot -Parent +$canonical = (Import-WelaAuditProfiles).catalog +$legacy = Get-Content (Join-Path $root 'config/baselines.json') -Raw | ConvertFrom-Json +Assert-WelaAuditCatalog -Catalog $legacy.catalog -CanonicalCatalog $canonical +$mappingPath = Join-Path $root 'config/eid_subcategory_mapping.csv' +$mappings = @(Import-Csv -LiteralPath $mappingPath) +$rows = @($mappings | Where-Object { $_.'Event ID' -match '^\d+$' } | ForEach-Object { [int]$_.'Event ID' } | Sort-Object -Unique | ForEach-Object { + Get-WelaEventMappingReview -Mappings $mappings -CanonicalCatalog $canonical -EventId $_ +}) +$result = [pscustomobject]@{ + SchemaVersion=1; Scope='catalog-identifiers-and-mapping-uncertainty'; GeneratedUtc=[DateTime]::UtcNow.ToString('o') + CanonicalCount=$canonical.Count; LegacyCount=@($legacy.catalog | Where-Object { $_.currentSetting.type -eq 'auditpol' }).Count + MappingSha256=(Get-FileHash -LiteralPath $mappingPath -Algorithm SHA256).Hash + CategoryHeadingRows=@($mappings | Where-Object { -not $_.'Event ID' }).Count + Provenance='Bundled mapping candidates, not a build-specific event-generation contract. See docs/audit-catalog-mappings.md.' + Events=$rows; DetectionReadyCount=0 +} +if ($ResultsPath) { $result | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $ResultsPath -Encoding UTF8 -ErrorAction Stop } +$result diff --git a/tests/AuditCatalogMappings.Tests.ps1 b/tests/AuditCatalogMappings.Tests.ps1 new file mode 100644 index 00000000..c6d3ca8a --- /dev/null +++ b/tests/AuditCatalogMappings.Tests.ps1 @@ -0,0 +1,62 @@ +$ErrorActionPreference = 'Stop' +Import-Module (Join-Path $PSScriptRoot '../modules/AuditProfiles.psm1') -Force +Import-Module (Join-Path $PSScriptRoot '../modules/AuditCatalog.psm1') -Force +$script:count = 0 +function Assert($Condition, $Message) { if (-not $Condition) { throw $Message }; $script:count++ } +function Reject([scriptblock]$Action) { + try { & $Action; throw 'Expected rejection did not occur.' } + catch { Assert ($_.Exception.Message -match 'mismatch|Duplicate|duplicate') "Unexpected failure: $_" } +} +$root = Split-Path $PSScriptRoot -Parent +$canonical = (Import-WelaAuditProfiles).catalog +$legacyPath = Join-Path $root 'config/baselines.json' +$legacy = Get-Content $legacyPath -Raw | ConvertFrom-Json +Assert-WelaAuditCatalog $legacy.catalog $canonical +foreach ($case in @('wrong-guid','duplicate-guid','unknown-name','duplicate-id')) { + $copy = Get-Content $legacyPath -Raw | ConvertFrom-Json + $row = $copy.catalog | Where-Object subCategory -eq 'Token Right Adjusted Events' + switch ($case) { + 'wrong-guid' { $row.select.guid='0CCE922E-69AE-11D9-BED3-505054503030' } + 'duplicate-guid' { $copy.catalog += $row } + 'unknown-name' { $row.subCategory='Token Right Adjusted Typo' } + 'duplicate-id' { ($copy.catalog | Where-Object subCategory -eq 'RPC Events').id=$row.id } + } + Reject { Assert-WelaAuditCatalog $copy.catalog $canonical } +} +$mappings = @(Import-Csv (Join-Path $root 'config/eid_subcategory_mapping.csv')) +$ambiguous = Get-WelaEventMappingReview $mappings $canonical 4703 +Assert ($ambiguous.State -eq 'Conditional' -and $ambiguous.Candidates.Count -eq 2 -and $ambiguous.Reasons -contains 'MultipleSubcategories' -and -not $ambiguous.DetectionReady) '4703 conflicting source mappings must remain conditional.' +$object = Get-WelaEventMappingReview $mappings $canonical 4663 +Assert ($object.State -eq 'Conditional' -and -not $object.DetectionReady) 'Object EventID alone never establishes matching object/SACL/outcome.' +$unknown = Get-WelaEventMappingReview $mappings $canonical 65535 +Assert ($unknown.State -eq 'Unknown' -and $unknown.Candidates.Count -eq 0) 'Unknown events cannot acquire a policy or readiness.' +$zero = Get-WelaEventMappingReview $mappings $canonical 0 +Assert ($zero.MappingCount -eq 0 -and $zero.State -eq 'Unknown') 'Blank category rows must not turn into EventID zero.' +$category = Get-WelaEventMappingReview $mappings $canonical 4608 +Assert ($category.Reasons -contains 'CategoryOnlyOrUnknownGuid' -and -not $category.DetectionReady) 'Category GUIDs cannot establish advanced subcategory readiness.' +$rpc = Get-WelaEventMappingReview $mappings $canonical 5712 +Assert ($rpc.Candidates.Count -eq 1 -and $rpc.Candidates[0].Name -eq 'RPC Events' -and $rpc.State -eq 'Conditional') 'A unique mapping still retains outcome/context uncertainty.' +$bad = [pscustomobject]@{'Event ID'='5712';Subcategory='Token Right Adjusted Events';GUID='0CCE922E-69AE-11D9-BED3-505054503030'} +$mismatch = Get-WelaEventMappingReview @($bad) $canonical 5712 +Assert ($mismatch.State -eq 'Unknown' -and $mismatch.Reasons -contains 'NameGuidMismatch') 'Mismatched candidate metadata must not be accepted.' + +# Exercise the actual legacy renderer for every named baseline with distinct RPC/token state. +. (Join-Path $root 'WELA.ps1') help -Role Client -Build 26100 6>$null | Out-Null +function GetAuditpol { @{ '0CCE922E-69AE-11D9-BED3-505054503030'='Failure'; '0CCE924A-69AE-11D9-BED3-505054503030'='Success' } } +function CheckRegistryValue { $false } +function Get-WelaNativeSources { @() } +function Get-WelaNativeSourceState { 'Unknown' } +function Get-WelaOutgoingNtlmState { [pscustomobject]@{Description='Unknown';PolicySource='Unknown'} } +function Get-WelaDomainNtlmState { [pscustomobject]@{Description='Unknown'} } +foreach ($baselineName in $legacy.baselines.PSObject.Properties.Name) { + $rows = BuildAuditResult -all_rules @() -Baseline $baselineName -enabledguid @('0CCE924A-69AE-11D9-BED3-505054503030') + Assert (($rows | Where-Object SubCategory -eq 'RPC Events').CurrentSetting -eq 'Failure') "$baselineName must read RPC independently." + Assert (($rows | Where-Object SubCategory -eq 'Token Right Adjusted Events').CurrentSetting -eq 'Success') "$baselineName must read Token independently." +} +$tmp = Join-Path ([IO.Path]::GetTempPath()) ('wela-mapping-review-'+[guid]::NewGuid().ToString('N')+'.json') +try { + & (Join-Path $root 'scripts/Review-AuditCatalog.ps1') -ResultsPath $tmp | Out-Null + $export = Get-Content $tmp -Raw | ConvertFrom-Json + Assert ($export.DetectionReadyCount -eq 0 -and $export.MappingSha256.Length -eq 64 -and $export.Events.Count -gt 100) 'Review export retains corpus fingerprint and explicit no-readiness semantics.' +} finally { Remove-Item $tmp -ErrorAction SilentlyContinue } +Write-Host "PASS: $script:count catalog/mapping assertions; all legacy baselines preserve distinct RPC/token state." diff --git a/tests/AuditCatalogMappings.Windows.Tests.ps1 b/tests/AuditCatalogMappings.Windows.Tests.ps1 new file mode 100644 index 00000000..202710ef --- /dev/null +++ b/tests/AuditCatalogMappings.Windows.Tests.ps1 @@ -0,0 +1,21 @@ +# Query only: no audit-policy writes or benign event generation. +$ErrorActionPreference = 'Stop' +Import-Module (Join-Path $PSScriptRoot '../modules/AuditProfiles.psm1') -Force +Import-Module (Join-Path $PSScriptRoot '../modules/AuditCatalog.psm1') -Force +$legacy = Get-Content (Join-Path $PSScriptRoot '../config/baselines.json') -Raw | ConvertFrom-Json +$canonical = (Import-WelaAuditProfiles).catalog +Assert-WelaAuditCatalog $legacy.catalog $canonical +$listing = @(& auditpol.exe /list '/subcategory:*' /v 2>&1) +if ($LASTEXITCODE -ne 0) { throw "auditpol listing failed: $($listing -join ' ')" } +$text = $listing -join "`n" +$current = Get-WelaEffectiveAuditPolicy +foreach ($name in @('RPC Events','Token Right Adjusted Events')) { + $row = $legacy.catalog | Where-Object subCategory -eq $name + if ($text -notmatch [regex]::Escape($row.select.guid) -or -not $current.ContainsKey($row.select.guid)) { throw "Native Windows omitted $name / $($row.select.guid)." } + # The hosted image uses English; on localized hosts only GUID presence is asserted. + if ([Globalization.CultureInfo]::InstalledUICulture.TwoLetterISOLanguageName -eq 'en') { + if (-not @($listing | Where-Object { $_ -match [regex]::Escape($row.select.guid) -and $_ -match [regex]::Escape($name) }).Count) { throw "Native name/GUID mismatch for $name." } + } + Write-Host "$name $($row.select.guid) observed mask=$($current[$row.select.guid])" +} +Write-Host 'PASS: native audit identifiers queried; no policy changes or event-generation claims.' diff --git a/tests/AuditProfileOutput.Tests.ps1 b/tests/AuditProfileOutput.Tests.ps1 index 029cdb02..c577ffff 100644 --- a/tests/AuditProfileOutput.Tests.ps1 +++ b/tests/AuditProfileOutput.Tests.ps1 @@ -1,6 +1,7 @@ # Exercise the real profile, audit renderer, rule coverage and CSV output with # injected audit observations. Only temporary files are written; no Windows policy changes. $ErrorActionPreference = 'Stop' +Import-Module (Join-Path $PSScriptRoot '../modules/AuditCatalog.psm1') -Force Import-Module (Join-Path $PSScriptRoot '../modules/AuditProfiles.psm1') -Force $tokens = $null; $parseErrors = $null $ast = [System.Management.Automation.Language.Parser]::ParseFile((Join-Path $PSScriptRoot '../WELA.ps1'), [ref]$tokens, [ref]$parseErrors) diff --git a/tests/LdapDiagnostics.Tests.ps1 b/tests/LdapDiagnostics.Tests.ps1 new file mode 100644 index 00000000..2ff5c8c7 --- /dev/null +++ b/tests/LdapDiagnostics.Tests.ps1 @@ -0,0 +1,166 @@ +$ErrorActionPreference='Stop' +$script:ScriptRoot=Split-Path $PSScriptRoot -Parent +. (Join-Path $script:ScriptRoot 'scripts/Configuration.ps1') +. (Join-Path $script:ScriptRoot 'scripts/LdapDiagnostics.ps1') +$script:count=0 +function Assert($Condition,$Message) { if (-not $Condition) { throw $Message }; $script:count++ } +function Reject([scriptblock]$Action,[string]$Pattern) { + $message=''; try { & $Action | Out-Null } catch { $message=$_.Exception.Message } + Assert ($message -match $Pattern) "Expected '$Pattern', got '$message'." +} +function Reset-Fixture { + $script:values=@{}; $script:writes=@(); $script:role='Applicable'; $script:readError=$false + $script:writeError=$false; $script:ignoreWrite=$false; $script:race=$false; $script:reads=0 + $script:types=@{}; $script:journal=$null; $script:failName=$null +} +function Get-WelaLdapHost { [pscustomobject]@{Status=$script:role;Diagnostic='fixture';ComputerName='dc1';Build=26100} } +function Get-WelaRegistryState { + param($Path,$Name) + if ($script:readError) { throw 'read denied' } + $script:reads++ + if ($script:race -and $script:journal -and (Test-Path $script:journal)) { $script:values['15 Field Engineering']=4; $script:types['15 Field Engineering']='DWord' } + [pscustomobject]@{KeyExists=$true;ValueExists=$script:values.ContainsKey($Name);Value=$script:values[$Name];Type=$script:types[$Name]} +} +function New-WelaRegistryKey { param($Path) } +function Set-ItemProperty { + param($LiteralPath,$Name,$Value,$Type,$ErrorAction) + Assert ($script:journal -and (Test-Path $script:journal)) 'Every native write must follow the complete pre-change journal.' + if ($script:writeError -or $Name -eq $script:failName) { throw 'write denied' } + $script:writes += $Name + if (-not $script:ignoreWrite) { $script:values[$Name]=$Value; $script:types[$Name]=$Type } +} +function Remove-ItemProperty { + param($LiteralPath,$Name,$ErrorAction) + Assert ($script:journal -and (Test-Path $script:journal)) 'Every removal must follow a recovery journal.' + if ($script:writeError -or $Name -eq $script:failName) { throw 'remove denied' } + $script:writes += $Name + if (-not $script:ignoreWrite) { $script:values.Remove($Name); $script:types.Remove($Name) } +} +$cleanup=@() +function New-FixtureContext([switch]$DryRun) { + $path=Join-Path ([IO.Path]::GetTempPath()) ('wela-ldap-'+[guid]::NewGuid().ToString('N')) + $script:cleanup += $path; $script:journal=Join-Path $path 'before.jsonl' + New-WelaConfigurationContext -Auto -DryRun:$DryRun -BackupPath $path +} +try { + Reset-Fixture + foreach ($mode in @('Preserve','Diagnostic','MdiCleanup')) { + $plan=Get-WelaLdapPlan (Get-WelaLdapSnapshot) $mode + Assert ($plan.Controls.Count -eq 4 -and $plan.VerificationScope -match 'not established') 'Every plan enumerates the complete bounded set and keeps event evidence separate.' + } + foreach ($value in @(-1,0,1.5,2147483648,$true)) { + Reject { Get-WelaLdapPlan (Get-WelaLdapSnapshot) Diagnostic @{SearchTime=$value} } 'Invalid' + } + Reject { Get-WelaLdapPlan (Get-WelaLdapSnapshot) MdiCleanup @{SearchTime=1} } 'require Diagnostic' + Reject { Get-WelaLdapPlan (Get-WelaLdapSnapshot) Diagnostic @{Typo=1} } 'Invalid' + $script:values['15 Field Engineering']=4; $script:types['15 Field Engineering']='DWord' + $preserved=Invoke-WelaLdapCommand -Action Configure + Assert ($script:writes.Count -eq 0 -and $preserved.Plan.Mode -eq 'Preserve' -and $script:values['15 Field Engineering'] -eq 4) 'Default command preserves another operator diagnostic level.' + + foreach ($scenario in @('apply','dry','cleanup','read','write','ignored','race','type','drift','preserved-drift','stale')) { + Reset-Fixture + $mode='Diagnostic'; $thresholds=@{SearchTime=100;Expensive=10000;Inefficient=1000} + if ($scenario -eq 'cleanup') { + $mode='MdiCleanup'; $thresholds=@{} + foreach ($definition in Get-WelaLdapDefinitions) { $script:values[$definition.Name]=1; $script:types[$definition.Name]='DWord' } + $script:values['15 Field Engineering']=5 + } + if ($scenario -eq 'preserved-drift') { $thresholds=@{} } + if ($scenario -eq 'type') { $script:values['15 Field Engineering']='5'; $script:types['15 Field Engineering']='String' } + $plan=Get-WelaLdapPlan (Get-WelaLdapSnapshot) $mode $thresholds + if ($scenario -eq 'read') { $script:readError=$true } + if ($scenario -eq 'write') { $script:writeError=$true } + if ($scenario -eq 'ignored') { $script:ignoreWrite=$true } + if ($scenario -eq 'race') { $script:race=$true } + if ($scenario -eq 'stale') { $script:values['15 Field Engineering']=2; $script:types['15 Field Engineering']='DWord' } + $context=New-FixtureContext -DryRun:($scenario -eq 'dry') + Set-WelaLdapDiagnostics $context $plan + if ($scenario -eq 'drift') { $script:values['15 Field Engineering']=0 } + if ($scenario -eq 'preserved-drift') { $script:values['Expensive Search Results Threshold']=5; $script:types['Expensive Search Results Threshold']='DWord' } + $result=Complete-WelaConfiguration $context + switch ($scenario) { + 'apply' { + Assert ($result.ExitCode -eq 0 -and $script:writes.Count -eq 4 -and $script:writes[-1] -eq '15 Field Engineering') 'Verify thresholds before enabling verbosity.' + $before=Get-Content $script:journal -Raw | ConvertFrom-Json + Assert ($before.Before.Values.Count -eq 4 -and @($before.Before.Values | Where-Object {$_.State.ValueExists}).Count -eq 0) 'Journal contains all exact missing-value states.' + $again=Get-WelaLdapPlan (Get-WelaLdapSnapshot) Diagnostic $thresholds + Set-WelaLdapDiagnostics $context $again + Assert ($script:writes.Count -eq 4 -and $context.Results[1].Status -eq 'AlreadyCompliant') 'Repeat configuration performs no duplicate writes.' + } + 'dry' { Assert ($result.ExitCode -eq 0 -and $script:writes.Count -eq 0 -and -not (Test-Path $context.BackupPath)) 'Dry-run makes no settings/journal changes.' } + 'cleanup' { Assert ($result.ExitCode -eq 0 -and $script:values.Count -eq 0 -and $script:writes[0] -eq '15 Field Engineering') 'Explicit MDI cleanup removes only named values, verbosity first.' } + 'ignored' { Assert ($result.ExitCode -eq 1 -and $script:writes.Count -eq 1 -and $script:writes -notcontains '15 Field Engineering') 'Failed threshold readback cannot proceed to enabling diagnostics.' } + 'drift' { Assert ($result.ExitCode -eq 1 -and $context.Results[0].Status -eq 'Overridden') 'Final diagnostic drift fails.' } + 'preserved-drift' { Assert ($result.ExitCode -eq 1) 'Unselected thresholds must remain preserved.' } + default { Assert ($result.ExitCode -eq 1 -and $script:writes.Count -eq 0) "Scenario $scenario must block before native writes." } + } + } + # Exercise actual partial mutation boundaries: recovery is journal-based and + # does not automatically overwrite the successfully changed subset. + foreach ($mode in @('Diagnostic','MdiCleanup')) { + Reset-Fixture + $thresholds=@{SearchTime=100;Expensive=10000;Inefficient=1000} + if ($mode -eq 'MdiCleanup') { + $thresholds=@{} + foreach ($definition in Get-WelaLdapDefinitions) { $script:values[$definition.Name]=1; $script:types[$definition.Name]='DWord' } + $script:values['15 Field Engineering']=5 + $script:failName='Search Time Threshold (msecs)' + } else { $script:failName='15 Field Engineering' } + $script:values['Unrelated diagnostic']=7; $script:types['Unrelated diagnostic']='DWord' + $plan=Get-WelaLdapPlan (Get-WelaLdapSnapshot) $mode $thresholds + $context=New-FixtureContext + Set-WelaLdapDiagnostics $context $plan + $result=Complete-WelaConfiguration $context + $journal=Get-Content $script:journal -Raw | ConvertFrom-Json + Assert ($result.ExitCode -eq 1 -and $journal.Before.Values.Count -eq 4) 'A failure after earlier writes retains the complete recovery snapshot.' + Assert ($script:values['Unrelated diagnostic'] -eq 7) 'Partial failures preserve unrelated registry values.' + if ($mode -eq 'Diagnostic') { + Assert ($script:writes.Count -eq 3 -and -not $script:values.ContainsKey('15 Field Engineering')) 'Failed verbosity write leaves verified thresholds and does not claim success.' + } else { + Assert ($script:writes.Count -eq 1 -and -not $script:values.ContainsKey('15 Field Engineering') -and $script:values['Search Time Threshold (msecs)'] -eq 1 -and $script:values['Expensive Search Results Threshold'] -eq 1 -and $script:values['Inefficient Search Results Threshold'] -eq 1) 'Cleanup stops after the first failed threshold removal without restoring verbosity or removing later values.' + } + } + Reset-Fixture; $script:role='NotApplicable' + $report=Invoke-WelaLdapCommand -Action Audit + Assert ($report.Snapshot.Values.Count -eq 0 -and $script:reads -eq 0) 'Member/client/non-DC CA never queries or creates NTDS settings.' + Reject { Invoke-WelaLdapCommand -Action Configure -Mode Diagnostic } 'blocked' + Reset-Fixture; $script:role='Unknown' + Assert ((Invoke-WelaLdapCommand).ExitCode -eq 1) 'Unknown host applicability cannot look successful.' + + # Execute the real general configure body with all native boundaries replaced. + $ast=[Management.Automation.Language.Parser]::ParseFile((Join-Path $script:ScriptRoot 'WELA.ps1'),[ref]$null,[ref]$null) + # Preserve the real ordering of the LDAP guard and early profile dispatch, while + # replacing the profile handler so configure can never change this test host. + $dispatchNodes=@($ast.EndBlock.Statements | Where-Object { + $_ -is [Management.Automation.Language.IfStatementAst] -and + ($_.Extent.Text -match 'LDAP options require' -or $_.Extent.Text -match 'Invoke-WelaProfileCommand -Command') + }) + Assert ($dispatchNodes.Count -eq 2) 'Exercise both real CLI boundaries in source order.' + $dispatch=[scriptblock]::Create('param($Cmd,$Profile,$LdapAction,$LdapMode,$LdapSearchTimeMs,$LdapExpensiveThreshold,$LdapInefficientThreshold)' + [Environment]::NewLine + (($dispatchNodes | ForEach-Object {$_.Extent.Text}) -join [Environment]::NewLine)) + function Invoke-WelaProfileCommand { param($Command) $script:profileDispatched=$true } + foreach ($command in @('plan','audit','audit-settings','configure')) { + foreach ($option in @('LdapAction','LdapMode','LdapSearchTimeMs','LdapExpensiveThreshold','LdapInefficientThreshold')) { + $script:profileDispatched=$false + $arguments=@{Cmd=$command;Profile='fixture'}; $arguments[$option]='fixture' + Reject { & $dispatch @arguments } 'LDAP options require' + Assert (-not $script:profileDispatched) "LDAP option $option must block $command before unrelated profile dispatch." + } + } + $node=$ast.Find({param($n) $n -is [Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq 'ConfigureAuditSettings'},$false) + . ([scriptblock]::Create($node.Extent.Text)) + function TestWindows {$true}; function TestAdministrator {$true} + function Get-WelaHostContext {[pscustomobject]@{Role='DomainController';Build=26100}} + function Get-WelaEffectiveAuditPolicy {@{}} + function Get-WelaAuditProfilePlan {[pscustomobject]@{profile='fixture'}} + function Assert-WelaAuditProfileTarget {} + function Set-WelaEventLogProfileControls {} + function Set-WelaEventLogControl {} + function Set-WelaRegistryControl {param($Context,$Path,$Name) if ($Path -like '*NTDS*') {throw 'General configure attempted NTDS diagnostics'} } + function Set-RegistryConfig {param($RegPaths) if (@($RegPaths | Where-Object {$_.Path -like '*NTDS*'}).Count) {throw 'General configure attempted NTDS diagnostics'} } + function Set-WelaOutgoingNtlmPolicy {};function Set-WelaDomainNtlmAudit {};function Show-WelaAuditProfilePrerequisites {};function Set-WelaProfileAuditControls {};function Set-WelaCertificateAuditControl {} + function Complete-WelaConfiguration {[pscustomobject]@{ExitCode=0}} + $script:PowerShellPolicyRoots=@(); $script:IncludeOptional=$false + $general=ConfigureAuditSettings -Auto -DryRun + Assert ($general.ExitCode -eq 0) 'Normal DC configure must not re-enable or disable LDAP diagnostics.' + Write-Host "PASS: $script:count LDAP diagnostics assertions; no Windows changes." +} finally { foreach ($path in $cleanup) { if (Test-Path $path) { Remove-Item $path -Recurse -Force } } } diff --git a/tests/LdapDiagnostics.Windows.Tests.ps1 b/tests/LdapDiagnostics.Windows.Tests.ps1 new file mode 100644 index 00000000..d2def0bc --- /dev/null +++ b/tests/LdapDiagnostics.Windows.Tests.ps1 @@ -0,0 +1,8 @@ +$ErrorActionPreference='Stop' +$root=Split-Path $PSScriptRoot -Parent +. (Join-Path $root 'scripts/Configuration.ps1') +. (Join-Path $root 'scripts/LdapDiagnostics.ps1') +$result=Invoke-WelaLdapCommand -Action Audit +if ($result.Snapshot.Host.Status -notin @('Applicable','NotApplicable')) { throw 'Unexpected unknown native host applicability.' } +if ($result.Snapshot.Host.Status -eq 'NotApplicable' -and $result.Snapshot.Values.Count) { throw 'Non-DC incorrectly queried NTDS values.' } +Write-Host "PASS: native read-only LDAP applicability $($result.Snapshot.Host.Status); no registry writes or LDAP queries." diff --git a/tests/NativeProviders.Tests.ps1 b/tests/NativeProviders.Tests.ps1 index 6683eba3..de0a3b8b 100644 --- a/tests/NativeProviders.Tests.ps1 +++ b/tests/NativeProviders.Tests.ps1 @@ -1,5 +1,7 @@ # Real catalog + public audit renderer/exports; Windows reads are injected at the OS boundary. $ErrorActionPreference = 'Stop' +Import-Module (Join-Path $PSScriptRoot '../modules/AuditProfiles.psm1') -Force +Import-Module (Join-Path $PSScriptRoot '../modules/AuditCatalog.psm1') -Force Import-Module (Join-Path $PSScriptRoot '../modules/NativeProviders.psm1') -Force $module = Get-Module NativeProviders & $module { diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index d0b04e15..c8fbb366 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -9,6 +9,9 @@ - Windows標準のドメイン/Kerberos環境向けに、送信元設定と明示的に選択した収集サーバーのサブスクリプションを監査・計画・設定する任意実行の`wef-source`と`wec-collector`を追加しました。実際の収集先ID、既存リスナーと範囲を限定した受信規則、送信元SID、メンバーホストでの読み取り権限の追加、明示的なASDのWSMan強化設定と共通チャネル設定を、復旧記録・変更検出・読み戻しで確認します。DCのグループ管理権限と異なる既存サブスクリプションは変更しません。JSONにはクエリ・チャネル・実行状態の証拠を保持し、実効読み取り権限・イベント到着・転送後のSigma検知範囲は未検証と表示します。隔離Windows環境での配備検証は別途必要です。 (#406) (@Shirofune-Security) - CIS v4.0.0 Level 2向けに、Windows PowerShell 5.1トランスクリプトの明示的な監査・計画・設定機能を追加しました。管理者が選択した既存の出力ディレクトリを確認し、ポリシーとは分けて報告します。型を含むレジストリ変更前の状態を保存し、共有される32/64ビットのビューと変更後の状態を検証します。呼び出しヘッダーの設定は保持し、ACL・共有・保存期間は変更せず、Sigma EVTX検知範囲の向上も自動加算しません。使い捨て環境の実トランスクリプトテストでは元のポリシーを復元します。中央保存先の権限と収集は別途検証が必要です。 (#405) (@Shirofune-Security) +- 通常の設定でDCのField Engineeringを自動的にレベル5へ変更せず、既存のLDAP 1644診断設定を保持するようにしました。明示的な`ldap-diagnostics`の監査・計画・設定を追加し、保持、しきい値を指定した診断、MDIの旧設定削除を選択できます。役割・ビルド確認、型付き復旧記録、競合検出、順序付きの読み戻しと最終状態確認に対応します。LDAP専用オプションを他のプロファイルコマンドへ指定すると、実行前に拒否します。イベント生成・量・転送は隔離DCでの検証が必要です。 (#404) (@Shirofune-Security) +- 従来のToken Right Adjusted EventsのGUIDを修正し、すべてのベースラインでRPCとトークン監査の状態を個別に評価するようにしました。実行時のカタログID・明示的な名前別表記の検証と、ハッシュ付きの読み取り専用EventID対応表レビューを追加しました。曖昧な対応、カテゴリだけの対応、不明な対応から検知可能とは判断しません。 (#403) (@Shirofune-Security) + - MDIのドメイン/Exchange Configuration監査と、明示的に選択した証明書テンプレート/登録サービスオブジェクト向けに、任意実行の`ad-object-sacl`監査・計画・設定・保守的なロールバックを追加しました。接続先DCとスキーマGUIDを検証し、既存のセキュリティ設定を保持したまま不足する監査ACEだけをSACLに追加します。変更前のSDDLと追加ACEを保存し、書き込み後と最終状態を確認します。任意のdMSA前提条件が不明な場合は未確認のスキップ項目として報告し、独立した他の5種類のドメインクラスの監査ACEは引き続き設定します。実効監査ポリシー、継承・レプリケーション、4662/5136イベントの証拠は隔離DCで別途検証が必要です。Sigma検知範囲の向上は未検証です。 (#402) (@Shirofune-Security) - Microsoft WEF Appendix Cのチャネルを監査・計画・設定する任意実行の`channel-settings`を追加しました。CAPI2の有効化、原典の正確なバイト数、明示的に指定したEvent Log Readersの読み取りACEに対応します。既存のセキュリティ記述子・ACEと大きいバッファを保持し、安全に扱えないACLは変更しません。共通の復旧記録、書き込み直前の状態確認と読み戻しで失敗を報告します。Appendix E/Fの標準チャネル一覧からSysmonとEMETを除外し、実際のIDによるアクセス・イベント生成・収集は未検証と表示します。Windowsラボでの検証は別途必要です。 (#401) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index 24478f94..c04b33c3 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -9,6 +9,9 @@ - Added separate opt-in `wef-source` and `wec-collector` audit, plan and configure commands for native domain/Kerberos source settings and explicitly selected collector subscriptions. Actual collector identity, scoped existing ingress/listener prerequisites, explicit source SIDs, additive member-host read permissions, optional ASD WSMan hardening and shared channel controls are checked with journals, drift guards and readback. DC group authority and different existing subscriptions are preserved. JSON retains query/channel/runtime evidence without claiming effective read access, event arrival or forwarded Sigma coverage; isolated Windows deployment validation remains pending. (#406) (@Shirofune-Security) - Added explicit CIS v4.0.0 Level 2 Windows PowerShell 5.1 transcription audit, plan and configure actions. An operator-selected existing output directory is checked and reported separately from policy; typed canonical registry writes are journaled, verified through shared 32/64-bit views and checked for drift while preserving invocation-header preferences. No ACL/share/retention changes or automatic Sigma EVTX credit are introduced; disposable native transcript tests restore original policy, and central authorization/collection remains a deployment check. (#405) (@Shirofune-Security) +- Preserved LDAP 1644 diagnostics during normal configuration instead of automatically enabling Field Engineering level 5 on DCs. Added explicit `ldap-diagnostics` audit/plan/configure modes for preservation, tunable diagnostics and MDI legacy cleanup, with role/build checks, typed recovery snapshots, race guards, ordered readback and final drift checks. LDAP-only options are rejected before unrelated profile commands can run. Generated events, volume and forwarding remain isolated-DC validation. (#404) (@Shirofune-Security) +- Corrected the legacy Token Right Adjusted Events GUID so RPC and token auditing are assessed independently in every baseline. Added runtime catalog identity/alias checks and a read-only, fingerprinted EventID mapping review that preserves ambiguous, category-only and unknown candidates without detection credit. (#403) (@Shirofune-Security) + - Added opt-in `ad-object-sacl` audit, plan, configure and conservative rollback actions for MDI domain/Exchange Configuration auditing and explicitly selected certificate template/enrollment service objects. Exact DC binding, schema GUID checks, additive SACL-only changes, pre-write SDDL/ACE receipts and read-back preserve existing security entries. Unknown optional dMSA prerequisites are reported as a separate skipped gap while the five independent domain class ACEs continue. Effective audit policy, inheritance/replication and 4662/5136 event evidence remain separate isolated-DC checks; no Sigma uplift is claimed. (#402) (@Shirofune-Security) - Added opt-in `channel-settings` audit, plan and configure actions for Microsoft WEF Appendix C, including CAPI2 enablement, exact source byte sizes and an explicitly requested Event Log Readers read ACE. Existing descriptor components/ACEs and larger buffers are preserved or unsafe ACL edits are refused; shared journaling, fresh-state guards and readback report failures. Native Appendix E/F channel inventories exclude Sysmon/EMET and retain unverified identity access, generation and ingestion prerequisites. Windows lab evidence remains pending. (#401) (@Shirofune-Security)