From c338dae12e8ec0339e898d63337803ee752a9769 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Sat, 19 Sep 2026 05:42:25 +0900 Subject: [PATCH 1/4] Add opt-in AD directory object SACL profiles and recovery (issue #371) --- .github/workflows/ad-object-sacl.yml | 31 ++ CHANGELOG-Japanese.md | 1 + CHANGELOG.md | 1 + WELA.ps1 | 31 +- docs/ad-object-sacl.md | 84 ++++++ scripts/AdObjectSacl.ps1 | 389 +++++++++++++++++++++++++ scripts/Configuration.ps1 | 2 +- tests/AdObjectSacl.Tests.ps1 | 176 +++++++++++ tests/AdObjectSacl.Windows.Tests.ps1 | 93 ++++++ website/docs/resources/changelog.ja.md | 1 + website/docs/resources/changelog.md | 1 + 11 files changed, 807 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/ad-object-sacl.yml create mode 100644 docs/ad-object-sacl.md create mode 100644 scripts/AdObjectSacl.ps1 create mode 100644 tests/AdObjectSacl.Tests.ps1 create mode 100644 tests/AdObjectSacl.Windows.Tests.ps1 diff --git a/.github/workflows/ad-object-sacl.yml b/.github/workflows/ad-object-sacl.yml new file mode 100644 index 00000000..ccf68cf8 --- /dev/null +++ b/.github/workflows/ad-object-sacl.yml @@ -0,0 +1,31 @@ +name: AD object SACL regressions +on: + push: + branches: ['**'] + paths: + - 'WELA.ps1' + - 'scripts/Configuration.ps1' + - 'scripts/AdObjectSacl.ps1' + - 'tests/AdObjectSacl*' + - '.github/workflows/ad-object-sacl.yml' + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + ad-object-sacl: + runs-on: windows-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Mocked AD orchestration in Windows PowerShell 5.1 + shell: powershell + run: ./tests/AdObjectSacl.Tests.ps1 + - name: Offline native descriptors and LDAP requests in Windows PowerShell 5.1 + shell: powershell + run: ./tests/AdObjectSacl.Windows.Tests.ps1 + - name: Mocked AD orchestration in PowerShell 7 + shell: pwsh + run: ./tests/AdObjectSacl.Tests.ps1 + - name: Offline native descriptors and LDAP requests in PowerShell 7 + shell: pwsh + run: ./tests/AdObjectSacl.Windows.Tests.ps1 diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index c79f4a86..3d4a9c3e 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,6 +4,7 @@ **改善:** +- MDIのドメイン/Exchange Configuration監査と、明示的に選択した証明書テンプレート/登録サービスオブジェクト向けに、任意実行の`ad-object-sacl`監査・計画・設定・保守的なロールバックを追加しました。接続先DCとスキーマGUIDを検証し、既存のセキュリティ設定を保持したまま不足する監査ACEだけをSACLに追加します。変更前のSDDLと追加ACEを保存し、書き込み後と最終状態を確認します。実効監査ポリシー、継承・レプリケーション、4662/5136イベントの証拠は隔離DCで別途検証が必要です。Sigma検知範囲の向上は未検証です。 (issue #371) (@Shirofune-Security) - ネイティブのDomain/Private/Publicテキストログを監査・計画・設定する任意実行の`firewall-logging`を追加しました。許可・破棄ログの有効化、最小サイズの確認、既存パスと大きな上限値の保持、CIS v4.0.0のパスの明示的な選択に対応します。ファイアウォールサービスのディレクトリ権限を確認し、ローカル設定と実効設定を記録して変更後の実効設定を検証します。通信制御やACLは変更しません。実通信によるログ生成と収集の検証は別途必要です。 (#394) (@Shirofune-Security) - イベントログのサイズ監査と設定に共通のバイト単位プロファイルを導入し、AppLocker・ファイアウォールログの256 MiB、Setupの32 MiB、ASD推奨のSecurityログ2048 MiBに対応した。`-LogProfile`と`configure-eventlogs`で送信元と収集サーバーのサイズ・保存方式を選択できる。明示的に指定しない限り、既存の大きいバッファと保存方式は維持する。結果には検証した設定を記録し、未測定の保存日数は不明と表示する。 (#396) (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab71489f..432acd33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ **Improvements:** +- 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. Effective audit policy, inheritance/replication and 4662/5136 event evidence remain separate isolated-DC checks; no Sigma uplift is claimed. (issue #371) (@Shirofune-Security) - Added opt-in `firewall-logging` audit, plan and configure actions for native Domain/Private/Public text logs, with allowed/dropped logging, minimum size checks, preserved operator paths/larger limits, and explicit CIS v4.0.0 paths. Configuration checks firewall service directory permissions, journals local/effective state and verifies effective policy without changing firewall enforcement or ACLs. Traffic and ingestion validation remains required. (#394) (@Shirofune-Security) - Unified event-log size auditing and configuration with shared byte-based profiles, including 256 MiB AppLocker/firewall logs, 32 MiB Setup and ASD 2048 MiB Security. Added separate source and collector size/mode choices through `-LogProfile` and `configure-eventlogs`; larger buffers and existing retention modes are preserved unless explicitly changed. Results include verified state and unknown retention duration. (#396) (@Shirofune-Security) diff --git a/WELA.ps1 b/WELA.ps1 index c8196e0a..21d9a6c7 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -22,6 +22,11 @@ [ValidateRange(16384, 32767)][int]$FirewallMinimumSizeKiB = 16384, [string]$HtmlPath, [ValidateSet('Audit', 'Plan', 'Configure')][string]$SmbAction = 'Audit', + [ValidateSet('Audit', 'Plan', 'Configure', 'Rollback')][string]$AdSaclAction = 'Audit', + [string]$AdServer, + [ValidateSet('MdiDomain', 'MdiConfiguration', 'PkiObjects')][string[]]$AdSaclProfile, + [string[]]$AdObjectDn, + [string]$AdReceiptPath, [switch]$Help ) @@ -38,6 +43,7 @@ $SaclTargetsPath = Join-Path $ScriptRoot "config/audit_sacl_targets.json" . (Join-Path $ScriptRoot "scripts/Configuration.ps1") . (Join-Path $ScriptRoot "scripts/FirewallLogging.ps1") . (Join-Path $ScriptRoot "scripts/SmbAuditing.ps1") +. (Join-Path $ScriptRoot "scripts/AdObjectSacl.ps1") Import-Module (Join-Path $ScriptRoot "modules/AuditProfiles.psm1") -ErrorAction Stop Import-Module (Join-Path $ScriptRoot "modules/NativeProviders.psm1") -ErrorAction Stop Import-Module (Join-Path $ScriptRoot "modules/EventLogSettings.psm1") -ErrorAction Stop @@ -1674,6 +1680,7 @@ Usage: ./WELA.ps1 smb-auditing -SmbAction Plan ./WELA.ps1 smb-auditing -SmbAction Configure -DryRun # SMB auditing is opt-in and never changes signing/encryption requirements or guest access. + ./WELA.ps1 ad-object-sacl -AdSaclAction Plan -AdServer dc01.example.test -AdSaclProfile MdiDomain ./WELA.ps1 profiles # List versioned advanced audit-policy profiles ./WELA.ps1 plan -Profile wela-2.2.0 -Role Client -Build 26100 -PlanPath plan.json ./WELA.ps1 audit-settings -Profile microsoft-sct-win11-24h2 -PlanPath audit.json @@ -1703,10 +1710,16 @@ Write-Host "WELA v$WELAVersion - $WELAReleaseName" Write-Host "" # Reject unsupported dry-run requests before reaching any command's mutation path. +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 $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')) { - throw "-DryRun is supported only by configure (including configure -Profile), configure-eventlogs, firewall-logging -FirewallAction Configure and smb-auditing -SmbAction Configure. No command was run." + -not ($Cmd -eq 'smb-auditing' -and $SmbAction -eq 'Configure') -and + -not ($Cmd -eq 'ad-object-sacl' -and $AdSaclAction -in @('Configure', 'Rollback'))) { + throw "-DryRun is supported only by configure (including configure -Profile), configure-eventlogs, firewall-logging -FirewallAction Configure, smb-auditing -SmbAction Configure and ad-object-sacl -AdSaclAction Configure|Rollback. No command was run." } if ($Profile -and $Cmd -in @('eventlog-profiles', 'audit-filesize', 'configure-eventlogs')) { throw '-Profile selects advanced audit policy only. Use -LogProfile for event-log size/mode settings.' @@ -1750,6 +1763,20 @@ switch ($Cmd.ToLower()) { if ($report.ExitCode) { exit $report.ExitCode } } catch { Write-Host "[Failed] SMB auditing: $_" -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]' + Write-Host 'Rollback uses -AdSaclAction Rollback -AdReceiptPath trusted-receipt.json without profile selection. See docs/ad-object-sacl.md for prerequisites, scope, recovery and required DC lab evidence.' + return + } + if ($Profile -or $Baseline) { throw 'ad-object-sacl uses explicit -AdSaclProfile; Security audit policy is a separate prerequisite.' } + try { + $report = Invoke-WelaAdSaclCommand -Action $AdSaclAction -Server $AdServer -Profiles $AdSaclProfile -ObjectDn $AdObjectDn ` + -ReceiptPath $AdReceiptPath -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath -ResultsPath $ResultsPath + $report + if ($report.ExitCode) { exit $report.ExitCode } + } catch { Write-Host "[Failed] AD object SACL: $_" -ForegroundColor Red; exit 1 } + } "profiles" { (Import-WelaAuditProfiles).profiles | Select-Object id, version, scope, appliesTo | Format-List } diff --git a/docs/ad-object-sacl.md b/docs/ad-object-sacl.md new file mode 100644 index 00000000..555d927e --- /dev/null +++ b/docs/ad-object-sacl.md @@ -0,0 +1,84 @@ +# AD directory object auditing + +`ad-object-sacl` is a dedicated opt-in command for **built-in AD DS SACL auditing**. It requires Windows PowerShell 5.1 or PowerShell 7 on Windows, an explicit DC FQDN, and credentials permitted to read the complete security descriptors and update SACLs on the selected objects. Use an account with the necessary directory security privilege; the command does not grant privileges or modify authorization. It does not use the local file/registry `configure-sacl` targets. + +```powershell +# Audit/plan read directory state without changing it. Export exact DNs and ACEs. +./WELA.ps1 ad-object-sacl -AdServer dc01.example.test -AdSaclAction Plan ` + -AdSaclProfile MdiDomain -ResultsPath ad-plan.json + +# Configure is explicit. DryRun performs the same reads, without AD writes or backups. +./WELA.ps1 ad-object-sacl -AdServer dc01.example.test -AdSaclAction Configure ` + -AdSaclProfile MdiDomain -DryRun -ResultsPath ad-preview.json +./WELA.ps1 ad-object-sacl -AdServer dc01.example.test -AdSaclAction Configure ` + -AdSaclProfile MdiDomain -Auto -BackupPath .\new-ad-backup -ResultsPath ad-result.json + +# Current/former Exchange configuration is a separate forest-wide choice. +./WELA.ps1 ad-object-sacl -AdServer dc01.example.test -AdSaclAction Plan ` + -AdSaclProfile MdiConfiguration -ResultsPath exchange-plan.json + +# PKI objects are explicitly selected, not every object in the Configuration partition. +./WELA.ps1 ad-object-sacl -AdServer dc01.example.test -AdSaclAction Plan ` + -AdSaclProfile PkiObjects ` + -AdObjectDn 'CN=LabTemplate,CN=Certificate Templates,CN=Public Key Services,CN=Services,CN=Configuration,DC=example,DC=test' ` + -ResultsPath pki-plan.json +``` + +Multiple profiles or PKI DNs can be passed as PowerShell arrays. `-Profile` and `-Baseline` select Security audit policy and are rejected by this command. The AD-specific parameters are rejected on other commands. There is no implicit domain discovery: aliases, LDAP URLs, ports, and a RootDSE host different from `-AdServer` are refused. All requests use one connection to that DC with Negotiate authentication, LDAP signing/sealing, and referral chasing disabled. AD LDS and unknown DC write capability are refused. RODCs can be read; required changes are reported `Blocked`. + +## Exact profiles + +All ACEs use **Everyone (`S-1-1-0`)**. An empty object GUID is `00000000-0000-0000-0000-000000000000`; it does not restrict the ACE to one property. Plans export the target DN, class, SID, decimal rights mask, named rights, audit outcomes, ACE flags, object GUID, inherited class GUID, and inheritance. Schema GUIDs are checked against the same DC before planning writes. + +`MdiDomain` targets RootDSE `defaultNamingContext`. Each ACE audits **Success**, applies to **descendants only** of the listed class, and has ACE flags `74` (`0x4A`: Success, ContainerInherit, InheritOnly), empty object GUID and the following inherited class GUID. The precise masks follow Microsoft's [readiness script at commit 730dad6](https://github.com/microsoft/Microsoft-Defender-for-Identity/blob/730dad6870154279b6c41009c9ebab84ffa24689/Test-MdiReadiness/Test-MdiReadiness.ps1), rather than approximating the UI's “Full control minus read” instructions. + +| Descendant class | Mask | Inherited class GUID | +| --- | ---: | --- | +| user | 852331 (`0xD016B`) | bf967aba-0de6-11d0-a285-00aa003049e2 | +| group | 852331 (`0xD016B`) | bf967a9c-0de6-11d0-a285-00aa003049e2 | +| computer | 852331 (`0xD016B`) | bf967a86-0de6-11d0-a285-00aa003049e2 | +| msDS-ManagedServiceAccount | 852331 (`0xD016B`) | ce206244-5827-4a86-ba1c-1c0c386c1b64 | +| msDS-GroupManagedServiceAccount | 852075 (`0xD006B`) | 7b8b558a-93a5-4af7-adca-c017e67f1057 | +| msDS-DelegatedManagedServiceAccount | 852075 (`0xD006B`) | 0feb936f-47b3-49f2-9386-1dedc2c23765 | + +Both masks include CreateChild, DeleteChild, Self, WriteProperty, DeleteTree, Delete, WriteDacl and WriteOwner. `852331` additionally includes ExtendedRight. dMSA is omitted, with a diagnostic, unless its schema class exists and a domain DC computer reports a version at least `10.0 (26100)`. This follows [MDI's Server 2025 domain condition](https://learn.microsoft.com/en-us/defender-for-identity/deploy/configure-windows-event-collection#configure-auditing-on-domain-objects). Missing mandatory schema classes or unreadable/unknown applicability produce `Unknown`, not an assumed audit configuration. Protected child SACLs and inheritance propagation are not established by a root ACE read-back. + +`MdiConfiguration` targets RootDSE `configurationNamingContext`, with **WriteProperty (`32`, `0x20`), Success and Failure**, **this object and all descendants**, flags `194` (`0xC2`), and both GUIDs empty. Microsoft's [Configuration container guidance](https://learn.microsoft.com/en-us/defender-for-identity/deploy/configure-windows-event-collection#configure-auditing-on-the-configuration-container) is conditional on current or former Exchange deployments. WELA checks for an `msExchOrganizationContainer`; no matching object produces `NotApplicable`. If all historical Exchange configuration was removed, inspect that history manually: absence does not prove Exchange never existed. Configuration is replicated forest-wide; this operation is not scoped to one domain's users. + +`PkiObjects` is a **WELA targeted profile**, not a claim that MDI prescribes a PKI SACL baseline. Each explicitly selected object gets a Success-only, **this-object-only** ACE: **WriteProperty, Delete, WriteDacl and WriteOwner (`852000`, `0xD0020`)**, flags `64` (`0x40`), both GUIDs empty. It accepts only these existing objects under this connection's Configuration naming context: + +| Object class | Allowed container | Schema class GUID (validated, not placed in the direct-object ACE) | +| --- | --- | --- | +| pKICertificateTemplate | `CN=Certificate Templates,CN=Public Key Services,CN=Services,` | e5209ca2-3bba-11d2-90cc-00c04fd91ab1 | +| pKIEnrollmentService | `CN=Enrollment Services,CN=Public Key Services,CN=Services,` | ee4aa692-3bba-11d2-90cc-00c04fd91ab1 | + +See Microsoft's [certificate template schema](https://learn.microsoft.com/en-us/windows/win32/adschema/c-pkicertificatetemplate) and [enrollment service schema](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-adsc/208d42e8-1932-4767-87c5-b8511991e69b). WriteProperty covers changes such as template attributes and an enrollment service's published `certificateTemplates` list; object creation, child objects, enrollment access rights and CA `AuditFilter` are separate. No security enforcement, enrollment permissions, CA settings, DACLs or owners are changed. + +## Verification, concurrency and recovery + +The command reads owner, group, DACL and SACL together using the critical [LDAP security descriptor flags control](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/3888c2b7-35b9-45b7-afeb-b772aa932dd0). A missing object, incomplete descriptor or permission failure is an error. It records the original complete SDDL and binary descriptor, object GUID, same-DC `uSNChanged`, target DN and requested ACEs in `before.jsonl`. A separate `ad-sacl-*.json` receipt records the exact missing ACE additions and expected descriptor **before** the write. Keep these files private and treat receipts as trusted administrator input; they contain directory security configuration. + +Existing ACEs are retained byte-for-byte. A same-scope ACE with a superset of the rights/outcomes already satisfies the request; WELA adds only missing audit ACEs. After the prompt and durable journal, WELA rereads the same DC's object GUID, USN and complete descriptor and refuses a changed object. The LDAP modify uses a **critical SACL-only** control, preserving owner/group/DACL on the server. Read-back verifies every original ACE, non-SACL security information and requested auditing; a final read detects later drift. Microsoft documents that [`uSNChanged` is local to a DC](https://learn.microsoft.com/en-us/windows/win32/adschema/a-usnchanged), so the command never substitutes another DC for these checks. + +**Use an exclusive maintenance window for SACL changes.** The immediate comparison is not an atomic compare-and-swap: a concurrent writer in the final read/write window can still lose a SACL update. WELA does not claim LDAP transaction protection, lock other writers, or automatically restore a whole descriptor after an uncertain result. Stop other SACL editors/automation, review failures against the journal and current descriptor, and verify again after inheritance/replication have settled. + +```powershell +# Remove additions from one trusted receipt, first as a dry run. +./WELA.ps1 ad-object-sacl -AdServer dc01.example.test -AdSaclAction Rollback ` + -AdReceiptPath .\new-ad-backup\ad-sacl-RECEIPT-ID.json -DryRun +./WELA.ps1 ad-object-sacl -AdServer dc01.example.test -AdSaclAction Rollback ` + -AdReceiptPath .\new-ad-backup\ad-sacl-RECEIPT-ID.json -Auto ` + -BackupPath .\new-rollback-backup -ResultsPath rollback-result.json +``` + +Rollback checks the DC and object identity, validates that the receipt's expected SACL contains precisely the recorded additions plus the old ACEs, and requires the current SACL to match that expected sequence. It removes only those exact additions, using the current descriptor and a SACL-only write; it never restores old owner/group/DACL data. Repeated rollback is idempotent. Reordered/merged ACEs or any intervening SACL edit make automatic ownership ambiguous and are refused. A failed or interrupted apply can leave a receipt without a completed write; inspect current state first. If automatic rollback is refused, compare the original SDDL/ACE bytes, receipt additions and current SACL on the exact DC, identify additions manually, remove only those demonstrably attributable to this run, and preserve all unrelated current entries. Do not restore the complete saved SDDL over later changes. + +`SaclConfigured`/`Applied`/`AlreadyCompliant` refer to the **selected object's SACL**. `ChangeRequired`, `NotApplicable`, `Unknown`, `Blocked`, runner `Skipped`, `Failed` and `Overridden` remain distinct. Exit 0 means no read/write/verification failures; dry runs and skipped requests do not establish configuration. No Sigma rule uplift is claimed. + +## Required isolated-DC evidence before closing issue #371 + +`AuditPolicyPrerequisites` reports **Unknown** separately: this LDAP command neither reads nor configures the DC's effective audit policy. Verify Directory Service Access Success (and Failure for Configuration failure auditing), GUID `0cce923b-69ae-11d9-bed3-505054503030`, and Directory Service Changes Success, GUID `0cce923c-69ae-11d9-bed3-505054503030`, on each DC that will process test operations. Check GPO precedence and effective results; enabling a policy alone does not supply an object SACL. [Event 5136 requires matching object auditing and records the modified attribute on a DC](https://learn.microsoft.com/en-us/windows/security/threat-protection/auditing/event-5136). + +The automated tests use mock directory responses, in-memory Windows security descriptors and captured LDAP requests. They **never bind to or modify live AD**. Windows PowerShell 5.1 and PowerShell 7 CI validate native construction, preservation, idempotency, conservative rollback and the shared runner. Live Windows/DC validation has not been performed by this change. + +For completion, use isolated patched DC snapshots and dedicated test accounts, groups, computers, templates and enrollment service objects. Record DC build, forest/domain, before/after SDDL, effective audit policy and WELA JSON. Make a benign attribute change on each selected object through the exact DC, then capture matching Security **4662** and **5136** XML, including computer, object DN/GUID, subject, access/property and attribute fields. Inspect inherited ACEs on each descendant class and protected objects; verify other DCs after replication. For certificate template or publication-list changes, collect the event from the **DC processing the AD change**, not automatically from the CA. Validate central ingestion, rerun for idempotency, exercise rollback and verify unrelated authorization and audit entries remain. No generated-event, replication, retention or ingestion guarantee is made here. Sysmon and external telemetry are out of scope. diff --git a/scripts/AdObjectSacl.ps1 b/scripts/AdObjectSacl.ps1 new file mode 100644 index 00000000..6a643318 --- /dev/null +++ b/scripts/AdObjectSacl.ps1 @@ -0,0 +1,389 @@ +# Explicit, additive AD DS audit ACEs. No AD: drive, server discovery, or DACL writes. +function Search-WelaAdDirectory { + param($Session, [string]$Dn, [string]$Filter = '(objectClass=*)', [string]$Scope = 'Base', + [string[]]$Attributes, [switch]$SecurityDescriptor) + $request = [System.DirectoryServices.Protocols.SearchRequest]::new($Dn, $Filter, + [System.DirectoryServices.Protocols.SearchScope]::$Scope, $Attributes) + if ($SecurityDescriptor) { + $control = [System.DirectoryServices.Protocols.SecurityDescriptorFlagControl]::new( + [System.DirectoryServices.Protocols.SecurityMasks]15) + $control.IsCritical = $true + $null = $request.Controls.Add($control) + } + $response = $Session.Connection.SendRequest($request) + foreach ($entry in $response.Entries) { + $values = @{} + foreach ($name in $entry.Attributes.AttributeNames) { + $type = if ($name -in @('nTSecurityDescriptor', 'objectGUID', 'schemaIDGUID')) { [byte[]] } else { [string] } + $values[$name] = $entry.Attributes[$name].GetValues($type) + } + [pscustomobject]@{ Dn = $entry.DistinguishedName; Values = $values } + } +} + +function Get-WelaAdSingleValue { + param($Entry, [string]$Name) + if (-not $Entry.Values.ContainsKey($Name) -or @($Entry.Values[$Name]).Count -ne 1) { + throw "Required AD attribute is missing or ambiguous: $Name ($($Entry.Dn))." + } + return ,$Entry.Values[$Name][0] +} + +function New-WelaAdConnection { + param([string]$Server) + Add-Type -AssemblyName System.DirectoryServices.Protocols -ErrorAction Stop + $identifier = [System.DirectoryServices.Protocols.LdapDirectoryIdentifier]::new($Server, 389, $true, $false) + $connection = [System.DirectoryServices.Protocols.LdapConnection]::new($identifier) + try { + $connection.AuthType = [System.DirectoryServices.Protocols.AuthType]::Negotiate + $connection.Timeout = [TimeSpan]::FromSeconds(30) + $connection.SessionOptions.ProtocolVersion = 3 + $connection.SessionOptions.Signing = $true + $connection.SessionOptions.Sealing = $true + $connection.SessionOptions.ReferralChasing = [System.DirectoryServices.Protocols.ReferralChasingOptions]::None + return $connection + } catch { $connection.Dispose(); throw } +} + +function Open-WelaAdSession { + param([string]$Server) + if ($env:OS -ne 'Windows_NT') { throw 'AD object SACL operations require Windows PowerShell 5.1 or PowerShell 7 on Windows.' } + if ($Server -notmatch '^(?=.{1,253}$)[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)+$') { + throw 'AdServer must be the exact DNS host name of one DC (FQDN), without a port, path, or LDAP URL.' + } + $connection = New-WelaAdConnection $Server + try { + $connection.Bind() + $session = [pscustomobject]@{ Server = $Server; Connection = $connection; DomainDn = ''; ConfigurationDn = ''; SchemaDn = ''; DsaDn = ''; Writable = $false } + $root = @(Search-WelaAdDirectory -Session $session -Dn '' -Attributes @('dnsHostName', 'defaultNamingContext', 'configurationNamingContext', 'schemaNamingContext', 'dsServiceName', 'supportedCapabilities', 'supportedControl')) + if ($root.Count -ne 1) { throw 'RootDSE was not returned uniquely.' } + if ((Get-WelaAdSingleValue $root[0] 'dnsHostName') -ine $Server) { throw 'RootDSE dnsHostName differs from AdServer; aliases and domain-wide targets are refused.' } + if ($root[0].Values['supportedCapabilities'] -notcontains '1.2.840.113556.1.4.800' -or + $root[0].Values['supportedControl'] -notcontains '1.2.840.113556.1.4.801') { throw 'AD DS and the security-descriptor flags control must be supported.' } + $session.DomainDn = Get-WelaAdSingleValue $root[0] 'defaultNamingContext' + $session.ConfigurationDn = Get-WelaAdSingleValue $root[0] 'configurationNamingContext' + $session.SchemaDn = Get-WelaAdSingleValue $root[0] 'schemaNamingContext' + $session.DsaDn = Get-WelaAdSingleValue $root[0] 'dsServiceName' + $dsa = @(Search-WelaAdDirectory -Session $session -Dn $session.DsaDn -Attributes @('msDS-isRODC')) + if ($dsa.Count -ne 1 -or (Get-WelaAdSingleValue $dsa[0] 'msDS-isRODC') -notin @('TRUE', 'FALSE')) { throw 'DC write capability is unknown.' } + $session.Writable = (Get-WelaAdSingleValue $dsa[0] 'msDS-isRODC') -eq 'FALSE' + return $session + } catch { $connection.Dispose(); throw } +} + +function Get-WelaAdAuditDefinitions { + # Exact masks in Microsoft's Test-MdiReadiness at 730dad6870154279b6c41009c9ebab84ffa24689. + $classes = @( + @('user', 'bf967aba-0de6-11d0-a285-00aa003049e2', 852331), + @('group', 'bf967a9c-0de6-11d0-a285-00aa003049e2', 852331), + @('computer', 'bf967a86-0de6-11d0-a285-00aa003049e2', 852331), + @('msDS-ManagedServiceAccount', 'ce206244-5827-4a86-ba1c-1c0c386c1b64', 852331), + @('msDS-GroupManagedServiceAccount', '7b8b558a-93a5-4af7-adca-c017e67f1057', 852075), + @('msDS-DelegatedManagedServiceAccount', '0feb936f-47b3-49f2-9386-1dedc2c23765', 852075) + ) + foreach ($item in $classes) { + [pscustomobject]@{ Class = $item[0]; Sid = 'S-1-1-0'; AccessMask = $item[2]; AuditFlags = 'Success'; AceFlags = 74; + ObjectType = [guid]::Empty.ToString(); InheritedObjectType = $item[1]; Inheritance = 'DescendantsOnly'; + Rights = 'CreateChild, DeleteChild, Self, WriteProperty, DeleteTree, Delete, WriteDacl, WriteOwner' + $(if ($item[2] -eq 852331) { ', ExtendedRight' } else { '' }) } + } +} + +function Test-WelaAdSchemaClass { + param($Session, [string]$Class, [string]$Guid) + # Class is from our fixed allowlist, never user-provided LDAP filter text. + $rows = @(Search-WelaAdDirectory -Session $Session -Dn $Session.SchemaDn -Scope OneLevel -Filter "(&(objectClass=classSchema)(lDAPDisplayName=$Class))" -Attributes @('schemaIDGUID')) + if ($rows.Count -eq 0) { return $false } + if ($rows.Count -ne 1 -or ([guid]::new([byte[]](Get-WelaAdSingleValue $rows[0] 'schemaIDGUID'))).ToString() -ne $Guid) { + throw "Schema GUID mismatch or ambiguous class: $Class." + } + return $true +} + +function Test-WelaAdDmsaDomain { + param($Session) + $rows = @(Search-WelaAdDirectory -Session $Session -Dn $Session.DomainDn -Scope Subtree -Filter '(&(objectClass=computer)(primaryGroupID=516))' -Attributes @('operatingSystemVersion')) + if (-not $rows.Count) { throw 'No domain controller computer versions were readable for the dMSA applicability check.' } + $unknown = $false + foreach ($row in $rows) { + if (-not $row.Values.ContainsKey('operatingSystemVersion')) { $unknown = $true; continue } + $version = [string](Get-WelaAdSingleValue $row 'operatingSystemVersion') + if ($version -match '^10\.0\s*\((\d+)\)$') { if ([int]$Matches[1] -ge 26100) { return $true } } + else { $unknown = $true } + } + if ($unknown) { throw 'DC versions could not all be classified; dMSA applicability is unknown.' } + return $false +} + +function ConvertTo-WelaAdBinaryString { + param($Object) + if ($null -eq $Object) { return $null } + $bytes = New-Object byte[] $Object.BinaryLength + $Object.GetBinaryForm($bytes, 0) + return [Convert]::ToBase64String($bytes) +} + +function New-WelaAdAuditAce { + param($Definition) + $sid = [Security.Principal.SecurityIdentifier]::new($Definition.Sid) + if ($Definition.InheritedObjectType -ne [guid]::Empty.ToString()) { + return [Security.AccessControl.ObjectAce]::new([Security.AccessControl.AceFlags]$Definition.AceFlags, + [Security.AccessControl.AceQualifier]::SystemAudit, [int]$Definition.AccessMask, $sid, + [Security.AccessControl.ObjectAceFlags]::InheritedObjectAceTypePresent, [guid]::Empty, + [guid]$Definition.InheritedObjectType, $false, $null) + } + return [Security.AccessControl.CommonAce]::new([Security.AccessControl.AceFlags]$Definition.AceFlags, + [Security.AccessControl.AceQualifier]::SystemAudit, [int]$Definition.AccessMask, $sid, $false, $null) +} + +function Get-WelaAdDescriptorInfo { + param([string]$Binary) + $sd = [Security.AccessControl.RawSecurityDescriptor]::new([Convert]::FromBase64String($Binary), 0) + $aces = @(); if ($sd.SystemAcl) { foreach ($ace in $sd.SystemAcl) { $aces += ConvertTo-WelaAdBinaryString $ace } } + [pscustomobject]@{ Binary = $Binary; Sddl = $sd.GetSddlForm([Security.AccessControl.AccessControlSections]::All); + Owner = [string]$sd.Owner; Group = [string]$sd.Group; Dacl = ConvertTo-WelaAdBinaryString $sd.DiscretionaryAcl; + ControlFlags = [int]$sd.ControlFlags; Sacl = $aces } +} + +function Get-WelaAdObjectState { + param($Session, [string]$Dn) + $rows = @(Search-WelaAdDirectory -Session $Session -Dn $Dn -Attributes @('nTSecurityDescriptor', 'objectGUID', 'uSNChanged', 'objectClass') -SecurityDescriptor) + if ($rows.Count -ne 1) { throw "AD object missing or ambiguous: $Dn." } + $binary = [Convert]::ToBase64String([byte[]](Get-WelaAdSingleValue $rows[0] 'nTSecurityDescriptor')) + $info = Get-WelaAdDescriptorInfo $binary + [pscustomobject]@{ Server = $Session.Server; Dn = $rows[0].Dn; + ObjectGuid = ([guid]::new([byte[]](Get-WelaAdSingleValue $rows[0] 'objectGUID'))).ToString(); + UsnChanged = [string](Get-WelaAdSingleValue $rows[0] 'uSNChanged'); Classes = @($rows[0].Values['objectClass']); Descriptor = $info } +} + +function Test-WelaAdAcePresent { + param($Descriptor, $Definition) + $wanted = New-WelaAdAuditAce $Definition + foreach ($encoded in $Descriptor.Sacl) { + $ace = [Security.AccessControl.GenericAce]::CreateFromBinaryForm([Convert]::FromBase64String($encoded), 0) + # Accept an existing audit ACE granting a superset of the required audited + # rights/outcomes, but only with the exact inheritance and object scope. + if ($ace -isnot [Security.AccessControl.QualifiedAce] -or $ace.IsCallback -or $ace.AceQualifier -ne $wanted.AceQualifier -or + $ace.SecurityIdentifier -ne $wanted.SecurityIdentifier -or ($ace.AccessMask -band $wanted.AccessMask) -ne $wanted.AccessMask) { continue } + if (([int]$ace.AceFlags -band 63) -ne ([int]$wanted.AceFlags -band 63) -or + ([int]$ace.AceFlags -band [int]$wanted.AceFlags) -ne [int]$wanted.AceFlags) { continue } + if ($wanted -is [Security.AccessControl.ObjectAce]) { + if ($ace -isnot [Security.AccessControl.ObjectAce] -or $ace.ObjectAceFlags -ne $wanted.ObjectAceFlags -or + $ace.ObjectAceType -ne $wanted.ObjectAceType -or $ace.InheritedObjectAceType -ne $wanted.InheritedObjectAceType) { continue } + } elseif ($ace -is [Security.AccessControl.ObjectAce] -and [int]$ace.ObjectAceFlags -ne 0) { continue } + return $true + } + return $false +} + +function New-WelaAdSaclAddition { + param($Before, [array]$Definitions) + $sd = [Security.AccessControl.RawSecurityDescriptor]::new([Convert]::FromBase64String($Before.Descriptor.Binary), 0) + $oldCount = if ($sd.SystemAcl) { $sd.SystemAcl.Count } else { 0 } + $acl = [Security.AccessControl.RawAcl]::new([byte]4, $oldCount + $Definitions.Count) + if ($sd.SystemAcl) { foreach ($ace in $sd.SystemAcl) { $acl.InsertAce($acl.Count, $ace) } } + $added = @() + foreach ($definition in $Definitions) { + if (Test-WelaAdAcePresent $Before.Descriptor $definition) { continue } + $ace = New-WelaAdAuditAce $definition + # Insert explicit ACEs before inherited ACEs; preserve every existing ACE. + $position = 0 + while ($position -lt $acl.Count -and -not $acl[$position].IsInherited) { $position++ } + $acl.InsertAce($position, $ace) + $added += ConvertTo-WelaAdBinaryString $ace + } + $sd.SystemAcl = $acl + $sd.SetFlags($sd.ControlFlags -bor [Security.AccessControl.ControlFlags]::SystemAclPresent) + [pscustomobject]@{ Binary = ConvertTo-WelaAdBinaryString $sd; AddedAces = $added } +} + +function Test-WelaAdPreserved { + param($Before, $After) + if ($Before.ObjectGuid -ne $After.ObjectGuid -or $Before.Server -ine $After.Server -or $Before.Dn -ine $After.Dn -or + $Before.Descriptor.Owner -ne $After.Descriptor.Owner -or $Before.Descriptor.Group -ne $After.Descriptor.Group -or + $Before.Descriptor.Dacl -ne $After.Descriptor.Dacl -or + ($Before.Descriptor.ControlFlags -band 65519) -ne ($After.Descriptor.ControlFlags -band 65519)) { return $false } + $remaining = New-Object 'System.Collections.Generic.List[string]' + foreach ($ace in $After.Descriptor.Sacl) { $remaining.Add($ace) } + foreach ($ace in $Before.Descriptor.Sacl) { if (-not $remaining.Remove($ace)) { return $false } } + return $true +} + +function Write-WelaAdSacl { + param($Session, [string]$Dn, [string]$Binary) + if (-not $Session.Writable) { throw 'The explicitly selected DC is read-only; no write was sent.' } + $modification = [System.DirectoryServices.Protocols.DirectoryAttributeModification]::new() + $modification.Name = 'nTSecurityDescriptor' + $modification.Operation = [System.DirectoryServices.Protocols.DirectoryAttributeOperation]::Replace + $null = $modification.Add([Convert]::FromBase64String($Binary)) + $request = [System.DirectoryServices.Protocols.ModifyRequest]::new($Dn, $modification) + # The DC modifies SACL only. Owner/group/DACL are never sent as a requested change. + $control = [System.DirectoryServices.Protocols.SecurityDescriptorFlagControl]::new([System.DirectoryServices.Protocols.SecurityMasks]::Sacl) + $control.IsCritical = $true + $null = $request.Controls.Add($control) + $null = $Session.Connection.SendRequest($request) +} + +function Get-WelaAdSaclPlan { + param($Session, [string[]]$Profiles, [string[]]$ObjectDn) + $requests = @() + if ($Profiles -contains 'MdiDomain') { $requests += [pscustomobject]@{ Profile = 'MdiDomain'; Dn = $Session.DomainDn } } + if ($Profiles -contains 'MdiConfiguration') { $requests += [pscustomobject]@{ Profile = 'MdiConfiguration'; Dn = $Session.ConfigurationDn } } + if ($Profiles -contains 'PkiObjects') { + if (-not $ObjectDn.Count) { throw 'PkiObjects requires explicit -AdObjectDn certificate template or enrollment service object DNs.' } + foreach ($dn in @($ObjectDn | Select-Object -Unique)) { $requests += [pscustomobject]@{ Profile = 'PkiObjects'; Dn = $dn } } + } elseif ($ObjectDn.Count) { throw '-AdObjectDn is valid only with the PkiObjects profile.' } + foreach ($request in $requests) { + $definitions = @(); $before = $null; $notes = @(); $status = 'Unknown' + try { + if ($request.Profile -eq 'MdiDomain') { + foreach ($definition in Get-WelaAdAuditDefinitions) { + $exists = Test-WelaAdSchemaClass $Session $definition.Class $definition.InheritedObjectType + if ($definition.Class -eq 'msDS-DelegatedManagedServiceAccount') { + if (-not $exists -or -not (Test-WelaAdDmsaDomain $Session)) { $notes += 'dMSA skipped: schema class and a domain DC version >= 10.0 (26100) are required.'; continue } + } elseif (-not $exists) { throw "Required MDI schema class is absent: $($definition.Class)." } + $definitions += $definition + } + } elseif ($request.Profile -eq 'MdiConfiguration') { + $exchange = @(Search-WelaAdDirectory -Session $Session -Dn $Session.ConfigurationDn -Scope Subtree -Filter '(objectClass=msExchOrganizationContainer)' -Attributes @('objectClass')) + if (-not $exchange.Count) { $status = 'NotApplicable'; throw 'No Exchange organization container observed. MDI Configuration auditing is intended for current or former Exchange deployments; review removed-history cases manually.' } + $definitions = @([pscustomobject]@{ Class = ''; Sid = 'S-1-1-0'; AccessMask = 32; AuditFlags = 'Success, Failure'; AceFlags = 194; ObjectType = [guid]::Empty.ToString(); InheritedObjectType = [guid]::Empty.ToString(); Inheritance = 'ThisObjectAndAllDescendants'; Rights = 'WriteProperty' }) + } else { + $before = Get-WelaAdObjectState $Session $request.Dn + $class = $null; $guid = $null + if ($before.Classes -contains 'pKICertificateTemplate' -and $before.Dn.EndsWith(",CN=Certificate Templates,CN=Public Key Services,CN=Services,$($Session.ConfigurationDn)", [StringComparison]::OrdinalIgnoreCase)) { + $class = 'pKICertificateTemplate'; $guid = 'e5209ca2-3bba-11d2-90cc-00c04fd91ab1' + } elseif ($before.Classes -contains 'pKIEnrollmentService' -and $before.Dn.EndsWith(",CN=Enrollment Services,CN=Public Key Services,CN=Services,$($Session.ConfigurationDn)", [StringComparison]::OrdinalIgnoreCase)) { + $class = 'pKIEnrollmentService'; $guid = 'ee4aa692-3bba-11d2-90cc-00c04fd91ab1' + } else { throw 'PkiObjects accepts only existing certificate template/enrollment service objects under the selected forest PKI containers.' } + if (-not (Test-WelaAdSchemaClass $Session $class $guid)) { throw "PKI schema class missing: $class." } + $definitions = @([pscustomobject]@{ Class = $class; Sid = 'S-1-1-0'; AccessMask = 852000; AuditFlags = 'Success'; AceFlags = 64; ObjectType = [guid]::Empty.ToString(); InheritedObjectType = [guid]::Empty.ToString(); Inheritance = 'ThisObjectOnly'; Rights = 'WriteProperty, Delete, WriteDacl, WriteOwner' }) + $notes += 'WELA targeted PKI profile, not an MDI-prescribed PKI baseline; no child objects, enrollment rights or CA AuditFilter changes.' + } + if (-not $before) { $before = Get-WelaAdObjectState $Session $request.Dn } + $missing = @($definitions | Where-Object { -not (Test-WelaAdAcePresent $before.Descriptor $_) }) + $status = if ($missing.Count) { 'ChangeRequired' } else { 'SaclConfigured' } + if (-not $Session.Writable -and $missing.Count) { $status = 'Blocked'; $notes += 'Selected DC is read-only.' } + } catch { $notes += $_.Exception.Message } + [pscustomobject]@{ Profile = $request.Profile; Server = $Session.Server; Dn = $request.Dn; Status = $status; + Definitions = $definitions; Before = $before; Diagnostic = $notes -join ' ' } + } +} + +function Set-WelaAdSaclControls { + param($Session, $Context, [array]$Plan) + foreach ($entry in $Plan) { + $id = "AdSacl/$($entry.Profile)/$($entry.Dn)" + if ($entry.Status -notin @('SaclConfigured', 'ChangeRequired')) { + $Context.Results.Add([pscustomobject]@{ Id = $id; Kind = 'AdObjectSacl'; Target = @{ Server = $Session.Server; Dn = $entry.Dn }; Desired = $entry.Definitions; + Before = $entry.Before; After = $null; Status = $(if ($entry.Status -eq 'NotApplicable') { 'Skipped' } else { 'Failed' }); Diagnostic = $entry.Diagnostic }) + continue + } + $state = @{ Session = $Session; Entry = $entry; Observed = $null; Baseline = $null; Context = $Context } + $read = { + param($state) + $snapshot = Get-WelaAdObjectState $state.Session $state.Entry.Dn + if ($snapshot.ObjectGuid -ne $state.Entry.Before.ObjectGuid) { throw 'Target object identity changed after planning.' } + if ($state.Baseline -and -not (Test-WelaAdPreserved $state.Baseline $snapshot)) { throw 'Existing owner, group, DACL or SACL ACE changed; inspect the recovery journal. No automatic restore is attempted.' } + $state.Observed = $snapshot + return $snapshot + } + $test = { param($snapshot, $state) + foreach ($definition in $state.Entry.Definitions) { if (-not (Test-WelaAdAcePresent $snapshot.Descriptor $definition)) { return $false } } + return $true + } + $apply = { + param($state) + $before = $state.Observed + $addition = New-WelaAdSaclAddition $before $state.Entry.Definitions + $receipt = [ordered]@{ Version = 1; Kind = 'WelaAdSaclAddition'; Server = $state.Session.Server; Dn = $before.Dn; + ObjectGuid = $before.ObjectGuid; Before = $before; AddedAces = $addition.AddedAces; ExpectedBinary = $addition.Binary } + # A durable receipt precedes the write, including exact additions for + # conservative rollback even if the process stops after LDAP success. + $receiptPath = Join-Path $state.Context.BackupPath ('ad-sacl-' + [guid]::NewGuid().ToString('N') + '.json') + $receipt | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $receiptPath -Encoding UTF8 -ErrorAction Stop + $fresh = Get-WelaAdObjectState $state.Session $state.Entry.Dn + if ($fresh.ObjectGuid -ne $before.ObjectGuid -or $fresh.UsnChanged -ne $before.UsnChanged -or $fresh.Descriptor.Binary -ne $before.Descriptor.Binary) { throw 'AD object changed after journaling; no write was sent. Re-audit and retry.' } + $state.Baseline = $before + Write-WelaAdSacl $state.Session $before.Dn $addition.Binary + "SACL-only write sent; recovery receipt: $receiptPath. Event generation and inheritance propagation remain unverified." + } + Invoke-WelaConfigurationControl -Context $Context -Id $id -Kind AdObjectSacl -Target @{ Server = $Session.Server; Dn = $entry.Dn } ` + -Desired $entry.Definitions -Read $read -Compliant $test -Apply $apply -CallbackState $state ` + -Description 'Add only missing audit ACEs on this exact DC/object. Directory auditing may increase event volume.' + } +} + +function Invoke-WelaAdSaclRollback { + param($Session, $Context, [string]$ReceiptPath) + $receipt = Get-Content -LiteralPath $ReceiptPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop + if ($receipt.Version -ne 1 -or $receipt.Kind -ne 'WelaAdSaclAddition' -or $receipt.Server -ine $Session.Server -or + -not $receipt.AddedAces.Count -or $receipt.ObjectGuid -ne $receipt.Before.ObjectGuid -or $receipt.Dn -ine $receipt.Before.Dn) { throw 'Invalid receipt, empty additions, or a different DC target.' } + $expected = Get-WelaAdDescriptorInfo $receipt.ExpectedBinary + # Receipts are recovery evidence, not a general descriptor-restore mechanism. + $remaining = New-Object 'System.Collections.Generic.List[string]' + foreach ($ace in $expected.Sacl) { $remaining.Add($ace) } + foreach ($ace in $receipt.AddedAces) { if (-not $remaining.Remove([string]$ace)) { throw 'Receipt additions do not match its expected SACL.' } } + if (($remaining.ToArray() -join '|') -ne (@($receipt.Before.Descriptor.Sacl) -join '|')) { throw 'Receipt would remove or replace pre-existing audit ACEs.' } + $state = @{ Session = $Session; Receipt = $receipt; Observed = $null; Expected = $expected; Baseline = $null } + $read = { param($state) + $snapshot = Get-WelaAdObjectState $state.Session $state.Receipt.Dn + if ($snapshot.ObjectGuid -ne $state.Receipt.ObjectGuid) { throw 'Rollback object identity mismatch.' } + if ($state.Baseline -and ($snapshot.Descriptor.Owner -ne $state.Baseline.Descriptor.Owner -or + $snapshot.Descriptor.Group -ne $state.Baseline.Descriptor.Group -or $snapshot.Descriptor.Dacl -ne $state.Baseline.Descriptor.Dacl -or + $snapshot.Descriptor.ControlFlags -ne $state.Baseline.Descriptor.ControlFlags)) { throw 'Owner/group/DACL/descriptor flags changed during rollback; inspect the journal.' } + $state.Observed = $snapshot + return $snapshot + } + $test = { param($snapshot, $state) + return (@($snapshot.Descriptor.Sacl) -join '|') -eq (@($state.Receipt.Before.Descriptor.Sacl) -join '|') + } + $apply = { param($state) + $before = $state.Observed + if ((@($before.Descriptor.Sacl) -join '|') -ne (@($state.Expected.Sacl) -join '|')) { throw 'SACL drift or ACE merging makes ownership ambiguous; automated rollback refused.' } + $fresh = Get-WelaAdObjectState $state.Session $state.Receipt.Dn + if ($fresh.UsnChanged -ne $before.UsnChanged -or $fresh.ObjectGuid -ne $before.ObjectGuid -or $fresh.Descriptor.Binary -ne $before.Descriptor.Binary) { throw 'Object changed before rollback; no write sent.' } + $sd = [Security.AccessControl.RawSecurityDescriptor]::new([Convert]::FromBase64String($fresh.Descriptor.Binary), 0) + for ($i = $sd.SystemAcl.Count - 1; $i -ge 0; $i--) { + $encoded = ConvertTo-WelaAdBinaryString $sd.SystemAcl[$i] + if ($state.Receipt.AddedAces -contains $encoded) { $sd.SystemAcl.RemoveAce($i) } + } + $state.Baseline = $fresh + Write-WelaAdSacl $state.Session $state.Receipt.Dn (ConvertTo-WelaAdBinaryString $sd) + 'Removed only exact receipt-owned audit ACEs; no owner/group/DACL restoration performed.' + } + Invoke-WelaConfigurationControl -Context $Context -Id "AdSaclRollback/$($receipt.Dn)" -Kind AdObjectSaclRollback ` + -Target @{ Server = $Session.Server; Dn = $receipt.Dn } -Desired @{ RemoveExactAces = $receipt.AddedAces } ` + -Read $read -Compliant $test -Apply $apply -CallbackState $state -Description 'Remove only the exact audit ACE additions from this trusted receipt.' +} + +function Invoke-WelaAdSaclCommand { + param([ValidateSet('Audit', 'Plan', 'Configure', 'Rollback')][string]$Action = 'Audit', [string]$Server, + [ValidateSet('MdiDomain', 'MdiConfiguration', 'PkiObjects')][string[]]$Profiles, [string[]]$ObjectDn, + [string]$ReceiptPath, [switch]$Auto, [switch]$DryRun, [string]$BackupPath, [string]$ResultsPath) + if ($DryRun -and $Action -notin @('Configure', 'Rollback')) { throw 'DryRun applies only to Configure or Rollback.' } + if ($Action -eq 'Rollback') { + if (-not $ReceiptPath -or $Profiles.Count -or $ObjectDn.Count) { throw 'Rollback requires AdReceiptPath and no profile/object selection.' } + } elseif (-not $Profiles.Count -or $ReceiptPath) { throw 'Select at least one explicit AdSaclProfile; AdReceiptPath is for Rollback only.' } + $session = Open-WelaAdSession $Server + try { + $plan = @() + if ($Action -ne 'Rollback') { $plan = @(Get-WelaAdSaclPlan $session $Profiles $ObjectDn) } + if ($Action -in @('Configure', 'Rollback')) { + $context = New-WelaConfigurationContext -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath + if ($Action -eq 'Rollback') { Invoke-WelaAdSaclRollback $session $context $ReceiptPath } + else { Set-WelaAdSaclControls $session $context $plan } + $report = Complete-WelaConfiguration -Context $context -Scope 'ad-object-sacl-only' ` + -SuccessMessage 'Requested SACL state verified on the selected DC; audit policy, inherited propagation and event generation remain separate checks.' + } else { + $report = [pscustomobject]@{ ExitCode = $(if (@($plan | Where-Object Status -in @('Unknown', 'Blocked')).Count) { 1 } else { 0 }); Scope = 'ad-object-sacl-only'; Results = $plan } + } + $report | Add-Member NoteProperty Server $session.Server + $report | Add-Member NoteProperty Action $Action + $report | Add-Member NoteProperty AuditPolicyPrerequisites @( + [pscustomobject]@{ Name = 'Directory Service Access'; Guid = '0cce923b-69ae-11d9-bed3-505054503030'; Required = 'Success (Failure also required for Configuration failure auditing)'; Status = 'Unknown'; Diagnostic = 'Remote DC audit policy is not read or changed by this LDAP command.' }, + [pscustomobject]@{ Name = 'Directory Service Changes'; Guid = '0cce923c-69ae-11d9-bed3-505054503030'; Required = 'Success'; Status = 'Unknown'; Diagnostic = 'Verify effective policy and 5136 on the DC handling the object change.' }) + $report | Add-Member NoteProperty VerificationScope 'Selected-DC object SACL state only. Inherited child SACLs, protected objects, policy, 4662/5136 generation, replication and collection are unverified. No Sigma uplift is claimed.' + if ($ResultsPath) { $report | ConvertTo-Json -Depth 16 | Set-Content -LiteralPath $ResultsPath -Encoding UTF8 -ErrorAction Stop } + return $report + } finally { $session.Connection.Dispose() } +} diff --git a/scripts/Configuration.ps1 b/scripts/Configuration.ps1 index b0fe1121..96f41a05 100644 --- a/scripts/Configuration.ps1 +++ b/scripts/Configuration.ps1 @@ -94,7 +94,7 @@ function Invoke-WelaConfigurationControl { function Complete-WelaConfiguration { param($Context, [string]$ResultsPath, $Plan, - [ValidateSet("native-windows-configuration", "advanced-audit-policy-only", "advanced-audit-policy-and-precedence", "firewall-text-logging-only", "event-log-size-and-mode-only", "smb-audit-policies-only")] + [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", "ad-object-sacl-only")] [string]$Scope = "native-windows-configuration", [string]$SuccessMessage = 'Configuration completed; all requested controls verified.') # A second read detects a value that was compliant earlier but changed during diff --git a/tests/AdObjectSacl.Tests.ps1 b/tests/AdObjectSacl.Tests.ps1 new file mode 100644 index 00000000..7cf3ab1a --- /dev/null +++ b/tests/AdObjectSacl.Tests.ps1 @@ -0,0 +1,176 @@ +$ErrorActionPreference = 'Stop' +$repo = Split-Path $PSScriptRoot -Parent +$script:ScriptRoot = $repo +. (Join-Path $repo 'scripts/Configuration.ps1') +. (Join-Path $repo 'scripts/AdObjectSacl.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-ad-sacl-' + [guid]::NewGuid().ToString('N')) +$null = New-Item -ItemType Directory -Path $root +$session = [pscustomobject]@{ Server = 'dc1.example.test'; DomainDn = 'DC=example,DC=test'; ConfigurationDn = 'CN=Configuration,DC=example,DC=test'; SchemaDn = 'CN=Schema,CN=Configuration,DC=example,DC=test'; Writable = $true } +$script:originalDmsa = ${function:Test-WelaAdDmsaDomain} +$script:originalSchema = ${function:Test-WelaAdSchemaClass} +$script:originalRead = ${function:Get-WelaAdObjectState} +$script:originalSearch = ${function:Search-WelaAdDirectory} +$script:originalPresent = ${function:Test-WelaAdAcePresent} +$script:originalAddition = ${function:New-WelaAdSaclAddition} +$script:originalWrite = ${function:Write-WelaAdSacl} +$script:originalInfo = ${function:Get-WelaAdDescriptorInfo} +function Reset-Mocks { + $script:writes = 0; $script:reads = 0; $script:readError = $false; $script:writeError = $false + $script:race = $false; $script:finalDrift = $false; $script:badReadback = $false; $script:removeExisting = $false + $script:absentClass = ''; $script:dmsa = $true; $script:exchange = $true; $script:onPrompt = $null + $script:state = [pscustomobject]@{ Server = $session.Server; Dn = $session.DomainDn; ObjectGuid = '01234567-89ab-cdef-0123-456789abcdef'; UsnChanged = '17'; Classes = @('top', 'domainDNS'); + Descriptor = [pscustomobject]@{ Binary = 'before'; Sddl = 'O:SYG:SYD:(A;;GA;;;SY)S:(AU;SA;WP;;;BA)'; Owner = 'S-1-5-18'; Group = 'S-1-5-18'; Dacl = 'unchanged-dacl'; ControlFlags = 32788; Sacl = @('unrelated') } } + $session.Writable = $true +} +function Test-WelaAdSchemaClass { param($Session, $Class, $Guid) return $Class -ne $script:absentClass } +function Test-WelaAdDmsaDomain { param($Session) return $script:dmsa } +function Search-WelaAdDirectory { + param($Session, $Dn, $Filter, $Scope, $Attributes, [switch]$SecurityDescriptor) + if ($Filter -eq '(objectClass=msExchOrganizationContainer)' -and $script:exchange) { [pscustomobject]@{ Dn = 'CN=Exchange'; Values = @{} } } +} +function Get-WelaAdObjectState { + param($Session, $Dn) + $script:reads++ + if ($script:readError) { throw 'LDAP access denied' } + $snapshot = $script:state | ConvertTo-Json -Depth 10 | ConvertFrom-Json + if ($script:race -and $script:reads -ge 3) { $snapshot.UsnChanged = '99' } + if ($script:finalDrift -and $script:reads -ge 5) { $snapshot.Descriptor.Sacl = @('unrelated'); $snapshot.Descriptor.Binary = 'drift' } + return $snapshot +} +function Test-WelaAdAcePresent { param($Descriptor, $Definition) return $Descriptor.Sacl -contains ('added-' + $Definition.Class) } +function New-WelaAdSaclAddition { + param($Before, $Definitions) + [pscustomobject]@{ Binary = 'after'; AddedAces = @($Definitions | Where-Object { -not (Test-WelaAdAcePresent $Before.Descriptor $_) } | ForEach-Object { 'added-' + $_.Class }) } +} +function Write-WelaAdSacl { + param($Session, $Dn, $Binary) + if ($script:writeError) { throw 'LDAP insufficientAccessRights' } + $script:writes++ + if (-not $script:badReadback) { + $script:state.Descriptor.Sacl = @('unrelated') + @(Get-WelaAdAuditDefinitions | ForEach-Object { 'added-' + $_.Class }) + $script:state.Descriptor.Binary = $Binary + } + if ($script:removeExisting) { $script:state.Descriptor.Sacl = @($script:state.Descriptor.Sacl | Where-Object { $_ -ne 'unrelated' }) } + $script:state.UsnChanged = '18' +} +function Read-Host { param($Prompt) if ($script:onPrompt) { & $script:onPrompt }; return 'y' } +function Get-Context([bool]$Dry = $false, [bool]$Automatic = $true) { + New-WelaConfigurationContext -Auto:$Automatic -DryRun:$Dry -BackupPath (Join-Path $root ([guid]::NewGuid().ToString('N'))) +} +function Invoke-TestConfigure($Context) { + $plan = @(Get-WelaAdSaclPlan $session @('MdiDomain') @()) + Set-WelaAdSaclControls $session $Context $plan + Complete-WelaConfiguration -Context $Context -Scope ad-object-sacl-only +} +try { + $defs = @(Get-WelaAdAuditDefinitions) + Assert ($defs.Count -eq 6) 'all six MDI descendant classes are defined' + Assert ((@($defs.AccessMask) -join ',') -eq '852331,852331,852331,852331,852075,852075') 'exact Microsoft readiness masks, including gMSA/dMSA distinction' + Assert (@($defs | Where-Object { $_.Sid -ne 'S-1-1-0' -or $_.AceFlags -ne 74 -or $_.Inheritance -ne 'DescendantsOnly' -or $_.ObjectType -ne [guid]::Empty.ToString() }).Count -eq 0) 'success, descendant-only, unrestricted property scope is explicit' + Assert (($defs.InheritedObjectType | Select-Object -Unique).Count -eq 6) 'all inherited class GUIDs differ' + Reset-Mocks + $plan = @(Get-WelaAdSaclPlan $session @('MdiDomain') @()) + Assert ($plan.Count -eq 1 -and $plan[0].Definitions.Count -eq 6 -and $plan[0].Status -eq 'ChangeRequired') 'domain plan covers exact root' + Assert ($plan[0].Server -eq $session.Server -and $plan[0].Dn -eq $session.DomainDn) 'plan binds exact server and DN' + $script:dmsa = $false + $plan = @(Get-WelaAdSaclPlan $session @('MdiDomain') @()) + Assert ($plan[0].Definitions.Count -eq 5 -and $plan[0].Diagnostic -like '*dMSA skipped*') 'dMSA omitted without a 2025 domain DC' + $script:absentClass = 'user' + Assert ((@(Get-WelaAdSaclPlan $session @('MdiDomain') @()))[0].Status -eq 'Unknown') 'missing required class blocks writes' + Reset-Mocks; $script:exchange = $false + Assert ((@(Get-WelaAdSaclPlan $session @('MdiConfiguration') @()))[0].Status -eq 'NotApplicable') 'configuration gated on Exchange history evidence' + $script:exchange = $true + $plan = @(Get-WelaAdSaclPlan $session @('MdiConfiguration') @()) + Assert ($plan[0].Definitions[0].AccessMask -eq 32 -and $plan[0].Definitions[0].AceFlags -eq 194) 'Configuration WriteProperty success/failure inheritance is exact' + Assert-Throws { Get-WelaAdSaclPlan $session @('PkiObjects') @() } 'PKI requires explicit target DNs' + $script:state.Dn = "CN=Test,CN=Certificate Templates,CN=Public Key Services,CN=Services,$($session.ConfigurationDn)" + $script:state.Classes = @('top', 'pKICertificateTemplate') + $plan = @(Get-WelaAdSaclPlan $session @('PkiObjects') @($script:state.Dn)) + Assert ($plan[0].Status -eq 'ChangeRequired' -and $plan[0].Definitions[0].AccessMask -eq 852000 -and $plan[0].Definitions[0].AceFlags -eq 64) 'PKI direct-object write/delete/ACL audit only' + $script:state.Dn = "CN=Test,$($session.DomainDn)" + Assert ((@(Get-WelaAdSaclPlan $session @('PkiObjects') @($script:state.Dn)))[0].Status -eq 'Unknown') 'PKI class outside trusted forest containers refused' + Reset-Mocks; $session.Writable = $false + Assert ((@(Get-WelaAdSaclPlan $session @('MdiDomain') @()))[0].Status -eq 'Blocked') 'RODC plan explicitly blocked' + Reset-Mocks; $script:readError = $true + $ctx = Get-Context; $report = Invoke-TestConfigure $ctx + Assert ($report.ExitCode -eq 1 -and $script:writes -eq 0) 'access denied is failed/unknown, never an empty descriptor' + Reset-Mocks; $ctx = Get-Context $true + $report = Invoke-TestConfigure $ctx + Assert ($report.DryRun -and $script:writes -eq 0 -and -not (Test-Path $ctx.BackupPath)) 'dry run does not write AD or journal' + Reset-Mocks; $ctx = Get-Context + $report = Invoke-TestConfigure $ctx + Assert ($report.ExitCode -eq 0 -and $report.Results[0].Status -eq 'Applied' -and $script:writes -eq 1) 'additive apply and final verification succeed' + $journal = Get-Content (Join-Path $ctx.BackupPath 'before.jsonl') | ConvertFrom-Json + Assert ($journal.Before.Descriptor.Sddl -like 'O:SY*' -and $journal.Before.ObjectGuid -eq $script:state.ObjectGuid) 'journal contains original SDDL and identity' + $receipts = @(Get-ChildItem $ctx.BackupPath -Filter 'ad-sacl-*.json') + $receipt = Get-Content $receipts[0].FullName -Raw | ConvertFrom-Json + Assert ($receipt.AddedAces.Count -eq 6 -and $receipt.Before.Descriptor.Binary -eq 'before') 'receipt stores only intended additions and before binary' + $ctx2 = Get-Context; $report2 = Invoke-TestConfigure $ctx2 + Assert ($script:writes -eq 1 -and $report2.Results[0].Status -eq 'AlreadyCompliant') 'repeat run is idempotent' + Reset-Mocks; $script:race = $true; $ctx = Get-Context + $report = Invoke-TestConfigure $ctx + Assert ($report.ExitCode -eq 1 -and $script:writes -eq 0 -and $report.Results[0].Diagnostic -like '*changed after journaling*') 'USN race fails closed before write' + Reset-Mocks; $script:badReadback = $true; $ctx = Get-Context + Assert ((Invoke-TestConfigure $ctx).ExitCode -eq 1) 'missing audit ACE readback fails' + Reset-Mocks; $script:removeExisting = $true; $ctx = Get-Context + $report = Invoke-TestConfigure $ctx + Assert ($report.ExitCode -eq 1 -and $report.Results[0].Diagnostic -like '*Existing owner*') 'loss of pre-existing audit ACE fails verification' + Reset-Mocks; $script:finalDrift = $true; $ctx = Get-Context + $report = Invoke-TestConfigure $ctx + Assert ($report.ExitCode -eq 1 -and $report.Results[0].Status -eq 'Overridden') 'later missing WELA ACEs are overridden' + Reset-Mocks; $ctx = Get-Context $false $false + $script:onPrompt = { Remove-Item -LiteralPath $ctx.BackupPath -Recurse -Force } + $report = Invoke-TestConfigure $ctx + Assert ($report.ExitCode -eq 1 -and $script:writes -eq 0) 'journal failure prevents mutation' + + # Test schema and DC eligibility adapters with controlled directory responses. + Set-Item function:Test-WelaAdDmsaDomain $script:originalDmsa + function Search-WelaAdDirectory { param($Session, $Dn, $Filter, $Scope, $Attributes) + [pscustomobject]@{ Dn = 'CN=DC1'; Values = @{ operatingSystemVersion = @($script:version) } } + } + $script:version = '10.0 (20348)'; Assert (-not (Test-WelaAdDmsaDomain $session)) 'Server 2022 is not dMSA eligible' + $script:version = '10.0 (26100)'; Assert (Test-WelaAdDmsaDomain $session) 'Server 2025 version proves dMSA applicability' + $script:version = 'unreadable'; Assert-Throws { Test-WelaAdDmsaDomain $session } 'unknown DC versions remain unknown' + Set-Item function:Test-WelaAdSchemaClass $script:originalSchema + function Search-WelaAdDirectory { param($Session, $Dn, $Filter, $Scope, $Attributes) + [pscustomobject]@{ Dn = 'CN=User'; Values = @{ schemaIDGUID = @(,([guid]$script:schemaGuid).ToByteArray()) } } + } + $script:schemaGuid = $defs[0].InheritedObjectType + Assert (Test-WelaAdSchemaClass $session user $script:schemaGuid) 'schema binary GUID checked' + Assert-Throws { Test-WelaAdSchemaClass $session user $defs[1].InheritedObjectType } 'unexpected schema GUID rejected' + + # RootDSE binding validation with a fully fake connection: no platform/native calls. + $savedOs = $env:OS + try { + $env:OS = 'Windows_NT'; $script:rootHost = 'dc1.example.test'; $script:rodc = 'FALSE'; $script:disposed = 0 + function New-WelaAdConnection { param($Server) + $fake = [pscustomobject]@{} + $fake | Add-Member ScriptMethod Bind { } + $fake | Add-Member ScriptMethod Dispose { $script:disposed++ } + return $fake + } + function Search-WelaAdDirectory { param($Session, $Dn, $Filter, $Scope, $Attributes) + if ($Dn -eq '') { + [pscustomobject]@{ Dn = ''; Values = @{ dnsHostName = @($script:rootHost); defaultNamingContext = @('DC=example,DC=test'); + configurationNamingContext = @('CN=Configuration,DC=example,DC=test'); schemaNamingContext = @('CN=Schema,CN=Configuration,DC=example,DC=test'); + dsServiceName = @('CN=NTDS Settings,CN=DC1'); supportedCapabilities = @('1.2.840.113556.1.4.800'); supportedControl = @('1.2.840.113556.1.4.801') } } + } else { [pscustomobject]@{ Dn = $Dn; Values = @{ 'msDS-isRODC' = @($script:rodc) } } } + } + $bound = Open-WelaAdSession 'dc1.example.test' + Assert ($bound.Writable -and $bound.Server -eq 'dc1.example.test') 'RootDSE confirms exact selected writable DC' + $script:rodc = 'TRUE'; Assert (-not (Open-WelaAdSession 'dc1.example.test').Writable) 'RootDSE RODC capability remains read-only' + $script:rootHost = 'dc2.example.test' + Assert-Throws { Open-WelaAdSession 'dc1.example.test' } 'wrong RootDSE host fails closed' + Assert ($script:disposed -eq 1) 'failed binding validation disposes connection' + Assert-Throws { Open-WelaAdSession 'LDAP://dc1.example.test' } 'LDAP URLs are not accepted as exact DC names' + } finally { $env:OS = $savedOs } + + # No network is ever used. Native SID/ACL APIs are exercised in the Windows suite. + $tokens = $null; $errors = $null + [void][Management.Automation.Language.Parser]::ParseFile((Join-Path $repo 'WELA.ps1'), [ref]$tokens, [ref]$errors) + Assert ($errors.Count -eq 0) 'WELA entry point parses' + Write-Host "Passed $script:checks AD object SACL mocked assertions. No live AD connection or mutation occurred." +} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } diff --git a/tests/AdObjectSacl.Windows.Tests.ps1 b/tests/AdObjectSacl.Windows.Tests.ps1 new file mode 100644 index 00000000..a0d8e245 --- /dev/null +++ b/tests/AdObjectSacl.Windows.Tests.ps1 @@ -0,0 +1,93 @@ +# Offline fixtures and captured LDAP requests only. Never bind to or modify AD. +$ErrorActionPreference = 'Stop' +if ($env:OS -ne 'Windows_NT') { Write-Host 'Skipped: native Windows SID/ACL APIs required.'; exit 0 } +$repo = Split-Path $PSScriptRoot -Parent +$script:ScriptRoot = $repo +. (Join-Path $repo 'scripts/Configuration.ps1') +. (Join-Path $repo 'scripts/AdObjectSacl.ps1') +Add-Type -AssemblyName System.DirectoryServices.Protocols +$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 } +function New-State([string]$Sddl) { + $sd = [Security.AccessControl.RawSecurityDescriptor]::new($Sddl) + [pscustomobject]@{ Server = 'dc1.example.test'; Dn = 'DC=example,DC=test'; ObjectGuid = '01234567-89ab-cdef-0123-456789abcdef'; UsnChanged = '100'; Descriptor = Get-WelaAdDescriptorInfo (ConvertTo-WelaAdBinaryString $sd) } +} +$root = Join-Path ([IO.Path]::GetTempPath()) ('wela-ad-native-' + [guid]::NewGuid().ToString('N')) +$null = New-Item -ItemType Directory -Path $root +try { + $before = New-State 'O:SYG:BAD:PAI(A;;GA;;;SY)(A;;RP;;;BA)S:PAI(AU;SA;WP;;;BA)(AU;CISAID;RP;;;WD)' + $definitions = @(Get-WelaAdAuditDefinitions) + $addition = New-WelaAdSaclAddition $before $definitions + Assert ($addition.AddedAces.Count -eq 6) 'adds six missing object-specific audit ACEs' + $after = New-State $before.Descriptor.Sddl + $after.Descriptor = Get-WelaAdDescriptorInfo $addition.Binary + Assert (Test-WelaAdPreserved $before $after) 'preserves owner/group/DACL/flags and all pre-existing audit ACE bytes' + foreach ($definition in $definitions) { + Assert (Test-WelaAdAcePresent $after.Descriptor $definition) "exact class ACE found: $($definition.Class)" + $ace = New-WelaAdAuditAce $definition + Assert ($ace.AceType -eq [Security.AccessControl.AceType]::SystemAuditObject -and [int]$ace.AceFlags -eq 74 -and [int]$ace.ObjectAceFlags -eq 2) 'native object ACE has success and descendant-only flags' + Assert ($ace.InheritedObjectAceType -eq [guid]$definition.InheritedObjectType -and $ace.ObjectAceType -eq [guid]::Empty) 'native inherited class GUID and unrestricted object/property GUID' + } + $second = New-WelaAdSaclAddition $after $definitions + Assert ($second.AddedAces.Count -eq 0 -and $second.Binary -eq $addition.Binary) 'byte-identical second application' + $empty = New-State 'O:SYG:SYD:(A;;GA;;;SY)' + $emptyAfter = New-State $empty.Descriptor.Sddl + $emptyAfter.Descriptor = Get-WelaAdDescriptorInfo (New-WelaAdSaclAddition $empty $definitions).Binary + Assert (Test-WelaAdPreserved $empty $emptyAfter) 'adding first SACL preserves all non-SACL information' + $superset = $definitions[0] | Select-Object * + $superset.AccessMask = 983551; $superset.AceFlags = 202 + $broad = New-WelaAdSaclAddition $empty @($superset) + Assert (Test-WelaAdAcePresent (Get-WelaAdDescriptorInfo $broad.Binary) $definitions[0]) 'same-scope Success+Failure superset avoids duplicate auditing' + Assert (-not (Test-WelaAdAcePresent (Get-WelaAdDescriptorInfo $broad.Binary) $definitions[1])) 'wrong inherited class does not satisfy required ACE' + $wrongScope = $definitions[0] | Select-Object *; $wrongScope.AceFlags = 66 + $scopeFixture = New-WelaAdSaclAddition $empty @($wrongScope) + Assert (-not (Test-WelaAdAcePresent (Get-WelaAdDescriptorInfo $scopeFixture.Binary) $definitions[0])) 'different inheritance is not treated as exact scope' + $config = [pscustomobject]@{ Sid = 'S-1-1-0'; AccessMask = 32; AceFlags = 194; InheritedObjectType = [guid]::Empty.ToString() } + $configAce = New-WelaAdAuditAce $config + Assert ($configAce.AceType -eq [Security.AccessControl.AceType]::SystemAudit -and [int]$configAce.AceFlags -eq 194) 'Configuration audit ACE success+failure this object and all descendants' + $pki = [pscustomobject]@{ Sid = 'S-1-1-0'; AccessMask = 852000; AceFlags = 64; InheritedObjectType = [guid]::Empty.ToString() } + $pkiAce = New-WelaAdAuditAce $pki + Assert ($pkiAce.AccessMask -eq 852000 -and $pkiAce.InheritanceFlags -eq 'None') 'PKI direct-object permissions use no inheritance' + + # Construct native LDAP requests with a fake transport. No network call occurs. + $connection = [pscustomobject]@{} + $connection | Add-Member ScriptMethod SendRequest { param($Request) $script:captured = $Request; throw 'Captured offline' } + $session = [pscustomobject]@{ Server = $before.Server; Writable = $true; Connection = $connection } + Assert-Throws { Write-WelaAdSacl $session $before.Dn $addition.Binary } 'write request captured offline' + Assert ($script:captured -is [System.DirectoryServices.Protocols.ModifyRequest] -and $script:captured.DistinguishedName -eq $before.Dn) 'exact DN in native modify request' + Assert ($script:captured.Modifications.Count -eq 1 -and $script:captured.Modifications[0].Name -eq 'nTSecurityDescriptor') 'only security descriptor attribute is modified' + Assert ($script:captured.Controls[0].SecurityMasks -eq [System.DirectoryServices.Protocols.SecurityMasks]::Sacl -and $script:captured.Controls[0].IsCritical) 'critical SACL-only write control' + Assert-Throws { Search-WelaAdDirectory $session $before.Dn -Attributes @('nTSecurityDescriptor') -SecurityDescriptor } 'read request captured offline' + Assert ($script:captured -is [System.DirectoryServices.Protocols.SearchRequest] -and [int]$script:captured.Controls[0].SecurityMasks -eq 15) 'read requests owner/group/DACL/SACL together' + $session.Writable = $false; $script:captured = $null + Assert-Throws { Write-WelaAdSacl $session $before.Dn $addition.Binary } 'RODC write refused' + Assert ($null -eq $script:captured) 'RODC refusal happens before transport' + + # Mock the state/transport boundary, retain real ACL transformations and runner. + $script:state = $after; $script:writes = 0 + function Get-WelaAdObjectState { param($Session, $Dn) return $script:state } + function Write-WelaAdSacl { param($Session, $Dn, $Binary) + $script:writes++; $script:state.Descriptor = Get-WelaAdDescriptorInfo $Binary; $script:state.UsnChanged = '101' + } + $session.Writable = $true + $receiptPath = Join-Path $root 'receipt.json' + [pscustomobject]@{ Version = 1; Kind = 'WelaAdSaclAddition'; Server = $before.Server; Dn = $before.Dn; ObjectGuid = $before.ObjectGuid; + Before = $before; AddedAces = $addition.AddedAces; ExpectedBinary = $addition.Binary } | ConvertTo-Json -Depth 12 | Set-Content $receiptPath -Encoding UTF8 + $ctx = New-WelaConfigurationContext -Auto -BackupPath (Join-Path $root 'rollback') + Invoke-WelaAdSaclRollback $session $ctx $receiptPath + $report = Complete-WelaConfiguration $ctx -Scope ad-object-sacl-only + Assert ($report.ExitCode -eq 0 -and $script:writes -eq 1) 'rollback verified through runner' + Assert (($script:state.Descriptor.Sacl -join '|') -eq ($before.Descriptor.Sacl -join '|')) 'rollback preserves all original ACEs and removes owned additions' + Assert ($script:state.Descriptor.Dacl -eq $before.Descriptor.Dacl -and $script:state.Descriptor.Owner -eq $before.Descriptor.Owner) 'rollback never restores/replaces authorization data' + $ctx = New-WelaConfigurationContext -Auto -BackupPath (Join-Path $root 'rollback-repeat') + Invoke-WelaAdSaclRollback $session $ctx $receiptPath + Assert ($ctx.Results[0].Status -eq 'AlreadyCompliant' -and $script:writes -eq 1) 'repeated rollback is idempotent' + $script:state.Descriptor = Get-WelaAdDescriptorInfo $addition.Binary + $withDrift = New-WelaAdSaclAddition $script:state @($config) + $script:state.Descriptor = Get-WelaAdDescriptorInfo $withDrift.Binary + $ctx = New-WelaConfigurationContext -Auto -BackupPath (Join-Path $root 'rollback-drift') + Invoke-WelaAdSaclRollback $session $ctx $receiptPath + Assert ($ctx.Results[0].Status -eq 'Failed' -and $script:writes -eq 1) 'rollback refuses ambiguous intervening SACL changes' + Write-Host "Passed $script:checks native Windows AD descriptor/LDAP tests. No AD bind or live mutation occurred." +} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index 4d03a568..516b44c5 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,6 +7,7 @@ **改善:** +- MDIのドメイン/Exchange Configuration監査と、明示的に選択した証明書テンプレート/登録サービスオブジェクト向けに、任意実行の`ad-object-sacl`監査・計画・設定・保守的なロールバックを追加しました。接続先DCとスキーマGUIDを検証し、既存のセキュリティ設定を保持したまま不足する監査ACEだけをSACLに追加します。変更前のSDDLと追加ACEを保存し、書き込み後と最終状態を確認します。実効監査ポリシー、継承・レプリケーション、4662/5136イベントの証拠は隔離DCで別途検証が必要です。Sigma検知範囲の向上は未検証です。 (issue #371) (@Shirofune-Security) - ネイティブのDomain/Private/Publicテキストログを監査・計画・設定する任意実行の`firewall-logging`を追加しました。許可・破棄ログの有効化、最小サイズの確認、既存パスと大きな上限値の保持、CIS v4.0.0のパスの明示的な選択に対応します。ファイアウォールサービスのディレクトリ権限を確認し、ローカル設定と実効設定を記録して変更後の実効設定を検証します。通信制御やACLは変更しません。実通信によるログ生成と収集の検証は別途必要です。 (#394) (@Shirofune-Security) - イベントログのサイズ監査と設定に共通のバイト単位プロファイルを導入し、AppLocker・ファイアウォールログの256 MiB、Setupの32 MiB、ASD推奨のSecurityログ2048 MiBに対応した。`-LogProfile`と`configure-eventlogs`で送信元と収集サーバーのサイズ・保存方式を選択できる。明示的に指定しない限り、既存の大きいバッファと保存方式は維持する。結果には検証した設定を記録し、未測定の保存日数は不明と表示する。 (#396) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index 50139bca..9684dfc6 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,6 +7,7 @@ **Improvements:** +- 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. Effective audit policy, inheritance/replication and 4662/5136 event evidence remain separate isolated-DC checks; no Sigma uplift is claimed. (issue #371) (@Shirofune-Security) - Added opt-in `firewall-logging` audit, plan and configure actions for native Domain/Private/Public text logs, with allowed/dropped logging, minimum size checks, preserved operator paths/larger limits, and explicit CIS v4.0.0 paths. Configuration checks firewall service directory permissions, journals local/effective state and verifies effective policy without changing firewall enforcement or ACLs. Traffic and ingestion validation remains required. (#394) (@Shirofune-Security) - Unified event-log size auditing and configuration with shared byte-based profiles, including 256 MiB AppLocker/firewall logs, 32 MiB Setup and ASD 2048 MiB Security. Added separate source and collector size/mode choices through `-LogProfile` and `configure-eventlogs`; larger buffers and existing retention modes are preserved unless explicitly changed. Results include verified state and unknown retention duration. (#396) (@Shirofune-Security) From 496423bb8c1044373a9bb1eebf7747256fd5bf67 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Sat, 19 Sep 2026 05:44:21 +0900 Subject: [PATCH 2/4] Reference PR 402 in bilingual changelogs --- CHANGELOG-Japanese.md | 2 +- CHANGELOG.md | 2 +- website/docs/resources/changelog.ja.md | 2 +- website/docs/resources/changelog.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index 3d4a9c3e..8b2ede26 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,7 +4,7 @@ **改善:** -- MDIのドメイン/Exchange Configuration監査と、明示的に選択した証明書テンプレート/登録サービスオブジェクト向けに、任意実行の`ad-object-sacl`監査・計画・設定・保守的なロールバックを追加しました。接続先DCとスキーマGUIDを検証し、既存のセキュリティ設定を保持したまま不足する監査ACEだけをSACLに追加します。変更前のSDDLと追加ACEを保存し、書き込み後と最終状態を確認します。実効監査ポリシー、継承・レプリケーション、4662/5136イベントの証拠は隔離DCで別途検証が必要です。Sigma検知範囲の向上は未検証です。 (issue #371) (@Shirofune-Security) +- MDIのドメイン/Exchange Configuration監査と、明示的に選択した証明書テンプレート/登録サービスオブジェクト向けに、任意実行の`ad-object-sacl`監査・計画・設定・保守的なロールバックを追加しました。接続先DCとスキーマGUIDを検証し、既存のセキュリティ設定を保持したまま不足する監査ACEだけをSACLに追加します。変更前のSDDLと追加ACEを保存し、書き込み後と最終状態を確認します。実効監査ポリシー、継承・レプリケーション、4662/5136イベントの証拠は隔離DCで別途検証が必要です。Sigma検知範囲の向上は未検証です。 (#402) (@Shirofune-Security) - ネイティブのDomain/Private/Publicテキストログを監査・計画・設定する任意実行の`firewall-logging`を追加しました。許可・破棄ログの有効化、最小サイズの確認、既存パスと大きな上限値の保持、CIS v4.0.0のパスの明示的な選択に対応します。ファイアウォールサービスのディレクトリ権限を確認し、ローカル設定と実効設定を記録して変更後の実効設定を検証します。通信制御やACLは変更しません。実通信によるログ生成と収集の検証は別途必要です。 (#394) (@Shirofune-Security) - イベントログのサイズ監査と設定に共通のバイト単位プロファイルを導入し、AppLocker・ファイアウォールログの256 MiB、Setupの32 MiB、ASD推奨のSecurityログ2048 MiBに対応した。`-LogProfile`と`configure-eventlogs`で送信元と収集サーバーのサイズ・保存方式を選択できる。明示的に指定しない限り、既存の大きいバッファと保存方式は維持する。結果には検証した設定を記録し、未測定の保存日数は不明と表示する。 (#396) (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index 432acd33..899e7d99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ **Improvements:** -- 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. Effective audit policy, inheritance/replication and 4662/5136 event evidence remain separate isolated-DC checks; no Sigma uplift is claimed. (issue #371) (@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. Effective audit policy, inheritance/replication and 4662/5136 event evidence remain separate isolated-DC checks; no Sigma uplift is claimed. (#402) (@Shirofune-Security) - Added opt-in `firewall-logging` audit, plan and configure actions for native Domain/Private/Public text logs, with allowed/dropped logging, minimum size checks, preserved operator paths/larger limits, and explicit CIS v4.0.0 paths. Configuration checks firewall service directory permissions, journals local/effective state and verifies effective policy without changing firewall enforcement or ACLs. Traffic and ingestion validation remains required. (#394) (@Shirofune-Security) - Unified event-log size auditing and configuration with shared byte-based profiles, including 256 MiB AppLocker/firewall logs, 32 MiB Setup and ASD 2048 MiB Security. Added separate source and collector size/mode choices through `-LogProfile` and `configure-eventlogs`; larger buffers and existing retention modes are preserved unless explicitly changed. Results include verified state and unknown retention duration. (#396) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index 516b44c5..f5d25c7f 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,7 +7,7 @@ **改善:** -- MDIのドメイン/Exchange Configuration監査と、明示的に選択した証明書テンプレート/登録サービスオブジェクト向けに、任意実行の`ad-object-sacl`監査・計画・設定・保守的なロールバックを追加しました。接続先DCとスキーマGUIDを検証し、既存のセキュリティ設定を保持したまま不足する監査ACEだけをSACLに追加します。変更前のSDDLと追加ACEを保存し、書き込み後と最終状態を確認します。実効監査ポリシー、継承・レプリケーション、4662/5136イベントの証拠は隔離DCで別途検証が必要です。Sigma検知範囲の向上は未検証です。 (issue #371) (@Shirofune-Security) +- MDIのドメイン/Exchange Configuration監査と、明示的に選択した証明書テンプレート/登録サービスオブジェクト向けに、任意実行の`ad-object-sacl`監査・計画・設定・保守的なロールバックを追加しました。接続先DCとスキーマGUIDを検証し、既存のセキュリティ設定を保持したまま不足する監査ACEだけをSACLに追加します。変更前のSDDLと追加ACEを保存し、書き込み後と最終状態を確認します。実効監査ポリシー、継承・レプリケーション、4662/5136イベントの証拠は隔離DCで別途検証が必要です。Sigma検知範囲の向上は未検証です。 (#402) (@Shirofune-Security) - ネイティブのDomain/Private/Publicテキストログを監査・計画・設定する任意実行の`firewall-logging`を追加しました。許可・破棄ログの有効化、最小サイズの確認、既存パスと大きな上限値の保持、CIS v4.0.0のパスの明示的な選択に対応します。ファイアウォールサービスのディレクトリ権限を確認し、ローカル設定と実効設定を記録して変更後の実効設定を検証します。通信制御やACLは変更しません。実通信によるログ生成と収集の検証は別途必要です。 (#394) (@Shirofune-Security) - イベントログのサイズ監査と設定に共通のバイト単位プロファイルを導入し、AppLocker・ファイアウォールログの256 MiB、Setupの32 MiB、ASD推奨のSecurityログ2048 MiBに対応した。`-LogProfile`と`configure-eventlogs`で送信元と収集サーバーのサイズ・保存方式を選択できる。明示的に指定しない限り、既存の大きいバッファと保存方式は維持する。結果には検証した設定を記録し、未測定の保存日数は不明と表示する。 (#396) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index 9684dfc6..6939b74f 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,7 +7,7 @@ **Improvements:** -- 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. Effective audit policy, inheritance/replication and 4662/5136 event evidence remain separate isolated-DC checks; no Sigma uplift is claimed. (issue #371) (@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. Effective audit policy, inheritance/replication and 4662/5136 event evidence remain separate isolated-DC checks; no Sigma uplift is claimed. (#402) (@Shirofune-Security) - Added opt-in `firewall-logging` audit, plan and configure actions for native Domain/Private/Public text logs, with allowed/dropped logging, minimum size checks, preserved operator paths/larger limits, and explicit CIS v4.0.0 paths. Configuration checks firewall service directory permissions, journals local/effective state and verifies effective policy without changing firewall enforcement or ACLs. Traffic and ingestion validation remains required. (#394) (@Shirofune-Security) - Unified event-log size auditing and configuration with shared byte-based profiles, including 256 MiB AppLocker/firewall logs, 32 MiB Setup and ASD 2048 MiB Security. Added separate source and collector size/mode choices through `-LogProfile` and `configure-eventlogs`; larger buffers and existing retention modes are preserved unless explicitly changed. Results include verified state and unknown retention duration. (#396) (@Shirofune-Security) From cbd1c0643b1659311f6c2955eee4208fdd167b1f Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Sat, 19 Sep 2026 05:47:54 +0900 Subject: [PATCH 3/4] Confirm AD SACL receipt ownership and validate exact PKI parent --- docs/ad-object-sacl.md | 6 +++- scripts/AdObjectSacl.ps1 | 47 +++++++++++++++++++++++----- tests/AdObjectSacl.Tests.ps1 | 26 +++++++++++++-- tests/AdObjectSacl.Windows.Tests.ps1 | 11 +++++-- 4 files changed, 78 insertions(+), 12 deletions(-) diff --git a/docs/ad-object-sacl.md b/docs/ad-object-sacl.md index 555d927e..9142f0c6 100644 --- a/docs/ad-object-sacl.md +++ b/docs/ad-object-sacl.md @@ -54,10 +54,14 @@ Both masks include CreateChild, DeleteChild, Self, WriteProperty, DeleteTree, De See Microsoft's [certificate template schema](https://learn.microsoft.com/en-us/windows/win32/adschema/c-pkicertificatetemplate) and [enrollment service schema](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-adsc/208d42e8-1932-4767-87c5-b8511991e69b). WriteProperty covers changes such as template attributes and an enrollment service's published `certificateTemplates` list; object creation, child objects, enrollment access rights and CA `AuditFilter` are separate. No security enforcement, enrollment permissions, CA settings, DACLs or owners are changed. +PKI parent membership is proven by a one-level LDAP lookup under the exact approved container using the target's binary object GUID. A matching textual DN suffix, including an escaped comma in an object's name, cannot establish this membership. + ## Verification, concurrency and recovery The command reads owner, group, DACL and SACL together using the critical [LDAP security descriptor flags control](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/3888c2b7-35b9-45b7-afeb-b772aa932dd0). A missing object, incomplete descriptor or permission failure is an error. It records the original complete SDDL and binary descriptor, object GUID, same-DC `uSNChanged`, target DN and requested ACEs in `before.jsonl`. A separate `ad-sacl-*.json` receipt records the exact missing ACE additions and expected descriptor **before** the write. Keep these files private and treat receipts as trusted administrator input; they contain directory security configuration. +Receipts initially have `ReceiptStatus: Pending`, which never authorizes automatic rollback. Only after the LDAP write succeeds and a fresh read verifies the original security information and requested ACEs does WELA persist `Confirmed`, the confirmation time and observed state. Confirmation replaces the intent file while retaining its `.pending` backup. A failed/stale request, failed read-back, interruption or unpersisted confirmation requires manual recovery review; another writer's later matching ACEs do not turn an unconfirmed intention into WELA-owned changes. + Existing ACEs are retained byte-for-byte. A same-scope ACE with a superset of the rights/outcomes already satisfies the request; WELA adds only missing audit ACEs. After the prompt and durable journal, WELA rereads the same DC's object GUID, USN and complete descriptor and refuses a changed object. The LDAP modify uses a **critical SACL-only** control, preserving owner/group/DACL on the server. Read-back verifies every original ACE, non-SACL security information and requested auditing; a final read detects later drift. Microsoft documents that [`uSNChanged` is local to a DC](https://learn.microsoft.com/en-us/windows/win32/adschema/a-usnchanged), so the command never substitutes another DC for these checks. **Use an exclusive maintenance window for SACL changes.** The immediate comparison is not an atomic compare-and-swap: a concurrent writer in the final read/write window can still lose a SACL update. WELA does not claim LDAP transaction protection, lock other writers, or automatically restore a whole descriptor after an uncertain result. Stop other SACL editors/automation, review failures against the journal and current descriptor, and verify again after inheritance/replication have settled. @@ -71,7 +75,7 @@ Existing ACEs are retained byte-for-byte. A same-scope ACE with a superset of th -BackupPath .\new-rollback-backup -ResultsPath rollback-result.json ``` -Rollback checks the DC and object identity, validates that the receipt's expected SACL contains precisely the recorded additions plus the old ACEs, and requires the current SACL to match that expected sequence. It removes only those exact additions, using the current descriptor and a SACL-only write; it never restores old owner/group/DACL data. Repeated rollback is idempotent. Reordered/merged ACEs or any intervening SACL edit make automatic ownership ambiguous and are refused. A failed or interrupted apply can leave a receipt without a completed write; inspect current state first. If automatic rollback is refused, compare the original SDDL/ACE bytes, receipt additions and current SACL on the exact DC, identify additions manually, remove only those demonstrably attributable to this run, and preserve all unrelated current entries. Do not restore the complete saved SDDL over later changes. +Rollback requires a confirmed receipt, checks the DC and object identity, validates that the receipt's expected SACL contains precisely the recorded additions plus the old ACEs, and requires the current SACL to match that expected sequence. It removes only those exact additions, using the current descriptor and a SACL-only write; it never restores old owner/group/DACL data. Repeated rollback is idempotent. Reordered/merged ACEs or any intervening SACL edit make automatic ownership ambiguous and are refused. Pending receipts are refused even if current ACEs match the intended additions. If automatic rollback is refused, compare the original SDDL/ACE bytes, receipt additions and current SACL on the exact DC, identify additions manually, remove only those demonstrably attributable to this run, and preserve all unrelated current entries. Do not restore the complete saved SDDL over later changes. `SaclConfigured`/`Applied`/`AlreadyCompliant` refer to the **selected object's SACL**. `ChangeRequired`, `NotApplicable`, `Unknown`, `Blocked`, runner `Skipped`, `Failed` and `Overridden` remain distinct. Exit 0 means no read/write/verification failures; dry runs and skipped requests do not establish configuration. No Sigma rule uplift is claimed. diff --git a/scripts/AdObjectSacl.ps1 b/scripts/AdObjectSacl.ps1 index 6a643318..5c778b63 100644 --- a/scripts/AdObjectSacl.ps1 +++ b/scripts/AdObjectSacl.ps1 @@ -223,6 +223,21 @@ function Write-WelaAdSacl { $null = $Session.Connection.SendRequest($request) } +function Test-WelaAdPkiContainer { + param($Session, $Snapshot, [string]$ContainerDn) + # A one-level GUID lookup proves parent membership without interpreting DN + # text (escaped commas can otherwise impersonate an approved suffix). + $escapedGuid = (([guid]$Snapshot.ObjectGuid).ToByteArray() | ForEach-Object { '\{0:X2}' -f $_ }) -join '' + $rows = @(Search-WelaAdDirectory -Session $Session -Dn $ContainerDn -Scope OneLevel ` + -Filter "(objectGUID=$escapedGuid)" -Attributes @('objectGUID')) + if ($rows.Count -eq 0) { return $false } + if ($rows.Count -ne 1 -or $rows[0].Dn -ine $Snapshot.Dn -or + ([guid]::new([byte[]](Get-WelaAdSingleValue $rows[0] 'objectGUID'))).ToString() -ne $Snapshot.ObjectGuid) { + throw 'PKI container membership lookup returned an unexpected object identity.' + } + return $true +} + function Get-WelaAdSaclPlan { param($Session, [string[]]$Profiles, [string[]]$ObjectDn) $requests = @() @@ -250,9 +265,11 @@ function Get-WelaAdSaclPlan { } else { $before = Get-WelaAdObjectState $Session $request.Dn $class = $null; $guid = $null - if ($before.Classes -contains 'pKICertificateTemplate' -and $before.Dn.EndsWith(",CN=Certificate Templates,CN=Public Key Services,CN=Services,$($Session.ConfigurationDn)", [StringComparison]::OrdinalIgnoreCase)) { + if ($before.Classes -contains 'pKICertificateTemplate' -and + (Test-WelaAdPkiContainer $Session $before "CN=Certificate Templates,CN=Public Key Services,CN=Services,$($Session.ConfigurationDn)")) { $class = 'pKICertificateTemplate'; $guid = 'e5209ca2-3bba-11d2-90cc-00c04fd91ab1' - } elseif ($before.Classes -contains 'pKIEnrollmentService' -and $before.Dn.EndsWith(",CN=Enrollment Services,CN=Public Key Services,CN=Services,$($Session.ConfigurationDn)", [StringComparison]::OrdinalIgnoreCase)) { + } elseif ($before.Classes -contains 'pKIEnrollmentService' -and + (Test-WelaAdPkiContainer $Session $before "CN=Enrollment Services,CN=Public Key Services,CN=Services,$($Session.ConfigurationDn)")) { $class = 'pKIEnrollmentService'; $guid = 'ee4aa692-3bba-11d2-90cc-00c04fd91ab1' } else { throw 'PkiObjects accepts only existing certificate template/enrollment service objects under the selected forest PKI containers.' } if (-not (Test-WelaAdSchemaClass $Session $class $guid)) { throw "PKI schema class missing: $class." } @@ -295,17 +312,30 @@ function Set-WelaAdSaclControls { param($state) $before = $state.Observed $addition = New-WelaAdSaclAddition $before $state.Entry.Definitions - $receipt = [ordered]@{ Version = 1; Kind = 'WelaAdSaclAddition'; Server = $state.Session.Server; Dn = $before.Dn; - ObjectGuid = $before.ObjectGuid; Before = $before; AddedAces = $addition.AddedAces; ExpectedBinary = $addition.Binary } - # A durable receipt precedes the write, including exact additions for - # conservative rollback even if the process stops after LDAP success. + $receipt = [ordered]@{ Version = 1; Kind = 'WelaAdSaclAddition'; ReceiptStatus = 'Pending'; Server = $state.Session.Server; Dn = $before.Dn; + ObjectGuid = $before.ObjectGuid; Before = $before; AddedAces = $addition.AddedAces; ExpectedBinary = $addition.Binary; + ConfirmedUtc = $null; ConfirmedAfter = $null } + # Durable intent precedes mutation, but cannot authorize rollback. + # A failed/stale request may never have written its intended ACEs. $receiptPath = Join-Path $state.Context.BackupPath ('ad-sacl-' + [guid]::NewGuid().ToString('N') + '.json') $receipt | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $receiptPath -Encoding UTF8 -ErrorAction Stop $fresh = Get-WelaAdObjectState $state.Session $state.Entry.Dn if ($fresh.ObjectGuid -ne $before.ObjectGuid -or $fresh.UsnChanged -ne $before.UsnChanged -or $fresh.Descriptor.Binary -ne $before.Descriptor.Binary) { throw 'AD object changed after journaling; no write was sent. Re-audit and retry.' } $state.Baseline = $before Write-WelaAdSacl $state.Session $before.Dn $addition.Binary - "SACL-only write sent; recovery receipt: $receiptPath. Event generation and inheritance propagation remain unverified." + $verified = Get-WelaAdObjectState $state.Session $before.Dn + if (-not (Test-WelaAdPreserved $before $verified)) { throw 'Existing owner, group, DACL or SACL ACE changed after writing; receipt remains Pending and requires manual recovery review.' } + foreach ($definition in $state.Entry.Definitions) { + if (-not (Test-WelaAdAcePresent $verified.Descriptor $definition)) { throw 'Requested SACL did not verify after writing; receipt remains Pending and requires manual recovery review.' } + } + $receipt.ReceiptStatus = 'Confirmed' + $receipt.ConfirmedUtc = [DateTime]::UtcNow.ToString('o') + $receipt.ConfirmedAfter = $verified + $confirmedPath = $receiptPath + '.tmp' + $receipt | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $confirmedPath -Encoding UTF8 -ErrorAction Stop + # Preserve the complete Pending receipt if confirmation cannot persist. + [IO.File]::Replace($confirmedPath, $receiptPath, ($receiptPath + '.pending')) + "SACL-only write and read-back verified; confirmed recovery receipt: $receiptPath. Event generation and inheritance propagation remain unverified." } Invoke-WelaConfigurationControl -Context $Context -Id $id -Kind AdObjectSacl -Target @{ Server = $Session.Server; Dn = $entry.Dn } ` -Desired $entry.Definitions -Read $read -Compliant $test -Apply $apply -CallbackState $state ` @@ -318,6 +348,9 @@ function Invoke-WelaAdSaclRollback { $receipt = Get-Content -LiteralPath $ReceiptPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop if ($receipt.Version -ne 1 -or $receipt.Kind -ne 'WelaAdSaclAddition' -or $receipt.Server -ine $Session.Server -or -not $receipt.AddedAces.Count -or $receipt.ObjectGuid -ne $receipt.Before.ObjectGuid -or $receipt.Dn -ine $receipt.Before.Dn) { throw 'Invalid receipt, empty additions, or a different DC target.' } + if ($receipt.ReceiptStatus -ne 'Confirmed' -or -not $receipt.ConfirmedUtc -or + $receipt.ConfirmedAfter.ObjectGuid -ne $receipt.ObjectGuid -or $receipt.ConfirmedAfter.Server -ine $Session.Server -or + $receipt.ConfirmedAfter.Dn -ine $receipt.Dn) { throw 'Unconfirmed receipt: automatic rollback cannot establish ACE ownership. Review Pending/failed/interrupted changes manually.' } $expected = Get-WelaAdDescriptorInfo $receipt.ExpectedBinary # Receipts are recovery evidence, not a general descriptor-restore mechanism. $remaining = New-Object 'System.Collections.Generic.List[string]' diff --git a/tests/AdObjectSacl.Tests.ps1 b/tests/AdObjectSacl.Tests.ps1 index 7cf3ab1a..b4f1d854 100644 --- a/tests/AdObjectSacl.Tests.ps1 +++ b/tests/AdObjectSacl.Tests.ps1 @@ -20,7 +20,7 @@ $script:originalInfo = ${function:Get-WelaAdDescriptorInfo} function Reset-Mocks { $script:writes = 0; $script:reads = 0; $script:readError = $false; $script:writeError = $false $script:race = $false; $script:finalDrift = $false; $script:badReadback = $false; $script:removeExisting = $false - $script:absentClass = ''; $script:dmsa = $true; $script:exchange = $true; $script:onPrompt = $null + $script:absentClass = ''; $script:dmsa = $true; $script:exchange = $true; $script:onPrompt = $null; $script:pkiMember = $true; $script:pkiRequest = $null $script:state = [pscustomobject]@{ Server = $session.Server; Dn = $session.DomainDn; ObjectGuid = '01234567-89ab-cdef-0123-456789abcdef'; UsnChanged = '17'; Classes = @('top', 'domainDNS'); Descriptor = [pscustomobject]@{ Binary = 'before'; Sddl = 'O:SYG:SYD:(A;;GA;;;SY)S:(AU;SA;WP;;;BA)'; Owner = 'S-1-5-18'; Group = 'S-1-5-18'; Dacl = 'unchanged-dacl'; ControlFlags = 32788; Sacl = @('unrelated') } } $session.Writable = $true @@ -30,6 +30,10 @@ function Test-WelaAdDmsaDomain { param($Session) return $script:dmsa } function Search-WelaAdDirectory { param($Session, $Dn, $Filter, $Scope, $Attributes, [switch]$SecurityDescriptor) if ($Filter -eq '(objectClass=msExchOrganizationContainer)' -and $script:exchange) { [pscustomobject]@{ Dn = 'CN=Exchange'; Values = @{} } } + if ($Filter -like '(objectGUID=*') { + $script:pkiRequest = @{ Dn = $Dn; Scope = $Scope; Filter = $Filter } + if ($script:pkiMember) { [pscustomobject]@{ Dn = $script:state.Dn; Values = @{ objectGUID = @(,([guid]$script:state.ObjectGuid).ToByteArray()) } } } + } } function Get-WelaAdObjectState { param($Session, $Dn) @@ -37,7 +41,7 @@ function Get-WelaAdObjectState { if ($script:readError) { throw 'LDAP access denied' } $snapshot = $script:state | ConvertTo-Json -Depth 10 | ConvertFrom-Json if ($script:race -and $script:reads -ge 3) { $snapshot.UsnChanged = '99' } - if ($script:finalDrift -and $script:reads -ge 5) { $snapshot.Descriptor.Sacl = @('unrelated'); $snapshot.Descriptor.Binary = 'drift' } + if ($script:finalDrift -and $script:reads -ge 6) { $snapshot.Descriptor.Sacl = @('unrelated'); $snapshot.Descriptor.Binary = 'drift' } return $snapshot } function Test-WelaAdAcePresent { param($Descriptor, $Definition) return $Descriptor.Sacl -contains ('added-' + $Definition.Class) } @@ -90,8 +94,13 @@ try { $script:state.Classes = @('top', 'pKICertificateTemplate') $plan = @(Get-WelaAdSaclPlan $session @('PkiObjects') @($script:state.Dn)) Assert ($plan[0].Status -eq 'ChangeRequired' -and $plan[0].Definitions[0].AccessMask -eq 852000 -and $plan[0].Definitions[0].AceFlags -eq 64) 'PKI direct-object write/delete/ACL audit only' + Assert ($script:pkiRequest.Scope -eq 'OneLevel' -and $script:pkiRequest.Dn -eq "CN=Certificate Templates,CN=Public Key Services,CN=Services,$($session.ConfigurationDn)" -and + $script:pkiRequest.Filter -eq '(objectGUID=\67\45\23\01\AB\89\EF\CD\01\23\45\67\89\AB\CD\EF)') 'PKI membership uses exact parent and byte-escaped object GUID' + $script:pkiMember = $false $script:state.Dn = "CN=Test,$($session.DomainDn)" Assert ((@(Get-WelaAdSaclPlan $session @('PkiObjects') @($script:state.Dn)))[0].Status -eq 'Unknown') 'PKI class outside trusted forest containers refused' + $script:state.Dn = "CN=Foo\,CN=Certificate Templates,CN=Public Key Services,CN=Services,$($session.ConfigurationDn)" + Assert ((@(Get-WelaAdSaclPlan $session @('PkiObjects') @($script:state.Dn)))[0].Status -eq 'Unknown') 'escaped RDN suffix cannot impersonate approved PKI parent' Reset-Mocks; $session.Writable = $false Assert ((@(Get-WelaAdSaclPlan $session @('MdiDomain') @()))[0].Status -eq 'Blocked') 'RODC plan explicitly blocked' Reset-Mocks; $script:readError = $true @@ -108,13 +117,26 @@ try { $receipts = @(Get-ChildItem $ctx.BackupPath -Filter 'ad-sacl-*.json') $receipt = Get-Content $receipts[0].FullName -Raw | ConvertFrom-Json Assert ($receipt.AddedAces.Count -eq 6 -and $receipt.Before.Descriptor.Binary -eq 'before') 'receipt stores only intended additions and before binary' + Assert ($receipt.ReceiptStatus -eq 'Confirmed' -and $receipt.ConfirmedAfter.Descriptor.Binary -eq 'after' -and $receipt.ConfirmedUtc) 'successful write and readback confirm rollback ownership' $ctx2 = Get-Context; $report2 = Invoke-TestConfigure $ctx2 Assert ($script:writes -eq 1 -and $report2.Results[0].Status -eq 'AlreadyCompliant') 'repeat run is idempotent' Reset-Mocks; $script:race = $true; $ctx = Get-Context $report = Invoke-TestConfigure $ctx Assert ($report.ExitCode -eq 1 -and $script:writes -eq 0 -and $report.Results[0].Diagnostic -like '*changed after journaling*') 'USN race fails closed before write' + $pendingPath = @(Get-ChildItem $ctx.BackupPath -Filter 'ad-sacl-*.json')[0].FullName + Assert ((Get-Content $pendingPath -Raw | ConvertFrom-Json).ReceiptStatus -eq 'Pending') 'stale pre-write receipt remains pending' + $script:state.Descriptor.Sacl = @('unrelated') + @(Get-WelaAdAuditDefinitions | ForEach-Object { 'added-' + $_.Class }) + $script:state.Descriptor.Binary = 'after' + Assert-Throws { Invoke-WelaAdSaclRollback $session (Get-Context $true) $pendingPath } 'pending intent cannot authorize rollback of another writers matching ACEs' + Reset-Mocks; $script:writeError = $true; $ctx = Get-Context + $report = Invoke-TestConfigure $ctx + $pendingPath = @(Get-ChildItem $ctx.BackupPath -Filter 'ad-sacl-*.json')[0].FullName + Assert ($report.ExitCode -eq 1 -and (Get-Content $pendingPath -Raw | ConvertFrom-Json).ReceiptStatus -eq 'Pending') 'failed LDAP write cannot confirm receipt' + Assert-Throws { Invoke-WelaAdSaclRollback $session (Get-Context $true) $pendingPath } 'failed-write receipt cannot authorize automatic rollback' Reset-Mocks; $script:badReadback = $true; $ctx = Get-Context Assert ((Invoke-TestConfigure $ctx).ExitCode -eq 1) 'missing audit ACE readback fails' + $pendingPath = @(Get-ChildItem $ctx.BackupPath -Filter 'ad-sacl-*.json')[0].FullName + Assert ((Get-Content $pendingPath -Raw | ConvertFrom-Json).ReceiptStatus -eq 'Pending') 'failed readback leaves receipt pending' Reset-Mocks; $script:removeExisting = $true; $ctx = Get-Context $report = Invoke-TestConfigure $ctx Assert ($report.ExitCode -eq 1 -and $report.Results[0].Diagnostic -like '*Existing owner*') 'loss of pre-existing audit ACE fails verification' diff --git a/tests/AdObjectSacl.Windows.Tests.ps1 b/tests/AdObjectSacl.Windows.Tests.ps1 index a0d8e245..7901d54f 100644 --- a/tests/AdObjectSacl.Windows.Tests.ps1 +++ b/tests/AdObjectSacl.Windows.Tests.ps1 @@ -66,13 +66,15 @@ try { # Mock the state/transport boundary, retain real ACL transformations and runner. $script:state = $after; $script:writes = 0 - function Get-WelaAdObjectState { param($Session, $Dn) return $script:state } + function Get-WelaAdObjectState { param($Session, $Dn) return ($script:state | ConvertTo-Json -Depth 12 | ConvertFrom-Json) } function Write-WelaAdSacl { param($Session, $Dn, $Binary) $script:writes++; $script:state.Descriptor = Get-WelaAdDescriptorInfo $Binary; $script:state.UsnChanged = '101' + if ($script:corruptRollback) { $script:state.Descriptor.Owner = 'S-1-5-19' } } $session.Writable = $true $receiptPath = Join-Path $root 'receipt.json' - [pscustomobject]@{ Version = 1; Kind = 'WelaAdSaclAddition'; Server = $before.Server; Dn = $before.Dn; ObjectGuid = $before.ObjectGuid; + [pscustomobject]@{ Version = 1; Kind = 'WelaAdSaclAddition'; ReceiptStatus = 'Confirmed'; ConfirmedUtc = [DateTime]::UtcNow.ToString('o'); + ConfirmedAfter = $after; Server = $before.Server; Dn = $before.Dn; ObjectGuid = $before.ObjectGuid; Before = $before; AddedAces = $addition.AddedAces; ExpectedBinary = $addition.Binary } | ConvertTo-Json -Depth 12 | Set-Content $receiptPath -Encoding UTF8 $ctx = New-WelaConfigurationContext -Auto -BackupPath (Join-Path $root 'rollback') Invoke-WelaAdSaclRollback $session $ctx $receiptPath @@ -89,5 +91,10 @@ try { $ctx = New-WelaConfigurationContext -Auto -BackupPath (Join-Path $root 'rollback-drift') Invoke-WelaAdSaclRollback $session $ctx $receiptPath Assert ($ctx.Results[0].Status -eq 'Failed' -and $script:writes -eq 1) 'rollback refuses ambiguous intervening SACL changes' + $script:state.Descriptor = Get-WelaAdDescriptorInfo $addition.Binary + $script:corruptRollback = $true + $ctx = New-WelaConfigurationContext -Auto -BackupPath (Join-Path $root 'rollback-corruption') + Invoke-WelaAdSaclRollback $session $ctx $receiptPath + Assert ($ctx.Results[0].Status -eq 'Failed' -and $ctx.Results[0].Diagnostic -like '*Owner/group/DACL*') 'independent rollback snapshot detects owner corruption' Write-Host "Passed $script:checks native Windows AD descriptor/LDAP tests. No AD bind or live mutation occurred." } finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } From 92bf29ec223f3f8123188099b6233e3d4d300eba Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Sat, 19 Sep 2026 06:20:41 +0900 Subject: [PATCH 4/4] Isolate unknown dMSA prerequisites from other AD audit classes --- CHANGELOG-Japanese.md | 2 +- CHANGELOG.md | 2 +- docs/ad-object-sacl.md | 2 +- scripts/AdObjectSacl.ps1 | 36 +++++++++++++++++++++----- tests/AdObjectSacl.Tests.ps1 | 36 +++++++++++++++++++++++--- website/docs/resources/changelog.ja.md | 2 +- website/docs/resources/changelog.md | 2 +- 7 files changed, 67 insertions(+), 15 deletions(-) diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index 8b2ede26..d78b5900 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,7 +4,7 @@ **改善:** -- MDIのドメイン/Exchange Configuration監査と、明示的に選択した証明書テンプレート/登録サービスオブジェクト向けに、任意実行の`ad-object-sacl`監査・計画・設定・保守的なロールバックを追加しました。接続先DCとスキーマGUIDを検証し、既存のセキュリティ設定を保持したまま不足する監査ACEだけをSACLに追加します。変更前のSDDLと追加ACEを保存し、書き込み後と最終状態を確認します。実効監査ポリシー、継承・レプリケーション、4662/5136イベントの証拠は隔離DCで別途検証が必要です。Sigma検知範囲の向上は未検証です。 (#402) (@Shirofune-Security) +- MDIのドメイン/Exchange Configuration監査と、明示的に選択した証明書テンプレート/登録サービスオブジェクト向けに、任意実行の`ad-object-sacl`監査・計画・設定・保守的なロールバックを追加しました。接続先DCとスキーマGUIDを検証し、既存のセキュリティ設定を保持したまま不足する監査ACEだけをSACLに追加します。変更前のSDDLと追加ACEを保存し、書き込み後と最終状態を確認します。任意のdMSA前提条件が不明な場合は未確認のスキップ項目として報告し、独立した他の5種類のドメインクラスの監査ACEは引き続き設定します。実効監査ポリシー、継承・レプリケーション、4662/5136イベントの証拠は隔離DCで別途検証が必要です。Sigma検知範囲の向上は未検証です。 (#402) (@Shirofune-Security) - ネイティブのDomain/Private/Publicテキストログを監査・計画・設定する任意実行の`firewall-logging`を追加しました。許可・破棄ログの有効化、最小サイズの確認、既存パスと大きな上限値の保持、CIS v4.0.0のパスの明示的な選択に対応します。ファイアウォールサービスのディレクトリ権限を確認し、ローカル設定と実効設定を記録して変更後の実効設定を検証します。通信制御やACLは変更しません。実通信によるログ生成と収集の検証は別途必要です。 (#394) (@Shirofune-Security) - イベントログのサイズ監査と設定に共通のバイト単位プロファイルを導入し、AppLocker・ファイアウォールログの256 MiB、Setupの32 MiB、ASD推奨のSecurityログ2048 MiBに対応した。`-LogProfile`と`configure-eventlogs`で送信元と収集サーバーのサイズ・保存方式を選択できる。明示的に指定しない限り、既存の大きいバッファと保存方式は維持する。結果には検証した設定を記録し、未測定の保存日数は不明と表示する。 (#396) (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index 899e7d99..f36e8c77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ **Improvements:** -- 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. Effective audit policy, inheritance/replication and 4662/5136 event evidence remain separate isolated-DC checks; no Sigma uplift is claimed. (#402) (@Shirofune-Security) +- Added opt-in `ad-object-sacl` audit, plan, configure and conservative rollback actions for MDI domain/Exchange Configuration auditing and explicitly selected certificate template/enrollment service objects. Exact DC binding, schema GUID checks, additive SACL-only changes, pre-write SDDL/ACE receipts and read-back preserve existing security entries. Unknown optional dMSA prerequisites are reported as a separate skipped gap while the five independent domain class ACEs continue. Effective audit policy, inheritance/replication and 4662/5136 event evidence remain separate isolated-DC checks; no Sigma uplift is claimed. (#402) (@Shirofune-Security) - Added opt-in `firewall-logging` audit, plan and configure actions for native Domain/Private/Public text logs, with allowed/dropped logging, minimum size checks, preserved operator paths/larger limits, and explicit CIS v4.0.0 paths. Configuration checks firewall service directory permissions, journals local/effective state and verifies effective policy without changing firewall enforcement or ACLs. Traffic and ingestion validation remains required. (#394) (@Shirofune-Security) - Unified event-log size auditing and configuration with shared byte-based profiles, including 256 MiB AppLocker/firewall logs, 32 MiB Setup and ASD 2048 MiB Security. Added separate source and collector size/mode choices through `-LogProfile` and `configure-eventlogs`; larger buffers and existing retention modes are preserved unless explicitly changed. Results include verified state and unknown retention duration. (#396) (@Shirofune-Security) diff --git a/docs/ad-object-sacl.md b/docs/ad-object-sacl.md index 9142f0c6..4a62a303 100644 --- a/docs/ad-object-sacl.md +++ b/docs/ad-object-sacl.md @@ -41,7 +41,7 @@ All ACEs use **Everyone (`S-1-1-0`)**. An empty object GUID is `00000000-0000-00 | msDS-GroupManagedServiceAccount | 852075 (`0xD006B`) | 7b8b558a-93a5-4af7-adca-c017e67f1057 | | msDS-DelegatedManagedServiceAccount | 852075 (`0xD006B`) | 0feb936f-47b3-49f2-9386-1dedc2c23765 | -Both masks include CreateChild, DeleteChild, Self, WriteProperty, DeleteTree, Delete, WriteDacl and WriteOwner. `852331` additionally includes ExtendedRight. dMSA is omitted, with a diagnostic, unless its schema class exists and a domain DC computer reports a version at least `10.0 (26100)`. This follows [MDI's Server 2025 domain condition](https://learn.microsoft.com/en-us/defender-for-identity/deploy/configure-windows-event-collection#configure-auditing-on-domain-objects). Missing mandatory schema classes or unreadable/unknown applicability produce `Unknown`, not an assumed audit configuration. Protected child SACLs and inheritance propagation are not established by a root ACE read-back. +Both masks include CreateChild, DeleteChild, Self, WriteProperty, DeleteTree, Delete, WriteDacl and WriteOwner. `852331` additionally includes ExtendedRight. dMSA is omitted, with a diagnostic, unless its schema class exists and a domain DC computer reports a version at least `10.0 (26100)`. This follows [MDI's Server 2025 domain condition](https://learn.microsoft.com/en-us/defender-for-identity/deploy/configure-windows-event-collection#configure-auditing-on-domain-objects). Missing or unreadable mandatory schema classes still fail the domain request closed. If only the conditional dMSA schema/DC check is unreadable or cannot classify DC versions, the other five class ACEs continue; the plan records dMSA in `SkippedDefinitions` with `PrerequisiteStatus: Unknown` and the original diagnostic. Known inapplicability uses `NotApplicable` instead. Configure emits a separate `Skipped` prerequisite row, writes only the selected five definitions, and reports completion with a skipped control. This does not establish dMSA auditing or complete six-class coverage; resolve the prerequisite and rerun to assess dMSA. Protected child SACLs and inheritance propagation are not established by a root ACE read-back. `MdiConfiguration` targets RootDSE `configurationNamingContext`, with **WriteProperty (`32`, `0x20`), Success and Failure**, **this object and all descendants**, flags `194` (`0xC2`), and both GUIDs empty. Microsoft's [Configuration container guidance](https://learn.microsoft.com/en-us/defender-for-identity/deploy/configure-windows-event-collection#configure-auditing-on-the-configuration-container) is conditional on current or former Exchange deployments. WELA checks for an `msExchOrganizationContainer`; no matching object produces `NotApplicable`. If all historical Exchange configuration was removed, inspect that history manually: absence does not prove Exchange never existed. Configuration is replicated forest-wide; this operation is not scoped to one domain's users. diff --git a/scripts/AdObjectSacl.ps1 b/scripts/AdObjectSacl.ps1 index 5c778b63..c8320cfa 100644 --- a/scripts/AdObjectSacl.ps1 +++ b/scripts/AdObjectSacl.ps1 @@ -248,14 +248,32 @@ function Get-WelaAdSaclPlan { foreach ($dn in @($ObjectDn | Select-Object -Unique)) { $requests += [pscustomobject]@{ Profile = 'PkiObjects'; Dn = $dn } } } elseif ($ObjectDn.Count) { throw '-AdObjectDn is valid only with the PkiObjects profile.' } foreach ($request in $requests) { - $definitions = @(); $before = $null; $notes = @(); $status = 'Unknown' + $definitions = @(); $skippedDefinitions = @(); $before = $null; $notes = @(); $status = 'Unknown' try { if ($request.Profile -eq 'MdiDomain') { foreach ($definition in Get-WelaAdAuditDefinitions) { - $exists = Test-WelaAdSchemaClass $Session $definition.Class $definition.InheritedObjectType if ($definition.Class -eq 'msDS-DelegatedManagedServiceAccount') { - if (-not $exists -or -not (Test-WelaAdDmsaDomain $Session)) { $notes += 'dMSA skipped: schema class and a domain DC version >= 10.0 (26100) are required.'; continue } - } elseif (-not $exists) { throw "Required MDI schema class is absent: $($definition.Class)." } + # dMSA is conditional. Its unknown schema/DC prerequisites + # must not prevent the five independent class ACEs. + $prerequisiteStatus = 'Applicable'; $diagnostic = '' + try { + $exists = Test-WelaAdSchemaClass $Session $definition.Class $definition.InheritedObjectType + if (-not $exists -or -not (Test-WelaAdDmsaDomain $Session)) { + $prerequisiteStatus = 'NotApplicable' + $diagnostic = 'dMSA skipped: schema class and a domain DC version >= 10.0 (26100) are required.' + } + } catch { + $prerequisiteStatus = 'Unknown' + $diagnostic = "dMSA skipped: prerequisite Unknown ($($_.Exception.Message)). dMSA auditing is not established; the other five class ACEs remain independent." + } + if ($prerequisiteStatus -ne 'Applicable') { + $notes += $diagnostic + $skippedDefinitions += [pscustomobject]@{ Definition = $definition; Status = 'Skipped'; PrerequisiteStatus = $prerequisiteStatus; Diagnostic = $diagnostic } + continue + } + } elseif (-not (Test-WelaAdSchemaClass $Session $definition.Class $definition.InheritedObjectType)) { + throw "Required MDI schema class is absent: $($definition.Class)." + } $definitions += $definition } } elseif ($request.Profile -eq 'MdiConfiguration') { @@ -282,7 +300,7 @@ function Get-WelaAdSaclPlan { if (-not $Session.Writable -and $missing.Count) { $status = 'Blocked'; $notes += 'Selected DC is read-only.' } } catch { $notes += $_.Exception.Message } [pscustomobject]@{ Profile = $request.Profile; Server = $Session.Server; Dn = $request.Dn; Status = $status; - Definitions = $definitions; Before = $before; Diagnostic = $notes -join ' ' } + Definitions = $definitions; SkippedDefinitions = $skippedDefinitions; Before = $before; Diagnostic = $notes -join ' ' } } } @@ -290,6 +308,12 @@ function Set-WelaAdSaclControls { param($Session, $Context, [array]$Plan) foreach ($entry in $Plan) { $id = "AdSacl/$($entry.Profile)/$($entry.Dn)" + foreach ($skipped in $entry.SkippedDefinitions) { + $Context.Results.Add([pscustomobject]@{ Id = "$id/$($skipped.Definition.Class)/Prerequisite"; Kind = 'AdObjectSaclPrerequisite'; + Target = @{ Server = $Session.Server; Dn = $entry.Dn; Class = $skipped.Definition.Class }; Desired = $skipped.Definition; + Before = $null; After = $null; Status = 'Skipped'; PrerequisiteStatus = $skipped.PrerequisiteStatus; Diagnostic = $skipped.Diagnostic }) + Write-Host "[Skipped] $id/$($skipped.Definition.Class) $($skipped.Diagnostic)" -ForegroundColor Yellow + } if ($entry.Status -notin @('SaclConfigured', 'ChangeRequired')) { $Context.Results.Add([pscustomobject]@{ Id = $id; Kind = 'AdObjectSacl'; Target = @{ Server = $Session.Server; Dn = $entry.Dn }; Desired = $entry.Definitions; Before = $entry.Before; After = $null; Status = $(if ($entry.Status -eq 'NotApplicable') { 'Skipped' } else { 'Failed' }); Diagnostic = $entry.Diagnostic }) @@ -415,7 +439,7 @@ function Invoke-WelaAdSaclCommand { $report | Add-Member NoteProperty AuditPolicyPrerequisites @( [pscustomobject]@{ Name = 'Directory Service Access'; Guid = '0cce923b-69ae-11d9-bed3-505054503030'; Required = 'Success (Failure also required for Configuration failure auditing)'; Status = 'Unknown'; Diagnostic = 'Remote DC audit policy is not read or changed by this LDAP command.' }, [pscustomobject]@{ Name = 'Directory Service Changes'; Guid = '0cce923c-69ae-11d9-bed3-505054503030'; Required = 'Success'; Status = 'Unknown'; Diagnostic = 'Verify effective policy and 5136 on the DC handling the object change.' }) - $report | Add-Member NoteProperty VerificationScope 'Selected-DC object SACL state only. Inherited child SACLs, protected objects, policy, 4662/5136 generation, replication and collection are unverified. No Sigma uplift is claimed.' + $report | Add-Member NoteProperty VerificationScope 'Selected-DC object SACL state only. Skipped or Unknown class prerequisites do not establish auditing for those classes. Inherited child SACLs, protected objects, policy, 4662/5136 generation, replication and collection are unverified. No Sigma uplift is claimed.' if ($ResultsPath) { $report | ConvertTo-Json -Depth 16 | Set-Content -LiteralPath $ResultsPath -Encoding UTF8 -ErrorAction Stop } return $report } finally { $session.Connection.Dispose() } diff --git a/tests/AdObjectSacl.Tests.ps1 b/tests/AdObjectSacl.Tests.ps1 index b4f1d854..ff0b09c7 100644 --- a/tests/AdObjectSacl.Tests.ps1 +++ b/tests/AdObjectSacl.Tests.ps1 @@ -21,12 +21,19 @@ function Reset-Mocks { $script:writes = 0; $script:reads = 0; $script:readError = $false; $script:writeError = $false $script:race = $false; $script:finalDrift = $false; $script:badReadback = $false; $script:removeExisting = $false $script:absentClass = ''; $script:dmsa = $true; $script:exchange = $true; $script:onPrompt = $null; $script:pkiMember = $true; $script:pkiRequest = $null + $script:dmsaError = ''; $script:schemaErrorClass = ''; $script:plannedAdditions = @() $script:state = [pscustomobject]@{ Server = $session.Server; Dn = $session.DomainDn; ObjectGuid = '01234567-89ab-cdef-0123-456789abcdef'; UsnChanged = '17'; Classes = @('top', 'domainDNS'); Descriptor = [pscustomobject]@{ Binary = 'before'; Sddl = 'O:SYG:SYD:(A;;GA;;;SY)S:(AU;SA;WP;;;BA)'; Owner = 'S-1-5-18'; Group = 'S-1-5-18'; Dacl = 'unchanged-dacl'; ControlFlags = 32788; Sacl = @('unrelated') } } $session.Writable = $true } -function Test-WelaAdSchemaClass { param($Session, $Class, $Guid) return $Class -ne $script:absentClass } -function Test-WelaAdDmsaDomain { param($Session) return $script:dmsa } +function Test-WelaAdSchemaClass { param($Session, $Class, $Guid) + if ($Class -eq $script:schemaErrorClass) { throw 'Schema lookup access denied' } + return $Class -ne $script:absentClass +} +function Test-WelaAdDmsaDomain { param($Session) + if ($script:dmsaError) { throw $script:dmsaError } + return $script:dmsa +} function Search-WelaAdDirectory { param($Session, $Dn, $Filter, $Scope, $Attributes, [switch]$SecurityDescriptor) if ($Filter -eq '(objectClass=msExchOrganizationContainer)' -and $script:exchange) { [pscustomobject]@{ Dn = 'CN=Exchange'; Values = @{} } } @@ -47,14 +54,15 @@ function Get-WelaAdObjectState { function Test-WelaAdAcePresent { param($Descriptor, $Definition) return $Descriptor.Sacl -contains ('added-' + $Definition.Class) } function New-WelaAdSaclAddition { param($Before, $Definitions) - [pscustomobject]@{ Binary = 'after'; AddedAces = @($Definitions | Where-Object { -not (Test-WelaAdAcePresent $Before.Descriptor $_) } | ForEach-Object { 'added-' + $_.Class }) } + $script:plannedAdditions = @($Definitions | Where-Object { -not (Test-WelaAdAcePresent $Before.Descriptor $_) } | ForEach-Object { 'added-' + $_.Class }) + [pscustomobject]@{ Binary = 'after'; AddedAces = $script:plannedAdditions } } function Write-WelaAdSacl { param($Session, $Dn, $Binary) if ($script:writeError) { throw 'LDAP insufficientAccessRights' } $script:writes++ if (-not $script:badReadback) { - $script:state.Descriptor.Sacl = @('unrelated') + @(Get-WelaAdAuditDefinitions | ForEach-Object { 'added-' + $_.Class }) + $script:state.Descriptor.Sacl = @($script:state.Descriptor.Sacl) + $script:plannedAdditions $script:state.Descriptor.Binary = $Binary } if ($script:removeExisting) { $script:state.Descriptor.Sacl = @($script:state.Descriptor.Sacl | Where-Object { $_ -ne 'unrelated' }) } @@ -82,8 +90,28 @@ try { $script:dmsa = $false $plan = @(Get-WelaAdSaclPlan $session @('MdiDomain') @()) Assert ($plan[0].Definitions.Count -eq 5 -and $plan[0].Diagnostic -like '*dMSA skipped*') 'dMSA omitted without a 2025 domain DC' + Assert ($plan[0].SkippedDefinitions[0].PrerequisiteStatus -eq 'NotApplicable') 'known dMSA omission is distinct from unknown prerequisites' $script:absentClass = 'user' Assert ((@(Get-WelaAdSaclPlan $session @('MdiDomain') @()))[0].Status -eq 'Unknown') 'missing required class blocks writes' + foreach ($errorMessage in @('DC versions could not all be classified; dMSA applicability is unknown.', 'LDAP DC version query access denied')) { + Reset-Mocks; $script:dmsaError = $errorMessage + $plan = @(Get-WelaAdSaclPlan $session @('MdiDomain') @()) + Assert ($plan[0].Status -eq 'ChangeRequired' -and $plan[0].Definitions.Count -eq 5) 'unknown optional dMSA check preserves five independent planned ACEs' + Assert ($plan[0].SkippedDefinitions.Count -eq 1 -and $plan[0].SkippedDefinitions[0].PrerequisiteStatus -eq 'Unknown' -and + $plan[0].SkippedDefinitions[0].Diagnostic.Contains($errorMessage)) 'plan records the skipped dMSA prerequisite and exact unknown diagnostic' + $ctx = Get-Context; $report = Invoke-TestConfigure $ctx + $applied = @($report.Results | Where-Object Kind -eq 'AdObjectSacl') + $gap = @($report.Results | Where-Object Kind -eq 'AdObjectSaclPrerequisite') + Assert ($report.ExitCode -eq 0 -and $report.Skipped -eq 1 -and $applied[0].Status -eq 'Applied' -and $applied[0].Desired.Count -eq 5) 'five ACEs configure with a visible skipped-control result' + Assert ($gap.Count -eq 1 -and $gap[0].Status -eq 'Skipped' -and $gap[0].PrerequisiteStatus -eq 'Unknown' -and $gap[0].Target.Class -eq 'msDS-DelegatedManagedServiceAccount') 'configuration retains structured dMSA Unknown gap' + Assert ($script:plannedAdditions.Count -eq 5 -and $script:state.Descriptor.Sacl -notcontains 'added-msDS-DelegatedManagedServiceAccount') 'unknown dMSA never produces a sixth ACE write' + } + Reset-Mocks; $script:schemaErrorClass = 'msDS-DelegatedManagedServiceAccount' + $plan = @(Get-WelaAdSaclPlan $session @('MdiDomain') @()) + Assert ($plan[0].Status -eq 'ChangeRequired' -and $plan[0].Definitions.Count -eq 5 -and $plan[0].SkippedDefinitions[0].PrerequisiteStatus -eq 'Unknown') 'optional dMSA schema read error also remains isolated' + Reset-Mocks; $script:schemaErrorClass = 'user' + $ctx = Get-Context; $report = Invoke-TestConfigure $ctx + Assert ($report.ExitCode -eq 1 -and $script:writes -eq 0 -and $report.Results[0].Status -eq 'Failed') 'mandatory schema lookup failure still fails closed' Reset-Mocks; $script:exchange = $false Assert ((@(Get-WelaAdSaclPlan $session @('MdiConfiguration') @()))[0].Status -eq 'NotApplicable') 'configuration gated on Exchange history evidence' $script:exchange = $true diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index f5d25c7f..04e3e556 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,7 +7,7 @@ **改善:** -- MDIのドメイン/Exchange Configuration監査と、明示的に選択した証明書テンプレート/登録サービスオブジェクト向けに、任意実行の`ad-object-sacl`監査・計画・設定・保守的なロールバックを追加しました。接続先DCとスキーマGUIDを検証し、既存のセキュリティ設定を保持したまま不足する監査ACEだけをSACLに追加します。変更前のSDDLと追加ACEを保存し、書き込み後と最終状態を確認します。実効監査ポリシー、継承・レプリケーション、4662/5136イベントの証拠は隔離DCで別途検証が必要です。Sigma検知範囲の向上は未検証です。 (#402) (@Shirofune-Security) +- MDIのドメイン/Exchange Configuration監査と、明示的に選択した証明書テンプレート/登録サービスオブジェクト向けに、任意実行の`ad-object-sacl`監査・計画・設定・保守的なロールバックを追加しました。接続先DCとスキーマGUIDを検証し、既存のセキュリティ設定を保持したまま不足する監査ACEだけをSACLに追加します。変更前のSDDLと追加ACEを保存し、書き込み後と最終状態を確認します。任意のdMSA前提条件が不明な場合は未確認のスキップ項目として報告し、独立した他の5種類のドメインクラスの監査ACEは引き続き設定します。実効監査ポリシー、継承・レプリケーション、4662/5136イベントの証拠は隔離DCで別途検証が必要です。Sigma検知範囲の向上は未検証です。 (#402) (@Shirofune-Security) - ネイティブのDomain/Private/Publicテキストログを監査・計画・設定する任意実行の`firewall-logging`を追加しました。許可・破棄ログの有効化、最小サイズの確認、既存パスと大きな上限値の保持、CIS v4.0.0のパスの明示的な選択に対応します。ファイアウォールサービスのディレクトリ権限を確認し、ローカル設定と実効設定を記録して変更後の実効設定を検証します。通信制御やACLは変更しません。実通信によるログ生成と収集の検証は別途必要です。 (#394) (@Shirofune-Security) - イベントログのサイズ監査と設定に共通のバイト単位プロファイルを導入し、AppLocker・ファイアウォールログの256 MiB、Setupの32 MiB、ASD推奨のSecurityログ2048 MiBに対応した。`-LogProfile`と`configure-eventlogs`で送信元と収集サーバーのサイズ・保存方式を選択できる。明示的に指定しない限り、既存の大きいバッファと保存方式は維持する。結果には検証した設定を記録し、未測定の保存日数は不明と表示する。 (#396) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index 6939b74f..90591251 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,7 +7,7 @@ **Improvements:** -- 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. Effective audit policy, inheritance/replication and 4662/5136 event evidence remain separate isolated-DC checks; no Sigma uplift is claimed. (#402) (@Shirofune-Security) +- Added opt-in `ad-object-sacl` audit, plan, configure and conservative rollback actions for MDI domain/Exchange Configuration auditing and explicitly selected certificate template/enrollment service objects. Exact DC binding, schema GUID checks, additive SACL-only changes, pre-write SDDL/ACE receipts and read-back preserve existing security entries. Unknown optional dMSA prerequisites are reported as a separate skipped gap while the five independent domain class ACEs continue. Effective audit policy, inheritance/replication and 4662/5136 event evidence remain separate isolated-DC checks; no Sigma uplift is claimed. (#402) (@Shirofune-Security) - Added opt-in `firewall-logging` audit, plan and configure actions for native Domain/Private/Public text logs, with allowed/dropped logging, minimum size checks, preserved operator paths/larger limits, and explicit CIS v4.0.0 paths. Configuration checks firewall service directory permissions, journals local/effective state and verifies effective policy without changing firewall enforcement or ACLs. Traffic and ingestion validation remains required. (#394) (@Shirofune-Security) - Unified event-log size auditing and configuration with shared byte-based profiles, including 256 MiB AppLocker/firewall logs, 32 MiB Setup and ASD 2048 MiB Security. Added separate source and collector size/mode choices through `-LogProfile` and `configure-eventlogs`; larger buffers and existing retention modes are preserved unless explicitly changed. Results include verified state and unknown retention duration. (#396) (@Shirofune-Security)