From bcd4e9717e6483c7e2e87fa280d1ab5beeb4059c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B0=E4=B8=AD=E3=82=B6=E3=83=83=E3=82=AF=20Isaac=20Ma?= =?UTF-8?q?this?= <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:12:56 +0900 Subject: [PATCH] Verify reviewed descendant SACL propagation and preservation (#429) * Verify reviewed descendant SACL propagation and preservation * Reference descendant SACL PR429 in release notes * Prepare protected disposable SACL fixtures through native handles * Use read-control handles for disposable native SACL protection --- .gitattributes | 1 + .github/workflows/selected-sacl.yml | 12 ++ CHANGELOG-Japanese.md | 1 + CHANGELOG.md | 2 + WELA.ps1 | 2 +- docs/selected-sacl-configuration.md | 31 +++- scripts/SelectedSaclConfiguration.ps1 | 65 ++++++-- scripts/SelectedSaclDescendants.ps1 | 122 +++++++++++++++ scripts/SelectedSaclNative.cs | 29 +++- tests/SelectedSacl.Tests.ps1 | 1 + tests/SelectedSaclDescendants.Tests.ps1 | 142 ++++++++++++++++++ .../SelectedSaclDescendants.Windows.Tests.ps1 | 95 ++++++++++++ tests/SelectedSaclFixtureProtection.cs | 33 ++++ website/docs/resources/changelog.ja.md | 1 + website/docs/resources/changelog.md | 2 + 15 files changed, 522 insertions(+), 17 deletions(-) create mode 100644 scripts/SelectedSaclDescendants.ps1 create mode 100644 tests/SelectedSaclDescendants.Tests.ps1 create mode 100644 tests/SelectedSaclDescendants.Windows.Tests.ps1 create mode 100644 tests/SelectedSaclFixtureProtection.cs diff --git a/.gitattributes b/.gitattributes index 2a26a27b..2d061ae1 100644 --- a/.gitattributes +++ b/.gitattributes @@ -39,6 +39,7 @@ modules/WecSubscriptionXml.cs text eol=lf scripts/AppLockerProbe.ps1 text eol=lf tests/AppLockerProbe*.ps1 text eol=lf +tests/SelectedSaclFixtureProtection.cs text eol=lf # Fixed local WMI probe source/worker fingerprints. /scripts/WmiProbe*.ps1 text eol=lf /scripts/WmiProbeNative.cs text eol=lf diff --git a/.github/workflows/selected-sacl.yml b/.github/workflows/selected-sacl.yml index ee394630..b19a4cc7 100644 --- a/.github/workflows/selected-sacl.yml +++ b/.github/workflows/selected-sacl.yml @@ -34,3 +34,15 @@ jobs: - name: Explicit disposable native targets and4657/4663 on PowerShell 7 shell: pwsh run: ./tests/SelectedSacl.Windows.Tests.ps1 -AllowDisposableSaclWrite + - name: Bounded descendant fixtures on Windows PowerShell 5.1 + shell: powershell + run: ./tests/SelectedSaclDescendants.Tests.ps1 + - name: Explicit disposable populated descendant trees on Windows PowerShell 5.1 + shell: powershell + run: ./tests/SelectedSaclDescendants.Windows.Tests.ps1 -AllowDisposableSaclWrite + - name: Bounded descendant fixtures on PowerShell 7 + shell: pwsh + run: ./tests/SelectedSaclDescendants.Tests.ps1 + - name: Explicit disposable populated descendant trees on PowerShell 7 + shell: pwsh + run: ./tests/SelectedSaclDescendants.Windows.Tests.ps1 -AllowDisposableSaclWrite diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index da322b12..1b58da44 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,6 +4,7 @@ **改善:** +- `targeted-sacl`で子孫への継承を明示的に許可した場合、件数・深さを制限した子孫一覧と記述子を計画・変更直前に照合し、変更前の記録、保護された子孫の保持、各オブジェクトの継承結果を確認するようにしました。上限超過、読み取り拒否、リンク、子孫の追加・消失・変更は処理を停止または失敗として記録し、親のみの既存動作は保持します。使い捨てファイル/レジストリ階層で継承と保護を検証し、子孫ACEの所有権、一括復旧、Sigma利用可能性は主張しません。 (#429) (@Shirofune-Security) - 固定のローカル名前空間読み取りを行う任意実行の `wmi-probe` を追加しました。実トークン・監査ポリシー・完全な SACL を観測し、WMI Security4662 を厳密に照合して、容量制限付きの非公開 XML とコードの指紋を記録します。本番の名前空間やポリシーは変更せず、Sigma の評価には加算しません。WMI 接続は明示的に管理するセキュリティ特権だけを使用し、意図しないスレッド特権の有効化を防ぎます。両 PowerShell エンジンの使い捨て Server 2022/2025 テストで実際のローカル 4662 と監査設定・名前空間の復元を確認しました。リモートアクセス、プロバイダー処理の成否、個々のクエリへの排他的な帰属は未検証です。 (#428) (@Shirofune-Security) - 正規バックアップと現在の WELA 監査コンポーネントを照合し、新規・無効・未リンクの GPO のみを作成する `gpo-create` の Review / Plan / Create を追加しました。実ファイルとネイティブレポートの厳密な検証、明示的なドメイン/書き込み可能 DC、変更しない保護付きバックアップコピー、永続 GUID 記録、内容・無効状態・権限・リンク・バージョンの直前/最終確認で既存ポリシーを保護します。Windows テストは Microsoft の固定バックアップの読み取りと対象外ポリシー/ワークグループの拒否を確認し、実 AD/SYSVOL への正常インポートとクライアント/イベントの受け入れ検証は別途必要です。適用や Sigma の有効性は主張しません。(#427) (@Shirofune-Security) - 既に無効なネイティブ購読のクエリと説明だけを変更する `wec-update` を追加。定義・実ホスト・コードの指紋、レビュー済み計画のハッシュ、永続レシート、直前確認と変更後の読み戻しにより、再作成や有効化をせずに変更を検証します。使い捨て Windows テストは実更新・復元と古い計画の拒否を確認します。稼働中ソースのブックマーク・配送・Sigma 準備状態は未検証です。 (#426) (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index af2fad2c..55b111fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ **Improvements:** +- Extended explicit `targeted-sacl` child consent with bounded reviewed descendant inventories, fresh preflight/pre-write checks, durable child snapshots, protected-subtree preservation and per-child native inheritance outcomes. Caps, denials, links, new/disappeared children and drift block or fail the run; parent-only behavior stays unchanged. Disposable populated file/registry tests verify inheritance and protection without child-ACE ownership, bulk rollback or Sigma credit. (#429) (@Shirofune-Security) + - Added opt-in `wmi-probe` for a fixed local namespace read with observed token, audit-policy and full SACL context, exact WMI Security4662 correlation, bounded private raw XML and source fingerprints. Production makes no namespace/policy changes and grants no Sigma credit. WMI connections now use only the explicitly scoped security privilege, avoiding unintended thread privilege expansion. Disposable Server 2022/2025 tests under both PowerShell engines verify real local 4662 events and exact policy/namespace cleanup. Remote access, provider-operation success and exclusive query attribution remain unverified. (#428) (@Shirofune-Security) - Added opt-in `gpo-create` review, plan and new disabled/unlinked GPO creation from an exact genuine backup matched to current WELA audit components. Strict payload/native-report validation, explicit domain/writable-DC identity, protected unchanged backup copies, durable GUID receipts and fresh/final content, flags, permissions, link and version checks preserve existing policies. Native Windows tests read a pinned Microsoft backup and exercise broad-payload/workgroup refusal; positive AD/SYSVOL import and client/event acceptance remain pending, with no deployment or Sigma credit. (#427) (@Shirofune-Security) - Added `wec-update` to review and apply query/description changes to one already disabled native subscription through existing-only WEC handles. Complete definition/context/code fingerprints, a separately reviewed plan hash, durable receipts, fresh checks and preserved-property readback reject drift without recreation or activation. Disposable Windows tests cover actual updates/restoration and stale plans; active-source bookmarks, delivery and Sigma readiness remain unverified. (#426) (@Shirofune-Security) diff --git a/WELA.ps1 b/WELA.ps1 index 6446fa3f..b44e5505 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -2139,7 +2139,7 @@ switch ($Cmd.ToLower()) { if ($report.ExitCode) {exit $report.ExitCode} } 'targeted-sacl' { - if ($Help) { Write-Host 'Usage: ./WELA.ps1 targeted-sacl -TargetSaclProfile profile-id [-TargetSaclId id,...] [-TargetSaclAction Audit|Plan] [-IncludeOptional] [-TargetSaclIncludeChildren] [-ResultsPath new-plan.json]. Configure requires -TargetSaclAction Configure -TargetSaclPlanPath reviewed.json -TargetSaclId same-ids [-TargetSaclIncludeChildren] [-IncludeOptional] [-DryRun] [-Auto] [-BackupPath new-directory] [-ResultsPath new-results.json]. Existing local targets only; see docs/selected-sacl-configuration.md.'; return } + if ($Help) { Write-Host 'Usage: ./WELA.ps1 targeted-sacl -TargetSaclProfile profile-id [-TargetSaclId id,...] [-TargetSaclAction Audit|Plan] [-IncludeOptional] [-TargetSaclIncludeChildren] [-ResultsPath new-plan.json]. Configure requires -TargetSaclAction Configure -TargetSaclPlanPath reviewed.json -TargetSaclId same-ids [-TargetSaclIncludeChildren] [-IncludeOptional] [-DryRun] [-Auto] [-BackupPath new-directory] [-ResultsPath new-results.json]. Existing local targets only; IncludeChildren requires a complete reviewed capture of at most 128 descendants per root, depth 16. See docs/selected-sacl-configuration.md.'; return } $report=Invoke-WelaSelectedSacl -Action $TargetSaclAction -Profile $TargetSaclProfile -Ids $TargetSaclId -PlanPath $TargetSaclPlanPath -IncludeOptional:$IncludeOptional -IncludeChildren:$TargetSaclIncludeChildren -DryRun:$DryRun -Auto:$Auto -BackupPath $BackupPath -ResultsPath $ResultsPath $report if ($report.ExitCode) { exit $report.ExitCode } diff --git a/docs/selected-sacl-configuration.md b/docs/selected-sacl-configuration.md index a44843db..6d68cf6d 100644 --- a/docs/selected-sacl-configuration.md +++ b/docs/selected-sacl-configuration.md @@ -39,7 +39,7 @@ $targetId = $target[0].Id The backup parent must already exist; use an operator-controlled recovery location. The final backup directory and result files must be new. Paths resolve relative to PowerShell's current location. `-Auto` accepts per-target confirmation only; it does not bypass selection, source/context, policy, inheritance or descriptor checks. `-DryRun` is supported only for Configure and creates no recovery files or target changes. The same exact target IDs, optional selection and inheritance consent must be supplied when consuming the plan. These dedicated options are rejected on unrelated commands, including legacy `configure-sacl`. -An Audit without target IDs returns the catalog and user inventory. Audit/Plan with IDs reads only selected target descriptors. Plan requires nonempty selection; Configure requires the saved plan and matching IDs. A changed catalog, generator, source profile, target identity/security descriptor, or actual host context requires a fresh review. Plan files use bounded strict JSON and trusted definitions are regenerated; editing a path, principal, mask or source identity cannot supply arbitrary native write instructions. This initial command uses built-in profiles, not `-ProfileFile` or external target catalogs. +An Audit without target IDs returns the catalog and user inventory. Audit/Plan with IDs reads selected target descriptors and, when child consent applies to a container, the bounded descendant inventory described below. Plan requires nonempty selection; Configure requires the saved plan and matching IDs. A changed catalog, generator, source profile, target identity/security descriptor, or actual host context requires a fresh review. Plan files use bounded strict JSON and trusted definitions are regenerated; editing a path, principal, mask or source identity cannot supply arbitrary native write instructions. This initial command uses built-in profiles, not `-ProfileFile` or external target catalogs. ## Exact policy and target boundaries @@ -47,23 +47,44 @@ WELA companion targets retain Everyone, Success+Failure and their declared right The required File System or Registry success/failure audit bits and typed `SCENoApplyLegacyAuditPolicy=1` must **already** be observed. Unselected optional or not-applicable policies and explicit No Auditing block configuration. An unchanged/Not Configured source can use separately established effective auditing, but this workflow does not enable it. Configure an appropriate policy separately through its authority and generate a fresh plan afterward. Handle Manipulation events and other prerequisites are separate; this command does not infer that all event families will fire. -Unloaded hives, missing files/keys, remote or mapped-network paths, reparse points, unknown user folders and unresolved catalog entries remain blocked. No offline hive is mounted, and no sensitive file or autostart key is created. Loaded-user paths use that user's known-folder metadata; another user's AppData is never replaced with the operator's environment. Directory and registry inheritance requires explicit `-TargetSaclIncludeChildren`: Windows can propagate inheritable SACL ACEs to **existing** descendants. Review those descendants separately; the report verifies the selected object, not complete descendant coverage. +Unloaded hives, missing files/keys, remote or mapped-network paths, reparse points, unknown user folders and unresolved catalog entries remain blocked. No offline hive is mounted, and no sensitive file or autostart key is created. Loaded-user paths use that user's known-folder metadata; another user's AppData is never replaced with the operator's environment. Directory and registry inheritance requires explicit `-TargetSaclIncludeChildren`: Windows can propagate inheritable SACL ACEs to **existing** descendants. The reviewed plan must now include a complete bounded descendant capture; an incomplete capture blocks configuration. Protection barriers are preserved and reported separately from inherited-ACE observations. ## Native preservation, receipts and recovery Native reads and writes use a handle to the selected object. File handles verify the final local path and file identity; registry components are opened without following symbolic links. Registry identity includes the observed last-write time, so unrelated edits can conservatively require a new plan. The writer rereads the handle immediately before `SetSecurityInfo` with **SACL_SECURITY_INFORMATION only**, passing no owner, group or DACL changes. The original SACL entries are retained as binary ACEs and the requested ordinary audit ACE is appended. Unknown or inherited entries are preserved without treating them as proof of the requested explicit ACE. An existing explicit ordinary ACE with matching flags/SID and all required rights is already compliant. -Each attempted change first creates `.pending.json`, containing the original descriptor bytes for the recorded observation scope, ACEs, target identity, source hashes and proposed audit entry. Only successful native write, preserved-state checks and matching readback create the separate `.confirmed.json`. A failed write, unreadable after-state or privilege-restoration failure leaves pending evidence and returns failure, without a confirmed ownership claim. Final checks detect changes after an earlier successful write. Partial failures stay visible; an applied earlier target is not silently rolled back. A confirmed receipt records its verified moment and must still be compared with the final result and current state. +Each attempted change first creates `.pending.json`, containing the original descriptor bytes for the recorded observation scope, ACEs, target identity, source hashes, proposed audit entry and every reviewed descendant snapshot. Only successful native write, selected-root preservation/readback and all required descendant outcomes create the separate `.confirmed.json`. A failed write, unreadable after-state or privilege-restoration failure leaves pending evidence and returns failure, without a confirmed ownership claim. Final checks detect changes after an earlier successful write. Partial failures stay visible; an applied earlier target is not silently rolled back. A confirmed receipt records its verified moment and must still be compared with the final result and current state. -For recovery, review the receipts and a fresh descriptor first. Remove only the explicit ACE demonstrated to have been added by this run; do not remove a matching ACE that was already present. Preserve the existing owner, group, DACL, protection flags and all newer audit entries. If Windows propagated inheritance, inspect descendants separately. No automatic full-descriptor replacement or bulk rollback is provided by this command. Pending receipts cannot establish that an ACE belongs to WELA; retain them for manual investigation. +For recovery, review the receipts and a fresh descriptor first. Remove only the explicit ACE demonstrated to have been added by this run; do not remove a matching ACE that was already present. Preserve the existing owner, group, DACL, protection flags and all newer audit entries. If Windows propagated inheritance, use the child snapshots and observations for manual assessment; a matching inherited ACE does not establish that this run owns it. No automatic full-descriptor replacement or bulk rollback is provided by this command. Pending receipts cannot establish that an ACE belongs to WELA; retain them for manual investigation. Windows security updates are not a compare-and-swap transaction against other administrators or GPO. Fresh-state checks and handle-bound mutation reduce races but do not lock out concurrent SACL writers. Use an isolated change window; no later policy persistence or race-free inheritance guarantee is claimed. +## Reviewed descendant evidence + +`-TargetSaclIncludeChildren` remains explicit consent for the native setter's inheritance effects. For each selected container it now requires two matching scans of at most **128 existing descendants**, at most **16 levels** deep, with at most **2 MiB** of serialized child snapshots. All selected roots must also fit the existing 4 MiB plan limit. Each scan has a 30-second check between native operations; individual Windows reads are not cancellable, so this is not a hard native-call timeout. These bounds cannot be overridden by `-Auto`. Select a smaller supported catalog scope or assess the tree separately when the capture cannot be completed; arbitrary paths and alternative hives cannot be supplied. + +The plan records each child's exact path, immediate parent, depth, native identity/descriptor, and SACL protection barrier. It includes children below a protected container so their preservation can be checked; it does not clear protection. Native registry enumeration requests only the additional enumerate right on the current container, uses the 64-bit view and rejects symbolic-link components before reading the target descriptor. File enumeration rejects reparse components, ambiguous names and repeated file identities such as hard-link aliases. Missing, denied, capped, linked, unstable or oversized captures stay `Incomplete` and block the entire preflight. Parent-only file operations retain their existing behavior. + +Configure regenerates the descendants and compares them to the reviewed plan, repeats the capture before writing the pending backup, and again after that backup immediately before the selected-root write. Overlapping selected ancestors/descendants are refused before any change. Descendants are **never** passed to the writer: Windows performs propagation from the one selected-root SACL update. After any attempted native write, a separate `*.descendants-observed.json` records the bounded child readback when available. A failed or unreadable after-state leaves Pending evidence and no Confirmed receipt. A final scan compares membership, identities and descriptors again; later drift fails the run even if a Confirmed receipt records an earlier verified moment. + +Per-child outcomes are distinct: + +- `InheritedAceObserved`: the required ordinary inherited audit ACE was seen, with all original binary ACEs, owner, group, DACL and non-SACL/protection flags preserved. Only SACL-present and SACL automatic-inheritance bookkeeping flags may change. +- `ProtectedUnchanged`: the child's full descriptor is unchanged under its own or an ancestor's SACL protection barrier; this is not inherited auditing coverage. +- `PreservedWithoutRequestedInheritance`: original state is preserved but the selected new ACE is parent-only; existing inheritable entries can still have triggered the scan. +- `NewUnreviewedChild` or `Unverified`: a child appeared, disappeared, changed identity, could not be read, lost an original ACE, gained an unexplained explicit/unknown ACE, or lacks its expected inherited audit ACE. The run fails rather than reporting full propagation. + +An already compliant root is not rewritten to repair child inheritance. If its expected descendant ACEs are missing, WELA blocks the plan and requires separate assessment. A fresh compliant root/descendant plan remains idempotent. + +This is observational verification, **not an atomic tree transaction**. Concurrent creation/deletion/ACL changes can happen between scans or during Windows propagation; newly created children may inherit despite having no pre-write backup. A post-write failure cannot undo such effects safely. File identity uses the native volume/file index/creation tuple; registry identity uses the path and last-write metadata, which can change during a SACL update and cannot prove that a key was not deleted and recreated during that interval. Registry post-write verification therefore reports path/descriptor preservation without claiming durable object identity. No receipt establishes child-ACE ownership or authorizes automatic/bulk rollback. Preserve Pending evidence and investigate against fresh state; do not restore full child descriptors or remove every matching inherited ACE. + +The Windows disposable fixture now uses populated file and registry trees, verifies actual native inherited ACEs on the open branch and unchanged protected branches, checks rerun inputs without a second write, and detects a newly appeared child. It snapshots/restores all audit masks and typed precedence, restores privilege state and verifies removal of its owned objects. It does not apply changes to production catalog paths. These tests establish only their observed Server 2022/2025 cases; they provide no general tree, future-child, forwarding or Sigma/backend coverage credit. + ## Validation Mocked tests cover selection, source-specific masks, unsupported consent, source/plan/target races, denied reads, partial writes, non-SACL drift, pending/confirmed receipts, idempotence and public command guards. The Windows workflow explicitly permits mutations only on GitHub-hosted disposable Server 2022/2025 runners: it creates owned temporary file/registry targets, temporarily enables their two audit subcategories and precedence, adds audit ACEs through the real adapter, and searches for benign 4663/4657 events matching the exact targets. It restores all original audit masks and typed precedence and removes only owned targets. This fixture does not modify any catalog system target. -Native CI results must be reviewed before claiming those test cases passed. Windows 11, DC/CA, user redirection, inheritance across populated trees, forwarding and actual Sigma/backend execution remain separate acceptance work. Every report remains `GenerationReadiness=Conditional` with `UsableRuleCredit=0`. +Native CI results must be reviewed before claiming those test cases passed. Windows 11, DC/CA, user redirection, large/changing production trees, forwarding and actual Sigma/backend execution remain separate acceptance work. Every report remains `GenerationReadiness=Conditional` with `UsableRuleCredit=0`. Primary API references: [GetSecurityInfo](https://learn.microsoft.com/en-us/windows/win32/api/aclapi/nf-aclapi-getsecurityinfo), [SetSecurityInfo and inheritance](https://learn.microsoft.com/en-us/windows/win32/api/aclapi/nf-aclapi-setsecurityinfo), [registry open/link behavior](https://learn.microsoft.com/en-us/windows/win32/api/winreg/nf-winreg-regopenkeyexw), [file handle and sharing flags](https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilew). diff --git a/scripts/SelectedSaclConfiguration.ps1 b/scripts/SelectedSaclConfiguration.ps1 index dbacec30..499fe99d 100644 --- a/scripts/SelectedSaclConfiguration.ps1 +++ b/scripts/SelectedSaclConfiguration.ps1 @@ -1,4 +1,5 @@ # Explicit selected, existing local targets. No audit-policy writes or hive loading. +. (Join-Path $PSScriptRoot 'SelectedSaclDescendants.ps1') function Get-WelaSelectedSaclHash { param([string[]]$Values) $encoding=New-Object Text.UTF8Encoding($false,$true) @@ -7,7 +8,7 @@ function Get-WelaSelectedSaclHash { try {([BitConverter]::ToString($sha.ComputeHash($encoding.GetBytes($text)))).Replace('-','').ToLowerInvariant()} finally {$sha.Dispose()} } function Get-WelaSelectedSaclSources { - foreach($path in @('config/audit_sacl_targets.json','config/audit_profiles.json','modules/AuditProfiles.psm1','modules/AuditCatalog.psm1','scripts/TargetedSaclPlanning.ps1','scripts/SelectedSaclConfiguration.ps1','scripts/SelectedSaclNative.cs')) { + foreach($path in @('config/audit_sacl_targets.json','config/audit_profiles.json','modules/AuditProfiles.psm1','modules/AuditCatalog.psm1','scripts/TargetedSaclPlanning.ps1','scripts/SelectedSaclConfiguration.ps1','scripts/SelectedSaclNative.cs','scripts/SelectedSaclDescendants.ps1')) { [pscustomobject]@{Path=$path;Sha256=(Get-FileHash -LiteralPath (Join-Path $PSScriptRoot "../$path") -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()} } } @@ -58,9 +59,9 @@ function Initialize-WelaSelectedSaclNative { function Resolve-WelaSelectedSaclNativePath { param($Definition) if($Definition.Resolution -notin @('Resolved','Redirected')){throw "Target path is unresolved: $($Definition.Resolution). No hive is loaded."} - $observation=Get-WelaSaclTargetObservation -Path $Definition.Path -Kind $Definition.Kind -SkipSaclRead - if($observation.PathState -ne 'Exists'){throw "Selected existing local target is unavailable: $($observation.PathState). $($observation.Diagnostic)"} if($Definition.Kind -eq 'FileSystem') { + $observation=Get-WelaSaclTargetObservation -Path $Definition.Path -Kind $Definition.Kind -SkipSaclRead + if($observation.PathState -ne 'Exists'){throw "Selected existing local target is unavailable: $($observation.PathState). $($observation.Diagnostic)"} if($Definition.Path -notmatch '^[A-Za-z]:\\'){throw 'Only absolute local filesystem targets are supported.'} if($Definition.Path.Substring(2).Contains(':') -or $Definition.Path -match '[*?<>|]|[ .](\\|$)'){throw 'Ambiguous filesystem target path.'} $full=[IO.Path]::GetFullPath($Definition.Path) @@ -68,6 +69,8 @@ function Resolve-WelaSelectedSaclNativePath { return $full } if($Definition.Kind -ne 'Registry'){throw 'Unsupported target kind.'} + # Do not let a registry provider preflight follow a link before the native + # component-by-component OPEN_LINK validation. Missing keys fail native open. $path=$Definition.Path -replace '^HKLM:\\','HKEY_LOCAL_MACHINE\' -replace '^Registry::','' if($path -notmatch '^HKEY_(LOCAL_MACHINE|USERS)\\[^\\]+' -or $path -match '\\\\|(^|\\)\.\.?($|\\)|[*?%/\x00-\x1f]'){throw 'Only canonical existing HKLM/HKU keys may be selected.'} return $path @@ -133,6 +136,7 @@ function Write-WelaSelectedSaclJson { param([string]$Path,$Value) $text=($Value | ConvertTo-Json -Depth 24 -Compress)+[Environment]::NewLine $bytes=[Text.UTF8Encoding]::new($false).GetBytes($text) + if($Value.Kind -eq 'WelaSelectedSaclPlan' -and $bytes.Length -gt 4194304){throw 'Reviewed plan exceeds the 4 MiB import limit; select fewer roots.'} $stream=[IO.File]::Open($Path,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::Read) try {$stream.Write($bytes,0,$bytes.Length);$stream.Flush($true)} finally {$stream.Dispose()} } @@ -160,6 +164,7 @@ function Read-WelaSelectedSaclPlan { if($row.Id -isnot [string] -or $row.Id -cnotmatch '^sacl-[0-9a-f]{24}$' -or $seen.ContainsKey($row.Id)){throw 'Invalid or duplicate reviewed target ID.'};$seen[$row.Id]=$true if((Get-WelaSelectedSaclDefinitionKey $row.Definition) -cne $row.DefinitionKey){throw 'Reviewed target definition was modified.'} $null=Get-WelaSelectedSaclSnapshotKey $row.Before + if($plan.IncludeChildren -and ($row.Before.Kind -eq 'Registry' -or $row.Before.IsDirectory)){$null=Get-WelaSelectedSaclDescendantKey $row.DescendantsBefore} } [pscustomobject]@{Path=$full;Hash=(Get-WelaSelectedSaclHash @([Convert]::ToBase64String($bytes)));Plan=$plan} } @@ -198,17 +203,26 @@ function Invoke-WelaSelectedSacl { Assert-WelaSelectedSaclSources $sources foreach($id in $Ids){if(@($catalog.Rows | Where-Object Id -ceq $id).Count -ne 1){throw "Unknown/stale target ID: $id"}} $rows=@(foreach($item in $catalog.Rows){if(-not $selected.ContainsKey($item.Id)){continue} - $row=[pscustomobject]@{Id=$item.Id;DefinitionKey=$item.DefinitionKey;Definition=$item.Definition;Before=$null;Ace=$null;Status='Blocked';Diagnostic='';After=$null} + $row=[pscustomobject]@{Id=$item.Id;DefinitionKey=$item.DefinitionKey;Definition=$item.Definition;Before=$null;Ace=$null;Status='Blocked';Diagnostic='';After=$null;DescendantsBefore=$null;DescendantsAfter=$null;DescendantVerification=$null} try { $row.Before=Get-WelaSelectedSaclSnapshot $item.Definition $row.Ace=Get-WelaSelectedSaclAce $item.Definition $row.Before -IncludeChildren:$IncludeChildren Assert-WelaSelectedSaclPrerequisites $item.Definition $row.Ace + if($IncludeChildren -and ($row.Before.Kind -eq 'Registry' -or $row.Before.IsDirectory)){ + $row.DescendantsBefore=Get-WelaSelectedSaclStableDescendants $item.Definition $row.Before + if($row.DescendantsBefore.Status -ne 'Complete'){throw ('Descendant capture incomplete: '+($row.DescendantsBefore.Diagnostics -join '; '))} + } if($imported){ $old=@($prior.Rows | Where-Object Id -ceq $item.Id)[0] + if($row.DescendantsBefore -and (Get-WelaSelectedSaclDescendantKey $old.DescendantsBefore) -cne (Get-WelaSelectedSaclDescendantKey $row.DescendantsBefore)){throw 'Reviewed descendants changed; review a new plan.'} if($old.DefinitionKey -cne $item.DefinitionKey -or (Get-WelaSelectedSaclSnapshotKey $old.Before) -cne (Get-WelaSelectedSaclSnapshotKey $row.Before) -or $old.Ace.Sid -cne $row.Ace.Sid -or $old.Ace.Mask -ne $row.Ace.Mask -or $old.Ace.Flags -ne $row.Ace.Flags -or $old.Ace.RequiredPolicyMask -ne $row.Ace.RequiredPolicyMask){throw 'Reviewed target definition/identity/descriptor changed; review a new plan.'} } $row.Status=if(Test-WelaSelectedSaclAce $row.Before $row.Ace){'AlreadyCompliant'}else{'ChangeRequired'} + if($row.DescendantsBefore -and $row.Status -eq 'AlreadyCompliant'){ + $row.DescendantVerification=Test-WelaSelectedSaclDescendantOutcomes $row.DescendantsBefore $row.DescendantsBefore $row.Ace + if($row.DescendantVerification.Status -ne 'Observed'){$row.Status='Blocked';throw 'Selected root already has its ACE, but reviewed descendant inheritance is unverified. No duplicate root ACE is added.'} + } } catch {$row.Diagnostic=$_.Exception.Message} $row }) @@ -223,6 +237,16 @@ function Invoke-WelaSelectedSacl { $physical[$key].Status='Blocked';$physical[$key].Diagnostic=$row.Diagnostic }else{$physical[$key]=$row} } + foreach($ancestor in $rows){ + if(-not $ancestor.DescendantsBefore){continue} + foreach($child in $rows){ + if($child -eq $ancestor -or -not $child.Before -or $child.Before.Kind -cne $ancestor.Before.Kind){continue} + if($child.Before.Path.StartsWith($ancestor.Before.Path.TrimEnd('\')+'\',[StringComparison]::OrdinalIgnoreCase)){ + $ancestor.Status='Blocked';$child.Status='Blocked' + $ancestor.Diagnostic='Selected ancestor and descendant overlap. Configure one root and review a fresh plan before selecting another.';$child.Diagnostic=$ancestor.Diagnostic + } + } + } $plan=[pscustomobject]@{SchemaVersion=1;Kind='WelaSelectedSaclPlan';CapturedUtc=[DateTime]::UtcNow.ToString('o');Profile=$Profile;IncludeOptional=[bool]$IncludeOptional;IncludeChildren=[bool]$IncludeChildren;Context=$context;Sources=$sources;Rows=$rows;GenerationReadiness='Conditional';UsableRuleCredit=0;Catalog=$(if(-not $Ids){$catalog.Rows}else{@()});UserInventory=$catalog.UserInventory} Assert-WelaSelectedSaclRun $plan $imported if($Action -ne 'Configure'){if($output){Write-WelaSelectedSaclJson $output $plan};return $plan} @@ -237,7 +261,7 @@ function Invoke-WelaSelectedSacl { $null=New-Item -ItemType Directory -Path $backup -ErrorAction Stop } foreach($row in $rows){ - if($row.Status -eq 'AlreadyCompliant'){$row.After=$row.Before;continue} + if($row.Status -eq 'AlreadyCompliant'){$row.After=$row.Before;$row.DescendantsAfter=$row.DescendantsBefore;continue} if($DryRun){$row.Status='Skipped';$row.Diagnostic='Dry run; no SACL or recovery file written.';continue} if(-not $Auto -and (Read-Host "Add the selected audit ACE to $($row.Definition.Path)? (y/N)") -cnotin @('y','Y')){$row.Status='Skipped';$row.Diagnostic='Declined.';continue} try { @@ -245,13 +269,32 @@ function Invoke-WelaSelectedSacl { Assert-WelaSelectedSaclPrerequisites $row.Definition $row.Ace $fresh=Get-WelaSelectedSaclSnapshot $row.Definition if($fresh.Identity -cne $row.Before.Identity -or $fresh.DescriptorBase64 -cne $row.Before.DescriptorBase64){throw 'Target changed before journal/write.'} - $receipt=[pscustomobject]@{SchemaVersion=1;Kind='WelaSelectedSaclReceipt';State='Pending';RecordedUtc=[DateTime]::UtcNow.ToString('o');Computer=$context.Computer;ContextKey=$context.Key;Id=$row.Id;Sources=$sources;Definition=$row.Definition;Before=$fresh;Ace=$row.Ace;After=$null} + if($row.DescendantsBefore){ + $freshChildren=Get-WelaSelectedSaclStableDescendants $row.Definition $fresh + if((Get-WelaSelectedSaclDescendantKey $freshChildren) -cne (Get-WelaSelectedSaclDescendantKey $row.DescendantsBefore)){throw 'Descendants changed before journal/write.'} + } + $receipt=[pscustomobject]@{SchemaVersion=1;Kind='WelaSelectedSaclReceipt';State='Pending';RecordedUtc=[DateTime]::UtcNow.ToString('o');Computer=$context.Computer;ContextKey=$context.Key;Id=$row.Id;Sources=$sources;Definition=$row.Definition;Before=$fresh;Ace=$row.Ace;After=$null;DescendantsBefore=$row.DescendantsBefore;DescendantsAfter=$null;DescendantVerification=$null;Ownership='Only the verified explicit selected-root addition; never descendant ACE ownership or bulk rollback authority.'} Write-WelaSelectedSaclJson (Join-Path $backup ($row.Id+'.pending.json')) $receipt Assert-WelaSelectedSaclRun $plan $imported Assert-WelaSelectedSaclPrerequisites $row.Definition $row.Ace - $row.After=Write-WelaSelectedSaclNative $row.Definition $fresh $row.Ace - Assert-WelaSelectedSaclPreserved $fresh $row.After $row.Ace - $receipt.State='Confirmed';$receipt.After=$row.After + if($row.DescendantsBefore){ + $lastChildren=Get-WelaSelectedSaclStableDescendants $row.Definition (Get-WelaSelectedSaclSnapshot $row.Definition) + if((Get-WelaSelectedSaclDescendantKey $lastChildren) -cne (Get-WelaSelectedSaclDescendantKey $row.DescendantsBefore)){throw 'Descendants changed after pending receipt; native write refused.'} + } + try { + $row.After=Write-WelaSelectedSaclNative $row.Definition $fresh $row.Ace + Assert-WelaSelectedSaclPreserved $fresh $row.After $row.Ace + } finally { + if($row.DescendantsBefore){ + try { + $row.DescendantsAfter=Get-WelaSelectedSaclStableDescendants $row.Definition (Get-WelaSelectedSaclSnapshot $row.Definition) + $row.DescendantVerification=Test-WelaSelectedSaclDescendantOutcomes $row.DescendantsBefore $row.DescendantsAfter $row.Ace + }catch{$row.DescendantVerification=[pscustomobject]@{Status='Unverified';Diagnostics=@($_.Exception.Message);Ownership='No descendant ownership or automatic rollback authority.'}} + Write-WelaSelectedSaclJson (Join-Path $backup ($row.Id+'.descendants-observed.json')) ([pscustomobject]@{Kind='WelaSelectedSaclDescendantObservation';RecordedUtc=[DateTime]::UtcNow.ToString('o');Id=$row.Id;After=$row.DescendantsAfter;Verification=$row.DescendantVerification}) + } + } + if($row.DescendantVerification -and $row.DescendantVerification.Status -ne 'Observed'){throw ('Descendant preservation/propagation unverified: '+($row.DescendantVerification.Diagnostics -join '; '))} + $receipt.State='Confirmed';$receipt.After=$row.After;$receipt.DescendantsAfter=$row.DescendantsAfter;$receipt.DescendantVerification=$row.DescendantVerification Write-WelaSelectedSaclJson (Join-Path $backup ($row.Id+'.confirmed.json')) $receipt $row.Status='Applied' }catch{$row.Status='Failed';$row.Diagnostic=$_.Exception.Message} @@ -261,6 +304,10 @@ function Invoke-WelaSelectedSacl { Assert-WelaSelectedSaclRun $plan $imported;Assert-WelaSelectedSaclPrerequisites $row.Definition $row.Ace $fresh=Get-WelaSelectedSaclSnapshot $row.Definition if($fresh.Identity -cne $row.After.Identity -or $fresh.DescriptorBase64 -cne $row.After.DescriptorBase64){throw 'Final selected target state drifted.'} + if($row.DescendantsAfter){ + $finalChildren=Get-WelaSelectedSaclStableDescendants $row.Definition $fresh + if((Get-WelaSelectedSaclDescendantKey $finalChildren) -cne (Get-WelaSelectedSaclDescendantKey $row.DescendantsAfter)){throw 'Final descendant membership, identity or descriptor drifted; earlier receipts describe an earlier moment only.'} + } }catch{$row.Status='Failed';$row.Diagnostic=$_.Exception.Message} } $report=[pscustomobject]@{SchemaVersion=1;Kind='WelaSelectedSaclResult';ExitCode=$(if(@($rows | Where-Object Status -eq 'Failed').Count){1}else{0});DryRun=[bool]$DryRun;BackupPath=$backup;Plan=$plan;Results=$rows;GenerationReadiness='Conditional';UsableRuleCredit=0} diff --git a/scripts/SelectedSaclDescendants.ps1 b/scripts/SelectedSaclDescendants.ps1 new file mode 100644 index 00000000..f26e9113 --- /dev/null +++ b/scripts/SelectedSaclDescendants.ps1 @@ -0,0 +1,122 @@ +# Bounded observations only. Descendants are never supplied to the native writer. +function Get-WelaSelectedSaclChildNames { + param($Definition,$Snapshot,[int]$Maximum) + $path=Resolve-WelaSelectedSaclNativePath $Definition + Initialize-WelaSelectedSaclNative + $privilege=New-Object Wela.SelectedSacl.Privilege;$target=$null + try { + $target=New-Object Wela.SelectedSacl.Target($Definition.Kind,$path,$true) + $before=$target.Read() + if((Get-WelaSelectedSaclSnapshotKey $before) -cne (Get-WelaSelectedSaclSnapshotKey $Snapshot)){throw 'Container changed before enumeration.'} + $children=$target.Enumerate($Maximum) + if((Get-WelaSelectedSaclSnapshotKey ($target.Read())) -cne (Get-WelaSelectedSaclSnapshotKey $before)){throw 'Container changed during enumeration.'} + $children + } finally {if($target){$target.Dispose()};$privilege.Dispose()} +} +function New-WelaSelectedSaclChildDefinition { + param([string]$Kind,[string]$Path) + [pscustomobject]@{Kind=$Kind;Path=$(if($Kind -eq 'Registry'){'Registry::'+$Path}else{$Path});Resolution='Resolved'} +} +function Get-WelaSelectedSaclDescendantKey { + param($Inventory) + if($null -eq $Inventory -or $Inventory.Status -cne 'Complete' -or $Inventory.Maximum -ne 128 -or $Inventory.MaximumDepth -ne 16 -or $Inventory.Entries -isnot [array] -or $Inventory.Entries.Count -gt 128){throw 'Descendant capture is incomplete or has unknown limits; review a new plan.'} + $fields=@('128','16',(Get-WelaSelectedSaclSnapshotKey $Inventory.Root)) + foreach($entry in $Inventory.Entries){ + if($entry.ProtectedBarrier -isnot [bool] -or $entry.Depth -lt 1 -or $entry.Depth -gt 16 -or $entry.Path -cne $entry.Snapshot.Path){throw 'Malformed descendant evidence.'} + $fields+=@($entry.Path,$entry.ParentPath,[string]$entry.Depth,[string]$entry.ProtectedBarrier,(Get-WelaSelectedSaclSnapshotKey $entry.Snapshot)) + } + Get-WelaSelectedSaclHash $fields +} +function Get-WelaSelectedSaclDescendants { + param($Definition,$RootSnapshot) + $entries=New-Object 'System.Collections.Generic.List[object]' + $diagnostics=New-Object 'System.Collections.Generic.List[string]' + $queue=New-Object 'System.Collections.Generic.Queue[object]' + $queue.Enqueue([pscustomobject]@{Definition=$Definition;Snapshot=$RootSnapshot;Depth=0;ProtectedBarrier=$false}) + $seen=New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase) + $null=$seen.Add($RootSnapshot.Path) + $identities=New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::Ordinal) + if($RootSnapshot.Kind -eq 'FileSystem'){$null=$identities.Add($RootSnapshot.Identity)} + $started=[DateTime]::UtcNow;$bytes=0 + try { + while($queue.Count){ + if(([DateTime]::UtcNow-$started).TotalSeconds -gt 30){throw 'Descendant scan time budget exceeded (individual native reads are not cancellable).'} + $parent=$queue.Dequeue() + if($parent.Snapshot.Kind -ne 'Registry' -and -not $parent.Snapshot.IsDirectory){continue} + $remaining=128-$entries.Count + $children=Get-WelaSelectedSaclChildNames $parent.Definition $parent.Snapshot ([Math]::Max(1,$remaining)) + if($children.Truncated -or @($children.Names).Count -gt $remaining){throw 'Descendant count exceeds the reviewed maximum of 128.'} + if($parent.Depth -ge 16 -and @($children.Names).Count){throw 'Descendant depth exceeds the reviewed maximum of 16.'} + foreach($name in $children.Names){ + if([string]::IsNullOrEmpty($name) -or $name -in @('.','..') -or $name -match '[\\/\x00-\x1f]' -or ($parent.Snapshot.Kind -eq 'FileSystem' -and $name -match '[:*?<>|]|[ .]$')){throw 'Ambiguous native descendant name.'} + $path=$parent.Snapshot.Path.TrimEnd('\')+'\'+$name + if(-not $seen.Add($path)){throw 'Duplicate descendant path during enumeration.'} + $childDefinition=New-WelaSelectedSaclChildDefinition $Definition.Kind $path + $snapshot=Get-WelaSelectedSaclSnapshot $childDefinition + if($snapshot.Path -ine $path -or $snapshot.Kind -cne $Definition.Kind){throw 'Descendant snapshot does not identify the enumerated child.'} + $null=Get-WelaSelectedSaclSnapshotKey $snapshot + if($snapshot.Kind -eq 'FileSystem' -and -not $identities.Add($snapshot.Identity)){throw 'Repeated file identity (hard link/alias) prevents unique descendant attribution.'} + $bytes+=[Text.Encoding]::UTF8.GetByteCount(($snapshot|ConvertTo-Json -Depth 12 -Compress)) + if($bytes -gt 2097152){throw 'Descendant snapshot evidence exceeds 2 MiB.'} + $barrier=$parent.ProtectedBarrier -or (($snapshot.ControlFlags -band 8192) -ne 0) + $entry=[pscustomobject]@{Path=$path;ParentPath=$parent.Snapshot.Path;Depth=$parent.Depth+1;ProtectedBarrier=[bool]$barrier;Snapshot=$snapshot} + $entries.Add($entry) + $queue.Enqueue([pscustomobject]@{Definition=$childDefinition;Snapshot=$snapshot;Depth=$entry.Depth;ProtectedBarrier=[bool]$barrier}) + } + } + # A second pass by the caller verifies membership and descriptor stability. + }catch{$diagnostics.Add($_.Exception.Message)} + [pscustomobject]@{Status=$(if($diagnostics.Count){'Incomplete'}else{'Complete'});Maximum=128;MaximumDepth=16;StartedUtc=$started.ToString('o');CompletedUtc=[DateTime]::UtcNow.ToString('o');Root=$RootSnapshot;Entries=@($entries.ToArray());Diagnostics=@($diagnostics.ToArray())} +} +function Get-WelaSelectedSaclStableDescendants { + param($Definition,$Snapshot) + $first=Get-WelaSelectedSaclDescendants $Definition $Snapshot + if($first.Status -ne 'Complete'){return $first} + $fresh=Get-WelaSelectedSaclSnapshot $Definition + $second=Get-WelaSelectedSaclDescendants $Definition $fresh + if($second.Status -eq 'Complete' -and (Get-WelaSelectedSaclDescendantKey $first) -cne (Get-WelaSelectedSaclDescendantKey $second)){ + $second.Status='Incomplete';$second.Diagnostics=@('Descendant membership, identity or descriptor changed between captures.') + } + $second +} +function Assert-WelaSelectedSaclDescendantPreservation { + param($Before,$After,[bool]$Protected) + if($Before.Kind -cne $After.Kind -or $Before.Path -cne $After.Path -or $Before.IsDirectory -ne $After.IsDirectory -or $Before.SecurityInformation -ne $After.SecurityInformation -or $Before.DescriptorScope -cne $After.DescriptorScope){throw 'Child identity/type or descriptor scope changed.'} + # Registry identity incorporates last-write time and therefore can change as part of an ACL update. + if($Before.Kind -eq 'FileSystem' -and $Before.Identity -cne $After.Identity){throw 'Child file identity changed.'} + if($Before.Owner -cne $After.Owner -or $Before.Group -cne $After.Group -or $Before.DaclBase64 -cne $After.DaclBase64 -or ($Before.ControlFlags -band (-bnot 2576)) -ne ($After.ControlFlags -band (-bnot 2576))){throw 'Child owner/group/DACL/protection or non-SACL controls changed.'} + if($Protected -and $Before.DescriptorBase64 -cne $After.DescriptorBase64){throw 'Protected child or protected subtree descriptor changed.'} + $counts=New-Object 'System.Collections.Generic.Dictionary[string,int]' ([StringComparer]::Ordinal) + foreach($entry in $After.Aces){if(-not $counts.ContainsKey($entry.Binary)){$counts[$entry.Binary]=0};$counts[$entry.Binary]++} + foreach($entry in $Before.Aces){if(-not $counts.ContainsKey($entry.Binary) -or $counts[$entry.Binary] -lt 1){throw 'Original child audit/unknown ACE changed or disappeared.'};$counts[$entry.Binary]--} + # Arbitrary new explicit/unknown ACEs cannot be attributed to inheritance. + foreach($entry in $After.Aces){if($counts[$entry.Binary] -gt 0 -and (-not $entry.Ordinary -or $entry.Type -ne 2 -or ($entry.Flags -band 16) -eq 0)){throw 'Unexplained explicit or unknown child ACE appeared.'}} +} +function Test-WelaSelectedSaclDescendantOutcomes { + param($Before,$After,$Ace) + $outcomes=New-Object 'System.Collections.Generic.List[object]' + $diagnostics=New-Object 'System.Collections.Generic.List[string]' + if($After.Status -ne 'Complete'){$diagnostics.Add('After-state inventory is incomplete: '+($After.Diagnostics -join '; '))} + $map=@{};foreach($entry in $After.Entries){$map[$entry.Path]=$entry} + foreach($entry in $Before.Entries){ + $status='Unverified';$message='';$actual=$null + try { + if(-not $map.ContainsKey($entry.Path)){throw 'Reviewed descendant disappeared or could not be observed.'} + $actual=$map[$entry.Path];$map.Remove($entry.Path) + if($actual.ParentPath -cne $entry.ParentPath -or $actual.Depth -ne $entry.Depth -or $actual.ProtectedBarrier -ne $entry.ProtectedBarrier){throw 'Child topology or inheritance protection changed.'} + Assert-WelaSelectedSaclDescendantPreservation $entry.Snapshot $actual.Snapshot $entry.ProtectedBarrier + if($entry.ProtectedBarrier){$status='ProtectedUnchanged'} + elseif(($Ace.Flags -band 3) -eq 0){$status='PreservedWithoutRequestedInheritance'} + else { + $flags=($Ace.Flags -band 192) -bor 16 + if($actual.Snapshot.Kind -eq 'Registry' -or $actual.Snapshot.IsDirectory){$flags=$flags -bor ($Ace.Flags -band 3)} + $expected=[pscustomobject]@{Sid=$Ace.Sid;Mask=$Ace.Mask;Flags=$flags} + if(-not (Test-WelaSelectedSaclAce $actual.Snapshot $expected)){throw 'Requested inherited audit ACE was not observed; propagation may be incomplete or blocked.'} + $status='InheritedAceObserved' + } + }catch{$message=$_.Exception.Message;$diagnostics.Add($entry.Path+': '+$message)} + $outcomes.Add([pscustomobject]@{Path=$entry.Path;Status=$status;Diagnostic=$message;Before=$entry.Snapshot;After=$(if($actual){$actual.Snapshot}else{$null})}) + } + foreach($entry in $map.Values){$outcomes.Add([pscustomobject]@{Path=$entry.Path;Status='NewUnreviewedChild';Diagnostic='Child appeared after the reviewed snapshot; no pre-write backup or ownership established.';Before=$null;After=$entry.Snapshot});$diagnostics.Add('New unreviewed descendant: '+$entry.Path)} + [pscustomobject]@{Status=$(if($diagnostics.Count){'Unverified'}else{'Observed'});Scope='Reviewed existing descendants at the recorded observations only; propagation is non-atomic. Registry recreation between post-write observations cannot be excluded by last-write metadata.';Ownership='No descendant ACE ownership or automatic rollback authority.';Outcomes=@($outcomes.ToArray());Diagnostics=@($diagnostics.ToArray())} +} diff --git a/scripts/SelectedSaclNative.cs b/scripts/SelectedSaclNative.cs index 0a26ae17..35bd07b4 100644 --- a/scripts/SelectedSaclNative.cs +++ b/scripts/SelectedSaclNative.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.IO; using System.Runtime.InteropServices; using System.Security.AccessControl; using System.Security.Principal; @@ -15,6 +16,7 @@ namespace Wela.SelectedSacl { public string DescriptorBase64; public string Owner; public string Group; public string DaclBase64; public int ControlFlags; public int SecurityInformation; public string DescriptorScope; public Ace[] Aces; } + public sealed class Children { public string[] Names; public bool Truncated; } public sealed class Privilege : IDisposable { [StructLayout(LayoutKind.Sequential)] struct Luid { public uint Low; public int High; } [StructLayout(LayoutKind.Sequential)] struct TokenPrivileges { public uint Count; public Luid Luid; public uint Attributes; } @@ -54,12 +56,14 @@ namespace Wela.SelectedSacl { [DllImport("advapi32.dll",CharSet=CharSet.Unicode)] static extern int RegOpenKeyEx(IntPtr key,string path,uint options,uint access,out IntPtr opened); [DllImport("advapi32.dll",CharSet=CharSet.Unicode)] static extern int RegQueryValueEx(IntPtr key,string name,IntPtr reserved,out uint type,IntPtr data,ref uint size); [DllImport("advapi32.dll",CharSet=CharSet.Unicode)] static extern int RegQueryInfoKey(IntPtr key,IntPtr cls,IntPtr clsSize,IntPtr reserved,IntPtr subKeys,IntPtr maxSubKey,IntPtr maxClass,IntPtr values,IntPtr maxValueName,IntPtr maxValue,IntPtr securitySize,out long written); + [DllImport("advapi32.dll",CharSet=CharSet.Unicode)] static extern int RegEnumKeyEx(IntPtr key,uint index,StringBuilder name,ref uint length,IntPtr reserved,IntPtr cls,IntPtr clsLength,IntPtr written); [DllImport("advapi32.dll")] static extern int RegCloseKey(IntPtr key); [DllImport("advapi32.dll")] static extern uint GetSecurityInfo(IntPtr handle,uint kind,uint flags,out IntPtr owner,out IntPtr group,out IntPtr dacl,out IntPtr sacl,out IntPtr descriptor); [DllImport("advapi32.dll")] static extern uint GetSecurityDescriptorLength(IntPtr descriptor); [DllImport("advapi32.dll")] static extern uint SetSecurityInfo(IntPtr handle,uint kind,uint flags,IntPtr owner,IntPtr group,IntPtr dacl,IntPtr sacl); IntPtr handle;readonly List keys=new List();readonly string path;readonly string kind;readonly uint objectType; - public Target(string kind,string path) { + public Target(string kind,string path) : this(kind,path,false) {} + public Target(string kind,string path,bool enumerate) { this.kind=kind;this.path=path;objectType=kind=="FileSystem"?1U:4U; try { if(kind=="FileSystem") { @@ -78,7 +82,7 @@ namespace Wela.SelectedSacl { else throw new InvalidOperationException("Only explicitly selected HKLM/HKU keys are supported."); if(parts.Length<2)throw new InvalidOperationException("A registry hive root cannot be selected."); for(int i=1;i129)throw new ArgumentOutOfRangeException("maximum"); + if(handle==IntPtr.Zero)throw new ObjectDisposedException("Target"); + List names=new List();bool truncated=false; + if(kind=="Registry") { + for(uint index=0;;index++) { + StringBuilder name=new StringBuilder(256);uint length=256; + int error=RegEnumKeyEx(handle,index,name,ref length,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero); + if(error==259)break;if(error!=0)throw new Win32Exception(error,"Registry child enumeration failed."); + if(names.Count==maximum){truncated=true;break;}names.Add(name.ToString()); + } + } else { + // The verified parent handle remains open without DELETE sharing during enumeration. + foreach(string entry in Directory.EnumerateFileSystemEntries(path)) { + if(names.Count==maximum){truncated=true;break;}names.Add(System.IO.Path.GetFileName(entry)); + } + } + HashSet seen=new HashSet(StringComparer.OrdinalIgnoreCase); + foreach(string name in names)if(String.IsNullOrEmpty(name)||name=="."||name==".."||name.IndexOfAny(new char[]{'\\','/','\0'})>=0||!seen.Add(name))throw new InvalidOperationException("Ambiguous or duplicate child name."); + names.Sort(StringComparer.OrdinalIgnoreCase);return new Children {Names=names.ToArray(),Truncated=truncated}; + } static string Bytes(GenericAcl acl){if(acl==null)return null;byte[] bytes=new byte[acl.BinaryLength];acl.GetBinaryForm(bytes,0);return Convert.ToBase64String(bytes);} static string Bytes(GenericAce ace){byte[] bytes=new byte[ace.BinaryLength];ace.GetBinaryForm(bytes,0);return Convert.ToBase64String(bytes);} public Snapshot Read() { diff --git a/tests/SelectedSacl.Tests.ps1 b/tests/SelectedSacl.Tests.ps1 index 6b5635c8..8d3aa6ca 100644 --- a/tests/SelectedSacl.Tests.ps1 +++ b/tests/SelectedSacl.Tests.ps1 @@ -41,6 +41,7 @@ function Get-WelaSelectedSaclSnapshot { if($script:scenario -eq 'read-denied'){throw 'Selected descriptor access denied'} Clone $script:states[$Definition.Path] } +function Get-WelaSelectedSaclChildNames {param($Definition,$Snapshot,$Maximum) [pscustomobject]@{Names=@();Truncated=$false}} function Write-WelaSelectedSaclNative { param($Definition,$Before,$Ace) $script:writes++ diff --git a/tests/SelectedSaclDescendants.Tests.ps1 b/tests/SelectedSaclDescendants.Tests.ps1 new file mode 100644 index 00000000..4fac80c0 --- /dev/null +++ b/tests/SelectedSaclDescendants.Tests.ps1 @@ -0,0 +1,142 @@ +$ErrorActionPreference='Stop' +$root=Split-Path $PSScriptRoot -Parent +Import-Module (Join-Path $root 'modules/AuditProfiles.psm1') -Force +. (Join-Path $root 'scripts/SelectedSaclConfiguration.ps1') +$script:count=0 +function Assert($Condition,$Message){if(-not $Condition){throw $Message};$script:count++} +function Clone($Value){$Value|ConvertTo-Json -Depth 24|ConvertFrom-Json} +function Throws($Action,$Pattern){$message='';try{& $Action|Out-Null}catch{$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected $Pattern, got $message"} +function Snapshot($Path,[bool]$Directory=$false){ + [pscustomobject]@{SecurityInformation=511;DescriptorScope='WinSDK-defined sections 0x1ff; future sections unobserved';Path=$Path;Kind='FileSystem';Identity=$Path;IsDirectory=$Directory;DescriptorBase64=('before-'+$Path);Owner='S-1-5-18';Group='S-1-5-18';DaclBase64='dacl';ControlFlags=32788;Aces=@([pscustomobject]@{Binary='original';Type=17;Flags=0;Mask=0;Sid=$null;Ordinary=$false})} +} +$script:definition=[pscustomobject]@{Origin='fixture';Scope='files';UserSid=$null;Path='C:\owned';Kind='FileSystem';Resolution='Resolved';PrincipalSid='S-1-1-0';Propagation='None';Inheritance='ContainerInherit,ObjectInherit';Rights=@('ReadData');AuditFlags=@('Success');Policy='fixture';PolicyMode='minimum';PolicySelected=$true;RequiredPolicyMask=1} +$key=Get-WelaSelectedSaclDefinitionKey $script:definition;$script:id='sacl-'+$key.Substring(0,24) +function Get-WelaSelectedSaclContext {[pscustomobject]@{Computer='fixture';Role='MemberServer';Build=26100;Key='same-host'}} +function Get-WelaSelectedSaclCatalog {param($Profile,$IncludeOptional,$Context) [pscustomobject]@{Profile=$Profile;Rows=@([pscustomobject]@{Id=$script:id;DefinitionKey=(Get-WelaSelectedSaclDefinitionKey $script:definition);Definition=$script:definition});UserInventory=@()}} +function Assert-WelaSelectedSaclPrerequisites {} +$script:states=@{};$script:names=@{};$script:scenario='';$script:writes=0;$script:enumerations=0 +function Reset { + $script:states=@{};$script:names=@{};$script:scenario='';$script:writes=0;$script:enumerations=0 + $script:states['C:\owned']=Snapshot 'C:\owned' $true + $script:states['C:\owned\open']=Snapshot 'C:\owned\open' $true + $script:states['C:\owned\open\leaf']=Snapshot 'C:\owned\open\leaf' + $script:states['C:\owned\protected']=Snapshot 'C:\owned\protected' $true + $script:states['C:\owned\protected'].ControlFlags=32788 -bor 8192 + $script:states['C:\owned\protected\leaf']=Snapshot 'C:\owned\protected\leaf' + $script:names['C:\owned']=@('open','protected');$script:names['C:\owned\open']=@('leaf');$script:names['C:\owned\protected']=@('leaf') +} +function Get-WelaSelectedSaclSnapshot { + param($Definition) + if($Definition.Path -eq 'C:\owned\open\leaf' -and $script:scenario -eq 'after-pending-drift' -and (Test-Path (Join-Path $script:backup ($script:id+'.pending.json')))){$script:states[$Definition.Path].DescriptorBase64='changed';$script:scenario=''} + if($Definition.Path -eq 'C:\owned\open\leaf' -and $script:scenario -eq 'final-drift' -and (Test-Path (Join-Path $script:backup ($script:id+'.confirmed.json')))){$script:states[$Definition.Path].DescriptorBase64='changed';$script:scenario=''} + if($script:scenario -eq 'denied' -and $Definition.Path -eq 'C:\owned\open\leaf'){throw 'Native descendant access denied'} + if(-not $script:states.ContainsKey($Definition.Path)){throw 'Missing child'} + Clone $script:states[$Definition.Path] +} +function Get-WelaSelectedSaclChildNames { + param($Definition,$Snapshot,$Maximum) + $script:enumerations++ + if($script:scenario -eq 'reparse'){throw 'Reparse-point target refused'} + if($script:scenario -eq 'registry-link'){throw 'Registry symbolic-link component refused'} + if($script:scenario -eq 'capture-drift' -and $script:enumerations -eq 4){$script:states['C:\owned\open\leaf'].DescriptorBase64='changed'} + $children=@($script:names[$Definition.Path] | Where-Object {$null -ne $_}) + [pscustomobject]@{Names=@($children|Select-Object -First $Maximum);Truncated=($children.Count -gt $Maximum)} +} +function Add-Ace($Snapshot,$Ace,[bool]$Inherited){ + $flags=$Ace.Flags + if($Inherited){$flags=($Ace.Flags -band 192) -bor 16;if($Snapshot.IsDirectory -or $Snapshot.Kind -eq 'Registry'){$flags=$flags -bor ($Ace.Flags -band 3)}} + $Snapshot.Aces+=@([pscustomobject]@{Binary=('added-'+$flags);Type=2;Flags=$flags;Mask=$Ace.Mask;Sid=$Ace.Sid;Ordinary=$true}) + $Snapshot.DescriptorBase64='after-'+$Snapshot.DescriptorBase64 +} +function Write-WelaSelectedSaclNative { + param($Definition,$Before,$Ace) + $script:writes++ + $pending=Get-Content -LiteralPath (Join-Path $script:backup ($script:id+'.pending.json')) -Raw|ConvertFrom-Json + Assert ($pending.State -eq 'Pending' -and $pending.DescendantsBefore.Entries.Count -eq 4) 'Every reviewed child snapshot is durably recorded before root write.' + if($script:scenario -eq 'native-failure'){throw 'Native root write failed'} + Add-Ace $script:states['C:\owned'] $Ace $false + if($script:scenario -ne 'missing-inheritance'){ + Add-Ace $script:states['C:\owned\open'] $Ace $true + Add-Ace $script:states['C:\owned\open\leaf'] $Ace $true + } + switch($script:scenario){ + child-dacl {$script:states['C:\owned\open\leaf'].DaclBase64='changed'} + child-identity {$script:states['C:\owned\open\leaf'].Identity='replacement'} + child-ace-loss {$script:states['C:\owned\open\leaf'].Aces=@($script:states['C:\owned\open\leaf'].Aces|Where-Object Binary -ne 'original')} + protected-drift {$script:states['C:\owned\protected\leaf'].DescriptorBase64='changed'} + child-new {$script:names['C:\owned\open']+=@('new');$script:states['C:\owned\open\new']=Snapshot 'C:\owned\open\new'} + child-disappeared {$script:names['C:\owned\open']=@()} + after-denied {$script:scenario='denied'} + } + Clone $script:states['C:\owned'] +} +function Read-Host { + if($script:scenario -eq 'prompt-child-drift'){$script:states['C:\owned\open\leaf'].DescriptorBase64='changed'} + 'y' +} +$temp=Join-Path ([IO.Path]::GetTempPath()) ('wela-descendants-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $temp +function Review { + Reset + $script:planPath=Join-Path $temp ([guid]::NewGuid().ToString('N')+'.json');$script:backup=Join-Path $temp ([guid]::NewGuid().ToString('N')) + Invoke-WelaSelectedSacl -Action Plan -Profile fixture -Ids $script:id -IncludeChildren -ResultsPath $script:planPath +} +function Apply([switch]$DryRun){Invoke-WelaSelectedSacl -Action Configure -PlanPath $script:planPath -Ids $script:id -IncludeChildren -BackupPath $script:backup -DryRun:$DryRun} +try { + $plan=Review + Assert ($plan.Rows[0].Status -eq 'ChangeRequired' -and $plan.Rows[0].DescendantsBefore.Entries.Count -eq 4 -and $script:writes -eq 0) 'Complete plan captures populated tree without native writes.' + Assert (@($plan.Rows[0].DescendantsBefore.Entries|Where-Object ProtectedBarrier).Count -eq 2) 'Protection propagates as an observation barrier to the protected subtree.' + $result=Apply -DryRun + Assert ($result.Results[0].Status -eq 'Skipped' -and -not(Test-Path $script:backup)) 'Descendant review does not weaken DryRun.' + $result=Apply + Assert ($result.ExitCode -eq 0 -and $result.Results[0].Status -eq 'Applied') 'Root addition with observed inheritance and preserved protected children succeeds.' + Assert (@($result.Results[0].DescendantVerification.Outcomes|Where-Object Status -eq 'InheritedAceObserved').Count -eq 2) 'Directory and leaf inheritance are separately observed.' + Assert (@($result.Results[0].DescendantVerification.Outcomes|Where-Object Status -eq 'ProtectedUnchanged').Count -eq 2) 'Protected descendants remain unchanged and are never called inherited coverage.' + $receipt=Get-Content (Join-Path $script:backup ($script:id+'.confirmed.json')) -Raw|ConvertFrom-Json + Assert ($receipt.DescendantVerification.Status -eq 'Observed' -and $receipt.Ownership -match 'never descendant') 'Confirmed root receipt explicitly excludes child ownership.' + $script:planPath=Join-Path $temp 'again.json';$script:backup=Join-Path $temp 'again-backup' + $null=Invoke-WelaSelectedSacl -Action Plan -Profile fixture -Ids $script:id -IncludeChildren -ResultsPath $script:planPath + $result=Apply + Assert ($result.Results[0].Status -eq 'AlreadyCompliant' -and $script:writes -eq 1) 'Reviewed populated-tree rerun adds no duplicate root or child ACE.' + foreach($case in @('denied','reparse','registry-link','capture-drift')){ + Reset;$script:scenario=$case + $capture=Get-WelaSelectedSaclStableDescendants $script:definition (Get-WelaSelectedSaclSnapshot $script:definition) + Assert ($capture.Status -eq 'Incomplete') "$case cannot be a complete descendant inventory." + } + Reset;$script:names['C:\owned']=@(1..129|ForEach-Object{"child$_"}) + $capture=Get-WelaSelectedSaclStableDescendants $script:definition (Get-WelaSelectedSaclSnapshot $script:definition) + Assert ($capture.Status -eq 'Incomplete' -and ($capture.Diagnostics -join '') -match '128') 'Count cap blocks rather than silently truncating coverage.' + Reset;$path='C:\owned';$script:names=@{} + foreach($i in 1..17){$script:names[$path]=@('deep');$path+='\deep';$script:states[$path]=Snapshot $path $true} + $capture=Get-WelaSelectedSaclStableDescendants $script:definition (Get-WelaSelectedSaclSnapshot $script:definition) + Assert ($capture.Status -eq 'Incomplete' -and ($capture.Diagnostics -join '') -match 'depth') 'Depth cap is explicit and blocks writes.' + Reset;$script:states['C:\owned\open\leaf'].Identity=$script:states['C:\owned\protected\leaf'].Identity + $capture=Get-WelaSelectedSaclStableDescendants $script:definition (Get-WelaSelectedSaclSnapshot $script:definition) + Assert ($capture.Status -eq 'Incomplete' -and ($capture.Diagnostics -join '') -match 'identity') 'Hard-link aliases cannot be called unique verified descendants.' + Reset;$script:states['C:\owned\open\leaf'].DescriptorBase64='x'*2097153 + $capture=Get-WelaSelectedSaclStableDescendants $script:definition (Get-WelaSelectedSaclSnapshot $script:definition) + Assert ($capture.Status -eq 'Incomplete' -and ($capture.Diagnostics -join '') -match '2 MiB') 'Snapshot evidence cap cannot truncate backups silently.' + $child=New-WelaSelectedSaclChildDefinition Registry 'HKEY_USERS\S-1-5-21-1\owned\child' + Assert ($child.Path -ceq 'Registry::HKEY_USERS\S-1-5-21-1\owned\child') 'Enumerated native registry paths keep an explicit provider boundary.' + Throws {Resolve-WelaSelectedSaclNativePath (New-WelaSelectedSaclChildDefinition Registry 'HKEY_USERS')} 'canonical existing HKLM/HKU' + Assert ((Resolve-WelaSelectedSaclNativePath $child) -ceq 'HKEY_USERS\S-1-5-21-1\owned\child') 'Native registry path validation does not perform a provider lookup that could follow a registry link.' + foreach($case in @('child-dacl','child-identity','child-ace-loss','protected-drift','child-new','child-disappeared','missing-inheritance','after-denied','native-failure')){ + $null=Review;$script:scenario=$case;$result=Apply + Assert ($result.ExitCode -eq 1 -and $result.Results[0].Status -eq 'Failed') "$case fails without claiming complete propagation." + Assert ((Test-Path (Join-Path $script:backup ($script:id+'.pending.json'))) -and -not(Test-Path (Join-Path $script:backup ($script:id+'.confirmed.json')))) "$case retains Pending evidence without confirmed root/child ownership." + } + $null=Review;$script:scenario='prompt-child-drift';$result=Apply + Assert ($result.ExitCode -eq 1 -and $script:writes -eq 0 -and -not(Test-Path (Join-Path $script:backup ($script:id+'.pending.json')))) 'Fresh child race after review/prompt refuses root mutation.' + $null=Review;$script:scenario='after-pending-drift';$result=Apply + Assert ($result.ExitCode -eq 1 -and $script:writes -eq 0 -and (Test-Path (Join-Path $script:backup ($script:id+'.pending.json'))) -and -not(Test-Path (Join-Path $script:backup ($script:id+'.confirmed.json')))) 'Race after Pending backup cannot authorize a native write or Confirmed ownership.' + $null=Review;$script:scenario='final-drift';$result=Apply + Assert ($result.ExitCode -eq 1 -and $result.Results[0].Diagnostic -match 'Final descendant' -and (Test-Path (Join-Path $script:backup ($script:id+'.confirmed.json')))) 'Final child drift fails the run despite an earlier confirmed observation.' + $null=Review;$script:states['C:\owned\open\leaf'].Identity='replaced' + Throws {Apply} 'preflight failed' + Assert ($script:writes -eq 0 -and -not(Test-Path $script:backup)) 'Changed child identity blocks the whole preflight before journal creation.' + $null=Review;$before=Get-WelaSelectedSaclSnapshot $script:definition;$ace=Get-WelaSelectedSaclAce $script:definition $before -IncludeChildren + Add-Ace $script:states['C:\owned'] $ace $false + $incomplete=Invoke-WelaSelectedSacl -Action Plan -Profile fixture -Ids $script:id -IncludeChildren + Assert ($incomplete.Rows[0].Status -eq 'Blocked' -and $incomplete.Rows[0].Diagnostic -match 'already has') 'Compliant root with missing child inheritance never produces a false AlreadyCompliant claim or duplicate write.' + Write-Host "PASS: $script:count descendant SACL fixture assertions; all native reads and writes mocked." +}finally{Remove-Item -LiteralPath $temp -Recurse -Force} +$global:LASTEXITCODE=0 diff --git a/tests/SelectedSaclDescendants.Windows.Tests.ps1 b/tests/SelectedSaclDescendants.Windows.Tests.ps1 new file mode 100644 index 00000000..4564056e --- /dev/null +++ b/tests/SelectedSaclDescendants.Windows.Tests.ps1 @@ -0,0 +1,95 @@ +param([switch]$AllowDisposableSaclWrite) +$ErrorActionPreference='Stop' +if(-not $AllowDisposableSaclWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'This mutating fixture requires explicit opt-in on a disposable GitHub-hosted Windows runner.'} +$root=Split-Path $PSScriptRoot -Parent +Import-Module (Join-Path $root 'modules/AuditProfiles.psm1') -Force +. (Join-Path $root 'scripts/Configuration.ps1') +. (Join-Path $root 'scripts/TargetedSaclPlanning.ps1') +. (Join-Path $root 'scripts/SelectedSaclConfiguration.ps1') +$script:count=0 +function Assert($Condition,$Message){if(-not $Condition){throw $Message};$script:count++} +function Fingerprint($Map){(@($Map.Keys|Sort-Object|ForEach-Object{"$_=$($Map[$_])"}) -join ';')} +$beforePolicy=Get-WelaEffectiveAuditPolicy +$precedencePath='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' +$beforePrecedence=Get-WelaRegistryState $precedencePath SCENoApplyLegacyAuditPolicy +$privilegeBefore=(Invoke-WelaNative whoami.exe @('/priv','/fo','csv')).Diagnostic +$nonce=[guid]::NewGuid().ToString('N');$temp=Join-Path ([IO.Path]::GetTempPath()) ('wela-sacl-'+$nonce) +$regSub='Software\WELASelectedSacl_'+$nonce;$regProvider='HKCU:\'+$regSub +$file=Join-Path $temp 'probe.txt';$sid=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value +$policyGuids=@('0CCE921D-69AE-11D9-BED3-505054503030','0CCE921E-69AE-11D9-BED3-505054503030') +$restored=$false +try { + $null=New-Item -ItemType Directory -Path $temp + $null=New-Item -Path $regProvider + Set-ItemProperty -LiteralPath $precedencePath -Name SCENoApplyLegacyAuditPolicy -Value 1 -Type DWord + foreach($guid in $policyGuids){Set-WelaEffectiveAuditPolicy -Guid $guid -Mask 1 -Mode minimum} + $fileTree=Join-Path $temp 'tree';$null=New-Item -ItemType Directory $fileTree + $regTree=Join-Path $regProvider 'Tree';$null=New-Item -Path $regTree + Add-Type -Path (Join-Path $PSScriptRoot 'SelectedSaclFixtureProtection.cs') -ErrorAction Stop + foreach($tree in @($fileTree,$regTree)){ + if($tree -eq $fileTree){$null=New-Item -ItemType Directory (Join-Path $tree 'open');$null=New-Item -ItemType Directory (Join-Path $tree 'protected')} + else{$null=New-Item -Path (Join-Path $tree 'open');$null=New-Item -Path (Join-Path $tree 'protected')} + # Fixture setup changes protection only on an owned object. Production never changes it. + $protected=Join-Path $tree 'protected' + $protectedDefinition=if($tree -eq $fileTree){[pscustomobject]@{Kind='FileSystem';Path=$protected;Resolution='Resolved'}}else{[pscustomobject]@{Kind='Registry';Path=('Registry::HKEY_USERS\'+$sid+'\'+$regSub+'\Tree\protected');Resolution='Resolved'}} + Write-Host ("Preparing owned native SACL protection for "+$protectedDefinition.Kind+": "+$protectedDefinition.Path) + $protectedBefore=Get-WelaSelectedSaclSnapshot $protectedDefinition + Initialize-WelaSelectedSaclNative;$privilege=New-Object Wela.SelectedSacl.Privilege + try {[Wela.SelectedSaclFixture.Protection]::Protect($protectedBefore.Kind,$protectedBefore.Path,$protectedBefore.DescriptorBase64,$nonce)}finally{$privilege.Dispose()} + $protectedAfter=Get-WelaSelectedSaclSnapshot $protectedDefinition + Assert (($protectedAfter.ControlFlags -band 8192) -ne 0 -and $protectedBefore.Owner -ceq $protectedAfter.Owner -and $protectedBefore.Group -ceq $protectedAfter.Group -and $protectedBefore.DaclBase64 -ceq $protectedAfter.DaclBase64) 'Native owned fixture setup sets SACL protection while preserving owner/group/DACL.' + foreach($branch in @('open','protected')){ + if($tree -eq $fileTree){[IO.File]::WriteAllText((Join-Path (Join-Path $tree $branch) 'leaf.txt'),'owned descendant fixture')} + else{$null=New-Item -Path (Join-Path (Join-Path $tree $branch) 'Leaf')} + } + } + $definitions=@( + [pscustomobject]@{Path=$fileTree;Kind='FileSystem';Resolution='Resolved';PrincipalSid='S-1-1-0';Propagation='None';Inheritance='ContainerInherit,ObjectInherit';Rights=@('ReadData');AuditFlags=@('Success');PolicyMode='minimum';PolicySelected=$true;RequiredPolicyMask=1}, + [pscustomobject]@{Path=('Registry::HKEY_USERS\'+$sid+'\'+$regSub+'\Tree');Kind='Registry';Resolution='Resolved';PrincipalSid='S-1-1-0';Propagation='None';Inheritance='ContainerInherit';Rights=@('SetValue');AuditFlags=@('Success');PolicyMode='minimum';PolicySelected=$true;RequiredPolicyMask=1} + ) + foreach($definition in $definitions){ + $before=Get-WelaSelectedSaclSnapshot $definition + $ace=Get-WelaSelectedSaclAce $definition $before -IncludeChildren + Assert-WelaSelectedSaclPrerequisites $definition $ace + $children=Get-WelaSelectedSaclStableDescendants $definition $before + Assert ($children.Status -eq 'Complete' -and $children.Entries.Count -eq 4) ('Native populated '+$definition.Kind+' enumeration captures all four existing children: '+($children.Diagnostics -join '; ')) + Assert (@($children.Entries|Where-Object ProtectedBarrier).Count -eq 2) 'Native SACL protection marks the protected object and its subtree.' + $journal=Join-Path $temp ($definition.Kind+'.pending.json') + Write-WelaSelectedSaclJson $journal ([pscustomobject]@{State='Pending';Before=$before;DescendantsBefore=$children;Ace=$ace}) + $saved=Get-Content -LiteralPath $journal -Raw|ConvertFrom-Json + Assert ($saved.DescendantsBefore.Entries.Count -eq 4 -and $saved.Before.DescriptorBase64 -ceq $before.DescriptorBase64) 'Actual complete parent/child backup exists before native root mutation.' + $fresh=Get-WelaSelectedSaclStableDescendants $definition (Get-WelaSelectedSaclSnapshot $definition) + Assert ((Get-WelaSelectedSaclDescendantKey $fresh) -ceq (Get-WelaSelectedSaclDescendantKey $children)) 'Native child pre-write snapshots remain stable.' + $after=Write-WelaSelectedSaclNative $definition $before $ace + Assert-WelaSelectedSaclPreserved $before $after $ace + $afterChildren=Get-WelaSelectedSaclStableDescendants $definition $after + $outcomes=Test-WelaSelectedSaclDescendantOutcomes $children $afterChildren $ace + if($outcomes.Status -ne 'Observed'){Write-Host ($outcomes|ConvertTo-Json -Depth 20)} + Assert ($outcomes.Status -eq 'Observed') 'Real native inheritance preserves all reviewed child owner/group/DACL/original ACEs/protection.' + Assert (@($outcomes.Outcomes|Where-Object Status -eq 'InheritedAceObserved').Count -eq 2) 'Actual inherited requested audit ACE appears on unprotected child container and leaf.' + Assert (@($outcomes.Outcomes|Where-Object Status -eq 'ProtectedUnchanged').Count -eq 2) 'Protected child and its descendant retain exact descriptors without inherited coverage claims.' + $again=Get-WelaSelectedSaclStableDescendants $definition (Get-WelaSelectedSaclSnapshot $definition) + Assert ((Get-WelaSelectedSaclDescendantKey $again) -ceq (Get-WelaSelectedSaclDescendantKey $afterChildren)) 'Actual final descendant membership and descriptor state is stable.' + Assert ((Test-WelaSelectedSaclAce $again.Root $ace) -and (Test-WelaSelectedSaclDescendantOutcomes $again $again $ace).Status -eq 'Observed') 'Native idempotence inputs verify root and all reviewed inheritance without another write.' + if($definition.Kind -eq 'FileSystem'){[IO.File]::WriteAllText((Join-Path $fileTree 'appeared.txt'),'owned new child')} + else{$null=New-Item -Path (Join-Path $regTree 'Appeared')} + $appeared=Get-WelaSelectedSaclStableDescendants $definition (Get-WelaSelectedSaclSnapshot $definition) + $changed=Test-WelaSelectedSaclDescendantOutcomes $again $appeared $ace + Assert ($changed.Status -eq 'Unverified' -and @($changed.Outcomes|Where-Object Status -eq 'NewUnreviewedChild').Count -eq 1) 'New actual child is unreviewed even when Windows inherited a matching audit ACE.' + Write-Host ('PASS: actual '+$definition.Kind+' populated-tree inheritance, protected-subtree preservation and final snapshots; no child ownership or future coverage claim.') + } + Assert ((Invoke-WelaNative whoami.exe @('/priv','/fo','csv')).Diagnostic -ceq $privilegeBefore) 'All native enumeration, snapshot, setup and writer operations restore process privilege state.' + Write-Host "PASS: $script:count actual descendant SACL assertions on owned disposable populated trees." +} finally { + foreach($guid in $policyGuids){Set-WelaEffectiveAuditPolicy -Guid $guid -Mask $beforePolicy[$guid] -Mode exact} + if($beforePrecedence.ValueExists){Set-ItemProperty -LiteralPath $precedencePath -Name SCENoApplyLegacyAuditPolicy -Type $beforePrecedence.Type -Value $beforePrecedence.Value} + else{Remove-ItemProperty -LiteralPath $precedencePath -Name SCENoApplyLegacyAuditPolicy -ErrorAction SilentlyContinue} + $afterPolicy=Get-WelaEffectiveAuditPolicy;$afterPrecedence=Get-WelaRegistryState $precedencePath SCENoApplyLegacyAuditPolicy + if((Fingerprint $beforePolicy) -cne (Fingerprint $afterPolicy) -or ($beforePrecedence|ConvertTo-Json -Compress) -cne ($afterPrecedence|ConvertTo-Json -Compress)){throw "Fixture policy restoration failed; retain owned evidence at $temp and $regProvider."} + if(Test-Path -LiteralPath $regProvider){Remove-Item -LiteralPath $regProvider -Recurse -Force} + if(Test-Path -LiteralPath $temp){Remove-Item -LiteralPath $temp -Recurse -Force} + if((Test-Path -LiteralPath $regProvider) -or (Test-Path -LiteralPath $temp)){throw 'Owned fixture objects remain after cleanup.'} + $restored=$true + Write-Host 'PASS: all59 native audit masks and typed precedence restored; only owned disposable targets removed.' +} +$global:LASTEXITCODE=0 diff --git a/tests/SelectedSaclFixtureProtection.cs b/tests/SelectedSaclFixtureProtection.cs new file mode 100644 index 00000000..844b2000 --- /dev/null +++ b/tests/SelectedSaclFixtureProtection.cs @@ -0,0 +1,33 @@ +// Disposable fixture setup only. Never loaded by WELA production commands. +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Security.AccessControl; +namespace Wela.SelectedSaclFixture { + public static class Protection { + [DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern IntPtr CreateFile(string name,uint access,uint share,IntPtr security,uint disposition,uint flags,IntPtr template); + [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr handle); + [DllImport("advapi32.dll",CharSet=CharSet.Unicode)] static extern int RegOpenKeyEx(IntPtr parent,string name,uint options,uint access,out IntPtr handle); + [DllImport("advapi32.dll")] static extern int RegCloseKey(IntPtr key); + [DllImport("advapi32.dll")] static extern uint SetSecurityInfo(IntPtr handle,uint kind,uint flags,IntPtr owner,IntPtr group,IntPtr dacl,IntPtr sacl); + public static void Protect(string kind,string path,string descriptor,string nonce) { + if(Environment.GetEnvironmentVariable("GITHUB_ACTIONS")!="true"||Environment.GetEnvironmentVariable("RUNNER_ENVIRONMENT")!="github-hosted"||String.IsNullOrEmpty(nonce)||nonce.Length!=32||!path.Contains(nonce)||!path.EndsWith("\\protected",StringComparison.OrdinalIgnoreCase))throw new InvalidOperationException("Only explicitly owned disposable protected fixture objects are accepted."); + IntPtr handle=IntPtr.Zero,buffer=IntPtr.Zero;bool registry=kind=="Registry"; + try { + if(registry) { + if(!path.StartsWith("HKEY_USERS\\",StringComparison.Ordinal))throw new InvalidOperationException("Fixture must use its current HKU identity."); + int error=RegOpenKeyEx(new IntPtr(unchecked((int)0x80000003)),path.Substring(11),8,0x01020101,out handle); + if(error!=0)throw new Win32Exception(error,"Owned registry protection handle open failed."); + } else { + if(kind!="FileSystem")throw new InvalidOperationException("Unknown fixture kind."); + handle=CreateFile(path,0x01020000,3,IntPtr.Zero,3,0x02200000,IntPtr.Zero); + if(handle==new IntPtr(-1)){handle=IntPtr.Zero;throw new Win32Exception(Marshal.GetLastWin32Error());} + } + RawSecurityDescriptor sd=new RawSecurityDescriptor(Convert.FromBase64String(descriptor),0); + if(sd.SystemAcl!=null){byte[] bytes=new byte[sd.SystemAcl.BinaryLength];sd.SystemAcl.GetBinaryForm(bytes,0);buffer=Marshal.AllocHGlobal(bytes.Length);Marshal.Copy(bytes,0,buffer,bytes.Length);} + uint result=SetSecurityInfo(handle,registry?4U:1U,0x40000008,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,buffer); + if(result!=0)throw new Win32Exception((int)result,"Owned "+kind+" SetSecurityInfo(SACL|PROTECTED_SACL) failed."); + } finally {if(buffer!=IntPtr.Zero)Marshal.FreeHGlobal(buffer);if(handle!=IntPtr.Zero){if(registry)RegCloseKey(handle);else CloseHandle(handle);}} + } + } +} diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index 20b4f17d..b21b7c2a 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,6 +7,7 @@ **改善:** +- `targeted-sacl`で子孫への継承を明示的に許可した場合、件数・深さを制限した子孫一覧と記述子を計画・変更直前に照合し、変更前の記録、保護された子孫の保持、各オブジェクトの継承結果を確認するようにしました。上限超過、読み取り拒否、リンク、子孫の追加・消失・変更は処理を停止または失敗として記録し、親のみの既存動作は保持します。使い捨てファイル/レジストリ階層で継承と保護を検証し、子孫ACEの所有権、一括復旧、Sigma利用可能性は主張しません。 (#429) (@Shirofune-Security) - 固定のローカル名前空間読み取りを行う任意実行の `wmi-probe` を追加しました。実トークン・監査ポリシー・完全な SACL を観測し、WMI Security4662 を厳密に照合して、容量制限付きの非公開 XML とコードの指紋を記録します。本番の名前空間やポリシーは変更せず、Sigma の評価には加算しません。WMI 接続は明示的に管理するセキュリティ特権だけを使用し、意図しないスレッド特権の有効化を防ぎます。両 PowerShell エンジンの使い捨て Server 2022/2025 テストで実際のローカル 4662 と監査設定・名前空間の復元を確認しました。リモートアクセス、プロバイダー処理の成否、個々のクエリへの排他的な帰属は未検証です。 (#428) (@Shirofune-Security) - 正規バックアップと現在の WELA 監査コンポーネントを照合し、新規・無効・未リンクの GPO のみを作成する `gpo-create` の Review / Plan / Create を追加しました。実ファイルとネイティブレポートの厳密な検証、明示的なドメイン/書き込み可能 DC、変更しない保護付きバックアップコピー、永続 GUID 記録、内容・無効状態・権限・リンク・バージョンの直前/最終確認で既存ポリシーを保護します。Windows テストは Microsoft の固定バックアップの読み取りと対象外ポリシー/ワークグループの拒否を確認し、実 AD/SYSVOL への正常インポートとクライアント/イベントの受け入れ検証は別途必要です。適用や Sigma の有効性は主張しません。(#427) (@Shirofune-Security) - 既に無効なネイティブ購読のクエリと説明だけを変更する `wec-update` を追加。定義・実ホスト・コードの指紋、レビュー済み計画のハッシュ、永続レシート、直前確認と変更後の読み戻しにより、再作成や有効化をせずに変更を検証します。使い捨て Windows テストは実更新・復元と古い計画の拒否を確認します。稼働中ソースのブックマーク・配送・Sigma 準備状態は未検証です。 (#426) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index c61dbfdd..5cbe117b 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,6 +7,8 @@ **Improvements:** +- Extended explicit `targeted-sacl` child consent with bounded reviewed descendant inventories, fresh preflight/pre-write checks, durable child snapshots, protected-subtree preservation and per-child native inheritance outcomes. Caps, denials, links, new/disappeared children and drift block or fail the run; parent-only behavior stays unchanged. Disposable populated file/registry tests verify inheritance and protection without child-ACE ownership, bulk rollback or Sigma credit. (#429) (@Shirofune-Security) + - Added opt-in `wmi-probe` for a fixed local namespace read with observed token, audit-policy and full SACL context, exact WMI Security4662 correlation, bounded private raw XML and source fingerprints. Production makes no namespace/policy changes and grants no Sigma credit. WMI connections now use only the explicitly scoped security privilege, avoiding unintended thread privilege expansion. Disposable Server 2022/2025 tests under both PowerShell engines verify real local 4662 events and exact policy/namespace cleanup. Remote access, provider-operation success and exclusive query attribution remain unverified. (#428) (@Shirofune-Security) - Added opt-in `gpo-create` review, plan and new disabled/unlinked GPO creation from an exact genuine backup matched to current WELA audit components. Strict payload/native-report validation, explicit domain/writable-DC identity, protected unchanged backup copies, durable GUID receipts and fresh/final content, flags, permissions, link and version checks preserve existing policies. Native Windows tests read a pinned Microsoft backup and exercise broad-payload/workgroup refusal; positive AD/SYSVOL import and client/event acceptance remain pending, with no deployment or Sigma credit. (#427) (@Shirofune-Security) - Added `wec-update` to review and apply query/description changes to one already disabled native subscription through existing-only WEC handles. Complete definition/context/code fingerprints, a separately reviewed plan hash, durable receipts, fresh checks and preserved-property readback reject drift without recreation or activation. Disposable Windows tests cover actual updates/restoration and stale plans; active-source bookmarks, delivery and Sigma readiness remain unverified. (#426) (@Shirofune-Security)