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 67cc1484..9cb8750f 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を保存し、書き込み後と最終状態を確認します。任意のdMSA前提条件が不明な場合は未確認のスキップ項目として報告し、独立した他の5種類のドメインクラスの監査ACEは引き続き設定します。実効監査ポリシー、継承・レプリケーション、4662/5136イベントの証拠は隔離DCで別途検証が必要です。Sigma検知範囲の向上は未検証です。 (#402) (@Shirofune-Security) - Microsoft WEF Appendix Cのチャネルを監査・計画・設定する任意実行の`channel-settings`を追加しました。CAPI2の有効化、原典の正確なバイト数、明示的に指定したEvent Log Readersの読み取りACEに対応します。既存のセキュリティ記述子・ACEと大きいバッファを保持し、安全に扱えないACLは変更しません。共通の復旧記録、書き込み直前の状態確認と読み戻しで失敗を報告します。Appendix E/Fの標準チャネル一覧からSysmonとEMETを除外し、実際のIDによるアクセス・イベント生成・収集は未検証と表示します。Windowsラボでの検証は別途必要です。 (#401) (@Shirofune-Security) - ASDのガイドに基づく任意実行のWMI名前空間SACL監査・計画・設定を追加した。ローカル名前空間の明示的な選択と、子名前空間への継承の個別指定に対応する。完全なセキュリティ記述子の記録、SACLだけを更新するネイティブ要求、特権の復元確認、書き込み前の変更検出と読み戻し検証により、既存のアクセス権と未知の監査エントリを保持する。イベント生成と転送の検証は別途必要となる。 使い捨てのServer 2022/2025名前空間でPowerShell 5.1/7の制御フラグ読み戻しと冪等性を検証した。 (#399) (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77a8d0be..34855338 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. Unknown optional dMSA prerequisites are reported as a separate skipped gap while the five independent domain class ACEs continue. Effective audit policy, inheritance/replication and 4662/5136 event evidence remain separate isolated-DC checks; no Sigma uplift is claimed. (#402) (@Shirofune-Security) - Added opt-in `channel-settings` audit, plan and configure actions for Microsoft WEF Appendix C, including CAPI2 enablement, exact source byte sizes and an explicitly requested Event Log Readers read ACE. Existing descriptor components/ACEs and larger buffers are preserved or unsafe ACL edits are refused; shared journaling, fresh-state guards and readback report failures. Native Appendix E/F channel inventories exclude Sysmon/EMET and retain unverified identity access, generation and ingestion prerequisites. Windows lab evidence remains pending. (#401) (@Shirofune-Security) - Added opt-in WMI namespace SACL audit, plan and configure actions based on ASD guidance, with explicit local namespace selection and separate descendant-inheritance consent. Full descriptor journals, SACL-only native requests, checked privilege restoration, race guards and read-back verification preserve existing permissions and unknown audit entries; event generation and forwarding remain separate lab validation. Verified native control-flag readback and idempotence on disposable Server 2022/2025 namespaces under PowerShell 5.1/7. (#399) (@Shirofune-Security) diff --git a/WELA.ps1 b/WELA.ps1 index 1873851a..30ace2ba 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -23,6 +23,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, [ValidateSet('Audit', 'Plan', 'Configure')][string]$ChannelAction = 'Audit', [string]$ChannelProfile = 'microsoft-wef-appendix-c', [ValidateSet('Baseline', 'Suspect', 'Both')][string]$WefQuerySet = 'Both', @@ -48,6 +53,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") . (Join-Path $ScriptRoot "scripts/AppLockerReadiness.ps1") . (Join-Path $ScriptRoot "scripts/WmiNamespaceAuditing.ps1") Import-Module (Join-Path $ScriptRoot "modules/AuditProfiles.psm1") -ErrorAction Stop @@ -1706,6 +1712,7 @@ Usage: ./WELA.ps1 applocker-readiness -ResultsPath applocker.json ./WELA.ps1 applocker-readiness -AppLockerAction Plan -AppLockerPolicyPath operator-audit.xml # SMB auditing is opt-in and never changes signing/encryption requirements or guest access. + ./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 @@ -1749,12 +1756,18 @@ if ($PSBoundParameters.ContainsKey('SaclMode') -and throw '-SaclMode requires -Profile with plan, audit, audit-settings or configure. It does not control configure-sacl. No command was run.' } # Reject unsupported dry-run requests before reaching any command's mutation path. +if ($Cmd -ne 'ad-object-sacl' -and @($PSBoundParameters.Keys | Where-Object { + $_ -in @('AdSaclAction', 'AdServer', 'AdSaclProfile', 'AdObjectDn', 'AdReceiptPath') +}).Count) { + throw 'AD object SACL options require the dedicated ad-object-sacl command. No command was run.' +} if ($DryRun -and -not ($Cmd -eq 'applocker-readiness' -and $AppLockerAction -eq 'Import') -and $Cmd -notin @('configure', 'configure-eventlogs') -and -not ($Cmd -eq 'firewall-logging' -and $FirewallAction -eq 'Configure') -and -not ($Cmd -eq 'smb-auditing' -and $SmbAction -eq 'Configure') -and -not ($Cmd -eq 'channel-settings' -and $ChannelAction -eq 'Configure') -and + -not ($Cmd -eq 'ad-object-sacl' -and $AdSaclAction -in @('Configure', 'Rollback')) -and -not ($Cmd -eq 'wmi-auditing' -and $WmiAction -eq 'Configure')) { - throw "-DryRun is supported only by configure (including configure -Profile), configure-eventlogs, firewall-logging -FirewallAction Configure, smb-auditing -SmbAction Configure, wmi-auditing -WmiAction Configure, channel-settings -ChannelAction Configure, and applocker-readiness -AppLockerAction Import. No command was run." + throw "-DryRun is supported only by configure (including configure -Profile), configure-eventlogs, firewall-logging -FirewallAction Configure, smb-auditing -SmbAction Configure, wmi-auditing -WmiAction Configure, channel-settings -ChannelAction Configure, applocker-readiness -AppLockerAction Import, and ad-object-sacl -AdSaclAction Configure|Rollback. No command was run." } if (($WmiNamespace -or $WmiIncludeChildren -or $PSBoundParameters.ContainsKey('WmiAction')) -and $Cmd -ne 'wmi-auditing') { throw '-WmiAction, -WmiNamespace and -WmiIncludeChildren require wmi-auditing. No command was run.' @@ -1834,6 +1847,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 } + } "applocker-readiness" { if ($Help) { Write-Host 'Usage: ./WELA.ps1 applocker-readiness [-AppLockerAction Audit|Plan|Import] [-AppLockerPolicyPath operator.xml] [-Auto] [-DryRun] [-BackupPath new-directory] [-ResultsPath file.json]' diff --git a/docs/ad-object-sacl.md b/docs/ad-object-sacl.md new file mode 100644 index 00000000..4a62a303 --- /dev/null +++ b/docs/ad-object-sacl.md @@ -0,0 +1,88 @@ +# 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 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. + +`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. + +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. + +```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 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. + +## 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..c8320cfa --- /dev/null +++ b/scripts/AdObjectSacl.ps1 @@ -0,0 +1,446 @@ +# 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 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 = @() + 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 = @(); $skippedDefinitions = @(); $before = $null; $notes = @(); $status = 'Unknown' + try { + if ($request.Profile -eq 'MdiDomain') { + foreach ($definition in Get-WelaAdAuditDefinitions) { + if ($definition.Class -eq 'msDS-DelegatedManagedServiceAccount') { + # 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') { + $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 + (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 + (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." } + $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; SkippedDefinitions = $skippedDefinitions; Before = $before; Diagnostic = $notes -join ' ' } + } +} + +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 }) + 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'; 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 + $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 ` + -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.' } + 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]' + 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. 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/scripts/Configuration.ps1 b/scripts/Configuration.ps1 index 9f0f3741..a301b898 100644 --- a/scripts/Configuration.ps1 +++ b/scripts/Configuration.ps1 @@ -94,7 +94,7 @@ function Invoke-WelaConfigurationControl { function Complete-WelaConfiguration { param($Context, [string]$ResultsPath, $Plan, - [ValidateSet("native-windows-configuration", "advanced-audit-policy-only", "advanced-audit-policy-and-precedence", "firewall-text-logging-only", "event-log-size-and-mode-only", "smb-audit-policies-only", "native-channel-settings-only", "wmi-namespace-sacl-only")] + [ValidateSet("native-windows-configuration", "advanced-audit-policy-only", "advanced-audit-policy-and-precedence", "firewall-text-logging-only", "event-log-size-and-mode-only", "smb-audit-policies-only", "native-channel-settings-only", "wmi-namespace-sacl-only", "ad-object-sacl-only")] [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..ff0b09c7 --- /dev/null +++ b/tests/AdObjectSacl.Tests.ps1 @@ -0,0 +1,226 @@ +$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: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) + 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 = @{} } } + 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) + $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 6) { $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) + $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 = @($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' }) } + $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' + 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 + $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' + 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 + $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' + 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' + 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..7901d54f --- /dev/null +++ b/tests/AdObjectSacl.Windows.Tests.ps1 @@ -0,0 +1,100 @@ +# 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 | 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'; 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 + $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' + $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 } diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index c3236ae3..a654f834 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を保存し、書き込み後と最終状態を確認します。任意のdMSA前提条件が不明な場合は未確認のスキップ項目として報告し、独立した他の5種類のドメインクラスの監査ACEは引き続き設定します。実効監査ポリシー、継承・レプリケーション、4662/5136イベントの証拠は隔離DCで別途検証が必要です。Sigma検知範囲の向上は未検証です。 (#402) (@Shirofune-Security) - Microsoft WEF Appendix Cのチャネルを監査・計画・設定する任意実行の`channel-settings`を追加しました。CAPI2の有効化、原典の正確なバイト数、明示的に指定したEvent Log Readersの読み取りACEに対応します。既存のセキュリティ記述子・ACEと大きいバッファを保持し、安全に扱えないACLは変更しません。共通の復旧記録、書き込み直前の状態確認と読み戻しで失敗を報告します。Appendix E/Fの標準チャネル一覧からSysmonとEMETを除外し、実際のIDによるアクセス・イベント生成・収集は未検証と表示します。Windowsラボでの検証は別途必要です。 (#401) (@Shirofune-Security) - ASDのガイドに基づく任意実行のWMI名前空間SACL監査・計画・設定を追加した。ローカル名前空間の明示的な選択と、子名前空間への継承の個別指定に対応する。完全なセキュリティ記述子の記録、SACLだけを更新するネイティブ要求、特権の復元確認、書き込み前の変更検出と読み戻し検証により、既存のアクセス権と未知の監査エントリを保持する。イベント生成と転送の検証は別途必要となる。 使い捨てのServer 2022/2025名前空間でPowerShell 5.1/7の制御フラグ読み戻しと冪等性を検証した。 (#399) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index 3fc174e6..630d34bd 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. Unknown optional dMSA prerequisites are reported as a separate skipped gap while the five independent domain class ACEs continue. Effective audit policy, inheritance/replication and 4662/5136 event evidence remain separate isolated-DC checks; no Sigma uplift is claimed. (#402) (@Shirofune-Security) - Added opt-in `channel-settings` audit, plan and configure actions for Microsoft WEF Appendix C, including CAPI2 enablement, exact source byte sizes and an explicitly requested Event Log Readers read ACE. Existing descriptor components/ACEs and larger buffers are preserved or unsafe ACL edits are refused; shared journaling, fresh-state guards and readback report failures. Native Appendix E/F channel inventories exclude Sysmon/EMET and retain unverified identity access, generation and ingestion prerequisites. Windows lab evidence remains pending. (#401) (@Shirofune-Security) - Added opt-in WMI namespace SACL audit, plan and configure actions based on ASD guidance, with explicit local namespace selection and separate descendant-inheritance consent. Full descriptor journals, SACL-only native requests, checked privilege restoration, race guards and read-back verification preserve existing permissions and unknown audit entries; event generation and forwarding remain separate lab validation. Verified native control-flag readback and idempotence on disposable Server 2022/2025 namespaces under PowerShell 5.1/7. (#399) (@Shirofune-Security)