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)