Integrate completed native logging stack into rule eligibility

This commit is contained in:
Shirofune-Security committed 2026-09-19 11:50:48 +09:00
commit ab70ec740a
16 files changed
+1044 -6

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
name: LDAP diagnostic regressions
on:
push:
branches: ['**']
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
ldap-diagnostics:
runs-on: windows-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: LDAP fixtures on Windows PowerShell 5.1
shell: powershell
run: ./tests/LdapDiagnostics.Tests.ps1
- name: Native applicability on Windows PowerShell 5.1
shell: powershell
run: ./tests/LdapDiagnostics.Windows.Tests.ps1
- name: LDAP fixtures on PowerShell 7
shell: pwsh
run: ./tests/LdapDiagnostics.Tests.ps1
- name: Native applicability on PowerShell 7
shell: pwsh
run: ./tests/LdapDiagnostics.Windows.Tests.ps1
@@ -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
+2
View File
@@ -6,6 +6,8 @@
- 読み取り専用の`rule-eligibility`を追加し、ルール・対応表のハッシュ、ルールごとの判定理由、対象外の理由、分子・分母を明示します。任意で取り込んだラボ資料を、対応範囲を限定した完全なルール定義、ネイティブXML、設定、収集・クエリ実行の証拠と照合し、未対応・未確認の項目はConditionalとします。監査のCSV/JSON/HTMLとNavigator出力では、設定が有効なだけでルールを利用可能と判定しません。取り込んだReady判定は記録された環境・時点に限られ、実環境での一連の検証を保証しません。 (#407) (@Shirofune-Security)
- Windows標準のドメイン/Kerberos環境向けに、送信元設定と明示的に選択した収集サーバーのサブスクリプションを監査・計画・設定する任意実行の`wef-source`と`wec-collector`を追加しました。実際の収集先ID、既存リスナーと範囲を限定した受信規則、送信元SID、メンバーホストでの読み取り権限の追加、明示的なASDのWSMan強化設定と共通チャネル設定を、復旧記録・変更検出・読み戻しで確認します。DCのグループ管理権限と異なる既存サブスクリプションは変更しません。JSONにはクエリ・チャネル・実行状態の証拠を保持し、実効読み取り権限・イベント到着・転送後のSigma検知範囲は未検証と表示します。隔離Windows環境での配備検証は別途必要です。 (#406) (@Shirofune-Security)
- CIS v4.0.0 Level 2向けに、Windows PowerShell 5.1トランスクリプトの明示的な監査・計画・設定機能を追加しました。管理者が選択した既存の出力ディレクトリを確認し、ポリシーとは分けて報告します。型を含むレジストリ変更前の状態を保存し、共有される32/64ビットのビューと変更後の状態を検証します。呼び出しヘッダーの設定は保持し、ACL・共有・保存期間は変更せず、Sigma EVTX検知範囲の向上も自動加算しません。使い捨て環境の実トランスクリプトテストでは元のポリシーを復元します。中央保存先の権限と収集は別途検証が必要です。 (#405) (@Shirofune-Security)
- 通常の設定でDCのField Engineeringを自動的にレベル5へ変更せず、既存のLDAP 1644診断設定を保持するようにしました。明示的な`ldap-diagnostics`の監査・計画・設定を追加し、保持、しきい値を指定した診断、MDIの旧設定削除を選択できます。役割・ビルド確認、型付き復旧記録、競合検出、順序付きの読み戻しと最終状態確認に対応します。LDAP専用オプションを他のプロファイルコマンドへ指定すると、実行前に拒否します。イベント生成・量・転送は隔離DCでの検証が必要です。 (#404) (@Shirofune-Security)
- 従来のToken Right Adjusted EventsのGUIDを修正し、すべてのベースラインでRPCとトークン監査の状態を個別に評価するようにしました。実行時のカタログID・明示的な名前別表記の検証と、ハッシュ付きの読み取り専用EventID対応表レビューを追加しました。曖昧な対応、カテゴリだけの対応、不明な対応から検知可能とは判断しません。 (#403) (@Shirofune-Security)
- MDIのドメイン/Exchange Configuration監査と、明示的に選択した証明書テンプレート/登録サービスオブジェクト向けに、任意実行の`ad-object-sacl`監査・計画・設定・保守的なロールバックを追加しました。接続先DCとスキーマGUIDを検証し、既存のセキュリティ設定を保持したまま不足する監査ACEだけをSACLに追加します。変更前のSDDLと追加ACEを保存し、書き込み後と最終状態を確認します。任意のdMSA前提条件が不明な場合は未確認のスキップ項目として報告し、独立した他の5種類のドメインクラスの監査ACEは引き続き設定します。実効監査ポリシー、継承・レプリケーション、4662/5136イベントの証拠は隔離DCで別途検証が必要です。Sigma検知範囲の向上は未検証です。 (#402) (@Shirofune-Security)
+2
View File
@@ -6,6 +6,8 @@
- Added read-only `rule-eligibility` reports with pinned corpus/mapping hashes, per-rule reasons, explicit scope exclusions and numerator/denominator totals. Optional imported lab artifacts are checked against a narrow complete-rule parser, native XML, configuration, ingestion and query evidence; unsupported or incomplete cases stay Conditional. Audit CSV/JSON/HTML and Navigator outputs no longer treat enabled settings as proven usable rules. Imported Ready results apply only to their recorded context/time; no live end-to-end validation is implied. (#407) (@Shirofune-Security)
- Added separate opt-in `wef-source` and `wec-collector` audit, plan and configure commands for native domain/Kerberos source settings and explicitly selected collector subscriptions. Actual collector identity, scoped existing ingress/listener prerequisites, explicit source SIDs, additive member-host read permissions, optional ASD WSMan hardening and shared channel controls are checked with journals, drift guards and readback. DC group authority and different existing subscriptions are preserved. JSON retains query/channel/runtime evidence without claiming effective read access, event arrival or forwarded Sigma coverage; isolated Windows deployment validation remains pending. (#406) (@Shirofune-Security)
- Added explicit CIS v4.0.0 Level 2 Windows PowerShell 5.1 transcription audit, plan and configure actions. An operator-selected existing output directory is checked and reported separately from policy; typed canonical registry writes are journaled, verified through shared 32/64-bit views and checked for drift while preserving invocation-header preferences. No ACL/share/retention changes or automatic Sigma EVTX credit are introduced; disposable native transcript tests restore original policy, and central authorization/collection remains a deployment check. (#405) (@Shirofune-Security)
- Preserved LDAP 1644 diagnostics during normal configuration instead of automatically enabling Field Engineering level 5 on DCs. Added explicit `ldap-diagnostics` audit/plan/configure modes for preservation, tunable diagnostics and MDI legacy cleanup, with role/build checks, typed recovery snapshots, race guards, ordered readback and final drift checks. LDAP-only options are rejected before unrelated profile commands can run. Generated events, volume and forwarding remain isolated-DC validation. (#404) (@Shirofune-Security)
- Corrected the legacy Token Right Adjusted Events GUID so RPC and token auditing are assessed independently in every baseline. Added runtime catalog identity/alias checks and a read-only, fingerprinted EventID mapping review that preserves ambiguous, category-only and unknown candidates without detection credit. (#403) (@Shirofune-Security)
- Added opt-in `ad-object-sacl` audit, plan, configure and conservative rollback actions for MDI domain/Exchange Configuration auditing and explicitly selected certificate template/enrollment service objects. Exact DC binding, schema GUID checks, additive SACL-only changes, pre-write SDDL/ACE receipts and read-back preserve existing security entries. Unknown optional dMSA prerequisites are reported as a separate skipped gap while the five independent domain class ACEs continue. Effective audit policy, inheritance/replication and 4662/5136 event evidence remain separate isolated-DC checks; no Sigma uplift is claimed. (#402) (@Shirofune-Security)
+50 -5
View File
@@ -42,6 +42,13 @@
[string]$RuleEvidencePath,
[string]$RuleCorpusPath,
[string]$RuleManifestPath,
[ValidateSet('Audit', 'Plan', 'Configure')][string]$TranscriptionAction = 'Audit',
[string]$TranscriptDirectory,
[ValidateSet('Audit','Plan','Configure')][string]$LdapAction = 'Audit',
[ValidateSet('Preserve','Diagnostic','MdiCleanup')][string]$LdapMode = 'Preserve',
[ValidateRange(1,2147483647)][int]$LdapSearchTimeMs,
[ValidateRange(1,2147483647)][int]$LdapExpensiveThreshold,
[ValidateRange(1,2147483647)][int]$LdapInefficientThreshold,
[switch]$Help
)
@@ -58,9 +65,11 @@ $SaclTargetsPath = Join-Path $ScriptRoot "config/audit_sacl_targets.json"
. (Join-Path $ScriptRoot "scripts/Configuration.ps1")
. (Join-Path $ScriptRoot "scripts/FirewallLogging.ps1")
. (Join-Path $ScriptRoot "scripts/SmbAuditing.ps1")
. (Join-Path $ScriptRoot "scripts/LdapDiagnostics.ps1")
. (Join-Path $ScriptRoot "scripts/AdObjectSacl.ps1")
. (Join-Path $ScriptRoot "scripts/AppLockerReadiness.ps1")
. (Join-Path $ScriptRoot "scripts/WmiNamespaceAuditing.ps1")
. (Join-Path $ScriptRoot "scripts/PowerShellTranscription.ps1")
Import-Module (Join-Path $ScriptRoot "modules/AuditProfiles.psm1") -ErrorAction Stop
Import-Module (Join-Path $ScriptRoot "modules/RuleEligibility.psm1") -ErrorAction Stop
Import-Module (Join-Path $ScriptRoot "modules/AuditCatalog.psm1") -ErrorAction Stop
@@ -1459,9 +1468,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.
@@ -1727,6 +1734,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
@@ -1747,6 +1757,7 @@ Usage:
./WELA.ps1 rule-eligibility -ResultsPath eligibility.json -HtmlPath eligibility.html
./WELA.ps1 rule-eligibility -RuleEvidencePath reviewed-lab-evidence.json -ResultsPath evidence-review.json
./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.
@@ -1800,19 +1811,24 @@ 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) {
throw 'AD object SACL options require the dedicated ad-object-sacl command. No command was run.'
}
if ($DryRun -and -not ($Cmd -eq 'applocker-readiness' -and $AppLockerAction -eq 'Import') -and $Cmd -notin @('configure', 'configure-eventlogs') -and
if ($DryRun -and -not ($Cmd -eq 'ldap-diagnostics' -and $LdapAction -eq 'Configure') -and -not ($Cmd -eq 'applocker-readiness' -and $AppLockerAction -eq 'Import') -and $Cmd -notin @('configure', 'configure-eventlogs') -and
-not ($Cmd -eq 'firewall-logging' -and $FirewallAction -eq 'Configure') -and
-not ($Cmd -eq 'smb-auditing' -and $SmbAction -eq 'Configure') -and
-not ($Cmd -eq 'powershell-transcription' -and $TranscriptionAction -eq 'Configure') -and
-not ($Cmd -eq 'channel-settings' -and $ChannelAction -eq 'Configure') -and
-not ($Cmd -in @('wef-source','wec-collector') -and $WefAction -eq 'Configure') -and
-not ($Cmd -eq 'ad-object-sacl' -and $AdSaclAction -in @('Configure', 'Rollback')) -and
-not ($Cmd -eq 'wmi-auditing' -and $WmiAction -eq 'Configure')) {
throw "-DryRun is supported only by configure (including configure -Profile), configure-eventlogs, firewall-logging -FirewallAction Configure, smb-auditing -SmbAction Configure, wmi-auditing -WmiAction Configure, channel-settings -ChannelAction Configure, wef-source/wec-collector -WefAction Configure, applocker-readiness -AppLockerAction Import, and ad-object-sacl -AdSaclAction Configure|Rollback. No command was run."
throw "-DryRun is supported only by configure (including configure -Profile), configure-eventlogs, firewall-logging -FirewallAction Configure, smb-auditing -SmbAction Configure, powershell-transcription -TranscriptionAction Configure, wmi-auditing -WmiAction Configure, channel-settings -ChannelAction Configure, wef-source/wec-collector -WefAction Configure, applocker-readiness -AppLockerAction Import, ad-object-sacl -AdSaclAction Configure|Rollback, and ldap-diagnostics -LdapAction Configure. No command was run."
}
if (($WmiNamespace -or $WmiIncludeChildren -or $PSBoundParameters.ContainsKey('WmiAction')) -and $Cmd -ne 'wmi-auditing') {
throw '-WmiAction, -WmiNamespace and -WmiIncludeChildren require wmi-auditing. No command was run.'
@@ -1832,6 +1848,10 @@ if (($PSBoundParameters.ContainsKey('ChannelAction') -or $PSBoundParameters.Cont
throw 'Channel options require channel-settings. No command was run.'
}
if ($Cmd -ne 'ldap-diagnostics' -and @($PSBoundParameters.Keys | Where-Object { $_ -in @('LdapAction','LdapMode','LdapSearchTimeMs','LdapExpensiveThreshold','LdapInefficientThreshold') }).Count) {
throw 'LDAP options require the dedicated ldap-diagnostics command. No command was run.'
}
if ($Profile -and $Cmd.ToLower() -in @('plan', 'audit', 'audit-settings', 'configure') -and -not $Help) {
Invoke-WelaProfileCommand -Command $Cmd.ToLower()
return
@@ -1868,6 +1888,18 @@ switch ($Cmd.ToLower()) {
if ($report.ExitCode) { exit $report.ExitCode }
} catch { Write-Host "[Failed] WEF configuration: $_" -ForegroundColor Red; exit 1 }
}
'ldap-diagnostics' {
if ($Help) { Write-Host 'Usage: ./WELA.ps1 ldap-diagnostics [-LdapAction Audit|Plan|Configure] [-LdapMode Preserve|Diagnostic|MdiCleanup] [-LdapSearchTimeMs positive-ms] [-LdapExpensiveThreshold positive-count] [-LdapInefficientThreshold positive-count] [-Auto] [-DryRun] [-BackupPath new-directory] [-ResultsPath report.json]. See docs/ldap-diagnostics.md.'; return }
if ($Profile -or $Baseline -or $Role -or $Build -or $HtmlPath) { throw 'ldap-diagnostics observes the actual local DC and uses -LdapMode/-ResultsPath; audit profiles, role overrides and HTML output do not apply.' }
if ($LdapAction -eq 'Configure' -and $LdapMode -ne 'Preserve' -and -not (TestAdministrator)) { throw 'LDAP configuration requires Administrator privileges.' }
$thresholds=@{}
if ($PSBoundParameters.ContainsKey('LdapSearchTimeMs')) { $thresholds.SearchTime=$LdapSearchTimeMs }
if ($PSBoundParameters.ContainsKey('LdapExpensiveThreshold')) { $thresholds.Expensive=$LdapExpensiveThreshold }
if ($PSBoundParameters.ContainsKey('LdapInefficientThreshold')) { $thresholds.Inefficient=$LdapInefficientThreshold }
$report=Invoke-WelaLdapCommand -Action $LdapAction -Mode $LdapMode -Thresholds $thresholds -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath -ResultsPath $ResultsPath
$report
if ($report.ExitCode) { exit $report.ExitCode }
}
'channel-settings' {
if ($Help) {
Write-Host 'Usage: ./WELA.ps1 channel-settings [-ChannelAction Audit|Plan|Configure] [-ChannelProfile microsoft-wef-appendix-c] [-WefQuerySet Baseline|Suspect|Both] [-GrantEventLogReaders] [-Auto] [-DryRun] [-BackupPath new-directory] [-ResultsPath file.json]'
@@ -1922,6 +1954,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]'
+37
View File
@@ -0,0 +1,37 @@
# Explicit LDAP 1644 diagnostics
Normal `configure` preserves existing LDAP diagnostics and explains that 1644 is not selected. It no longer sets `15 Field Engineering=5` automatically on domain controllers. Microsoft's [current MDI guidance](https://learn.microsoft.com/en-us/defender-for-identity/deploy/configure-windows-event-collection#update-legacy-configurations) no longer requires these diagnostic settings. Security auditing and the other MDI prerequisites remain separate.
The dedicated local workflow supports Server 2022/2025 DC builds 20348/26100 in 64-bit Windows PowerShell 5.1 or PowerShell 7. Client, member server and member-server CA hosts are not applicable. Unknown or conflicting role/build evidence blocks writes; no NTDS settings are created on a non-DC.
LDAP options require the dedicated `ldap-diagnostics` command. Supplying them to another command, including `configure -Profile`, stops before that command runs.
```powershell
./WELA.ps1 ldap-diagnostics -LdapAction Audit -ResultsPath ldap-before.json
./WELA.ps1 ldap-diagnostics -LdapAction Plan -LdapMode Diagnostic -LdapSearchTimeMs 100
./WELA.ps1 ldap-diagnostics -LdapAction Configure -LdapMode Diagnostic -LdapSearchTimeMs 100 -DryRun
./WELA.ps1 ldap-diagnostics -LdapAction Configure -LdapMode Diagnostic -LdapSearchTimeMs 100 -Auto -BackupPath C:\WelaRecovery\ldap-01 -ResultsPath ldap-result.json
```
`Preserve` is the default mode, including when Configure is selected. It changes no settings. `Diagnostic` explicitly requests Field Engineering level 5 and only the thresholds supplied by the operator. Omitted thresholds retain their existing typed values; they are not silently lowered to 1. Thresholds must be positive integers through 2147483647. Windows treats an absent/zero time threshold as its documented default; use cleanup to remove an override instead of supplying zero.
| Option | NTDS Parameters value | Unit | Microsoft documented default, not a host observation |
|---|---|---|---:|
| `-LdapSearchTimeMs` | Search Time Threshold (msecs) | milliseconds | 30000 |
| `-LdapExpensiveThreshold` | Expensive Search Results Threshold | entry threshold | 10000 |
| `-LdapInefficientThreshold` | Inefficient Search Results Threshold | entry threshold | 1000 |
These values are described in Microsoft's [1644 diagnostics procedure](https://learn.microsoft.com/en-us/troubleshoot/windows-server/active-directory/event1644reader-analyze-ldap-query-performance). The example's 100 ms is a starting point to evaluate, not a baseline requirement. Level 5 also generates other Directory Service events. Measure event rate, log rollover and DC workload over a bounded interval before wider rollout. WELA neither issues LDAP searches nor measures that volume automatically.
`MdiCleanup` is an explicit removal choice. It removes **all four named values** shown in the plan, including customized values: Field Engineering first, followed by the three thresholds. It leaves their registry keys and unrelated diagnostic values intact. Select it only after reviewing existing operator diagnostics; normal configure and Preserve never perform cleanup.
```powershell
./WELA.ps1 ldap-diagnostics -LdapAction Plan -LdapMode MdiCleanup
./WELA.ps1 ldap-diagnostics -LdapAction Configure -LdapMode MdiCleanup -DryRun
```
Writes require a fresh complete snapshot and a saved `before.jsonl` recovery record containing original existence, type and value for every control. Unknown types and concurrent changes block writes. Each selected threshold is read back before verbose logging is enabled; errors stop the remaining changes and produce a nonzero result. Repeated configuration is idempotent, and the final check detects drift in selected and preserved values. Dry-run does not create a backup directory or modify Windows. A partial failure retains its journal; it does not trigger automatic restoration over newer changes.
For recovery, compare fresh values with both the journal's original state and this run's desired values. Restore original values/types only where this run's changes still remain; remove only a value that was originally absent. Preserve any newer operator changes and keep the recovery journal. MDI cleanup is not a general rollback command.
Tests cover default preservation, explicit setup/cleanup, positive bounds, type/read/write errors, journal ordering, stale plans, races, partial failures, repeated application and final drift. Windows CI queries native role applicability without configuring a DC. Before closing #383, use an isolated DC snapshot to verify a benign query against the selected thresholds, retain matching 1644 XML and before/after policy, measure volume, and verify forwarding. Registry readback is not event-generation or Sigma detection evidence. Sysmon is out of scope.
+73
View File
@@ -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.
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).
+1 -1
View File
@@ -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", "wef-source-configuration-only", "wec-collector-subscriptions-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", "wef-source-configuration-only", "wec-collector-subscriptions-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
+147
View File
@@ -0,0 +1,147 @@
# Opt-in local DC diagnostics; separate from MDI-required Security auditing.
function Get-WelaLdapDefinitions {
$root = 'HKLM:\SYSTEM\CurrentControlSet\Services\NTDS'
@(
[pscustomobject]@{Id='FieldEngineering';Path="$root\Diagnostics";Name='15 Field Engineering';Unit='verbosity';DocumentedDefault=0}
[pscustomobject]@{Id='SearchTime';Path="$root\Parameters";Name='Search Time Threshold (msecs)';Unit='milliseconds';DocumentedDefault=30000}
[pscustomobject]@{Id='Expensive';Path="$root\Parameters";Name='Expensive Search Results Threshold';Unit='entry threshold';DocumentedDefault=10000}
[pscustomobject]@{Id='Inefficient';Path="$root\Parameters";Name='Inefficient Search Results Threshold';Unit='entry threshold';DocumentedDefault=1000}
)
}
function Get-WelaLdapHost {
if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { return [pscustomobject]@{Status='NotApplicable';Diagnostic='Windows domain controller required.'} }
$os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
$system = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop
if ($os.ProductType -notin @(1,2,3) -or $system.DomainRole -notin @(0,1,2,3,4,5)) { throw 'Cannot classify the Windows role.' }
if ($os.ProductType -ne 2 -and $system.DomainRole -notin @(4,5)) { return [pscustomobject]@{Status='NotApplicable';Diagnostic='Client/member/CA without DC role: no NTDS diagnostics changes.'} }
if ($os.ProductType -ne 2 -or $system.DomainRole -notin @(4,5)) { throw 'Conflicting domain-controller role observations.' }
if ([int]$os.BuildNumber -notin @(20348,26100)) { return [pscustomobject]@{Status='Unknown';Diagnostic='This workflow supports Server 2022/2025 DC builds 20348/26100.'} }
if (-not [Environment]::Is64BitProcess) { throw 'Run the LDAP workflow in 64-bit PowerShell.' }
[pscustomobject]@{Status='Applicable';ComputerName=$env:COMPUTERNAME;Build=[int]$os.BuildNumber;Diagnostic='Local domain controller; event generation remains unverified.'}
}
function Get-WelaLdapSnapshot {
$hostState = Get-WelaLdapHost
$values = @()
if ($hostState.Status -eq 'Applicable') {
foreach ($definition in Get-WelaLdapDefinitions) {
$values += [pscustomobject]@{Definition=$definition;State=(Get-WelaRegistryState -Path $definition.Path -Name $definition.Name)}
}
}
[pscustomobject]@{Host=$hostState;Values=$values}
}
function Get-WelaLdapPlan {
param($Snapshot, [ValidateSet('Preserve','Diagnostic','MdiCleanup')][string]$Mode='Preserve', [hashtable]$Thresholds=@{})
if ($Mode -ne 'Diagnostic' -and $Thresholds.Count) { throw 'Thresholds require Diagnostic mode.' }
foreach ($id in $Thresholds.Keys) {
if ($id -notin @('SearchTime','Expensive','Inefficient') -or $Thresholds[$id] -is [bool] -or
$Thresholds[$id] -isnot [ValueType] -or [double]$Thresholds[$id] -lt 1 -or [double]$Thresholds[$id] -gt 2147483647 -or
[double]$Thresholds[$id] -ne [int]$Thresholds[$id]) { throw "Invalid positive DWORD threshold: $id" }
}
$rows = foreach ($entry in $Snapshot.Values) {
$desired = $null; $operation = 'Preserve'
if ($Mode -eq 'MdiCleanup') { $operation='Remove' }
elseif ($Mode -eq 'Diagnostic') {
if ($entry.Definition.Id -eq 'FieldEngineering') { $operation='Set'; $desired=5 }
elseif ($Thresholds.ContainsKey($entry.Definition.Id)) { $operation='Set'; $desired=[int]$Thresholds[$entry.Definition.Id] }
}
[pscustomobject]@{Definition=$entry.Definition;Before=$entry.State;Operation=$operation;Desired=$desired}
}
[pscustomobject]@{
Mode=$Mode;Host=$Snapshot.Host;Controls=@($rows)
Guidance='MDI no longer requires 1644. Diagnostic mode is an explicit troubleshooting/detection choice; MdiCleanup explicitly removes the four listed legacy values. Preserve makes no changes.'
Volume='Field Engineering level 5 can also generate other Directory Service events. Measure volume in a bounded window before wider rollout.'
VerificationScope='Registry policy only; 1644 generation, thresholds in practice, volume and forwarding are not established.'
}
}
function Test-WelaLdapDesired {
param($Snapshot,$Plan)
if ($Snapshot.Host.Status -ne 'Applicable') { return $false }
foreach ($row in $Plan.Controls) {
$actual = @($Snapshot.Values | Where-Object { $_.Definition.Id -eq $row.Definition.Id })
if ($actual.Count -ne 1) { return $false }
$state=$actual[0].State
if ($row.Operation -eq 'Set' -and (-not $state.ValueExists -or $state.Type -ne 'DWord' -or $state.Value -ne $row.Desired)) { return $false }
if ($row.Operation -eq 'Remove' -and $state.ValueExists) { return $false }
if ($row.Operation -eq 'Preserve' -and -not (Test-WelaLdapValueEqual $state $row.Before)) { return $false }
}
return $true
}
function Test-WelaLdapValueEqual {
param($Left,$Right)
# Creating an absent parent for another selected value must not appear as value drift.
return $Left.ValueExists -eq $Right.ValueExists -and $Left.Type -ceq $Right.Type -and
(ConvertTo-Json $Left.Value -Compress) -ceq (ConvertTo-Json $Right.Value -Compress)
}
function Set-WelaLdapDiagnostics {
param($Context,$Plan)
$state = @{Plan=$Plan;Expected=$null}
$read = {
param($state)
$current = Get-WelaLdapSnapshot
if ($current.Host.Status -ne 'Applicable') { throw "LDAP diagnostics unavailable: $($current.Host.Diagnostic)" }
$current
}
$test = {
param($snapshot,$state)
if (-not $state.Expected) { $state.Expected=$snapshot }
Test-WelaLdapDesired $snapshot $state.Plan
}
$apply = {
param($state)
$fresh=Get-WelaLdapSnapshot
if ((ConvertTo-Json $fresh -Depth 12 -Compress) -cne (ConvertTo-Json $state.Expected -Depth 12 -Compress)) { throw 'LDAP state changed after the pre-change journal; no write performed.' }
# Plan is independently checked against the current pre-change state too.
foreach ($row in $state.Plan.Controls) {
$before=@($fresh.Values | Where-Object { $_.Definition.Id -eq $row.Definition.Id })[0].State
if (-not (Test-WelaLdapValueEqual $before $row.Before)) { throw 'LDAP plan is stale; review a fresh plan.' }
if ($before.ValueExists -and $before.Type -ne 'DWord') { throw "Unknown registry type for $($row.Definition.Name); preserved without mutation." }
}
# Configure thresholds before enabling verbose logging. Cleanup disables it first.
$ordered = if ($state.Plan.Mode -eq 'Diagnostic') { @($state.Plan.Controls | Sort-Object { $_.Definition.Id -eq 'FieldEngineering' }) } else { @($state.Plan.Controls) }
foreach ($row in $ordered) {
if ($row.Operation -eq 'Preserve') { continue }
$before=Get-WelaRegistryState -Path $row.Definition.Path -Name $row.Definition.Name
if (-not (Test-WelaLdapValueEqual $before $row.Before)) { throw "LDAP value changed before writing $($row.Definition.Name); remaining changes stopped." }
if ($row.Operation -eq 'Remove') {
if ($before.ValueExists) { Remove-ItemProperty -LiteralPath $row.Definition.Path -Name $row.Definition.Name -ErrorAction Stop }
} elseif (-not $before.ValueExists -or $before.Type -ne 'DWord' -or $before.Value -ne $row.Desired) {
New-WelaRegistryKey -Path $row.Definition.Path
Set-ItemProperty -LiteralPath $row.Definition.Path -Name $row.Definition.Name -Value $row.Desired -Type DWord -ErrorAction Stop
}
$after=Get-WelaRegistryState -Path $row.Definition.Path -Name $row.Definition.Name
if (($row.Operation -eq 'Remove' -and $after.ValueExists) -or
($row.Operation -eq 'Set' -and (-not $after.ValueExists -or $after.Type -ne 'DWord' -or $after.Value -ne $row.Desired))) {
throw "LDAP value readback failed for $($row.Definition.Name); remaining changes stopped."
}
}
}
Invoke-WelaConfigurationControl -Context $Context -Id 'LdapDiagnostics/LocalDC' -Kind RegistrySet -Target 'NTDS diagnostics/parameters (four named values only)' -Desired $Plan `
-Read $read -Compliant $test -Apply $apply -CallbackState $state -Description 'Apply the explicitly selected LDAP diagnostic mode and listed values.'
}
function Invoke-WelaLdapCommand {
param([ValidateSet('Audit','Plan','Configure')][string]$Action='Audit',
[ValidateSet('Preserve','Diagnostic','MdiCleanup')][string]$Mode='Preserve', [hashtable]$Thresholds=@{},
[switch]$Auto,[switch]$DryRun,[string]$BackupPath,[string]$ResultsPath)
$snapshot=Get-WelaLdapSnapshot
$plan=Get-WelaLdapPlan -Snapshot $snapshot -Mode $Mode -Thresholds $Thresholds
$report=[pscustomobject]@{ExitCode=$(if ($snapshot.Host.Status -eq 'Unknown') {1} else {0});Scope='ldap-1644-diagnostics';Plan=$plan;Snapshot=$snapshot}
if ($Action -eq 'Configure' -and $Mode -ne 'Preserve') {
if ($snapshot.Host.Status -ne 'Applicable') { throw "LDAP configuration blocked: $($snapshot.Host.Diagnostic)" }
$context=New-WelaConfigurationContext -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath
Set-WelaLdapDiagnostics -Context $context -Plan $plan
$report=Complete-WelaConfiguration -Context $context -SuccessMessage 'Selected LDAP registry changes verified; event generation and forwarding remain unverified.'
$report.Scope='ldap-1644-diagnostics'
$report | Add-Member NoteProperty Plan $plan
}
if ($ResultsPath) { $report | ConvertTo-Json -Depth 16 | Set-Content -LiteralPath $ResultsPath -Encoding UTF8 -ErrorAction Stop }
Write-Host $plan.Guidance
Write-Host $plan.Volume
$report
}
+246
View File
@@ -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
}
+166
View File
@@ -0,0 +1,166 @@
$ErrorActionPreference='Stop'
$script:ScriptRoot=Split-Path $PSScriptRoot -Parent
. (Join-Path $script:ScriptRoot 'scripts/Configuration.ps1')
. (Join-Path $script:ScriptRoot 'scripts/LdapDiagnostics.ps1')
$script:count=0
function Assert($Condition,$Message) { if (-not $Condition) { throw $Message }; $script:count++ }
function Reject([scriptblock]$Action,[string]$Pattern) {
$message=''; try { & $Action | Out-Null } catch { $message=$_.Exception.Message }
Assert ($message -match $Pattern) "Expected '$Pattern', got '$message'."
}
function Reset-Fixture {
$script:values=@{}; $script:writes=@(); $script:role='Applicable'; $script:readError=$false
$script:writeError=$false; $script:ignoreWrite=$false; $script:race=$false; $script:reads=0
$script:types=@{}; $script:journal=$null; $script:failName=$null
}
function Get-WelaLdapHost { [pscustomobject]@{Status=$script:role;Diagnostic='fixture';ComputerName='dc1';Build=26100} }
function Get-WelaRegistryState {
param($Path,$Name)
if ($script:readError) { throw 'read denied' }
$script:reads++
if ($script:race -and $script:journal -and (Test-Path $script:journal)) { $script:values['15 Field Engineering']=4; $script:types['15 Field Engineering']='DWord' }
[pscustomobject]@{KeyExists=$true;ValueExists=$script:values.ContainsKey($Name);Value=$script:values[$Name];Type=$script:types[$Name]}
}
function New-WelaRegistryKey { param($Path) }
function Set-ItemProperty {
param($LiteralPath,$Name,$Value,$Type,$ErrorAction)
Assert ($script:journal -and (Test-Path $script:journal)) 'Every native write must follow the complete pre-change journal.'
if ($script:writeError -or $Name -eq $script:failName) { throw 'write denied' }
$script:writes += $Name
if (-not $script:ignoreWrite) { $script:values[$Name]=$Value; $script:types[$Name]=$Type }
}
function Remove-ItemProperty {
param($LiteralPath,$Name,$ErrorAction)
Assert ($script:journal -and (Test-Path $script:journal)) 'Every removal must follow a recovery journal.'
if ($script:writeError -or $Name -eq $script:failName) { throw 'remove denied' }
$script:writes += $Name
if (-not $script:ignoreWrite) { $script:values.Remove($Name); $script:types.Remove($Name) }
}
$cleanup=@()
function New-FixtureContext([switch]$DryRun) {
$path=Join-Path ([IO.Path]::GetTempPath()) ('wela-ldap-'+[guid]::NewGuid().ToString('N'))
$script:cleanup += $path; $script:journal=Join-Path $path 'before.jsonl'
New-WelaConfigurationContext -Auto -DryRun:$DryRun -BackupPath $path
}
try {
Reset-Fixture
foreach ($mode in @('Preserve','Diagnostic','MdiCleanup')) {
$plan=Get-WelaLdapPlan (Get-WelaLdapSnapshot) $mode
Assert ($plan.Controls.Count -eq 4 -and $plan.VerificationScope -match 'not established') 'Every plan enumerates the complete bounded set and keeps event evidence separate.'
}
foreach ($value in @(-1,0,1.5,2147483648,$true)) {
Reject { Get-WelaLdapPlan (Get-WelaLdapSnapshot) Diagnostic @{SearchTime=$value} } 'Invalid'
}
Reject { Get-WelaLdapPlan (Get-WelaLdapSnapshot) MdiCleanup @{SearchTime=1} } 'require Diagnostic'
Reject { Get-WelaLdapPlan (Get-WelaLdapSnapshot) Diagnostic @{Typo=1} } 'Invalid'
$script:values['15 Field Engineering']=4; $script:types['15 Field Engineering']='DWord'
$preserved=Invoke-WelaLdapCommand -Action Configure
Assert ($script:writes.Count -eq 0 -and $preserved.Plan.Mode -eq 'Preserve' -and $script:values['15 Field Engineering'] -eq 4) 'Default command preserves another operator diagnostic level.'
foreach ($scenario in @('apply','dry','cleanup','read','write','ignored','race','type','drift','preserved-drift','stale')) {
Reset-Fixture
$mode='Diagnostic'; $thresholds=@{SearchTime=100;Expensive=10000;Inefficient=1000}
if ($scenario -eq 'cleanup') {
$mode='MdiCleanup'; $thresholds=@{}
foreach ($definition in Get-WelaLdapDefinitions) { $script:values[$definition.Name]=1; $script:types[$definition.Name]='DWord' }
$script:values['15 Field Engineering']=5
}
if ($scenario -eq 'preserved-drift') { $thresholds=@{} }
if ($scenario -eq 'type') { $script:values['15 Field Engineering']='5'; $script:types['15 Field Engineering']='String' }
$plan=Get-WelaLdapPlan (Get-WelaLdapSnapshot) $mode $thresholds
if ($scenario -eq 'read') { $script:readError=$true }
if ($scenario -eq 'write') { $script:writeError=$true }
if ($scenario -eq 'ignored') { $script:ignoreWrite=$true }
if ($scenario -eq 'race') { $script:race=$true }
if ($scenario -eq 'stale') { $script:values['15 Field Engineering']=2; $script:types['15 Field Engineering']='DWord' }
$context=New-FixtureContext -DryRun:($scenario -eq 'dry')
Set-WelaLdapDiagnostics $context $plan
if ($scenario -eq 'drift') { $script:values['15 Field Engineering']=0 }
if ($scenario -eq 'preserved-drift') { $script:values['Expensive Search Results Threshold']=5; $script:types['Expensive Search Results Threshold']='DWord' }
$result=Complete-WelaConfiguration $context
switch ($scenario) {
'apply' {
Assert ($result.ExitCode -eq 0 -and $script:writes.Count -eq 4 -and $script:writes[-1] -eq '15 Field Engineering') 'Verify thresholds before enabling verbosity.'
$before=Get-Content $script:journal -Raw | ConvertFrom-Json
Assert ($before.Before.Values.Count -eq 4 -and @($before.Before.Values | Where-Object {$_.State.ValueExists}).Count -eq 0) 'Journal contains all exact missing-value states.'
$again=Get-WelaLdapPlan (Get-WelaLdapSnapshot) Diagnostic $thresholds
Set-WelaLdapDiagnostics $context $again
Assert ($script:writes.Count -eq 4 -and $context.Results[1].Status -eq 'AlreadyCompliant') 'Repeat configuration performs no duplicate writes.'
}
'dry' { Assert ($result.ExitCode -eq 0 -and $script:writes.Count -eq 0 -and -not (Test-Path $context.BackupPath)) 'Dry-run makes no settings/journal changes.' }
'cleanup' { Assert ($result.ExitCode -eq 0 -and $script:values.Count -eq 0 -and $script:writes[0] -eq '15 Field Engineering') 'Explicit MDI cleanup removes only named values, verbosity first.' }
'ignored' { Assert ($result.ExitCode -eq 1 -and $script:writes.Count -eq 1 -and $script:writes -notcontains '15 Field Engineering') 'Failed threshold readback cannot proceed to enabling diagnostics.' }
'drift' { Assert ($result.ExitCode -eq 1 -and $context.Results[0].Status -eq 'Overridden') 'Final diagnostic drift fails.' }
'preserved-drift' { Assert ($result.ExitCode -eq 1) 'Unselected thresholds must remain preserved.' }
default { Assert ($result.ExitCode -eq 1 -and $script:writes.Count -eq 0) "Scenario $scenario must block before native writes." }
}
}
# Exercise actual partial mutation boundaries: recovery is journal-based and
# does not automatically overwrite the successfully changed subset.
foreach ($mode in @('Diagnostic','MdiCleanup')) {
Reset-Fixture
$thresholds=@{SearchTime=100;Expensive=10000;Inefficient=1000}
if ($mode -eq 'MdiCleanup') {
$thresholds=@{}
foreach ($definition in Get-WelaLdapDefinitions) { $script:values[$definition.Name]=1; $script:types[$definition.Name]='DWord' }
$script:values['15 Field Engineering']=5
$script:failName='Search Time Threshold (msecs)'
} else { $script:failName='15 Field Engineering' }
$script:values['Unrelated diagnostic']=7; $script:types['Unrelated diagnostic']='DWord'
$plan=Get-WelaLdapPlan (Get-WelaLdapSnapshot) $mode $thresholds
$context=New-FixtureContext
Set-WelaLdapDiagnostics $context $plan
$result=Complete-WelaConfiguration $context
$journal=Get-Content $script:journal -Raw | ConvertFrom-Json
Assert ($result.ExitCode -eq 1 -and $journal.Before.Values.Count -eq 4) 'A failure after earlier writes retains the complete recovery snapshot.'
Assert ($script:values['Unrelated diagnostic'] -eq 7) 'Partial failures preserve unrelated registry values.'
if ($mode -eq 'Diagnostic') {
Assert ($script:writes.Count -eq 3 -and -not $script:values.ContainsKey('15 Field Engineering')) 'Failed verbosity write leaves verified thresholds and does not claim success.'
} else {
Assert ($script:writes.Count -eq 1 -and -not $script:values.ContainsKey('15 Field Engineering') -and $script:values['Search Time Threshold (msecs)'] -eq 1 -and $script:values['Expensive Search Results Threshold'] -eq 1 -and $script:values['Inefficient Search Results Threshold'] -eq 1) 'Cleanup stops after the first failed threshold removal without restoring verbosity or removing later values.'
}
}
Reset-Fixture; $script:role='NotApplicable'
$report=Invoke-WelaLdapCommand -Action Audit
Assert ($report.Snapshot.Values.Count -eq 0 -and $script:reads -eq 0) 'Member/client/non-DC CA never queries or creates NTDS settings.'
Reject { Invoke-WelaLdapCommand -Action Configure -Mode Diagnostic } 'blocked'
Reset-Fixture; $script:role='Unknown'
Assert ((Invoke-WelaLdapCommand).ExitCode -eq 1) 'Unknown host applicability cannot look successful.'
# Execute the real general configure body with all native boundaries replaced.
$ast=[Management.Automation.Language.Parser]::ParseFile((Join-Path $script:ScriptRoot 'WELA.ps1'),[ref]$null,[ref]$null)
# Preserve the real ordering of the LDAP guard and early profile dispatch, while
# replacing the profile handler so configure can never change this test host.
$dispatchNodes=@($ast.EndBlock.Statements | Where-Object {
$_ -is [Management.Automation.Language.IfStatementAst] -and
($_.Extent.Text -match 'LDAP options require' -or $_.Extent.Text -match 'Invoke-WelaProfileCommand -Command')
})
Assert ($dispatchNodes.Count -eq 2) 'Exercise both real CLI boundaries in source order.'
$dispatch=[scriptblock]::Create('param($Cmd,$Profile,$LdapAction,$LdapMode,$LdapSearchTimeMs,$LdapExpensiveThreshold,$LdapInefficientThreshold)' + [Environment]::NewLine + (($dispatchNodes | ForEach-Object {$_.Extent.Text}) -join [Environment]::NewLine))
function Invoke-WelaProfileCommand { param($Command) $script:profileDispatched=$true }
foreach ($command in @('plan','audit','audit-settings','configure')) {
foreach ($option in @('LdapAction','LdapMode','LdapSearchTimeMs','LdapExpensiveThreshold','LdapInefficientThreshold')) {
$script:profileDispatched=$false
$arguments=@{Cmd=$command;Profile='fixture'}; $arguments[$option]='fixture'
Reject { & $dispatch @arguments } 'LDAP options require'
Assert (-not $script:profileDispatched) "LDAP option $option must block $command before unrelated profile dispatch."
}
}
$node=$ast.Find({param($n) $n -is [Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq 'ConfigureAuditSettings'},$false)
. ([scriptblock]::Create($node.Extent.Text))
function TestWindows {$true}; function TestAdministrator {$true}
function Get-WelaHostContext {[pscustomobject]@{Role='DomainController';Build=26100}}
function Get-WelaEffectiveAuditPolicy {@{}}
function Get-WelaAuditProfilePlan {[pscustomobject]@{profile='fixture'}}
function Assert-WelaAuditProfileTarget {}
function Set-WelaEventLogProfileControls {}
function Set-WelaEventLogControl {}
function Set-WelaRegistryControl {param($Context,$Path,$Name) if ($Path -like '*NTDS*') {throw 'General configure attempted NTDS diagnostics'} }
function Set-RegistryConfig {param($RegPaths) if (@($RegPaths | Where-Object {$_.Path -like '*NTDS*'}).Count) {throw 'General configure attempted NTDS diagnostics'} }
function Set-WelaOutgoingNtlmPolicy {};function Set-WelaDomainNtlmAudit {};function Show-WelaAuditProfilePrerequisites {};function Set-WelaProfileAuditControls {};function Set-WelaCertificateAuditControl {}
function Complete-WelaConfiguration {[pscustomobject]@{ExitCode=0}}
$script:PowerShellPolicyRoots=@(); $script:IncludeOptional=$false
$general=ConfigureAuditSettings -Auto -DryRun
Assert ($general.ExitCode -eq 0) 'Normal DC configure must not re-enable or disable LDAP diagnostics.'
Write-Host "PASS: $script:count LDAP diagnostics assertions; no Windows changes."
} finally { foreach ($path in $cleanup) { if (Test-Path $path) { Remove-Item $path -Recurse -Force } } }
+8
View File
@@ -0,0 +1,8 @@
$ErrorActionPreference='Stop'
$root=Split-Path $PSScriptRoot -Parent
. (Join-Path $root 'scripts/Configuration.ps1')
. (Join-Path $root 'scripts/LdapDiagnostics.ps1')
$result=Invoke-WelaLdapCommand -Action Audit
if ($result.Snapshot.Host.Status -notin @('Applicable','NotApplicable')) { throw 'Unexpected unknown native host applicability.' }
if ($result.Snapshot.Host.Status -eq 'NotApplicable' -and $result.Snapshot.Values.Count) { throw 'Non-DC incorrectly queried NTDS values.' }
Write-Host "PASS: native read-only LDAP applicability $($result.Snapshot.Host.Status); no registry writes or LDAP queries."
+137
View File
@@ -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 }
@@ -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."
+2
View File
@@ -9,6 +9,8 @@
- 読み取り専用の`rule-eligibility`を追加し、ルール・対応表のハッシュ、ルールごとの判定理由、対象外の理由、分子・分母を明示します。任意で取り込んだラボ資料を、対応範囲を限定した完全なルール定義、ネイティブXML、設定、収集・クエリ実行の証拠と照合し、未対応・未確認の項目はConditionalとします。監査のCSV/JSON/HTMLとNavigator出力では、設定が有効なだけでルールを利用可能と判定しません。取り込んだReady判定は記録された環境・時点に限られ、実環境での一連の検証を保証しません。 (#407) (@Shirofune-Security)
- Windows標準のドメイン/Kerberos環境向けに、送信元設定と明示的に選択した収集サーバーのサブスクリプションを監査・計画・設定する任意実行の`wef-source`と`wec-collector`を追加しました。実際の収集先ID、既存リスナーと範囲を限定した受信規則、送信元SID、メンバーホストでの読み取り権限の追加、明示的なASDのWSMan強化設定と共通チャネル設定を、復旧記録・変更検出・読み戻しで確認します。DCのグループ管理権限と異なる既存サブスクリプションは変更しません。JSONにはクエリ・チャネル・実行状態の証拠を保持し、実効読み取り権限・イベント到着・転送後のSigma検知範囲は未検証と表示します。隔離Windows環境での配備検証は別途必要です。 (#406) (@Shirofune-Security)
- CIS v4.0.0 Level 2向けに、Windows PowerShell 5.1トランスクリプトの明示的な監査・計画・設定機能を追加しました。管理者が選択した既存の出力ディレクトリを確認し、ポリシーとは分けて報告します。型を含むレジストリ変更前の状態を保存し、共有される32/64ビットのビューと変更後の状態を検証します。呼び出しヘッダーの設定は保持し、ACL・共有・保存期間は変更せず、Sigma EVTX検知範囲の向上も自動加算しません。使い捨て環境の実トランスクリプトテストでは元のポリシーを復元します。中央保存先の権限と収集は別途検証が必要です。 (#405) (@Shirofune-Security)
- 通常の設定でDCのField Engineeringを自動的にレベル5へ変更せず、既存のLDAP 1644診断設定を保持するようにしました。明示的な`ldap-diagnostics`の監査・計画・設定を追加し、保持、しきい値を指定した診断、MDIの旧設定削除を選択できます。役割・ビルド確認、型付き復旧記録、競合検出、順序付きの読み戻しと最終状態確認に対応します。LDAP専用オプションを他のプロファイルコマンドへ指定すると、実行前に拒否します。イベント生成・量・転送は隔離DCでの検証が必要です。 (#404) (@Shirofune-Security)
- 従来のToken Right Adjusted EventsのGUIDを修正し、すべてのベースラインでRPCとトークン監査の状態を個別に評価するようにしました。実行時のカタログID・明示的な名前別表記の検証と、ハッシュ付きの読み取り専用EventID対応表レビューを追加しました。曖昧な対応、カテゴリだけの対応、不明な対応から検知可能とは判断しません。 (#403) (@Shirofune-Security)
- MDIのドメイン/Exchange Configuration監査と、明示的に選択した証明書テンプレート/登録サービスオブジェクト向けに、任意実行の`ad-object-sacl`監査・計画・設定・保守的なロールバックを追加しました。接続先DCとスキーマGUIDを検証し、既存のセキュリティ設定を保持したまま不足する監査ACEだけをSACLに追加します。変更前のSDDLと追加ACEを保存し、書き込み後と最終状態を確認します。任意のdMSA前提条件が不明な場合は未確認のスキップ項目として報告し、独立した他の5種類のドメインクラスの監査ACEは引き続き設定します。実効監査ポリシー、継承・レプリケーション、4662/5136イベントの証拠は隔離DCで別途検証が必要です。Sigma検知範囲の向上は未検証です。 (#402) (@Shirofune-Security)
+2
View File
@@ -9,6 +9,8 @@
- Added read-only `rule-eligibility` reports with pinned corpus/mapping hashes, per-rule reasons, explicit scope exclusions and numerator/denominator totals. Optional imported lab artifacts are checked against a narrow complete-rule parser, native XML, configuration, ingestion and query evidence; unsupported or incomplete cases stay Conditional. Audit CSV/JSON/HTML and Navigator outputs no longer treat enabled settings as proven usable rules. Imported Ready results apply only to their recorded context/time; no live end-to-end validation is implied. (#407) (@Shirofune-Security)
- Added separate opt-in `wef-source` and `wec-collector` audit, plan and configure commands for native domain/Kerberos source settings and explicitly selected collector subscriptions. Actual collector identity, scoped existing ingress/listener prerequisites, explicit source SIDs, additive member-host read permissions, optional ASD WSMan hardening and shared channel controls are checked with journals, drift guards and readback. DC group authority and different existing subscriptions are preserved. JSON retains query/channel/runtime evidence without claiming effective read access, event arrival or forwarded Sigma coverage; isolated Windows deployment validation remains pending. (#406) (@Shirofune-Security)
- Added explicit CIS v4.0.0 Level 2 Windows PowerShell 5.1 transcription audit, plan and configure actions. An operator-selected existing output directory is checked and reported separately from policy; typed canonical registry writes are journaled, verified through shared 32/64-bit views and checked for drift while preserving invocation-header preferences. No ACL/share/retention changes or automatic Sigma EVTX credit are introduced; disposable native transcript tests restore original policy, and central authorization/collection remains a deployment check. (#405) (@Shirofune-Security)
- Preserved LDAP 1644 diagnostics during normal configuration instead of automatically enabling Field Engineering level 5 on DCs. Added explicit `ldap-diagnostics` audit/plan/configure modes for preservation, tunable diagnostics and MDI legacy cleanup, with role/build checks, typed recovery snapshots, race guards, ordered readback and final drift checks. LDAP-only options are rejected before unrelated profile commands can run. Generated events, volume and forwarding remain isolated-DC validation. (#404) (@Shirofune-Security)
- Corrected the legacy Token Right Adjusted Events GUID so RPC and token auditing are assessed independently in every baseline. Added runtime catalog identity/alias checks and a read-only, fingerprinted EventID mapping review that preserves ambiguous, category-only and unknown candidates without detection credit. (#403) (@Shirofune-Security)
- Added opt-in `ad-object-sacl` audit, plan, configure and conservative rollback actions for MDI domain/Exchange Configuration auditing and explicitly selected certificate template/enrollment service objects. Exact DC binding, schema GUID checks, additive SACL-only changes, pre-write SDDL/ACE receipts and read-back preserve existing security entries. Unknown optional dMSA prerequisites are reported as a separate skipped gap while the five independent domain class ACEs continue. Effective audit policy, inheritance/replication and 4662/5136 event evidence remain separate isolated-DC checks; no Sigma uplift is claimed. (#402) (@Shirofune-Security)