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

+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() {