Apply reviewed SACL plans to explicitly selected local targets (#422)

* Add reviewed configuration for selected native SACL targets

* Link selected SACL changelog to PR 422

* Identify native full-descriptor read failures without partial fallback

* Fix diagnostic variable scope in native C# helper

* Read explicit descriptor sections and retain observation scope
This commit is contained in:
田中ザック Isaac Mathis authored and GitHub committed 2026-09-20 19:36:05 +09:00
1 parent f1c1f74166
commit ff0e5c1890
14 files changed
+778 -4

No files matched your search

+268
View File
@@ -0,0 +1,268 @@
# Explicit selected, existing local targets. No audit-policy writes or hive loading.
function Get-WelaSelectedSaclHash {
param([string[]]$Values)
$encoding=New-Object Text.UTF8Encoding($false,$true)
$text=(@($Values | ForEach-Object {[Convert]::ToBase64String($encoding.GetBytes([string]$_))}) -join '|')
$sha=[Security.Cryptography.SHA256]::Create()
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')) {
[pscustomobject]@{Path=$path;Sha256=(Get-FileHash -LiteralPath (Join-Path $PSScriptRoot "../$path") -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()}
}
}
function Get-WelaSelectedSaclContext {
$role=Get-WelaHostContext; $detail=Get-WelaDefaultContext
if(-not (Test-WelaDefaultContextComplete $detail) -or $role.Build -ne $detail.Build){throw 'Complete consistent actual Windows context is required.'}
$matches=switch($role.Role){
Client {$detail.ProductType -eq 1 -and $detail.DomainRole -in @(0,1)}
MemberServer {$detail.ProductType -eq 3 -and $detail.DomainRole -in @(2,3)}
DomainController {$detail.ProductType -eq 2 -and $detail.DomainRole -in @(4,5)}
ADCS {$detail.ProductType -eq 3 -and $detail.DomainRole -in @(2,3) -and $detail.InstalledRoles -contains 'ADCS-Cert-Authority'}
default {$false}
}
if(-not $matches){throw 'Role contradicts detailed host context.'}
[pscustomobject]@{Computer=[Environment]::MachineName;Role=$role.Role;Build=$role.Build;Detail=$detail;Key=(Get-WelaSelectedSaclHash @([Environment]::MachineName,$role.Role,(Get-WelaDefaultContextKey $detail)))}
}
function Get-WelaSelectedSaclDefinitionKey {
param($Row)
Get-WelaSelectedSaclHash @($Row.Origin,$Row.Scope,$Row.UserSid,$Row.Path,$Row.Kind,$Row.PrincipalSid,($Row.AuditFlags -join ','),($Row.Rights -join ','),$Row.Inheritance,$Row.Propagation,$Row.Policy,$Row.PolicyMode,[string]$Row.PolicySelected,[string]$Row.RequiredPolicyMask)
}
function Get-WelaSelectedSaclSnapshotKey {
param($Snapshot)
if($null -eq $Snapshot -or $Snapshot.IsDirectory -isnot [bool] -or ($Snapshot.ControlFlags -isnot [int] -and $Snapshot.ControlFlags -isnot [long])){throw 'Malformed reviewed native snapshot.'}
if(($Snapshot.SecurityInformation -isnot [int] -and $Snapshot.SecurityInformation -isnot [long]) -or $Snapshot.SecurityInformation -ne 511 -or $Snapshot.DescriptorScope -cne 'WinSDK-defined sections 0x1ff; future sections unobserved'){throw 'Incomplete or unknown native descriptor observation scope.'}
$fields=@([string]$Snapshot.SecurityInformation,$Snapshot.DescriptorScope,$Snapshot.Path,$Snapshot.Kind,$Snapshot.Identity,[string]$Snapshot.IsDirectory,$Snapshot.DescriptorBase64,$Snapshot.Owner,$Snapshot.Group,$Snapshot.DaclBase64,[string]$Snapshot.ControlFlags)
foreach($ace in $Snapshot.Aces){$fields+=@($ace.Binary,[string]$ace.Type,[string]$ace.Flags,[string]$ace.Mask,$ace.Sid,[string]$ace.Ordinary)}
Get-WelaSelectedSaclHash $fields
}
function Get-WelaSelectedSaclCatalog {
param([string]$Profile,[switch]$IncludeOptional,$Context)
$current=Get-WelaEffectiveAuditPolicy
$plan=Get-WelaAuditProfilePlan -Profile $Profile -Role $Context.Role -Build $Context.Build -Current $current -IncludeOptional:$IncludeOptional
if($plan.referenceOnly){throw 'Reference-only defaults cannot select SACL configuration.'}
# User inventory/known-folder resolution is shared, but unselected object ACLs are not read.
$companion=Get-WelaTargetedSaclPlan -AuditPlan $plan -Live -SkipTargetObservation
$seen=@{}
$rows=@(foreach($row in $companion.Targets){
$key=Get-WelaSelectedSaclDefinitionKey $row; $id='sacl-'+$key.Substring(0,24)
if($seen.ContainsKey($id)){throw 'Duplicate selected SACL identity.'};$seen[$id]=$true
[pscustomobject]@{Id=$id;DefinitionKey=$key;Definition=$row}
})
[pscustomobject]@{Profile=$plan;Rows=$rows;UserInventory=$companion.UserInventory}
}
function Initialize-WelaSelectedSaclNative {
if($env:OS -ne 'Windows_NT' -or -not [Environment]::Is64BitProcess){throw 'Selected SACLs require native 64-bit Windows PowerShell.'}
if(-not ('Wela.SelectedSacl.Target' -as [type])) {Add-Type -Path (Join-Path $PSScriptRoot 'SelectedSaclNative.cs') -ErrorAction Stop}
}
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') {
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)
if($full -ine $Definition.Path){throw 'Filesystem target path must be canonical.'}
return $full
}
if($Definition.Kind -ne 'Registry'){throw 'Unsupported target kind.'}
$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
}
function Get-WelaSelectedSaclSnapshot {
param($Definition)
$path=Resolve-WelaSelectedSaclNativePath $Definition
Initialize-WelaSelectedSaclNative
$privilege=New-Object Wela.SelectedSacl.Privilege; $target=$null
try {$target=New-Object Wela.SelectedSacl.Target($Definition.Kind,$path);$target.Read()}
finally {if($target){$target.Dispose()};$privilege.Dispose()}
}
function Get-WelaSelectedSaclAce {
param($Definition,$Snapshot,[switch]$IncludeChildren)
if($Definition.PrincipalSid -notin @('S-1-1-0','S-1-5-11') -or $Definition.Propagation -ne 'None'){throw 'Unsupported catalog principal or propagation.'}
$maps=if($Definition.Kind -eq 'Registry') {@{QueryValues=1;SetValue=2;CreateSubKey=4;EnumerateSubKeys=8;Notify=16;Delete=65536;ReadPermissions=131072;ChangePermissions=262144;TakeOwnership=524288;ReadKey=131097;WriteKey=131078}} else {@{ReadData=1;WriteData=2;AppendData=4;ReadExtendedAttributes=8;WriteExtendedAttributes=16;ExecuteFile=32;DeleteSubdirectoriesAndFiles=64;ReadAttributes=128;WriteAttributes=256;Delete=65536;ReadPermissions=131072;ChangePermissions=262144;TakeOwnership=524288;Read=131209;Write=278;ReadAndExecute=131241;Modify=197055;FullControl=2032127;ListDirectory=1;CreateFiles=2;CreateDirectories=4;Traverse=32}}
$mask=0;foreach($right in $Definition.Rights){if(-not $maps.ContainsKey($right)){throw "Unsupported catalog audit right: $right"};$mask=$mask -bor $maps[$right]}
if(-not $mask){throw 'No audit rights selected.'}
$flags=0;$policyMask=0
foreach($flag in $Definition.AuditFlags){switch -Exact ($flag){Success {$flags=$flags -bor 64;$policyMask=$policyMask -bor 1} Failure {$flags=$flags -bor 128;$policyMask=$policyMask -bor 2} default {throw 'Unsupported audit outcome.'}}}
$inherit=$Definition.Inheritance -ne 'None' -and ($Definition.Kind -eq 'Registry' -or $Snapshot.IsDirectory)
$container=$Definition.Kind -eq 'Registry' -or $Snapshot.IsDirectory
$existingInheritance=$container -and @($Snapshot.Aces | Where-Object {($_.Flags -band 3) -ne 0}).Count -gt 0
if(($inherit -or $existingInheritance) -and -not $IncludeChildren){throw 'Source or existing SACL inheritance requires explicit -TargetSaclIncludeChildren consent; existing descendants can receive audit ACEs.'}
if($inherit){$flags=$flags -bor $(if($Definition.Kind -eq 'Registry'){2}else{3})}
[pscustomobject]@{Sid=$Definition.PrincipalSid;Mask=$mask;Flags=$flags;RequiredPolicyMask=$policyMask}
}
function Test-WelaSelectedSaclAce {
param($Snapshot,$Ace)
return @($Snapshot.Aces | Where-Object {$_.Ordinary -eq $true -and $_.Type -eq 2 -and $_.Sid -ceq $Ace.Sid -and $_.Flags -eq $Ace.Flags -and ($_.Mask -band $Ace.Mask) -eq $Ace.Mask}).Count -gt 0
}
function Assert-WelaSelectedSaclPreserved {
param($Before,$After,$Ace)
if($Before.SecurityInformation -ne $After.SecurityInformation -or $Before.DescriptorScope -cne $After.DescriptorScope){throw 'Native descriptor observation scope changed.'}
if($Before.Owner -cne $After.Owner -or $Before.Group -cne $After.Group -or $Before.DaclBase64 -cne $After.DaclBase64 -or ($Before.ControlFlags -band (-bnot 16)) -ne ($After.ControlFlags -band (-bnot 16))){throw 'Non-SACL descriptor components or control flags 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 'An original or unknown ACE changed or disappeared.'};$counts[$entry.Binary]--}
if(-not (Test-WelaSelectedSaclAce $After $Ace)){throw 'Requested audit ACE is absent after write.'}
}
function Assert-WelaSelectedSaclPrerequisites {
param($Definition,$Ace)
$precedence=Get-WelaAuditPrecedenceState
if(-not $precedence.Registry.ValueExists -or $precedence.Registry.Type -ne 'DWord' -or ($precedence.Registry.Value -isnot [int] -and $precedence.Registry.Value -isnot [long]) -or $precedence.Registry.Value -ne 1){throw 'Typed audit precedence DWORD=1 must already be effective; this command never enables it.'}
if($Definition.PolicyMode -eq 'not-applicable' -or ($Definition.PolicyMode -eq 'optional' -and -not $Definition.PolicySelected) -or ($Definition.PolicyMode -eq 'exact' -and $Definition.RequiredPolicyMask -eq 0)){throw 'Selected profile does not select this optional/applicable object-audit requirement.'}
$guid=if($Definition.Kind -eq 'Registry'){'0CCE921E-69AE-11D9-BED3-505054503030'}else{'0CCE921D-69AE-11D9-BED3-505054503030'}
$policies=Get-WelaEffectiveAuditPolicy;$value=$policies[$guid]
if(($value -isnot [int] -and $value -isnot [long]) -or $value -notin @(0,1,2,3) -or ($value -band $Ace.RequiredPolicyMask) -ne $Ace.RequiredPolicyMask){throw 'Required native object-audit outcomes are not already effective; no audit mask is changed.'}
}
function Write-WelaSelectedSaclNative {
param($Definition,$Before,$Ace)
$path=Resolve-WelaSelectedSaclNativePath $Definition
Initialize-WelaSelectedSaclNative
$privilege=New-Object Wela.SelectedSacl.Privilege;$target=$null
try {
$target=New-Object Wela.SelectedSacl.Target($Definition.Kind,$path)
$after=$target.Add($Before.Identity,$Before.DescriptorBase64,$Ace.Sid,$Ace.Mask,$Ace.Flags)
Assert-WelaSelectedSaclPreserved $Before $after $Ace
$after
} finally {if($target){$target.Dispose()};$privilege.Dispose()}
}
function Write-WelaSelectedSaclJson {
param([string]$Path,$Value)
$text=($Value | ConvertTo-Json -Depth 24 -Compress)+[Environment]::NewLine
$bytes=[Text.UTF8Encoding]::new($false).GetBytes($text)
$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()}
}
function Resolve-WelaSelectedSaclFilePath {
param([string]$Path)
$provider=$null;$drive=$null
$full=$ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path,[ref]$provider,[ref]$drive)
if($provider.Name -ne 'FileSystem'){throw 'A FileSystem path is required.'}
[IO.Path]::GetFullPath($full)
}
function Read-WelaSelectedSaclPlan {
param([string]$Path)
$full=Resolve-WelaSelectedSaclFilePath $Path
$file=Get-Item -LiteralPath $full -ErrorAction Stop
if($file -isnot [IO.FileInfo] -or $file.Length -lt 1 -or $file.Length -gt 4194304 -or ($file.Attributes -band [IO.FileAttributes]::ReparsePoint)){throw 'Plan must be a regular JSON file of at most 4 MiB.'}
$bytes=[IO.File]::ReadAllBytes($full);if($bytes.Length -gt 4194304){throw 'Plan size changed.'}
$encoding=New-Object Text.UTF8Encoding($false,$true)
$text=$encoding.GetString($bytes).TrimStart([char]0xFEFF)
$module=Get-Module AuditProfiles
$plan=& $module {param($Text) ConvertFrom-WelaCustomProfileJson $Text} $text
if($plan -isnot [pscustomobject] -or ($plan.SchemaVersion -isnot [int] -and $plan.SchemaVersion -isnot [long]) -or $plan.SchemaVersion -ne 1 -or $plan.Kind -cne 'WelaSelectedSaclPlan' -or $plan.IncludeChildren -isnot [bool] -or $plan.IncludeOptional -isnot [bool] -or $plan.Rows -isnot [array] -or -not $plan.Rows.Count -or $plan.Rows.Count -gt 100){throw 'Invalid selected SACL plan envelope.'}
foreach($property in $plan.PSObject.Properties){if($property.Name -cnotin @('SchemaVersion','Kind','CapturedUtc','Profile','IncludeOptional','IncludeChildren','Context','Sources','Rows','GenerationReadiness','UsableRuleCredit','Catalog','UserInventory')){throw 'Unknown selected SACL plan property.'}}
$seen=@{}
foreach($row in $plan.Rows){
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
}
[pscustomobject]@{Path=$full;Hash=(Get-WelaSelectedSaclHash @([Convert]::ToBase64String($bytes)));Plan=$plan}
}
function Assert-WelaSelectedSaclSources {
param($Expected)
$actual=@(Get-WelaSelectedSaclSources)
if(@($Expected).Count -ne $actual.Count){throw 'Plan source inventory differs.'}
for($i=0;$i -lt $actual.Count;$i++){if($Expected[$i].Path -cne $actual[$i].Path -or $Expected[$i].Sha256 -cne $actual[$i].Sha256){throw 'Plan/catalog source changed; generate a new plan.'}}
}
function Assert-WelaSelectedSaclRun {
param($Plan,$Imported)
Assert-WelaSelectedSaclSources $Plan.Sources
if((Get-WelaSelectedSaclContext).Key -cne $Plan.Context.Key){throw 'Actual host context changed or differs from plan.'}
if($Imported -and (Read-WelaSelectedSaclPlan $Imported.Path).Hash -cne $Imported.Hash){throw 'Selected plan file changed.'}
}
function Invoke-WelaSelectedSacl {
param([ValidateSet('Audit','Plan','Configure')][string]$Action='Audit',[string]$Profile,[string[]]$Ids,[string]$PlanPath,[switch]$IncludeOptional,[switch]$IncludeChildren,[switch]$DryRun,[switch]$Auto,[string]$BackupPath,[string]$ResultsPath)
if($DryRun -and $Action -ne 'Configure'){throw 'DryRun requires selected SACL Configure.'}
if($Action -ne 'Configure' -and ($PlanPath -or $Auto -or $BackupPath)){throw 'Plan input, Auto and BackupPath require Configure.'}
if($Action -in @('Plan','Configure') -and -not $Ids){throw 'Explicit nonempty target IDs are required.'}
$selected=@{};foreach($id in $Ids){if($id -cnotmatch '^sacl-[0-9a-f]{24}$' -or $selected.ContainsKey($id)){throw 'Invalid or duplicate selected target ID.'};$selected[$id]=$true}
$output=$null;if($ResultsPath){$output=Resolve-WelaSelectedSaclFilePath $ResultsPath;if(Test-Path -LiteralPath $output){throw 'Results must use a new file.'};if(-not (Test-Path -LiteralPath (Split-Path $output -Parent) -PathType Container)){throw 'Results parent must exist.'}}
$imported=$null
if($Action -eq 'Configure') {
if(-not $PlanPath){throw 'Configure requires a previously reviewed -TargetSaclPlanPath.'}
$imported=Read-WelaSelectedSaclPlan $PlanPath;$prior=$imported.Plan
Assert-WelaSelectedSaclSources $prior.Sources
if($Profile -and $Profile -cne $prior.Profile){throw 'Profile differs from the reviewed plan.'};$Profile=$prior.Profile
if([bool]$IncludeOptional -ne $prior.IncludeOptional -or [bool]$IncludeChildren -ne $prior.IncludeChildren){throw 'Optional/inheritance consent must match the plan explicitly.'}
if(@($prior.Rows).Count -ne $selected.Count -or @($prior.Rows | Where-Object {-not $selected.ContainsKey($_.Id)}).Count){throw 'Configure IDs must match exactly the reviewed plan selection.'}
}
if(-not $Profile){throw 'An explicit built-in -TargetSaclProfile is required for Audit/Plan.'}
$sources=@(Get-WelaSelectedSaclSources);$context=Get-WelaSelectedSaclContext
if($imported -and $context.Key -cne $prior.Context.Key){throw 'Plan belongs to a different actual host context.'}
$catalog=Get-WelaSelectedSaclCatalog -Profile $Profile -IncludeOptional:$IncludeOptional -Context $context
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}
try {
$row.Before=Get-WelaSelectedSaclSnapshot $item.Definition
$row.Ace=Get-WelaSelectedSaclAce $item.Definition $row.Before -IncludeChildren:$IncludeChildren
Assert-WelaSelectedSaclPrerequisites $item.Definition $row.Ace
if($imported){
$old=@($prior.Rows | Where-Object Id -ceq $item.Id)[0]
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'}
} catch {$row.Diagnostic=$_.Exception.Message}
$row
})
# Different source entries may identify the same physical key/file. Refuse a
# predictable partial apply: each must be reviewed again after the other write.
$physical=New-Object 'System.Collections.Generic.Dictionary[string,object]' ([StringComparer]::OrdinalIgnoreCase)
foreach($row in $rows){
if($null -eq $row.Before){continue}
$key=$row.Before.Kind+'|'+$row.Before.Path
if($physical.ContainsKey($key)){
$row.Status='Blocked';$row.Diagnostic='Multiple selected entries resolve to the same target. Configure one entry, then generate a fresh plan for the other.'
$physical[$key].Status='Blocked';$physical[$key].Diagnostic=$row.Diagnostic
}else{$physical[$key]=$row}
}
$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}
# Preflight the whole selected set before creating a journal or writing any target.
if(@($rows | Where-Object Status -eq 'Blocked').Count){throw ('Selected SACL preflight failed: '+(@($rows | Where-Object Status -eq 'Blocked' | ForEach-Object Diagnostic) -join '; '))}
$backup=$null
if(-not $DryRun){
if(-not $BackupPath){throw 'Configure requires an explicit new -BackupPath.'}
$backup=Resolve-WelaSelectedSaclFilePath $BackupPath
if(Test-Path -LiteralPath $backup){throw 'Backup directory must be new.'}
if(-not (Test-Path -LiteralPath (Split-Path $backup -Parent) -PathType Container)){throw 'Backup parent must exist.'}
$null=New-Item -ItemType Directory -Path $backup -ErrorAction Stop
}
foreach($row in $rows){
if($row.Status -eq 'AlreadyCompliant'){$row.After=$row.Before;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 {
Assert-WelaSelectedSaclRun $plan $imported
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}
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
Write-WelaSelectedSaclJson (Join-Path $backup ($row.Id+'.confirmed.json')) $receipt
$row.Status='Applied'
}catch{$row.Status='Failed';$row.Diagnostic=$_.Exception.Message}
}
foreach($row in $rows | Where-Object Status -in @('Applied','AlreadyCompliant')){
try {
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.'}
}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}
if($output){Write-WelaSelectedSaclJson $output $report};$report
}
+127
View File
@@ -0,0 +1,127 @@
// Handle-bound local file/registry SACL reads and additive audit writes only.
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Security.AccessControl;
using System.Security.Principal;
using System.Text;
namespace Wela.SelectedSacl {
public sealed class Ace {
public string Binary; public int Type; public int Flags; public int Mask; public string Sid; public bool Ordinary;
}
public sealed class Snapshot {
public string Path; public string Kind; public string Identity; public bool IsDirectory;
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 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; }
[DllImport("kernel32.dll")] static extern IntPtr GetCurrentProcess();
[DllImport("kernel32.dll")] static extern IntPtr GetCurrentThread();
[DllImport("kernel32.dll",SetLastError=true)] static extern bool CloseHandle(IntPtr value);
[DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenProcessToken(IntPtr process,uint access,out IntPtr token);
[DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenThreadToken(IntPtr thread,uint access,bool self,out IntPtr token);
[DllImport("advapi32.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern bool LookupPrivilegeValue(string system,string name,out Luid luid);
[DllImport("advapi32.dll",SetLastError=true)] static extern bool AdjustTokenPrivileges(IntPtr token,bool disable,ref TokenPrivileges value,uint size,out TokenPrivileges previous,out uint required);
IntPtr token; TokenPrivileges previous;
public Privilege() {
IntPtr threadToken;
if(OpenThreadToken(GetCurrentThread(),8,true,out threadToken)) {CloseHandle(threadToken);throw new InvalidOperationException("Impersonated callers are outside the selected-SACL workflow.");}
int error=Marshal.GetLastWin32Error();if(error!=1008)throw new Win32Exception(error,"Cannot establish absence of an impersonation token.");
if(!OpenProcessToken(GetCurrentProcess(),0x28,out token))throw new Win32Exception(Marshal.GetLastWin32Error());
try {
Luid luid;if(!LookupPrivilegeValue(null,"SeSecurityPrivilege",out luid))throw new Win32Exception(Marshal.GetLastWin32Error());
TokenPrivileges requested=new TokenPrivileges {Count=1,Luid=luid,Attributes=2};uint required;
bool ok=AdjustTokenPrivileges(token,false,ref requested,(uint)Marshal.SizeOf(typeof(TokenPrivileges)),out previous,out required);
error=Marshal.GetLastWin32Error();if(!ok||error!=0)throw new Win32Exception(error,"SeSecurityPrivilege is not available.");
}catch {CloseHandle(token);token=IntPtr.Zero;throw;}
}
public void Dispose() {
if(token==IntPtr.Zero)return;
try {TokenPrivileges ignored;uint required;bool ok=AdjustTokenPrivileges(token,false,ref previous,(uint)Marshal.SizeOf(typeof(TokenPrivileges)),out ignored,out required);int error=Marshal.GetLastWin32Error();if(!ok||error!=0)throw new Win32Exception(error,"SeSecurityPrivilege restoration failed.");}
finally {CloseHandle(token);token=IntPtr.Zero;}
}
}
public sealed class Target : IDisposable {
[StructLayout(LayoutKind.Sequential,Pack=4)] struct FileInfo {public uint Attributes;public long Created;public long Accessed;public long Written;public uint Volume;public uint SizeHigh;public uint SizeLow;public uint Links;public uint IndexHigh;public uint IndexLow;}
[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",SetLastError=true)] static extern bool CloseHandle(IntPtr value);
[DllImport("kernel32.dll",SetLastError=true)] static extern bool GetFileInformationByHandle(IntPtr handle,out FileInfo information);
[DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern uint GetFinalPathNameByHandle(IntPtr handle,StringBuilder path,uint size,uint flags);
[DllImport("kernel32.dll")] static extern IntPtr LocalFree(IntPtr value);
[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")] 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) {
this.kind=kind;this.path=path;objectType=kind=="FileSystem"?1U:4U;
try {
if(kind=="FileSystem") {
// No DELETE sharing: keep the opened object stable while reading/writing it.
handle=CreateFile(path,0x01020000,3,IntPtr.Zero,3,0x02200000,IntPtr.Zero);
if(handle==new IntPtr(-1)){handle=IntPtr.Zero;throw new Win32Exception(Marshal.GetLastWin32Error());}
FileInfo info;if(!GetFileInformationByHandle(handle,out info))throw new Win32Exception(Marshal.GetLastWin32Error());
if((info.Attributes&0x400)!=0)throw new InvalidOperationException("Reparse-point file target refused.");
StringBuilder final=new StringBuilder(32768);uint length=GetFinalPathNameByHandle(handle,final,(uint)final.Capacity,0);
if(length==0||length>=final.Capacity)throw new InvalidOperationException("Cannot verify final local file path.");
if(!String.Equals(final.ToString(),"\\\\?\\"+path,StringComparison.OrdinalIgnoreCase))throw new InvalidOperationException("Final handle path differs from selected path (link/redirection).");
} else if(kind=="Registry") {
string[] parts=path.Split('\\');IntPtr current;
if(parts[0]=="HKEY_LOCAL_MACHINE")current=new IntPtr(unchecked((int)0x80000002));
else if(parts[0]=="HKEY_USERS")current=new IntPtr(unchecked((int)0x80000003));
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.
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.");
if(error!=0&&error!=2)throw new Win32Exception(error,"Registry link state is unreadable.");
}
handle=current;
} else throw new InvalidOperationException("Unsupported selected target kind.");
}catch {Dispose();throw;}
}
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() {
if(handle==IntPtr.Zero)throw new ObjectDisposedException("Target");
// Explicit current SDK section union: owner/group/DACL/audit/label/resource/CAP/trust/access-filter.
// BACKUP_SECURITY_INFORMATION itself returns ACCESS_DENIED on the tested file handles;
// do not acquire broader privileges or silently retry with an incomplete section subset.
const uint securityInformation=0x000001ff;
IntPtr owner,group,dacl,sacl,descriptor;uint error=GetSecurityInfo(handle,objectType,securityInformation,out owner,out group,out dacl,out sacl,out descriptor);
if(error!=0)throw new Win32Exception((int)error,"GetSecurityInfo explicit sections 0x1ff failed for "+kind+" ("+error+").");
byte[] bytes;
try {uint length=GetSecurityDescriptorLength(descriptor);if(length<20||length>1048576)throw new InvalidOperationException("Invalid descriptor size.");bytes=new byte[length];Marshal.Copy(descriptor,bytes,0,(int)length);}
finally {LocalFree(descriptor);}
RawSecurityDescriptor sd=new RawSecurityDescriptor(bytes,0);List<Ace> entries=new List<Ace>();
if(sd.SystemAcl!=null)foreach(GenericAce ace in sd.SystemAcl) {
CommonAce common=ace as CommonAce;bool ordinary=common!=null&&!common.IsCallback&&common.AceType==AceType.SystemAudit;
entries.Add(new Ace {Binary=Bytes(ace),Type=(int)ace.AceType,Flags=(int)ace.AceFlags,Mask=ordinary?common.AccessMask:0,Sid=ordinary?common.SecurityIdentifier.Value:null,Ordinary=ordinary});
}
string identity;bool directory=false;
if(kind=="FileSystem") {FileInfo info;if(!GetFileInformationByHandle(handle,out info))throw new Win32Exception(Marshal.GetLastWin32Error());directory=(info.Attributes&16)!=0;identity=info.Volume+":"+info.IndexHigh+":"+info.IndexLow+":"+info.Created;}
else {long written;int result=RegQueryInfoKey(handle,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,out written);if(result!=0)throw new Win32Exception(result);identity=path+":"+written;}
return new Snapshot {SecurityInformation=(int)securityInformation,DescriptorScope="WinSDK-defined sections 0x1ff; future sections unobserved",Path=path,Kind=kind,Identity=identity,IsDirectory=directory,DescriptorBase64=Convert.ToBase64String(bytes),Owner=sd.Owner==null?null:sd.Owner.Value,Group=sd.Group==null?null:sd.Group.Value,DaclBase64=Bytes(sd.DiscretionaryAcl),ControlFlags=(int)sd.ControlFlags,Aces=entries.ToArray()};
}
public Snapshot Add(string expectedIdentity,string expectedDescriptor,string sid,int mask,int flags) {
Snapshot before=Read();if(before.Identity!=expectedIdentity||before.DescriptorBase64!=expectedDescriptor)throw new InvalidOperationException("Target changed after the recovery snapshot.");
if(mask<=0||(flags&~195)!=0||(flags&192)==0)throw new InvalidOperationException("Invalid selected audit ACE.");
RawSecurityDescriptor sd=new RawSecurityDescriptor(Convert.FromBase64String(before.DescriptorBase64),0);
RawAcl acl=sd.SystemAcl??new RawAcl(2,1);
acl.InsertAce(acl.Count,new CommonAce((AceFlags)flags,AceQualifier.SystemAudit,mask,new SecurityIdentifier(sid),false,null));
byte[] bytes=new byte[acl.BinaryLength];acl.GetBinaryForm(bytes,0);IntPtr buffer=Marshal.AllocHGlobal(bytes.Length);
try {Marshal.Copy(bytes,0,buffer,bytes.Length);uint error=SetSecurityInfo(handle,objectType,8,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,buffer);if(error!=0)throw new Win32Exception((int)error);}
finally {Marshal.FreeHGlobal(buffer);}
return Read();
}
public void Dispose(){if(kind=="FileSystem"&&handle!=IntPtr.Zero)CloseHandle(handle);for(int i=keys.Count-1;i>=0;i--)RegCloseKey(keys[i]);keys.Clear();handle=IntPtr.Zero;}
}
}
+4 -2
View File
@@ -89,7 +89,7 @@ function Resolve-WelaSaclUserFile {
}
function Get-WelaSaclTargetObservation {
param([string]$Path, [string]$Kind)
param([string]$Path, [string]$Kind, [switch]$SkipSaclRead)
if (-not $Path -or $Path -match '%[^%]+%') { return [pscustomobject]@{ PathState = 'Unknown'; SaclReadState = 'Unknown'; Diagnostic = 'Target path is unresolved.' } }
if ($Path.StartsWith('\\')) { return [pscustomobject]@{ PathState = 'RemoteNotInspected'; SaclReadState = 'Unknown'; Diagnostic = 'Network/redirected target requires assessment on the file server; planning does not authenticate to remote paths.' } }
try {
@@ -116,6 +116,7 @@ function Get-WelaSaclTargetObservation {
$state = if ($_.CategoryInfo.Category -eq 'ObjectNotFound') { 'Missing' } else { 'Inaccessible' }
return [pscustomobject]@{ PathState = $state; SaclReadState = 'Unknown'; Diagnostic = $_.Exception.Message }
}
if ($SkipSaclRead) { return [pscustomobject]@{ PathState = 'Exists'; SaclReadState = 'Not read'; Diagnostic = 'Path preflight only; selected SACL adapter reads security through its native handle.' } }
try {
$acl = Get-Acl -LiteralPath $Path -Audit -ErrorAction Stop
[pscustomobject]@{ PathState = 'Exists'; SaclReadState = 'Readable'; SaclProtected = $acl.AreAuditRulesProtected; Diagnostic = 'SACL can be read. ACE coverage, descendant inheritance and event generation have not been validated.' }
@@ -128,6 +129,7 @@ function Get-WelaTargetedSaclPlan {
[Parameter(Mandatory)]$AuditPlan,
[ValidateSet('Plan', 'Skip')][string]$Mode = 'Plan',
[switch]$Live,
[switch]$SkipTargetObservation,
[string]$TargetsPath = (Join-Path $PSScriptRoot '../config/audit_sacl_targets.json')
)
$definitions = Get-Content -LiteralPath $TargetsPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
@@ -163,7 +165,7 @@ function Get-WelaTargetedSaclPlan {
}
$observation = [pscustomobject]@{ PathState = 'Unknown'; SaclReadState = 'Unknown'; Diagnostic = $detail }
if ($Mode -eq 'Skip') { $observation = [pscustomobject]@{ PathState = 'Skipped'; SaclReadState = 'Unknown'; Diagnostic = 'Operator skipped target assessment; telemetry prerequisite remains unverified.' } }
elseif ($Live -and $resolution -in @('Resolved', 'Redirected')) { $observation = Get-WelaSaclTargetObservation -Path $path -Kind $kind }
elseif ($Live -and -not $SkipTargetObservation -and $resolution -in @('Resolved', 'Redirected')) { $observation = Get-WelaSaclTargetObservation -Path $path -Kind $kind }
elseif ($Live) { $observation = [pscustomobject]@{ PathState = $resolution; SaclReadState = 'Unknown'; Diagnostic = $detail } }
$selected = $policy.mode -in @('exact', 'minimum') -or ($policy.mode -eq 'optional' -and $AuditPlan.includeOptional)
$gap = if ($Mode -eq 'Skip') { 'SACL assessment explicitly skipped.' }