From 9e2b4b5b43ea4f0d5abeebd7482955cc721c21e4 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Sat, 19 Sep 2026 07:09:07 +0900 Subject: [PATCH 1/3] Make LDAP 1644 diagnostics explicit and preserve existing DC settings --- .github/workflows/ldap-diagnostics.yml | 25 ++++ CHANGELOG-Japanese.md | 2 + CHANGELOG.md | 2 + WELA.ps1 | 33 +++++- docs/ldap-diagnostics.md | 35 ++++++ scripts/LdapDiagnostics.ps1 | 147 ++++++++++++++++++++++++ tests/LdapDiagnostics.Tests.ps1 | 124 ++++++++++++++++++++ tests/LdapDiagnostics.Windows.Tests.ps1 | 8 ++ website/docs/resources/changelog.ja.md | 2 + website/docs/resources/changelog.md | 2 + 10 files changed, 375 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/ldap-diagnostics.yml create mode 100644 docs/ldap-diagnostics.md create mode 100644 scripts/LdapDiagnostics.ps1 create mode 100644 tests/LdapDiagnostics.Tests.ps1 create mode 100644 tests/LdapDiagnostics.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 9cb8750f..77a063c9 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,6 +4,8 @@ **改善:** +- 通常の設定でDCのField Engineeringを自動的にレベル5へ変更せず、既存のLDAP 1644診断設定を保持するようにしました。明示的な`ldap-diagnostics`の監査・計画・設定を追加し、保持、しきい値を指定した診断、MDIの旧設定削除を選択できます。役割・ビルド確認、型付き復旧記録、競合検出、順序付きの読み戻しと最終状態確認に対応します。イベント生成・量・転送は隔離DCでの検証が必要です。 (#383) (@Shirofune-Security) + - MDIのドメイン/Exchange Configuration監査と、明示的に選択した証明書テンプレート/登録サービスオブジェクト向けに、任意実行の`ad-object-sacl`監査・計画・設定・保守的なロールバックを追加しました。接続先DCとスキーマGUIDを検証し、既存のセキュリティ設定を保持したまま不足する監査ACEだけをSACLに追加します。変更前のSDDLと追加ACEを保存し、書き込み後と最終状態を確認します。任意のdMSA前提条件が不明な場合は未確認のスキップ項目として報告し、独立した他の5種類のドメインクラスの監査ACEは引き続き設定します。実効監査ポリシー、継承・レプリケーション、4662/5136イベントの証拠は隔離DCで別途検証が必要です。Sigma検知範囲の向上は未検証です。 (#402) (@Shirofune-Security) - Microsoft WEF Appendix Cのチャネルを監査・計画・設定する任意実行の`channel-settings`を追加しました。CAPI2の有効化、原典の正確なバイト数、明示的に指定したEvent Log Readersの読み取りACEに対応します。既存のセキュリティ記述子・ACEと大きいバッファを保持し、安全に扱えないACLは変更しません。共通の復旧記録、書き込み直前の状態確認と読み戻しで失敗を報告します。Appendix E/Fの標準チャネル一覧からSysmonとEMETを除外し、実際のIDによるアクセス・イベント生成・収集は未検証と表示します。Windowsラボでの検証は別途必要です。 (#401) (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34855338..b62472cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ **Improvements:** +- 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. Generated events, volume and forwarding remain isolated-DC validation. (#383) (@Shirofune-Security) + - Added opt-in `ad-object-sacl` audit, plan, configure and conservative rollback actions for MDI domain/Exchange Configuration auditing and explicitly selected certificate template/enrollment service objects. Exact DC binding, schema GUID checks, additive SACL-only changes, pre-write SDDL/ACE receipts and read-back preserve existing security entries. Unknown optional dMSA prerequisites are reported as a separate skipped gap while the five independent domain class ACEs continue. Effective audit policy, inheritance/replication and 4662/5136 event evidence remain separate isolated-DC checks; no Sigma uplift is claimed. (#402) (@Shirofune-Security) - Added opt-in `channel-settings` audit, plan and configure actions for Microsoft WEF Appendix C, including CAPI2 enablement, exact source byte sizes and an explicitly requested Event Log Readers read ACE. Existing descriptor components/ACEs and larger buffers are preserved or unsafe ACL edits are refused; shared journaling, fresh-state guards and readback report failures. Native Appendix E/F channel inventories exclude Sysmon/EMET and retain unverified identity access, generation and ingestion prerequisites. Windows lab evidence remains pending. (#401) (@Shirofune-Security) diff --git a/WELA.ps1 b/WELA.ps1 index 30ace2ba..4b3c6683 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -37,6 +37,11 @@ [ValidateSet('List', 'Audit', 'Plan', 'Configure')][string]$WmiAction = 'List', [string[]]$WmiNamespace, [switch]$WmiIncludeChildren, + [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 ) @@ -53,6 +58,7 @@ $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") @@ -1426,9 +1432,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. @@ -1694,6 +1698,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 @@ -1761,13 +1768,13 @@ 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 'channel-settings' -and $ChannelAction -eq 'Configure') -and -not ($Cmd -eq 'ad-object-sacl' -and $AdSaclAction -in @('Configure', 'Rollback')) -and -not ($Cmd -eq 'wmi-auditing' -and $WmiAction -eq 'Configure')) { - throw "-DryRun is supported only by configure (including configure -Profile), configure-eventlogs, firewall-logging -FirewallAction Configure, smb-auditing -SmbAction Configure, wmi-auditing -WmiAction Configure, channel-settings -ChannelAction Configure, applocker-readiness -AppLockerAction Import, and ad-object-sacl -AdSaclAction Configure|Rollback. No command was run." + throw "-DryRun is supported only by configure (including configure -Profile), configure-eventlogs, firewall-logging -FirewallAction Configure, smb-auditing -SmbAction Configure, wmi-auditing -WmiAction Configure, channel-settings -ChannelAction Configure, 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.' @@ -1792,7 +1799,23 @@ if ($Profile -and $Cmd.ToLower() -in @('plan', 'audit', 'audit-settings', 'confi return } +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.' +} + switch ($Cmd.ToLower()) { + '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/docs/ldap-diagnostics.md b/docs/ldap-diagnostics.md new file mode 100644 index 00000000..623f25ea --- /dev/null +++ b/docs/ldap-diagnostics.md @@ -0,0 +1,35 @@ +# 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. + +```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/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/tests/LdapDiagnostics.Tests.ps1 b/tests/LdapDiagnostics.Tests.ps1 new file mode 100644 index 00000000..4bb5b975 --- /dev/null +++ b/tests/LdapDiagnostics.Tests.ps1 @@ -0,0 +1,124 @@ +$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 +} +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) { 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) { 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." } + } + } + 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) + $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/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index a654f834..faae013c 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,6 +7,8 @@ **改善:** +- 通常の設定でDCのField Engineeringを自動的にレベル5へ変更せず、既存のLDAP 1644診断設定を保持するようにしました。明示的な`ldap-diagnostics`の監査・計画・設定を追加し、保持、しきい値を指定した診断、MDIの旧設定削除を選択できます。役割・ビルド確認、型付き復旧記録、競合検出、順序付きの読み戻しと最終状態確認に対応します。イベント生成・量・転送は隔離DCでの検証が必要です。 (#383) (@Shirofune-Security) + - MDIのドメイン/Exchange Configuration監査と、明示的に選択した証明書テンプレート/登録サービスオブジェクト向けに、任意実行の`ad-object-sacl`監査・計画・設定・保守的なロールバックを追加しました。接続先DCとスキーマGUIDを検証し、既存のセキュリティ設定を保持したまま不足する監査ACEだけをSACLに追加します。変更前のSDDLと追加ACEを保存し、書き込み後と最終状態を確認します。任意のdMSA前提条件が不明な場合は未確認のスキップ項目として報告し、独立した他の5種類のドメインクラスの監査ACEは引き続き設定します。実効監査ポリシー、継承・レプリケーション、4662/5136イベントの証拠は隔離DCで別途検証が必要です。Sigma検知範囲の向上は未検証です。 (#402) (@Shirofune-Security) - Microsoft WEF Appendix Cのチャネルを監査・計画・設定する任意実行の`channel-settings`を追加しました。CAPI2の有効化、原典の正確なバイト数、明示的に指定したEvent Log Readersの読み取りACEに対応します。既存のセキュリティ記述子・ACEと大きいバッファを保持し、安全に扱えないACLは変更しません。共通の復旧記録、書き込み直前の状態確認と読み戻しで失敗を報告します。Appendix E/Fの標準チャネル一覧からSysmonとEMETを除外し、実際のIDによるアクセス・イベント生成・収集は未検証と表示します。Windowsラボでの検証は別途必要です。 (#401) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index 630d34bd..533d5a64 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,6 +7,8 @@ **Improvements:** +- 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. Generated events, volume and forwarding remain isolated-DC validation. (#383) (@Shirofune-Security) + - Added opt-in `ad-object-sacl` audit, plan, configure and conservative rollback actions for MDI domain/Exchange Configuration auditing and explicitly selected certificate template/enrollment service objects. Exact DC binding, schema GUID checks, additive SACL-only changes, pre-write SDDL/ACE receipts and read-back preserve existing security entries. Unknown optional dMSA prerequisites are reported as a separate skipped gap while the five independent domain class ACEs continue. Effective audit policy, inheritance/replication and 4662/5136 event evidence remain separate isolated-DC checks; no Sigma uplift is claimed. (#402) (@Shirofune-Security) - Added opt-in `channel-settings` audit, plan and configure actions for Microsoft WEF Appendix C, including CAPI2 enablement, exact source byte sizes and an explicitly requested Event Log Readers read ACE. Existing descriptor components/ACEs and larger buffers are preserved or unsafe ACL edits are refused; shared journaling, fresh-state guards and readback report failures. Native Appendix E/F channel inventories exclude Sysmon/EMET and retain unverified identity access, generation and ingestion prerequisites. Windows lab evidence remains pending. (#401) (@Shirofune-Security) From c7cfb0d1128db7ae6397365a6be671d617eb7034 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Sat, 19 Sep 2026 07:15:31 +0900 Subject: [PATCH 2/3] Reject LDAP options before profile command dispatch --- CHANGELOG-Japanese.md | 2 +- CHANGELOG.md | 2 +- WELA.ps1 | 8 ++--- docs/ldap-diagnostics.md | 2 ++ tests/LdapDiagnostics.Tests.ps1 | 48 ++++++++++++++++++++++++-- website/docs/resources/changelog.ja.md | 2 +- website/docs/resources/changelog.md | 2 +- 7 files changed, 55 insertions(+), 11 deletions(-) diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index 77a063c9..962c7ead 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,7 +4,7 @@ **改善:** -- 通常の設定でDCのField Engineeringを自動的にレベル5へ変更せず、既存のLDAP 1644診断設定を保持するようにしました。明示的な`ldap-diagnostics`の監査・計画・設定を追加し、保持、しきい値を指定した診断、MDIの旧設定削除を選択できます。役割・ビルド確認、型付き復旧記録、競合検出、順序付きの読み戻しと最終状態確認に対応します。イベント生成・量・転送は隔離DCでの検証が必要です。 (#383) (@Shirofune-Security) +- 通常の設定でDCのField Engineeringを自動的にレベル5へ変更せず、既存のLDAP 1644診断設定を保持するようにしました。明示的な`ldap-diagnostics`の監査・計画・設定を追加し、保持、しきい値を指定した診断、MDIの旧設定削除を選択できます。役割・ビルド確認、型付き復旧記録、競合検出、順序付きの読み戻しと最終状態確認に対応します。LDAP専用オプションを他のプロファイルコマンドへ指定すると、実行前に拒否します。イベント生成・量・転送は隔離DCでの検証が必要です。 (#383) (@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 b62472cf..563f3b18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ **Improvements:** -- 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. Generated events, volume and forwarding remain isolated-DC validation. (#383) (@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. (#383) (@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 4b3c6683..a02ddce6 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -1794,15 +1794,15 @@ 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 } -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.' -} - switch ($Cmd.ToLower()) { '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 } diff --git a/docs/ldap-diagnostics.md b/docs/ldap-diagnostics.md index 623f25ea..3919aab7 100644 --- a/docs/ldap-diagnostics.md +++ b/docs/ldap-diagnostics.md @@ -4,6 +4,8 @@ Normal `configure` preserves existing LDAP diagnostics and explains that 1644 is 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 diff --git a/tests/LdapDiagnostics.Tests.ps1 b/tests/LdapDiagnostics.Tests.ps1 index 4bb5b975..2ff5c8c7 100644 --- a/tests/LdapDiagnostics.Tests.ps1 +++ b/tests/LdapDiagnostics.Tests.ps1 @@ -11,7 +11,7 @@ function Reject([scriptblock]$Action,[string]$Pattern) { 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:types=@{}; $script:journal=$null; $script:failName=$null } function Get-WelaLdapHost { [pscustomobject]@{Status=$script:role;Diagnostic='fixture';ComputerName='dc1';Build=26100} } function Get-WelaRegistryState { @@ -25,14 +25,14 @@ 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) { throw 'write denied' } + 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) { throw 'remove denied' } + 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) } } @@ -95,6 +95,31 @@ try { 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.' @@ -104,6 +129,23 @@ try { # 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} diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index faae013c..7edbbdb9 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,7 +7,7 @@ **改善:** -- 通常の設定でDCのField Engineeringを自動的にレベル5へ変更せず、既存のLDAP 1644診断設定を保持するようにしました。明示的な`ldap-diagnostics`の監査・計画・設定を追加し、保持、しきい値を指定した診断、MDIの旧設定削除を選択できます。役割・ビルド確認、型付き復旧記録、競合検出、順序付きの読み戻しと最終状態確認に対応します。イベント生成・量・転送は隔離DCでの検証が必要です。 (#383) (@Shirofune-Security) +- 通常の設定でDCのField Engineeringを自動的にレベル5へ変更せず、既存のLDAP 1644診断設定を保持するようにしました。明示的な`ldap-diagnostics`の監査・計画・設定を追加し、保持、しきい値を指定した診断、MDIの旧設定削除を選択できます。役割・ビルド確認、型付き復旧記録、競合検出、順序付きの読み戻しと最終状態確認に対応します。LDAP専用オプションを他のプロファイルコマンドへ指定すると、実行前に拒否します。イベント生成・量・転送は隔離DCでの検証が必要です。 (#383) (@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 533d5a64..5cdbc775 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,7 +7,7 @@ **Improvements:** -- 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. Generated events, volume and forwarding remain isolated-DC validation. (#383) (@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. (#383) (@Shirofune-Security) - Added opt-in `ad-object-sacl` audit, plan, configure and conservative rollback actions for MDI domain/Exchange Configuration auditing and explicitly selected certificate template/enrollment service objects. Exact DC binding, schema GUID checks, additive SACL-only changes, pre-write SDDL/ACE receipts and read-back preserve existing security entries. Unknown optional dMSA prerequisites are reported as a separate skipped gap while the five independent domain class ACEs continue. Effective audit policy, inheritance/replication and 4662/5136 event evidence remain separate isolated-DC checks; no Sigma uplift is claimed. (#402) (@Shirofune-Security) - Added opt-in `channel-settings` audit, plan and configure actions for Microsoft WEF Appendix C, including CAPI2 enablement, exact source byte sizes and an explicitly requested Event Log Readers read ACE. Existing descriptor components/ACEs and larger buffers are preserved or unsafe ACL edits are refused; shared journaling, fresh-state guards and readback report failures. Native Appendix E/F channel inventories exclude Sysmon/EMET and retain unverified identity access, generation and ingestion prerequisites. Windows lab evidence remains pending. (#401) (@Shirofune-Security) From bca56fbad93d53821ed9cf513dd7beab9448ee52 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Sat, 19 Sep 2026 07:18:27 +0900 Subject: [PATCH 3/3] Link LDAP diagnostics changelog to PR 404 --- CHANGELOG-Japanese.md | 2 +- CHANGELOG.md | 2 +- website/docs/resources/changelog.ja.md | 2 +- website/docs/resources/changelog.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index 962c7ead..f98a4e1c 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,7 +4,7 @@ **改善:** -- 通常の設定でDCのField Engineeringを自動的にレベル5へ変更せず、既存のLDAP 1644診断設定を保持するようにしました。明示的な`ldap-diagnostics`の監査・計画・設定を追加し、保持、しきい値を指定した診断、MDIの旧設定削除を選択できます。役割・ビルド確認、型付き復旧記録、競合検出、順序付きの読み戻しと最終状態確認に対応します。LDAP専用オプションを他のプロファイルコマンドへ指定すると、実行前に拒否します。イベント生成・量・転送は隔離DCでの検証が必要です。 (#383) (@Shirofune-Security) +- 通常の設定でDCのField Engineeringを自動的にレベル5へ変更せず、既存のLDAP 1644診断設定を保持するようにしました。明示的な`ldap-diagnostics`の監査・計画・設定を追加し、保持、しきい値を指定した診断、MDIの旧設定削除を選択できます。役割・ビルド確認、型付き復旧記録、競合検出、順序付きの読み戻しと最終状態確認に対応します。LDAP専用オプションを他のプロファイルコマンドへ指定すると、実行前に拒否します。イベント生成・量・転送は隔離DCでの検証が必要です。 (#404) (@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 563f3b18..0b43cb89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ **Improvements:** -- 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. (#383) (@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) - Added opt-in `ad-object-sacl` audit, plan, configure and conservative rollback actions for MDI domain/Exchange Configuration auditing and explicitly selected certificate template/enrollment service objects. Exact DC binding, schema GUID checks, additive SACL-only changes, pre-write SDDL/ACE receipts and read-back preserve existing security entries. Unknown optional dMSA prerequisites are reported as a separate skipped gap while the five independent domain class ACEs continue. Effective audit policy, inheritance/replication and 4662/5136 event evidence remain separate isolated-DC checks; no Sigma uplift is claimed. (#402) (@Shirofune-Security) - Added opt-in `channel-settings` audit, plan and configure actions for Microsoft WEF Appendix C, including CAPI2 enablement, exact source byte sizes and an explicitly requested Event Log Readers read ACE. Existing descriptor components/ACEs and larger buffers are preserved or unsafe ACL edits are refused; shared journaling, fresh-state guards and readback report failures. Native Appendix E/F channel inventories exclude Sysmon/EMET and retain unverified identity access, generation and ingestion prerequisites. Windows lab evidence remains pending. (#401) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index 7edbbdb9..2b8c26ec 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,7 +7,7 @@ **改善:** -- 通常の設定でDCのField Engineeringを自動的にレベル5へ変更せず、既存のLDAP 1644診断設定を保持するようにしました。明示的な`ldap-diagnostics`の監査・計画・設定を追加し、保持、しきい値を指定した診断、MDIの旧設定削除を選択できます。役割・ビルド確認、型付き復旧記録、競合検出、順序付きの読み戻しと最終状態確認に対応します。LDAP専用オプションを他のプロファイルコマンドへ指定すると、実行前に拒否します。イベント生成・量・転送は隔離DCでの検証が必要です。 (#383) (@Shirofune-Security) +- 通常の設定でDCのField Engineeringを自動的にレベル5へ変更せず、既存のLDAP 1644診断設定を保持するようにしました。明示的な`ldap-diagnostics`の監査・計画・設定を追加し、保持、しきい値を指定した診断、MDIの旧設定削除を選択できます。役割・ビルド確認、型付き復旧記録、競合検出、順序付きの読み戻しと最終状態確認に対応します。LDAP専用オプションを他のプロファイルコマンドへ指定すると、実行前に拒否します。イベント生成・量・転送は隔離DCでの検証が必要です。 (#404) (@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 5cdbc775..2a8e100f 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,7 +7,7 @@ **Improvements:** -- 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. (#383) (@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) - 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)