Merge pull request #404 from Shirofune-Security/feat/383-ldap-diagnostics

Make LDAP 1644 diagnostics explicit and preserve existing DC settings
This commit is contained in:
田中ザック Isaac Mathis authored and GitHub committed 2026-09-20 08:39:13 +09:00
commit 3172ea1101
10 files changed
+415 -5

No files matched your search

+25
View File
@@ -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
+1
View File
@@ -4,6 +4,7 @@
**改善:**
- 通常の設定で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)
+1
View File
@@ -4,6 +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. (#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)
+28 -5
View File
@@ -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")
@@ -1429,9 +1435,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.
@@ -1697,6 +1701,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
@@ -1764,13 +1771,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.'
@@ -1790,12 +1797,28 @@ 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
}
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]'
+37
View File
@@ -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.
+147
View File
@@ -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
}
+166
View File
@@ -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 } } }
+8
View File
@@ -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."
+1
View File
@@ -7,6 +7,7 @@
**改善:**
- 通常の設定で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)
+1
View File
@@ -7,6 +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. (#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)