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/5] 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 89253fa81272bc6022b34fcf99ee0301650f136a Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Sat, 19 Sep 2026 07:09:33 +0900 Subject: [PATCH 2/5] Add opt-in Windows PowerShell transcription for CIS Level 2 --- .../workflows/powershell-transcription.yml | 35 +++ CHANGELOG-Japanese.md | 1 + CHANGELOG.md | 1 + WELA.ps1 | 24 +- docs/powershell-transcription.md | 73 ++++++ scripts/Configuration.ps1 | 2 +- scripts/PowerShellTranscription.ps1 | 246 ++++++++++++++++++ tests/PowerShellTranscription.Tests.ps1 | 137 ++++++++++ .../PowerShellTranscription.Windows.Tests.ps1 | 111 ++++++++ website/docs/resources/changelog.ja.md | 1 + website/docs/resources/changelog.md | 1 + 11 files changed, 630 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/powershell-transcription.yml create mode 100644 docs/powershell-transcription.md create mode 100644 scripts/PowerShellTranscription.ps1 create mode 100644 tests/PowerShellTranscription.Tests.ps1 create mode 100644 tests/PowerShellTranscription.Windows.Tests.ps1 diff --git a/.github/workflows/powershell-transcription.yml b/.github/workflows/powershell-transcription.yml new file mode 100644 index 00000000..8999f774 --- /dev/null +++ b/.github/workflows/powershell-transcription.yml @@ -0,0 +1,35 @@ +name: Windows PowerShell transcription regressions +on: + push: + branches: ['**'] + paths: + - 'WELA.ps1' + - 'scripts/Configuration.ps1' + - 'scripts/PowerShellTranscription.ps1' + - 'tests/PowerShellTranscription*' + - '.github/workflows/powershell-transcription.yml' + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + transcription: + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Mocked policy and safety regressions in Windows PowerShell 5.1 + shell: powershell + run: ./tests/PowerShellTranscription.Tests.ps1 + - name: Native private-directory and 5.1 transcript evidence with verified restoration + shell: powershell + run: ./tests/PowerShellTranscription.Windows.Tests.ps1 -AllowDisposablePolicyWrite + - name: Mocked policy and safety regressions from PowerShell 7 + shell: pwsh + run: ./tests/PowerShellTranscription.Tests.ps1 + - name: Native 5.1 transcript evidence from PowerShell 7 host with restoration + shell: pwsh + run: ./tests/PowerShellTranscription.Windows.Tests.ps1 -AllowDisposablePolicyWrite diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index 9cb8750f..76ab51e4 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,6 +4,7 @@ **改善:** +- CIS v4.0.0 Level 2向けに、Windows PowerShell 5.1トランスクリプトの明示的な監査・計画・設定機能を追加しました。管理者が選択した既存の出力ディレクトリを確認し、ポリシーとは分けて報告します。型を含むレジストリ変更前の状態を保存し、共有される32/64ビットのビューと変更後の状態を検証します。呼び出しヘッダーの設定は保持し、ACL・共有・保存期間は変更せず、Sigma EVTX検知範囲の向上も自動加算しません。使い捨て環境の実トランスクリプトテストでは元のポリシーを復元します。中央保存先の権限と収集は別途検証が必要です。 (issue #376) (@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..6eb578fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ **Improvements:** +- Added explicit CIS v4.0.0 Level 2 Windows PowerShell 5.1 transcription audit, plan and configure actions. An operator-selected existing output directory is checked and reported separately from policy; typed canonical registry writes are journaled, verified through shared 32/64-bit views and checked for drift while preserving invocation-header preferences. No ACL/share/retention changes or automatic Sigma EVTX credit are introduced; disposable native transcript tests restore original policy, and central authorization/collection remains a deployment check. (issue #376) (@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..0be82a6e 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -37,6 +37,8 @@ [ValidateSet('List', 'Audit', 'Plan', 'Configure')][string]$WmiAction = 'List', [string[]]$WmiNamespace, [switch]$WmiIncludeChildren, + [ValidateSet('Audit', 'Plan', 'Configure')][string]$TranscriptionAction = 'Audit', + [string]$TranscriptDirectory, [switch]$Help ) @@ -56,6 +58,7 @@ $SaclTargetsPath = Join-Path $ScriptRoot "config/audit_sacl_targets.json" . (Join-Path $ScriptRoot "scripts/AdObjectSacl.ps1") . (Join-Path $ScriptRoot "scripts/AppLockerReadiness.ps1") . (Join-Path $ScriptRoot "scripts/WmiNamespaceAuditing.ps1") +. (Join-Path $ScriptRoot "scripts/PowerShellTranscription.ps1") Import-Module (Join-Path $ScriptRoot "modules/AuditProfiles.psm1") -ErrorAction Stop Import-Module (Join-Path $ScriptRoot "modules/NativeProviders.psm1") -ErrorAction Stop Import-Module (Join-Path $ScriptRoot "modules/EventLogSettings.psm1") -ErrorAction Stop @@ -1709,6 +1712,7 @@ Usage: ./WELA.ps1 smb-auditing -SmbAction Audit -ResultsPath smb-audit.json ./WELA.ps1 smb-auditing -SmbAction Plan ./WELA.ps1 smb-auditing -SmbAction Configure -DryRun + ./WELA.ps1 powershell-transcription -TranscriptionAction Plan -TranscriptDirectory C:\Transcripts -ResultsPath transcription-plan.json ./WELA.ps1 applocker-readiness -ResultsPath applocker.json ./WELA.ps1 applocker-readiness -AppLockerAction Plan -AppLockerPolicyPath operator-audit.xml # SMB auditing is opt-in and never changes signing/encryption requirements or guest access. @@ -1756,6 +1760,10 @@ if ($PSBoundParameters.ContainsKey('SaclMode') -and throw '-SaclMode requires -Profile with plan, audit, audit-settings or configure. It does not control configure-sacl. No command was run.' } # Reject unsupported dry-run requests before reaching any command's mutation path. +if ($Cmd -ne 'powershell-transcription' -and + ($PSBoundParameters.ContainsKey('TranscriptionAction') -or $PSBoundParameters.ContainsKey('TranscriptDirectory'))) { + throw 'Transcription options require the dedicated powershell-transcription command. No command was run.' +} if ($Cmd -ne 'ad-object-sacl' -and @($PSBoundParameters.Keys | Where-Object { $_ -in @('AdSaclAction', 'AdServer', 'AdSaclProfile', 'AdObjectDn', 'AdReceiptPath') }).Count) { @@ -1764,10 +1772,11 @@ if ($Cmd -ne 'ad-object-sacl' -and @($PSBoundParameters.Keys | Where-Object { if ($DryRun -and -not ($Cmd -eq 'applocker-readiness' -and $AppLockerAction -eq 'Import') -and $Cmd -notin @('configure', 'configure-eventlogs') -and -not ($Cmd -eq 'firewall-logging' -and $FirewallAction -eq 'Configure') -and -not ($Cmd -eq 'smb-auditing' -and $SmbAction -eq 'Configure') -and + -not ($Cmd -eq 'powershell-transcription' -and $TranscriptionAction -eq 'Configure') -and -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, powershell-transcription -TranscriptionAction Configure, wmi-auditing -WmiAction Configure, channel-settings -ChannelAction Configure, applocker-readiness -AppLockerAction Import, and ad-object-sacl -AdSaclAction Configure|Rollback. 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.' @@ -1847,6 +1856,19 @@ switch ($Cmd.ToLower()) { if ($report.ExitCode) { exit $report.ExitCode } } catch { Write-Host "[Failed] SMB auditing: $_" -ForegroundColor Red; exit 1 } } + 'powershell-transcription' { + if ($Help) { + Write-Host 'Usage: ./WELA.ps1 powershell-transcription [-TranscriptionAction Audit|Plan|Configure] [-TranscriptDirectory absolute-existing-directory] [-Auto] [-DryRun] [-BackupPath new-directory] [-ResultsPath file.json]' + Write-Host 'Explicit CIS v4.0.0 Level 2 option for Windows PowerShell 5.1. Plan/Configure require an operator-reviewed output directory; ACLs, quotas and retention are not changed. Text transcripts provide no automatic Sigma EVTX credit. See docs/powershell-transcription.md.' + return + } + if ($Profile -or $Baseline) { throw 'powershell-transcription is an explicit Level 2 option; -Profile and -Baseline select separate Security audit policies.' } + try { + $report = Invoke-WelaTranscriptCommand -Action $TranscriptionAction -OutputDirectory $TranscriptDirectory -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath -ResultsPath $ResultsPath + $report + if ($report.ExitCode) { exit $report.ExitCode } + } catch { Write-Host "[Failed] PowerShell transcription: $_" -ForegroundColor Red; exit 1 } + } 'ad-object-sacl' { if ($Help) { Write-Host 'Usage: ./WELA.ps1 ad-object-sacl -AdServer exact-dc-fqdn [-AdSaclAction Audit|Plan|Configure] -AdSaclProfile MdiDomain|MdiConfiguration|PkiObjects [-AdObjectDn exact-dn] [-Auto] [-DryRun] [-BackupPath new-directory] [-ResultsPath file.json]' diff --git a/docs/powershell-transcription.md b/docs/powershell-transcription.md new file mode 100644 index 00000000..86eaa4e1 --- /dev/null +++ b/docs/powershell-transcription.md @@ -0,0 +1,73 @@ +# Windows PowerShell transcription (CIS Level 2) + +`powershell-transcription` provides an **explicit, optional Level 2** transcription policy for Windows PowerShell 5.1. It is separate from advanced audit-policy profiles and is never automatically enabled by an L1 profile, `configure`, or script-block/module logging. The reviewed CIS Windows 11 Enterprise and Windows Server 2022 **v4.0.0**, section **18.10.87.2**, require `EnableTranscripting=1` at Level 2. This command is not a complete CIS compliance assessment and does not claim the latest benchmark version. + +```powershell +# Read current machine/current-user policy and destination observations. +./WELA.ps1 powershell-transcription -TranscriptionAction Audit -ResultsPath transcription-audit.json + +# Select and review an existing destination before configuration. +./WELA.ps1 powershell-transcription -TranscriptionAction Plan ` + -TranscriptDirectory C:\Transcripts -ResultsPath transcription-plan.json +./WELA.ps1 powershell-transcription -TranscriptionAction Configure ` + -TranscriptDirectory C:\Transcripts -DryRun -ResultsPath transcription-preview.json + +# Configure only after reviewing the directory's intended writers and collectors. +./WELA.ps1 powershell-transcription -TranscriptionAction Configure ` + -TranscriptDirectory '\\collector.example.test\Transcripts' ` + -Auto -BackupPath .\new-transcription-backup -ResultsPath transcription-result.json +``` + +`Plan` and `Configure` require `-TranscriptDirectory`. The path must be an existing literal absolute drive or UNC directory. Relative paths, environment variables, wildcards, dot segments, device paths, alternate streams and observed reparse-point components are rejected. The command never provisions a directory, share or ACL. `-Auto` accepts the ordinary configuration prompt; it does not prove that destination permissions are suitable. `-Profile` and `-Baseline` are rejected by this command, and transcription-specific parameters are rejected on unrelated commands. `-DryRun` is supported only with `Configure` and writes neither policy nor a recovery directory. + +## Policy and engine scope + +The machine policy key is: + +```text +HKLM\SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription +``` + +| Value | Requested state | Treatment | +| --- | --- | --- | +| `EnableTranscripting` | REG_DWORD `1` | Explicitly enabled for CIS L2 | +| `OutputDirectory` | REG_SZ containing the operator's exact absolute path | Verified before enablement | +| `EnableInvocationHeader` | Existing value, type or absence | Observed and preserved; not required by this CIS check | + +An explicit configuration repairs incorrect types such as REG_SZ `"1"` for `EnableTranscripting`. Unrelated values and current-user policy are preserved. Computer policy takes precedence over user policy, but this command reports observed registry state and does not claim GPO/MDM ownership or persistence. A policy refresh can override a direct registry write; manage the authoritative policy separately. [Microsoft Windows PowerShell policy documentation](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_group_policy_settings?view=powershell-5.1), [ADMX mapping](https://learn.microsoft.com/en-us/windows/client-management/mdm/policy-csp-admx-powershellexecutionpolicy#enabletranscripting). + +The required explicit output-directory review is a WELA deployment safeguard; the cited CIS check itself requires enablement and does not mandate a particular output path or invocation-header choice. + +Microsoft documents `HKLM\SOFTWARE\Policies` as **shared** by 32-bit and 64-bit processes. WELA reads the canonical path through both registry views on a 64-bit OS and refuses inconsistent observations. It writes once through the native view; it does not create a literal `Wow6432Node` policy subtree. [WOW registry sharing documentation](https://learn.microsoft.com/en-us/windows/win32/winprog64/shared-registry-keys). + +Installed Windows PowerShell 5.1 is checked using its engine registry version and executable presence. WELA can run under Windows PowerShell 5.1 or PowerShell 7 on Windows, but the target remains **Windows PowerShell 5.1**. PowerShell 7 uses separate PowerShell Core policy/configuration, including an optional Windows-policy fallback; this command does not configure or assess that fallback. No PowerShell 7 session coverage is inferred. Existing sessions are not restarted or asserted to adopt a changed policy. [PowerShell 7 policy documentation](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_group_policy_settings?view=powershell-7.5). + +## Destination review and collection + +Transcripts contain command input and output and can contain credentials or other sensitive data. The command deliberately requires a selected output location instead of silently accepting each user's Documents directory. Microsoft recommends restricting access when transcripts are centralized. [Microsoft transcription guidance](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_group_policy_settings?view=powershell-5.1#turn-on-powershell-transcription). + +The plan/result includes the directory's resolved path, creation time, attributes, owner, DACL SDDL and recognized ACEs. A null/empty DACL, or an identified broad read/list or modification grant, blocks configuration. Broad principals include Everyone, Authenticated Users, Users, Guests, Anonymous, Network and Interactive. Grant inspection is deliberately conservative: a deny ACE does not automatically cancel a reported broad allow. Conditional or uninterpreted access ACEs and failed reads remain `Unknown` and block configuration. WELA makes no ACL changes to work around these findings. + +`Destination.Status=Observed` means the directory was read without those identified grants. **It is not an effective-access or storage-security certification.** Arbitrary group membership, each run-as identity, protected child ACLs, ownership, deny interactions and a collector's access remain deployment checks. For UNC destinations, the readable filesystem DACL is observed while `ShareAuthorization=Unknown` remains explicit; the client does not inspect remote SMB share permissions or server quotas. Explicit `Configure` may proceed with this UNC uncertainty after the operator reviews the deployment. `WriterAuthorization` and `CollectorAuthorization` remain `Unknown` in all normal reports. + +Before rollout, provision a dedicated destination and verify that intended writers can create the date subdirectories and transcript files, while unauthorized users cannot read, replace or delete other users' transcripts. Limit collector/reviewer access, test both NTFS and SMB permissions for actual identities, and use an appropriate collection/retention design. A local administrator-only directory is suitable for the disposable CI test, not proof that ordinary user sessions can write centrally. WELA does not copy an ACL recipe across deployments or claim append-only/immutable storage. + +Plan capacity and retention separately: estimate daily transcript volume and peak sessions; set and monitor storage quotas/free space; define retention, rotation, archival and deletion ownership; and confirm the collector reads complete files with the correct identity and protects the destination. No quota, scheduled cleanup, SMB permission, retention policy, encryption or collection configuration is changed. Local transcript production cannot establish central ingestion. + +## Verification and recovery + +One configuration control journals the original typed machine/current-user values from both views and destination observations to `before.jsonl` before any registry mutation. A fresh policy/directory check after the prompt refuses drift. The reviewed `OutputDirectory` is written and reread **before** `EnableTranscripting` is enabled. Post-write and final reads verify the requested types/values, both views and preservation of the invocation-header preference. This is not a transaction or an atomic directory lock; another administrator, GPO or filesystem writer can change state after a check. + +`Applied`/`AlreadyCompliant` mean the machine policy and directory observations passed these checks. They do not prove transcript generation or access for another identity. `Failed` covers read/write problems, unsafe/unknown destination state and verification errors; `Overridden` covers later detected policy drift. `Skipped` includes dry runs and operator-declined changes. Exit 0 means no failed or overridden controls, including runs with skips; it is not a transcript-generation or CIS-wide compliance result. + +If a later write fails, an earlier `OutputDirectory` write can remain. Review `before.jsonl`, the current policy and the authoritative GPO/MDM source. To recover, restore **only** `OutputDirectory` and `EnableTranscripting` from `Before.Policy[0].Machine`, preserving each original value's registry type; remove a value when its original `ValueExists` was false. If necessary, temporarily set `EnableTranscripting` to DWORD `0` while restoring the previous location, then restore its original value/type or absence last. Leave invocation-header and unrelated values untouched. Remove a newly created `Transcription` key only if it was originally absent and is still empty; do not delete a whole policy subtree or restore old ACLs over later changes. The journal contains policy paths/security information and should be protected as administrator recovery data. + +## Evidence and limits + +Transcript files are **text**, separate from PowerShell EVTX events **4103/4104**. Reports set `SigmaEvtxCredit=0` and contain no transcript event-ID claim. Enabling transcription does not automatically make an EVTX Sigma rule usable or add to reported Sigma coverage. + +The mock suite covers typed values, shared views, idempotence, ordering, destination/policy races, denied reads/writes, partial failure, recovery journaling, header preservation and report limits. The Windows workflow targets disposable Server 2022/2025 runners under both WELA hosts (5.1 and 7). Its explicitly gated native test creates only owned private directories, saves original policy, configures transcription, launches fresh native Windows PowerShell 5.1 sessions (native/x86 where installed), and searches for benign markers in automatically produced transcript files. It restores exact original policy in `finally`, verifies restoration, and removes its generated files. If restoration fails, it fails the job and retains the private recovery evidence. PowerShell 7 is a test host, not a transcript-generation target. + +Windows-native CI must pass before claiming that evidence for this change. Production/central validation still requires actual client, server, DC and service identities: test a benign new session, record the transcript and effective policy, verify unauthorized read/modify attempts fail, check collection and quotas/retention, and verify recovery. CI's local private folder does not satisfy the central authorization/ingestion acceptance criterion. Sysmon and external telemetry are out of scope. + +Reviewed CIS references: [Windows 11 Enterprise v4.0.0, PDF pages 1286–1287](https://rayasec.com/wp-content/uploads/CIS-Benchmark/Microsoft-Windows-Desktop/CIS_Microsoft_Windows_11_Enterprise_Benchmark_v4.0.0.pdf#page=1286), [Windows Server 2022 v4.0.0, PDF pages 1029–1030](https://rayasec.com/wp-content/uploads/CIS-Benchmark/Microsoft-Windows-Server/CIS_Microsoft_Windows_Server_2022_Benchmark_v4.0.0.pdf#page=1029). diff --git a/scripts/Configuration.ps1 b/scripts/Configuration.ps1 index a301b898..f62cfb99 100644 --- a/scripts/Configuration.ps1 +++ b/scripts/Configuration.ps1 @@ -94,7 +94,7 @@ function Invoke-WelaConfigurationControl { function Complete-WelaConfiguration { param($Context, [string]$ResultsPath, $Plan, - [ValidateSet("native-windows-configuration", "advanced-audit-policy-only", "advanced-audit-policy-and-precedence", "firewall-text-logging-only", "event-log-size-and-mode-only", "smb-audit-policies-only", "native-channel-settings-only", "wmi-namespace-sacl-only", "ad-object-sacl-only")] + [ValidateSet("native-windows-configuration", "advanced-audit-policy-only", "advanced-audit-policy-and-precedence", "firewall-text-logging-only", "event-log-size-and-mode-only", "smb-audit-policies-only", "native-channel-settings-only", "wmi-namespace-sacl-only", "ad-object-sacl-only", "windows-powershell-transcription-policy-only")] [string]$Scope = "native-windows-configuration", [string]$SuccessMessage = 'Configuration completed; all requested controls verified.') # A second read detects a value that was compliant earlier but changed during diff --git a/scripts/PowerShellTranscription.ps1 b/scripts/PowerShellTranscription.ps1 new file mode 100644 index 00000000..80fa01d1 --- /dev/null +++ b/scripts/PowerShellTranscription.ps1 @@ -0,0 +1,246 @@ +# Optional Windows PowerShell 5.1 transcription. Text files, not EVTX coverage. +function Get-WelaTranscriptRegistryValue { + param([ValidateSet('LocalMachine', 'CurrentUser')][string]$Hive = 'LocalMachine', + [ValidateSet('Registry64', 'Registry32')][string]$View = 'Registry64', + [string]$SubKey = 'SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription', [string]$Name) + $base = $null; $key = $null + try { + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::$Hive, [Microsoft.Win32.RegistryView]::$View) + $key = $base.OpenSubKey($SubKey, $false) + $exists = $null -ne $key -and $key.GetValueNames() -contains $Name + [pscustomobject]@{ KeyExists = $null -ne $key; ValueExists = [bool]$exists; + Value = $(if ($exists) { $key.GetValue($Name, $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) } else { $null }); + Type = $(if ($exists) { $key.GetValueKind($Name).ToString() } else { $null }) } + } finally { if ($key) { $key.Dispose() }; if ($base) { $base.Dispose() } } +} + +function Set-WelaTranscriptRegistryValue { + param([ValidateSet('EnableTranscripting', 'OutputDirectory')][string]$Name, $Value, + [ValidateSet('DWord', 'String')][string]$Type) + $base = $null; $key = $null + try { + # SOFTWARE\Policies is shared by Registry32/Registry64 on supported Windows. + # Do not create a literal Wow6432Node policy subtree. + $view = if ([Environment]::Is64BitOperatingSystem) { [Microsoft.Win32.RegistryView]::Registry64 } else { [Microsoft.Win32.RegistryView]::Registry32 } + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $view) + $key = $base.CreateSubKey('SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription') + $key.SetValue($Name, $Value, [Microsoft.Win32.RegistryValueKind]::$Type) + } finally { if ($key) { $key.Dispose() }; if ($base) { $base.Dispose() } } +} + +function Get-WelaTranscriptCapability { + $result = [pscustomobject]@{ Status = 'Unknown'; TargetEngine = 'Windows PowerShell 5.1'; EngineVersion = $null; + WelaHostEdition = [string]$PSVersionTable.PSEdition; WelaHostVersion = $PSVersionTable.PSVersion.ToString(); Views = @(); Diagnostic = '' } + try { + if ($env:OS -ne 'Windows_NT') { throw 'This command requires Windows; PowerShell 7 on non-Windows cannot configure Windows PowerShell policy.' } + $result.Views = if ([Environment]::Is64BitOperatingSystem) { @('Registry64', 'Registry32') } else { @('Registry32') } + $engine = Get-WelaTranscriptRegistryValue -View $result.Views[0] -SubKey 'SOFTWARE\Microsoft\PowerShell\3\PowerShellEngine' -Name PowerShellVersion + if (-not $engine.ValueExists -or $engine.Type -ne 'String' -or [string]$engine.Value -notmatch '^5\.1(?:\.|$)') { throw 'Installed Windows PowerShell 5.1 engine could not be confirmed from its registry version.' } + $result.EngineVersion = [string]$engine.Value + $executable = Join-Path $env:windir 'System32\WindowsPowerShell\v1.0\powershell.exe' + if (-not (Test-Path -LiteralPath $executable -PathType Leaf -ErrorAction Stop)) { throw 'Native Windows PowerShell executable is missing.' } + $result.Status = 'Supported' + $result.Diagnostic = 'Windows PowerShell 5.1 policy only. PowerShell 7 has separate policy/configuration; its sessions are not assessed or configured.' + } catch { $result.Diagnostic = $_.Exception.Message } + return $result +} + +function Get-WelaTranscriptPolicy { + param([string[]]$Views) + foreach ($view in $Views) { + $machine = [ordered]@{}; $user = [ordered]@{} + foreach ($name in @('EnableTranscripting', 'OutputDirectory', 'EnableInvocationHeader')) { + $machine[$name] = Get-WelaTranscriptRegistryValue -View $view -Name $name + $user[$name] = Get-WelaTranscriptRegistryValue -Hive CurrentUser -View $view -Name $name + } + [pscustomobject]@{ View = $view; Machine = [pscustomobject]$machine; CurrentUser = [pscustomobject]$user } + } +} + +function Test-WelaTranscriptSharedPolicy { + param([array]$Policy) + if (-not $Policy.Count) { throw 'No registry view was observed.' } + if ($Policy.Count -gt 1) { + foreach ($hive in @('Machine', 'CurrentUser')) { + foreach ($name in @('EnableTranscripting', 'OutputDirectory', 'EnableInvocationHeader')) { + foreach ($field in @('KeyExists', 'ValueExists', 'Type', 'Value')) { + if ((ConvertTo-Json -InputObject $Policy[0].$hive.$name.$field -Compress) -cne + (ConvertTo-Json -InputObject $Policy[1].$hive.$name.$field -Compress)) { throw "Shared policy views differ: $hive/$name/$field. No architecture coverage is assumed." } + } + } + } + } +} + +function Test-WelaTranscriptDirectoryPath { + param([string]$Path) + if ([string]::IsNullOrWhiteSpace($Path) -or $Path -match '[*?%\x00-\x1f]' -or + $Path -notmatch '^(?:[A-Za-z]:\\|\\\\[^\\:]+\\[^\\:]+(?:\\|$))' -or + $Path -match '(?:^|\\)\.\.?($|\\)' -or $Path.Substring(2).Contains(':')) { + throw 'Supply a literal absolute drive or UNC directory, without wildcards, environment variables, device paths, alternate streams or dot segments.' + } +} + +function Get-WelaTranscriptAclObservation { + param([string]$Path) + $acl = Get-Acl -LiteralPath $Path -ErrorAction Stop + $sddl = $acl.GetSecurityDescriptorSddlForm([Security.AccessControl.AccessControlSections]'Owner,Group,Access') + $sd = [Security.AccessControl.RawSecurityDescriptor]::new($sddl) + $entries = @(); $risks = @(); $unknown = @() + if ($null -eq $sd.DiscretionaryAcl) { $risks += 'Null DACL permits unrestricted access.' } + elseif ($sd.DiscretionaryAcl.Count -eq 0) { $risks += 'Empty DACL permits no transcript writers.' } + foreach ($ace in $sd.DiscretionaryAcl) { + if ($ace -isnot [Security.AccessControl.CommonAce] -or $ace.IsCallback) { $unknown += 'Uninterpreted or conditional access ACE; authorization needs deployment review.'; continue } + $sid = $ace.SecurityIdentifier.Value + $mask = [int64]$ace.AccessMask; $flags = [int]$ace.AceFlags + $entries += [pscustomobject]@{ Sid = $sid; RightsMask = $mask; AceFlags = $flags; Type = $ace.AceQualifier.ToString() } + if ($ace.AceQualifier -ne [Security.AccessControl.AceQualifier]::AccessAllowed) { continue } + $broad = $sid -in @('S-1-1-0', 'S-1-5-7', 'S-1-5-11', 'S-1-5-32-545', 'S-1-5-32-546', 'S-1-5-2', 'S-1-5-4') + if (-not $broad) { continue } + # Conservative grant inspection, not a token/group/deny-aware AccessCheck. + # GenericRead/GenericAll and ReadData/ListDirectory expose transcript data/names. + if ($mask -band 2415919105) { $risks += "Broad principal $sid has a read/list grant; other users' transcripts may be exposed." } + # File-inheritable write/append, delete, change-permissions/owner, or generic write/all. + if (($mask -band 1343029312) -or (($flags -band 1) -and ($mask -band 6))) { + $risks += "Broad principal $sid has a modification grant; existing transcripts may be alterable." + } + } + [pscustomobject]@{ Sddl = $sddl; Owner = [string]$sd.Owner; Entries = $entries; Risks = $risks; Unknown = $unknown; + Assessment = 'Conservative ACL observations only; effective writer/collector access and authorized group membership require deployment validation.' } +} + +function Get-WelaTranscriptDestination { + param([string]$Path) + $result = [pscustomobject]@{ RequestedPath = $Path; Path = $null; Status = 'Unknown'; ConfigureAllowed = $false; + IsUnc = $Path.StartsWith('\\'); CreationTimeUtc = $null; Attributes = $null; Acl = $null; + ShareAuthorization = 'NotApplicable'; WriterAuthorization = 'Unknown'; CollectorAuthorization = 'Unknown'; Diagnostic = '' } + try { + Test-WelaTranscriptDirectoryPath $Path + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if ($item -isnot [IO.DirectoryInfo]) { throw 'Transcript destination must be an existing filesystem directory.' } + $result.Path = $item.FullName + $result.CreationTimeUtc = $item.CreationTimeUtc.ToString('o') + $result.Attributes = [int]$item.Attributes + # Refuse local or UNC path components that are observed reparse points. + $ancestor = $item + while ($null -ne $ancestor) { + if ($ancestor.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw "Reparse-point destination component requires separate review: $($ancestor.FullName)." } + $ancestor = $ancestor.Parent + } + $result.Acl = Get-WelaTranscriptAclObservation $result.Path + if ($result.Acl.Risks.Count) { $result.Status = 'Blocked'; throw ($result.Acl.Risks -join ' ') } + if ($result.Acl.Unknown.Count) { throw ($result.Acl.Unknown -join ' ') } + $result.Status = 'Observed'; $result.ConfigureAllowed = $true + $result.Diagnostic = 'Directory and DACL observed without identified broad access grants. This does not prove effective access, protected child files, append-only storage, retention or collection; review the deployment before Configure.' + if ($result.IsUnc) { + $result.ShareAuthorization = 'Unknown' + $result.Diagnostic += ' UNC share permissions, remote identities and server-side quotas are not evaluated.' + } + } catch { $result.Diagnostic = $_.Exception.Message } + return $result +} + +function Get-WelaTranscriptState { + param([string]$OutputDirectory) + $capability = Get-WelaTranscriptCapability + if ($capability.Status -ne 'Supported') { throw $capability.Diagnostic } + $policy = @(Get-WelaTranscriptPolicy $capability.Views) + Test-WelaTranscriptSharedPolicy $policy + $observedPath = $OutputDirectory + if (-not $observedPath -and $policy[0].Machine.OutputDirectory.ValueExists -and $policy[0].Machine.OutputDirectory.Type -eq 'String') { + $observedPath = [string]$policy[0].Machine.OutputDirectory.Value + } + $destination = if ($observedPath) { Get-WelaTranscriptDestination $observedPath } else { + [pscustomobject]@{ RequestedPath = $null; Path = $null; Status = 'Unknown'; ConfigureAllowed = $false; + Diagnostic = 'No explicit machine output directory was observed; per-user defaults/current-user policy and destination authorization are not established.' } + } + [pscustomobject]@{ Capability = $capability; Policy = $policy; Destination = $destination; + TranscriptGeneration = 'Unverified'; PowerShell7Sessions = 'NotAssessed'; Retention = 'Unknown'; Collection = 'Unknown' } +} + +function Test-WelaTranscriptConfigured { + param($Snapshot, [string]$OutputDirectory) + if (-not $Snapshot.Destination.ConfigureAllowed) { return $false } + foreach ($view in $Snapshot.Policy) { + if (-not $view.Machine.EnableTranscripting.ValueExists -or $view.Machine.EnableTranscripting.Type -ne 'DWord' -or + $view.Machine.EnableTranscripting.Value -ne 1 -or -not $view.Machine.OutputDirectory.ValueExists -or + $view.Machine.OutputDirectory.Type -ne 'String' -or [string]$view.Machine.OutputDirectory.Value -cne $OutputDirectory) { return $false } + } + return $Snapshot.Policy.Count -gt 0 +} + +function Set-WelaTranscriptControl { + param($Context, [string]$OutputDirectory) + $state = @{ OutputDirectory = $OutputDirectory; Observed = $null; PreservedHeader = $null } + $read = { param($state) + $snapshot = Get-WelaTranscriptState $state.OutputDirectory + if (-not $snapshot.Destination.ConfigureAllowed) { throw "Transcript destination cannot be configured: $($snapshot.Destination.Diagnostic)" } + if ($null -ne $state.PreservedHeader -and + ($snapshot.Policy[0].Machine.EnableInvocationHeader | Select-Object ValueExists, Type, Value | ConvertTo-Json -Compress) -cne $state.PreservedHeader) { + throw 'Invocation-header preference changed during configuration; no header change was requested.' + } + $state.Observed = $snapshot + return $snapshot + } + $test = { param($snapshot, $state) Test-WelaTranscriptConfigured $snapshot $state.OutputDirectory } + $apply = { param($state) + $before = $state.Observed + $fresh = Get-WelaTranscriptState $state.OutputDirectory + if (-not $fresh.Destination.ConfigureAllowed -or + ($fresh.Policy | ConvertTo-Json -Depth 10 -Compress) -cne ($before.Policy | ConvertTo-Json -Depth 10 -Compress) -or + ($fresh.Destination | ConvertTo-Json -Depth 10 -Compress) -cne ($before.Destination | ConvertTo-Json -Depth 10 -Compress)) { + throw 'Policy or destination changed after the recovery snapshot; no write was sent. Review and retry.' + } + $state.PreservedHeader = $before.Policy[0].Machine.EnableInvocationHeader | Select-Object ValueExists, Type, Value | ConvertTo-Json -Compress + # Establish the reviewed location before enabling new-session transcription. + if (-not $before.Policy[0].Machine.OutputDirectory.ValueExists -or $before.Policy[0].Machine.OutputDirectory.Type -ne 'String' -or + [string]$before.Policy[0].Machine.OutputDirectory.Value -cne $state.OutputDirectory) { + Set-WelaTranscriptRegistryValue -Name OutputDirectory -Value $state.OutputDirectory -Type String + } + $location = Get-WelaTranscriptRegistryValue -View $before.Capability.Views[0] -Name OutputDirectory + if (-not $location.ValueExists -or $location.Type -ne 'String' -or [string]$location.Value -cne $state.OutputDirectory) { + throw 'OutputDirectory write did not verify; EnableTranscripting was not changed.' + } + if (-not $before.Policy[0].Machine.EnableTranscripting.ValueExists -or $before.Policy[0].Machine.EnableTranscripting.Type -ne 'DWord' -or + $before.Policy[0].Machine.EnableTranscripting.Value -ne 1) { + Set-WelaTranscriptRegistryValue -Name EnableTranscripting -Value 1 -Type DWord + } + 'Windows PowerShell machine transcription policy written. New-session transcript generation, writer/collector authorization and collection remain unverified. No invocation-header, ACL, share, quota or retention setting was changed.' + } + Invoke-WelaConfigurationControl -Context $Context -Id 'PowerShellTranscription/CisV4L2' -Kind PowerShellTranscription ` + -Target @{ Hive = 'LocalMachine'; SubKey = 'SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription'; OutputDirectory = $OutputDirectory } ` + -Desired @{ EnableTranscripting = @{ Type = 'DWord'; Value = 1 }; OutputDirectory = @{ Type = 'String'; Value = $OutputDirectory }; EnableInvocationHeader = 'Preserve' } ` + -Read $read -Compliant $test -Apply $apply -CallbackState $state ` + -Description 'Enable CIS Level 2 Windows PowerShell transcription using this explicitly reviewed destination. Transcript text can contain sensitive input/output.' +} + +function Invoke-WelaTranscriptCommand { + param([ValidateSet('Audit', 'Plan', 'Configure')][string]$Action = 'Audit', [string]$OutputDirectory, + [switch]$Auto, [switch]$DryRun, [string]$BackupPath, [string]$ResultsPath) + if ($DryRun -and $Action -ne 'Configure') { throw 'DryRun applies only to transcription Configure.' } + if ($Action -in @('Plan', 'Configure') -and -not $OutputDirectory) { throw 'Transcription Plan/Configure requires an explicit -TranscriptDirectory to review.' } + if ($Action -eq 'Configure') { + $context = New-WelaConfigurationContext -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath + Set-WelaTranscriptControl $context $OutputDirectory + $report = Complete-WelaConfiguration -Context $context -Scope 'windows-powershell-transcription-policy-only' ` + -SuccessMessage 'Windows PowerShell transcription policy verified; transcript generation and destination authorization/collection remain separate checks.' + } else { + $snapshot = $null; $diagnostic = ''; $status = 'Unknown' + try { + $snapshot = Get-WelaTranscriptState $OutputDirectory + $targetPath = if ($OutputDirectory) { $OutputDirectory } else { [string]$snapshot.Policy[0].Machine.OutputDirectory.Value } + $status = if (-not $snapshot.Destination.ConfigureAllowed) { $snapshot.Destination.Status } + elseif (Test-WelaTranscriptConfigured $snapshot $targetPath) { 'PolicyConfigured' } else { 'ChangeRequired' } + $diagnostic = $snapshot.Destination.Diagnostic + } catch { $diagnostic = $_.Exception.Message } + $report = [pscustomobject]@{ Scope = 'windows-powershell-transcription-policy-only'; + ExitCode = $(if ($status -in @('Unknown', 'Blocked')) { 1 } else { 0 }); + Results = @([pscustomobject]@{ Status = $status; DesiredDirectory = $OutputDirectory; Before = $snapshot; Diagnostic = $diagnostic }) } + } + $report | Add-Member NoteProperty Action $Action + $report | Add-Member NoteProperty Benchmark 'CIS Windows 11 Enterprise / Windows Server 2022 v4.0.0, 18.10.87.2, Level 2 only; this is not a complete CIS assessment.' + $report | Add-Member NoteProperty VerificationScope 'Windows PowerShell 5.1 machine registry policy and destination observations only. Existing sessions, other identities, PowerShell 7 sessions, transcript generation, quota/retention and central collection are unverified.' + $report | Add-Member NoteProperty Telemetry @{ Format = 'Text transcript files'; EventIds = @(); SigmaEvtxCredit = 0; RelationTo4103And4104 = 'Separate output; no automatic EVTX rule applicability or coverage uplift.' } + if ($ResultsPath) { $report | ConvertTo-Json -Depth 16 | Set-Content -LiteralPath $ResultsPath -Encoding UTF8 -ErrorAction Stop } + return $report +} diff --git a/tests/PowerShellTranscription.Tests.ps1 b/tests/PowerShellTranscription.Tests.ps1 new file mode 100644 index 00000000..48895775 --- /dev/null +++ b/tests/PowerShellTranscription.Tests.ps1 @@ -0,0 +1,137 @@ +$ErrorActionPreference = 'Stop' +$repo = Split-Path $PSScriptRoot -Parent +$script:ScriptRoot = $repo +. (Join-Path $repo 'scripts/Configuration.ps1') +. (Join-Path $repo 'scripts/PowerShellTranscription.ps1') +$script:checks = 0 +function Assert($Condition, [string]$Message) { if (-not $Condition) { throw "FAIL: $Message" }; $script:checks++ } +function Assert-Throws([scriptblock]$Action, [string]$Message) { $thrown = $false; try { & $Action } catch { $thrown = $true }; Assert $thrown $Message } +$root = Join-Path ([IO.Path]::GetTempPath()) ('wela-transcription-' + [guid]::NewGuid().ToString('N')) +$null = New-Item -ItemType Directory -Path $root +$originalCapability = ${function:Get-WelaTranscriptCapability} +function New-Value($Value = $null, [string]$Type = '') { + [pscustomobject]@{ KeyExists = [bool]$Type; ValueExists = [bool]$Type; Value = $Value; Type = $(if ($Type) { $Type } else { $null }) } +} +function Reset-Mocks { + $script:machine = @{ EnableTranscripting = New-Value; OutputDirectory = New-Value; EnableInvocationHeader = New-Value } + $script:user = @{ EnableTranscripting = New-Value; OutputDirectory = New-Value; EnableInvocationHeader = New-Value } + $script:writes = @(); $script:reads = 0; $script:destinationReads = 0; $script:registryFailure = $false + $script:viewMismatch = $false; $script:destinationFailure = $false; $script:destinationRisk = $false + $script:race = $false; $script:drift = $false; $script:writeFailure = ''; $script:locationReadbackFailure = $false + $script:headerDrift = $false; $script:onPrompt = $null; $script:version = '5.1.26100.0' +} +function Get-WelaTranscriptCapability { + [pscustomobject]@{ Status = 'Supported'; TargetEngine = 'Windows PowerShell 5.1'; Views = @('Registry64', 'Registry32'); Diagnostic = 'Mock engine' } +} +function Get-WelaTranscriptRegistryValue { + param($Hive = 'LocalMachine', $View, $SubKey, $Name) + $script:reads++ + if ($script:registryFailure) { throw 'Registry read denied' } + if ($Name -eq 'PowerShellVersion') { return New-Value $script:version String } + $values = if ($Hive -eq 'CurrentUser') { $script:user } else { $script:machine } + $result = $values[$Name] | ConvertTo-Json -Depth 8 | ConvertFrom-Json + if ($script:viewMismatch -and $View -eq 'Registry32' -and $Name -eq 'EnableTranscripting' -and $Hive -eq 'LocalMachine') { $result = New-Value 2 DWord } + if ($script:drift -and $script:destinationReads -ge 3 -and $Name -eq 'EnableTranscripting' -and $Hive -eq 'LocalMachine') { $result = New-Value 0 DWord } + if ($script:headerDrift -and $script:writes.Count -gt 0 -and $Name -eq 'EnableInvocationHeader' -and $Hive -eq 'LocalMachine') { $result = New-Value 0 DWord } + return $result +} +function Get-WelaTranscriptDestination { + param($Path) + $script:destinationReads++ + $sddl = if ($script:race -and $script:destinationReads -gt 1) { 'changed' } else { 'private-directory-acl' } + [pscustomobject]@{ RequestedPath = $Path; Path = $Path; Status = $(if ($script:destinationRisk) { 'Blocked' } elseif ($script:destinationFailure) { 'Unknown' } else { 'Observed' }); + ConfigureAllowed = -not ($script:destinationFailure -or $script:destinationRisk); Acl = @{ Sddl = $sddl }; + ShareAuthorization = $(if ($Path.StartsWith('\\')) { 'Unknown' } else { 'NotApplicable' }); WriterAuthorization = 'Unknown'; CollectorAuthorization = 'Unknown'; + Diagnostic = 'Mock directory; effective access remains unverified' } +} +function Set-WelaTranscriptRegistryValue { + param($Name, $Value, $Type) + if ($Name -eq $script:writeFailure) { throw "Mock $Name write denied" } + $script:writes += $Name + if ($Name -eq 'OutputDirectory' -and $script:locationReadbackFailure) { return } + $script:machine[$Name] = New-Value $Value $Type + foreach ($entry in $script:machine.Values) { $entry.KeyExists = $true } +} +function Read-Host { param($Prompt) if ($script:onPrompt) { & $script:onPrompt }; return 'y' } +function Configure([switch]$DryRun, [switch]$Prompt, [string]$Path = 'C:\ReviewedTranscripts') { + Invoke-WelaTranscriptCommand -Action Configure -OutputDirectory $Path -Auto:(-not $Prompt) -DryRun:$DryRun ` + -BackupPath (Join-Path $root ([guid]::NewGuid().ToString('N'))) +} +try { + foreach ($path in @('C:\ReviewedTranscripts', '\\collector.example.test\Transcripts', '\\collector\Transcripts\Windows')) { + Test-WelaTranscriptDirectoryPath $path; Assert $true "absolute path accepted: $path" + } + foreach ($path in @('', '.\transcripts', 'C:relative', 'C:\..\transcripts', '%TEMP%\transcripts', 'C:\foo*', '\\?\C:\transcripts', '\\.\pipe\name', 'C:\transcripts:stream', 'https://collector/logs')) { + Assert-Throws { Test-WelaTranscriptDirectoryPath $path } "unsafe/ambiguous path rejected: $path" + } + Reset-Mocks + Assert-Throws { Invoke-WelaTranscriptCommand -Action Plan } 'plan requires an explicit reviewed path' + Assert-Throws { Invoke-WelaTranscriptCommand -Action Configure -Auto } 'configure cannot fall back to per-user Documents' + Assert-Throws { Invoke-WelaTranscriptCommand -Action Audit -DryRun } 'dry-run only applies to configure' + $report = Invoke-WelaTranscriptCommand -Action Audit + Assert ($report.Results[0].Status -eq 'Unknown' -and $script:writes.Count -eq 0) 'missing output policy remains unknown during audit' + $report = Invoke-WelaTranscriptCommand -Action Plan -OutputDirectory 'C:\ReviewedTranscripts' + Assert ($report.Results[0].Status -eq 'ChangeRequired' -and $report.Benchmark -like '*Level 2 only*') 'opt-in plan explicitly identifies Level 2' + Assert ($report.Telemetry.SigmaEvtxCredit -eq 0 -and $report.Telemetry.EventIds.Count -eq 0 -and $report.VerificationScope -like '*PowerShell 7*unverified*') 'transcript text gives no EVTX or PowerShell 7 session credit' + Reset-Mocks; $report = Configure -DryRun + Assert ($report.DryRun -and $report.Skipped -eq 1 -and $script:writes.Count -eq 0 -and -not (Test-Path $report.BackupPath)) 'dry-run makes no registry or recovery-directory writes' + Reset-Mocks; $report = Configure + Assert ($report.ExitCode -eq 0 -and $report.Results[0].Status -eq 'Applied') 'new policy applies and verifies' + Assert (($script:writes -join ',') -eq 'OutputDirectory,EnableTranscripting') 'reviewed location is written and verified before enabling transcription' + Assert ($script:machine.EnableTranscripting.Type -eq 'DWord' -and $script:machine.EnableTranscripting.Value -eq 1 -and $script:machine.OutputDirectory.Type -eq 'String') 'exact canonical registry types' + Assert (-not $script:machine.EnableInvocationHeader.ValueExists) 'absent invocation-header preference remains absent even when key is created' + $journal = Get-Content (Join-Path $report.BackupPath 'before.jsonl') -Raw | ConvertFrom-Json + Assert ($journal.Before.Policy.Count -eq 2 -and -not $journal.Before.Policy[0].Machine.OutputDirectory.ValueExists -and $journal.Before.Destination.Acl.Sddl -eq 'private-directory-acl') 'journal records both views and original destination security observations' + $script:destinationReads = 0; $again = Configure + Assert ($again.Results[0].Status -eq 'AlreadyCompliant' -and $script:writes.Count -eq 2) 'repeat configuration is idempotent' + Reset-Mocks; $script:machine.EnableInvocationHeader = New-Value 1 DWord + $script:user.EnableTranscripting = New-Value 0 DWord + $report = Configure + Assert ($report.ExitCode -eq 0 -and $script:machine.EnableInvocationHeader.Value -eq 1 -and $script:user.EnableTranscripting.Value -eq 0) 'existing header and user policy are preserved' + Reset-Mocks; $script:machine.EnableTranscripting = New-Value '1' String + $script:machine.OutputDirectory = New-Value 'C:\ReviewedTranscripts' ExpandString + $report = Configure + Assert ($report.ExitCode -eq 0 -and $script:machine.EnableTranscripting.Type -eq 'DWord' -and $script:machine.OutputDirectory.Type -eq 'String') 'numeric strings and ExpandString are repaired by explicit configure' + Reset-Mocks; $script:viewMismatch = $true; $report = Configure + Assert ($report.ExitCode -eq 1 -and $script:writes.Count -eq 0) 'shared-view mismatch fails closed' + Reset-Mocks; $script:registryFailure = $true; $report = Configure + Assert ($report.ExitCode -eq 1 -and $script:writes.Count -eq 0) 'registry read failure cannot be mistaken for absent configuration' + foreach ($risk in @('destinationFailure', 'destinationRisk')) { + Reset-Mocks; Set-Variable -Scope Script -Name $risk -Value $true; $report = Configure + Assert ($report.ExitCode -eq 1 -and $script:writes.Count -eq 0) 'unreadable or known-unsafe destination blocks writes' + } + Reset-Mocks; $report = Configure -Path '\\collector\Transcripts' + Assert ($report.ExitCode -eq 0 -and $report.Results[0].After.Destination.ShareAuthorization -eq 'Unknown' -and $report.Results[0].After.Destination.WriterAuthorization -eq 'Unknown') 'explicit UNC configure retains unknown share and writer authorization' + Reset-Mocks; $script:race = $true; $report = Configure + Assert ($report.ExitCode -eq 1 -and $script:writes.Count -eq 0 -and $report.Results[0].Diagnostic -like '*changed after*') 'destination ACL race fails before writing' + Reset-Mocks; $script:locationReadbackFailure = $true; $report = Configure + Assert ($report.ExitCode -eq 1 -and ($script:writes -join ',') -eq 'OutputDirectory') 'bad location readback prevents enablement' + Reset-Mocks; $script:writeFailure = 'EnableTranscripting'; $report = Configure + Assert ($report.ExitCode -eq 1 -and (Test-Path (Join-Path $report.BackupPath 'before.jsonl')) -and $script:machine.OutputDirectory.ValueExists) 'partial write failure retains accurate recovery evidence' + Reset-Mocks; $script:drift = $true; $report = Configure + Assert ($report.ExitCode -eq 1 -and $report.Results[0].Status -eq 'Overridden') 'final policy drift is overridden' + Reset-Mocks; $script:headerDrift = $true; $report = Configure + Assert ($report.ExitCode -eq 1 -and $report.Results[0].Diagnostic -like '*Invocation-header*') 'unrequested header change fails verification' + Reset-Mocks + $script:onPrompt = { Get-ChildItem $root -Directory | Remove-Item -Recurse -Force } + $report = Configure -Prompt + Assert ($report.ExitCode -eq 1 -and $script:writes.Count -eq 0) 'journal write failure prevents policy changes' + Reset-Mocks + $path = Join-Path $root 'report.json' + $null = Invoke-WelaTranscriptCommand -Action Plan -OutputDirectory 'C:\ReviewedTranscripts' -ResultsPath $path + $export = Get-Content $path -Raw | ConvertFrom-Json + Assert ($export.Telemetry.SigmaEvtxCredit -eq 0 -and $export.Results[0].Before.TranscriptGeneration -eq 'Unverified') 'JSON retains generation and coverage limits' + # Test actual capability classification through stubbed OS filesystem/registry reads. + Set-Item function:Get-WelaTranscriptCapability $originalCapability + $savedOs = $env:OS; $savedWindir = $env:windir + try { + $env:OS = 'Windows_NT'; $env:windir = $root + function Test-Path { param($LiteralPath, $PathType, $ErrorAction) return $true } + $script:version = '5.1.26100.0'; Assert ((Get-WelaTranscriptCapability).Status -eq 'Supported') '5.1 engine recognized from PowerShell 7 host' + $script:version = '4.0'; Assert ((Get-WelaTranscriptCapability).Status -eq 'Unknown') 'older engine is not assumed to support the target policy' + } finally { $env:OS = $savedOs; $env:windir = $savedWindir } + $tokens = $null; $errors = $null + [void][Management.Automation.Language.Parser]::ParseFile((Join-Path $repo 'WELA.ps1'), [ref]$tokens, [ref]$errors) + Assert ($errors.Count -eq 0) 'combined WELA entry point parses' + Write-Host "Passed $script:checks transcription mock assertions. No Windows policy, ACL or share was changed." +} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } diff --git a/tests/PowerShellTranscription.Windows.Tests.ps1 b/tests/PowerShellTranscription.Windows.Tests.ps1 new file mode 100644 index 00000000..77ac0501 --- /dev/null +++ b/tests/PowerShellTranscription.Windows.Tests.ps1 @@ -0,0 +1,111 @@ +param([switch]$AllowDisposablePolicyWrite) +$ErrorActionPreference = 'Stop' +if ($env:OS -ne 'Windows_NT') { Write-Host 'Skipped: native Windows ACL/registry tests require Windows.'; exit 0 } +if ($AllowDisposablePolicyWrite -and ($env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted')) { + throw 'Native policy mutation is restricted to this explicitly opted-in disposable GitHub-hosted runner test.' +} +$repo = Split-Path $PSScriptRoot -Parent +$script:ScriptRoot = $repo +. (Join-Path $repo 'scripts/Configuration.ps1') +. (Join-Path $repo 'scripts/PowerShellTranscription.ps1') +$script:checks = 0 +function Assert($Condition, [string]$Message) { if (-not $Condition) { throw "FAIL: $Message" }; $script:checks++ } +$root = Join-Path ([IO.Path]::GetTempPath()) ('wela-native-transcription-' + [guid]::NewGuid().ToString('N')) +$null = New-Item -ItemType Directory -Path $root +$policyTouched = $false; $restored = $true; $before = $null +function Set-PrivateDirectoryAcl([string]$Path) { + $acl = [Security.AccessControl.DirectorySecurity]::new() + $acl.SetAccessRuleProtection($true, $false) + $sid = [Security.Principal.WindowsIdentity]::GetCurrent().User + $acl.SetOwner($sid) + foreach ($identity in @($sid.Value, 'S-1-5-18', 'S-1-5-32-544') | Select-Object -Unique) { + $rule = [Security.AccessControl.FileSystemAccessRule]::new([Security.Principal.SecurityIdentifier]::new($identity), + [Security.AccessControl.FileSystemRights]::FullControl, [Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit', + [Security.AccessControl.PropagationFlags]::None, [Security.AccessControl.AccessControlType]::Allow) + $acl.AddAccessRule($rule) + } + Set-Acl -LiteralPath $Path -AclObject $acl +} +function Restore-OriginalPolicy($Policy) { + $base = $null; $key = $null + try { + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, [Microsoft.Win32.RegistryView]([string]$Policy[0].View)) + $key = $base.CreateSubKey('SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription') + # Stop new-session transcription while restoring the previous location. + $key.SetValue('EnableTranscripting', 0, [Microsoft.Win32.RegistryValueKind]::DWord) + foreach ($name in @('OutputDirectory', 'EnableTranscripting')) { + $original = $Policy[0].Machine.$name + if ($original.ValueExists) { $key.SetValue($name, $original.Value, [Microsoft.Win32.RegistryValueKind]([string]$original.Type)) } + else { $key.DeleteValue($name, $false) } + } + $deleteEmptyKey = -not $Policy[0].Machine.EnableTranscripting.KeyExists -and $key.GetValueNames().Count -eq 0 -and $key.GetSubKeyNames().Count -eq 0 + $key.Dispose(); $key = $null + if ($deleteEmptyKey) { $base.DeleteSubKey('SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription', $false) } + } finally { if ($key) { $key.Dispose() }; if ($base) { $base.Dispose() } } +} +try { + Set-PrivateDirectoryAcl $root + $destination = Get-WelaTranscriptDestination $root + Assert ($destination.ConfigureAllowed -and $destination.Status -eq 'Observed') 'actual private directory passes conservative ACL observations' + Assert ($destination.Acl.Sddl -and $destination.WriterAuthorization -eq 'Unknown') 'SDDL is captured without claiming all writers have access' + $unsafe = Join-Path $root 'unsafe-fixture'; $null = New-Item -ItemType Directory -Path $unsafe + $unsafeAcl = Get-Acl -LiteralPath $unsafe + $unsafeAcl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new([Security.Principal.SecurityIdentifier]::new('S-1-1-0'), + [Security.AccessControl.FileSystemRights]::ReadAndExecute, [Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit', + [Security.AccessControl.PropagationFlags]::None, [Security.AccessControl.AccessControlType]::Allow)) + Set-Acl -LiteralPath $unsafe -AclObject $unsafeAcl + $blocked = Get-WelaTranscriptDestination $unsafe + Assert (-not $blocked.ConfigureAllowed -and $blocked.Status -eq 'Blocked' -and $blocked.Acl.Risks.Count -gt 0) 'actual broad-read ACL fixture is blocked' + $missing = Get-WelaTranscriptDestination (Join-Path $root 'not-created') + Assert (-not $missing.ConfigureAllowed -and $missing.Status -eq 'Unknown') 'missing destination never auto-created' + $capability = Get-WelaTranscriptCapability + Assert ($capability.Status -eq 'Supported') 'actual native Windows PowerShell 5.1 installation detected' + $before = @(Get-WelaTranscriptPolicy $capability.Views) + Test-WelaTranscriptSharedPolicy $before + Assert ($before.Count -eq 2) 'actual Windows runner exposes shared 64/32 policy views' + if ($AllowDisposablePolicyWrite) { + $output = Join-Path $root 'transcripts'; $null = New-Item -ItemType Directory -Path $output + $before | ConvertTo-Json -Depth 12 | Set-Content (Join-Path $root 'original-policy.json') -Encoding UTF8 + $policyTouched = $true; $restored = $false + $report = Invoke-WelaTranscriptCommand -Action Configure -OutputDirectory $output -Auto -BackupPath (Join-Path $root 'backup') + Assert ($report.ExitCode -eq 0 -and $report.Results[0].Status -eq 'Applied') 'actual policy writes and both-view readback succeed' + $again = Invoke-WelaTranscriptCommand -Action Configure -OutputDirectory $output -Auto -BackupPath (Join-Path $root 'repeat-backup') + Assert ($again.ExitCode -eq 0 -and $again.Results[0].Status -eq 'AlreadyCompliant') 'actual repeated policy configuration is idempotent' + $executables = @((Join-Path $env:windir 'System32\WindowsPowerShell\v1.0\powershell.exe')) + $x86 = Join-Path $env:windir 'SysWOW64\WindowsPowerShell\v1.0\powershell.exe' + if (Test-Path -LiteralPath $x86 -PathType Leaf) { $executables += $x86 } + foreach ($executable in $executables) { + $marker = 'WELA-BENIGN-TRANSCRIPT-' + [guid]::NewGuid().ToString('N') + $command = "if (`$PSVersionTable.PSVersion.Major -ne 5 -or `$PSVersionTable.PSVersion.Minor -ne 1) { exit 9 }; Write-Output '$marker'" + $encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command)) + $start = [Diagnostics.ProcessStartInfo]::new() + $start.FileName = $executable; $start.Arguments = '-NoLogo -NoProfile -NonInteractive -EncodedCommand ' + $encoded + $start.UseShellExecute = $false; $start.RedirectStandardOutput = $true; $start.RedirectStandardError = $true + $process = [Diagnostics.Process]::Start($start) + try { + if (-not $process.WaitForExit(30000)) { $process.Kill(); throw 'Benign Windows PowerShell child timed out.' } + $childOutput = $process.StandardOutput.ReadToEnd(); $childError = $process.StandardError.ReadToEnd() + Assert ($process.ExitCode -eq 0 -and $childOutput.Contains($marker)) "benign native 5.1 session succeeded: $executable; $childError" + } finally { $process.Dispose() } + $found = $false + foreach ($file in @(Get-ChildItem -LiteralPath $output -Filter 'PowerShell_transcript*.txt' -Recurse -File)) { + if ((Get-Content -LiteralPath $file.FullName -Raw).Contains($marker)) { $found = $true } + } + Assert $found 'policy-created transcript contains the benign marker without Start-Transcript in the child' + } + Assert ($report.Telemetry.SigmaEvtxCredit -eq 0) 'native text generation still provides no automatic Sigma EVTX credit' + } else { Write-Host 'Native policy mutation skipped. Use the explicit disposable CI switch only on GitHub-hosted runners.' } +} finally { + try { + if ($policyTouched) { + Restore-OriginalPolicy $before + $after = @(Get-WelaTranscriptPolicy @($before.View)) + $restored = ($after | ConvertTo-Json -Depth 12 -Compress) -ceq ($before | ConvertTo-Json -Depth 12 -Compress) + Assert $restored 'exact original machine/current-user values, types and policy-key presence restored' + } + } finally { + if ($restored) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction Stop } + else { Write-Host "Policy restoration was not verified. Private evidence directory retained: $root" -ForegroundColor Red } + } +} +Write-Host "Passed $script:checks Windows transcription assertions. Native policy was restored; fixtures were removed. UNC authorization and collection remain lab checks." diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index a654f834..ea33c03f 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,6 +7,7 @@ **改善:** +- CIS v4.0.0 Level 2向けに、Windows PowerShell 5.1トランスクリプトの明示的な監査・計画・設定機能を追加しました。管理者が選択した既存の出力ディレクトリを確認し、ポリシーとは分けて報告します。型を含むレジストリ変更前の状態を保存し、共有される32/64ビットのビューと変更後の状態を検証します。呼び出しヘッダーの設定は保持し、ACL・共有・保存期間は変更せず、Sigma EVTX検知範囲の向上も自動加算しません。使い捨て環境の実トランスクリプトテストでは元のポリシーを復元します。中央保存先の権限と収集は別途検証が必要です。 (issue #376) (@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..5e38aa10 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,6 +7,7 @@ **Improvements:** +- Added explicit CIS v4.0.0 Level 2 Windows PowerShell 5.1 transcription audit, plan and configure actions. An operator-selected existing output directory is checked and reported separately from policy; typed canonical registry writes are journaled, verified through shared 32/64-bit views and checked for drift while preserving invocation-header preferences. No ACL/share/retention changes or automatic Sigma EVTX credit are introduced; disposable native transcript tests restore original policy, and central authorization/collection remains a deployment check. (issue #376) (@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 e0d531c6693be3254bc73abb7d3d551489477ccf Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Sat, 19 Sep 2026 07:13:22 +0900 Subject: [PATCH 3/5] Record passing Windows transcription evidence and link PR 405 --- CHANGELOG-Japanese.md | 2 +- CHANGELOG.md | 2 +- docs/powershell-transcription.md | 2 +- website/docs/resources/changelog.ja.md | 2 +- website/docs/resources/changelog.md | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index 76ab51e4..79c80a7e 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,7 +4,7 @@ **改善:** -- CIS v4.0.0 Level 2向けに、Windows PowerShell 5.1トランスクリプトの明示的な監査・計画・設定機能を追加しました。管理者が選択した既存の出力ディレクトリを確認し、ポリシーとは分けて報告します。型を含むレジストリ変更前の状態を保存し、共有される32/64ビットのビューと変更後の状態を検証します。呼び出しヘッダーの設定は保持し、ACL・共有・保存期間は変更せず、Sigma EVTX検知範囲の向上も自動加算しません。使い捨て環境の実トランスクリプトテストでは元のポリシーを復元します。中央保存先の権限と収集は別途検証が必要です。 (issue #376) (@Shirofune-Security) +- CIS v4.0.0 Level 2向けに、Windows PowerShell 5.1トランスクリプトの明示的な監査・計画・設定機能を追加しました。管理者が選択した既存の出力ディレクトリを確認し、ポリシーとは分けて報告します。型を含むレジストリ変更前の状態を保存し、共有される32/64ビットのビューと変更後の状態を検証します。呼び出しヘッダーの設定は保持し、ACL・共有・保存期間は変更せず、Sigma EVTX検知範囲の向上も自動加算しません。使い捨て環境の実トランスクリプトテストでは元のポリシーを復元します。中央保存先の権限と収集は別途検証が必要です。 (#405) (@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 6eb578fd..69992a84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ **Improvements:** -- Added explicit CIS v4.0.0 Level 2 Windows PowerShell 5.1 transcription audit, plan and configure actions. An operator-selected existing output directory is checked and reported separately from policy; typed canonical registry writes are journaled, verified through shared 32/64-bit views and checked for drift while preserving invocation-header preferences. No ACL/share/retention changes or automatic Sigma EVTX credit are introduced; disposable native transcript tests restore original policy, and central authorization/collection remains a deployment check. (issue #376) (@Shirofune-Security) +- Added explicit CIS v4.0.0 Level 2 Windows PowerShell 5.1 transcription audit, plan and configure actions. An operator-selected existing output directory is checked and reported separately from policy; typed canonical registry writes are journaled, verified through shared 32/64-bit views and checked for drift while preserving invocation-header preferences. No ACL/share/retention changes or automatic Sigma EVTX credit are introduced; disposable native transcript tests restore original policy, and central authorization/collection remains a deployment check. (#405) (@Shirofune-Security) - 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/docs/powershell-transcription.md b/docs/powershell-transcription.md index 86eaa4e1..3905d869 100644 --- a/docs/powershell-transcription.md +++ b/docs/powershell-transcription.md @@ -68,6 +68,6 @@ Transcript files are **text**, separate from PowerShell EVTX events **4103/4104* The mock suite covers typed values, shared views, idempotence, ordering, destination/policy races, denied reads/writes, partial failure, recovery journaling, header preservation and report limits. The Windows workflow targets disposable Server 2022/2025 runners under both WELA hosts (5.1 and 7). Its explicitly gated native test creates only owned private directories, saves original policy, configures transcription, launches fresh native Windows PowerShell 5.1 sessions (native/x86 where installed), and searches for benign markers in automatically produced transcript files. It restores exact original policy in `finally`, verifies restoration, and removes its generated files. If restoration fails, it fails the job and retains the private recovery evidence. PowerShell 7 is a test host, not a transcript-generation target. -Windows-native CI must pass before claiming that evidence for this change. Production/central validation still requires actual client, server, DC and service identities: test a benign new session, record the transcript and effective policy, verify unauthorized read/modify attempts fail, check collection and quotas/retention, and verify recovery. CI's local private folder does not satisfy the central authorization/ingestion acceptance criterion. Sysmon and external telemetry are out of scope. +Native CI passed on both Server 2022 and Server 2025 under Windows PowerShell 5.1 and PowerShell 7 in [run 35439090461](https://github.com/Yamato-Security/WELA/actions/runs/35439090461): 14 native assertions per OS/host combination, including fresh x64/x86 Windows PowerShell 5.1 transcript markers and verified restoration (56 native assertions total). Production/central validation still requires actual client, server, DC and service identities: test a benign new session, record the transcript and effective policy, verify unauthorized read/modify attempts fail, check collection and quotas/retention, and verify recovery. CI's local private folder does not satisfy the central authorization/ingestion acceptance criterion. Sysmon and external telemetry are out of scope. Reviewed CIS references: [Windows 11 Enterprise v4.0.0, PDF pages 1286–1287](https://rayasec.com/wp-content/uploads/CIS-Benchmark/Microsoft-Windows-Desktop/CIS_Microsoft_Windows_11_Enterprise_Benchmark_v4.0.0.pdf#page=1286), [Windows Server 2022 v4.0.0, PDF pages 1029–1030](https://rayasec.com/wp-content/uploads/CIS-Benchmark/Microsoft-Windows-Server/CIS_Microsoft_Windows_Server_2022_Benchmark_v4.0.0.pdf#page=1029). diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index ea33c03f..19a3359a 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,7 +7,7 @@ **改善:** -- CIS v4.0.0 Level 2向けに、Windows PowerShell 5.1トランスクリプトの明示的な監査・計画・設定機能を追加しました。管理者が選択した既存の出力ディレクトリを確認し、ポリシーとは分けて報告します。型を含むレジストリ変更前の状態を保存し、共有される32/64ビットのビューと変更後の状態を検証します。呼び出しヘッダーの設定は保持し、ACL・共有・保存期間は変更せず、Sigma EVTX検知範囲の向上も自動加算しません。使い捨て環境の実トランスクリプトテストでは元のポリシーを復元します。中央保存先の権限と収集は別途検証が必要です。 (issue #376) (@Shirofune-Security) +- CIS v4.0.0 Level 2向けに、Windows PowerShell 5.1トランスクリプトの明示的な監査・計画・設定機能を追加しました。管理者が選択した既存の出力ディレクトリを確認し、ポリシーとは分けて報告します。型を含むレジストリ変更前の状態を保存し、共有される32/64ビットのビューと変更後の状態を検証します。呼び出しヘッダーの設定は保持し、ACL・共有・保存期間は変更せず、Sigma EVTX検知範囲の向上も自動加算しません。使い捨て環境の実トランスクリプトテストでは元のポリシーを復元します。中央保存先の権限と収集は別途検証が必要です。 (#405) (@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 5e38aa10..c6e78fdf 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,7 +7,7 @@ **Improvements:** -- Added explicit CIS v4.0.0 Level 2 Windows PowerShell 5.1 transcription audit, plan and configure actions. An operator-selected existing output directory is checked and reported separately from policy; typed canonical registry writes are journaled, verified through shared 32/64-bit views and checked for drift while preserving invocation-header preferences. No ACL/share/retention changes or automatic Sigma EVTX credit are introduced; disposable native transcript tests restore original policy, and central authorization/collection remains a deployment check. (issue #376) (@Shirofune-Security) +- Added explicit CIS v4.0.0 Level 2 Windows PowerShell 5.1 transcription audit, plan and configure actions. An operator-selected existing output directory is checked and reported separately from policy; typed canonical registry writes are journaled, verified through shared 32/64-bit views and checked for drift while preserving invocation-header preferences. No ACL/share/retention changes or automatic Sigma EVTX credit are introduced; disposable native transcript tests restore original policy, and central authorization/collection remains a deployment check. (#405) (@Shirofune-Security) - 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 4/5] 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 5/5] 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)