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
This commit is contained in:
田中ザック Isaac Mathis authored and GitHub committed 2026-09-21 09:12:56 +09:00
1 parent b84b97b358
commit bcd4e9717e
15 files changed
+522 -17

No files matched your search

+1
View File
@@ -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
+12
View File
@@ -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
+1
View File
@@ -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)
+2
View File
@@ -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)
+1 -1
View File
@@ -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 }
+26 -5
View File
@@ -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 `<target-id>.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 `<target-id>.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 `<target-id>.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 `<target-id>.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).
+56 -9
View File
@@ -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}
+122
View File
@@ -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())}
}
+27 -2
View File
@@ -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<IntPtr> keys=new List<IntPtr>();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;i<parts.Length;i++) {
IntPtr opened;int error=RegOpenKeyEx(current,parts[i],8,0x01020101,out opened); // OPEN_LINK, 64-bit view, query/read-control/SACL.
IntPtr opened;int error=RegOpenKeyEx(current,parts[i],8,0x01020101U|((enumerate&&i==parts.Length-1)?8U:0U),out opened); // OPEN_LINK, 64-bit view, query/read-control/SACL.
if(error!=0)throw new Win32Exception(error);keys.Add(opened);current=opened;
uint type;uint size=0;error=RegQueryValueEx(current,"SymbolicLinkValue",IntPtr.Zero,out type,IntPtr.Zero,ref size);
if(error==0&&type==6)throw new InvalidOperationException("Registry symbolic-link component refused.");
@@ -88,6 +92,27 @@ namespace Wela.SelectedSacl {
} else throw new InvalidOperationException("Unsupported selected target kind.");
}catch {Dispose();throw;}
}
public Children Enumerate(int maximum) {
if(maximum<1||maximum>129)throw new ArgumentOutOfRangeException("maximum");
if(handle==IntPtr.Zero)throw new ObjectDisposedException("Target");
List<string> names=new List<string>();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<string> seen=new HashSet<string>(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() {
+1
View File
@@ -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++
+142
View File
@@ -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
@@ -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
+33
View File
@@ -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);}}
}
}
}
+1
View File
@@ -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)
+2
View File
@@ -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)