Guard targeted SACL planning paths and ignored skip options

This commit is contained in:
Shirofune-Security committed 2026-09-19 05:35:23 +09:00
1 parent 6489775d30
commit acde16f149
4 files changed
+117 -11

No files matched your search

+6
View File
@@ -1713,6 +1713,12 @@ Write-Host ""
Write-Host "WELA v$WELAVersion - $WELAReleaseName"
Write-Host ""
# SaclMode belongs only to the read-only profile companion plan. In particular,
# configure-sacl must never silently ignore an explicit request to Skip.
if ($PSBoundParameters.ContainsKey('SaclMode') -and
(-not $Profile -or $Cmd -notin @('plan', 'audit', 'audit-settings', 'configure'))) {
throw '-SaclMode requires -Profile with plan, audit, audit-settings or configure. It does not control configure-sacl. No command was run.'
}
# Reject unsupported dry-run requests before reaching any command's mutation path.
if ($DryRun -and $Cmd -notin @('configure', 'configure-eventlogs') -and
-not ($Cmd -eq 'firewall-logging' -and $FirewallAction -eq 'Configure') -and
+3 -1
View File
@@ -11,9 +11,11 @@
./WELA.ps1 configure -Profile wela-2.2.0 -SaclMode Skip -Auto -ResultsPath results.json
```
Explicit `-SaclMode` is accepted only with `-Profile` on plan/audit/audit-settings/configure; `configure-sacl -SaclMode Skip` is rejected before dispatch, because that separate command does not consume this plan.
No SACL is written by a profile command. Audit policy configuration retains its existing scope. `configure-sacl` remains a **separate, broader opt-in workflow**: it sets its own File System, Registry and Handle Manipulation policies and applies all existing WELA targets, including loading offline user hives. It does not consume this selected profile's plan. Review its scope before running it. This companion plan does not enable privileges, mount hives, install software or configure global object auditing.
On a matching local Windows role/build, the plan inventories ProfileList, Default and loaded HKU hives. Unloaded hives remain unresolved; it never silently substitutes the operator's user environment for another user's paths. Loaded-user Startup/AppData locations come from that user's unexpanded `User Shell Folders` values. Redirected, remote, missing, inaccessible and unresolved paths are explicit; UNC destinations are not contacted. Reparse points are flagged. Enumeration failures and unmatched loaded hives are retained as inventory diagnostics. An offline plan (or plan for a different role/build) inspects no host targets. `Skip` performs no user/target inspection.
On a matching local Windows role/build, the plan inventories ProfileList, Default and loaded HKU hives. Unloaded hives remain unresolved; it never silently substitutes the operator's user environment for another user's paths. Loaded-user Startup/AppData locations come from that user's unexpanded `User Shell Folders` values. Redirected, remote, missing, inaccessible and unresolved paths are explicit; UNC destinations are not contacted. Mapped network drives and reparse points in any path component are flagged before descendant or SACL inspection. These are point-in-time observations, not an atomic guard against concurrent path replacement. Enumeration failures, per-profile path errors and unmatched loaded hives make the inventory incomplete and remain visible as diagnostics. ProfileList paths expand only known machine variables; operator user variables are never substituted. An offline plan (or plan for a different role/build) inspects no host targets. `Skip` performs no user/target inspection.
`Exists` and `SaclReadState=Readable` mean only that the object and its SACL could be read. They do **not** prove the necessary audit ACE is present, inheritance reaches every descendant, mandatory/temporary profiles are covered, or a Security event was generated. All rows remain `GenerationReadiness=Conditional`, with zero usable-rule credit. A failure to read SACLs is reported separately from a missing path; elevated privileges may be necessary for those reads.
+41 -9
View File
@@ -1,5 +1,15 @@
# Read-only companion planning for the existing configure-sacl targets.
# Never loads offline hives, enables privileges, changes audit policy or writes ACLs.
function Expand-WelaSaclProfilePath {
param([string]$Path)
foreach ($token in [regex]::Matches($Path, '%([^%]+)%')) {
if ($token.Groups[1].Value -notin @('SystemDrive', 'SystemRoot', 'windir')) { throw 'Profile path contains a user-specific or unknown variable; operator values must not be substituted.' }
}
$expanded = [Environment]::ExpandEnvironmentVariables($Path)
if (-not $expanded -or $expanded -match '%[^%]+%' -or $expanded -notmatch '^(?:[A-Za-z]:\\|\\\\)') { throw 'Profile path is empty, unresolved or not absolute.' }
return $expanded
}
function Get-WelaSaclUserInventory {
$users = New-Object 'System.Collections.Generic.List[object]'
$diagnostics = New-Object 'System.Collections.Generic.List[string]'
@@ -18,15 +28,21 @@ function Get-WelaSaclUserInventory {
continue
}
$path = $null; $message = ''
try { $path = [Environment]::ExpandEnvironmentVariables([string](Get-ItemProperty -LiteralPath $key.PSPath -Name ProfileImagePath -ErrorAction Stop).ProfileImagePath) }
catch { $message = "Profile path unavailable: $($_.Exception.Message)" }
try {
$rawPath = [string](Get-ItemProperty -LiteralPath $key.PSPath -Name ProfileImagePath -ErrorAction Stop).ProfileImagePath
$path = Expand-WelaSaclProfilePath $rawPath
} catch {
$path = $null; $message = "Profile path unavailable: $($_.Exception.Message)"
$diagnostics.Add("$sid : $message")
}
$users.Add([pscustomobject]@{ Sid = $sid; ProfilePath = $path; HiveLoaded = $loaded.ContainsKey($sid); Diagnostic = $message })
$loaded.Remove($sid)
}
$default = [Environment]::ExpandEnvironmentVariables([string](Get-ItemProperty -LiteralPath $profileRoot -Name Default -ErrorAction Stop).Default)
$default = Expand-WelaSaclProfilePath ([string](Get-ItemProperty -LiteralPath $profileRoot -Name Default -ErrorAction Stop).Default)
$users.Add([pscustomobject]@{ Sid = 'Default'; ProfilePath = $default; HiveLoaded = $false; Diagnostic = 'Future-user template; hive is not loaded by planning.' })
} catch { $diagnostics.Add("Profile inventory incomplete: $($_.Exception.Message)") }
foreach ($sid in $loaded.Keys) {
$diagnostics.Add("Loaded hive $sid has no matching ProfileList entry; user file paths are unknown.")
$users.Add([pscustomobject]@{ Sid = $sid; ProfilePath = $null; HiveLoaded = $true; Diagnostic = 'Loaded hive has no matching ProfileList entry; user file paths are unknown.' })
}
[pscustomobject]@{ Users = @($users.ToArray()); Diagnostics = @($diagnostics.ToArray()); Complete = ($diagnostics.Count -eq 0) }
@@ -36,6 +52,7 @@ function Resolve-WelaSaclUserFile {
param($User, [string]$RelativePath)
# Resolve another user's known folders only from that user's loaded hive.
# Expanding the operator's APPDATA here would silently credit the wrong path.
$RelativePath = $RelativePath.Replace('\\', '\')
if (-not $User.HiveLoaded) { return [pscustomobject]@{ Path = $null; State = 'UnloadedHive'; Diagnostic = 'Known-folder redirection cannot be read without loading the user hive; no hive was loaded.' } }
if (-not $User.ProfilePath) { return [pscustomobject]@{ Path = $null; State = 'UnresolvedUserPath'; Diagnostic = 'Profile path is unavailable.' } }
try {
@@ -59,10 +76,25 @@ function Get-WelaSaclTargetObservation {
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 {
$item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop
if ($Kind -eq 'FileSystem' -and ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
return [pscustomobject]@{ PathState = 'ReparsePoint'; SaclReadState = 'Unknown'; Diagnostic = 'Reparse target requires separate assessment; its path is not credited.' }
}
if ($Kind -eq 'FileSystem') {
if ($Path -notmatch '^([A-Za-z]):\\') { return [pscustomobject]@{ PathState = 'Unknown'; SaclReadState = 'Unknown'; Diagnostic = 'Only absolute local drive paths are inspected.' } }
$drive = Get-PSDrive -Name $Matches[1] -PSProvider FileSystem -ErrorAction Stop
if ([string]$drive.DisplayRoot -like '\\*' -or [string]$drive.Root -like '\\*') {
return [pscustomobject]@{ PathState = 'RemoteNotInspected'; SaclReadState = 'Unknown'; Diagnostic = 'Mapped network drive is not inspected; no target path access was attempted.' }
}
$parts = @($Path.Substring(3) -split '\\' | Where-Object { $_ -ne '' })
if (@($parts | Where-Object { $_ -in @('.', '..') }).Count) { return [pscustomobject]@{ PathState = 'Unknown'; SaclReadState = 'Unknown'; Diagnostic = 'Dot segments require explicit path review before inspection.' } }
$checked = $Path.Substring(0, 3)
# Inspect each ancestor before resolving the next component. A leaf-only
# check can follow a junction/symlink into a remote share first.
for ($index = 0; $index -le $parts.Count; $index++) {
$item = Get-Item -LiteralPath $checked -Force -ErrorAction Stop
if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) {
return [pscustomobject]@{ PathState = 'ReparsePoint'; SaclReadState = 'Unknown'; Diagnostic = "Reparse component '$checked' requires separate assessment; descendants and SACL were not inspected." }
}
if ($index -lt $parts.Count) { $checked = $checked.TrimEnd('\') + '\' + $parts[$index] }
}
} else { $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop }
} catch {
$state = if ($_.CategoryInfo.Category -eq 'ObjectNotFound') { 'Missing' } else { 'Inaccessible' }
return [pscustomobject]@{ PathState = $state; SaclReadState = 'Unknown'; Diagnostic = $_.Exception.Message }
@@ -99,10 +131,10 @@ function Get-WelaTargetedSaclPlan {
foreach ($user in $instances) {
$resolution = 'Resolved'; $detail = ''; $path = $null
if ($section -eq 'user_registry') {
$path = "Registry::HKEY_USERS\$($user.Sid)\$($target.key)"
$path = "Registry::HKEY_USERS\$($user.Sid)\$(([string]$target.key).Replace('\\', '\'))"
if (-not $user.HiveLoaded) { $resolution = 'UnloadedHive'; $detail = 'User hive not loaded; planning never mounts NTUSER.DAT.' }
} elseif ($section -eq 'user_files') {
$path = "$($user.ProfilePath)\$($target.relpath)"
$path = "$($user.ProfilePath)\$(([string]$target.relpath).Replace('\\', '\'))"
if ($Live -and $Mode -eq 'Plan') {
$resolved = Resolve-WelaSaclUserFile -User $user -RelativePath $target.relpath
$resolution = $resolved.State; $detail = $resolved.Diagnostic
+67 -1
View File
@@ -62,8 +62,74 @@ $remote = Get-WelaSaclTargetObservation -Path '\\server\share\Startup' -Kind Fil
Assert ($remote.PathState -eq 'RemoteNotInspected') 'Read-only planning must not access remote known folders.'
$unloaded = Resolve-WelaSaclUserFile -User ([pscustomobject]@{HiveLoaded=$false}) -RelativePath 'AppData\Roaming\Signal'
Assert ($unloaded.State -eq 'UnloadedHive') 'Unloaded hive must not fall back to operator APPDATA.'
# Verify actual resolver against real catalog escaping, not a pre-normalized fixture.
$script:knownFolder = '%USERPROFILE%\AppData\Roaming'
$script:key = [pscustomobject]@{}
$script:key | Add-Member ScriptMethod GetValue { param($Name,$Default,$Options) if ($Options -ne [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) { throw 'Unsafe variable expansion mode' }; return $script:knownFolder }
function Get-Item { param($LiteralPath,[switch]$Force,$ErrorAction) return $script:key }
$definitions = Get-Content -LiteralPath (Join-Path $PSScriptRoot '../config/audit_sacl_targets.json') -Raw | ConvertFrom-Json
$user = [pscustomobject]@{Sid='S-1-5-21-1';ProfilePath='C:\Users\One';HiveLoaded=$true}
$signal = Resolve-WelaSaclUserFile -User $user -RelativePath $definitions.user_files[1].relpath
Assert ($signal.State -eq 'Resolved' -and $signal.Path -eq 'C:\Users\One\AppData\Roaming\Signal') 'Actual doubled-separator catalog path must not mislabel a default known folder as redirected.'
$script:knownFolder = '%USERPROFILE%\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup'
$startup = Resolve-WelaSaclUserFile -User $user -RelativePath $definitions.user_files[0].relpath
Assert ($startup.State -eq 'Resolved') 'Actual Startup catalog path normalizes before comparison.'
$script:knownFolder = '\\server\share\Startup'
Assert ((Resolve-WelaSaclUserFile -User $user -RelativePath $definitions.user_files[0].relpath).State -eq 'Redirected') 'Real redirected Startup remains distinguished.'
Assert (@($live.Targets | Where-Object { $_.Scope -eq 'user_registry' -and $_.Path -match '\\\\' }).Count -eq 0) 'User registry keys normalize catalog separators.'
# Failures inside an individual profile must affect global inventory completeness.
function Get-ChildItem {
param($LiteralPath,$ErrorAction)
if ($LiteralPath -eq 'Registry::HKEY_USERS') { return }
[pscustomobject]@{PSChildName='S-1-5-21-1';PSPath='Registry::profile-one'}
}
$script:profilePath = $null
function Get-ItemProperty {
param($LiteralPath,$Name,$ErrorAction)
if ($Name -eq 'Default') { return [pscustomobject]@{Default='C:\Users\Default'} }
if ($null -eq $script:profilePath) { throw 'Profile path denied' }
[pscustomobject]@{ProfileImagePath=$script:profilePath}
}
$inventory = Get-WelaSaclUserInventory
Assert (-not $inventory.Complete -and $inventory.Diagnostics.Count -gt 0 -and $inventory.Users[0].ProfilePath -eq $null) 'Unreadable per-user profile path must not yield complete inventory.'
$script:profilePath = '%USERPROFILE%\AnotherProfile'
$inventory = Get-WelaSaclUserInventory
Assert (-not $inventory.Complete -and $inventory.Users[0].ProfilePath -eq $null) 'ProfileList must not expand operator USERPROFILE for another user.'
$script:profilePath = 'C:\Users\One'
Assert (Get-WelaSaclUserInventory).Complete 'Known absolute profiles and Default form a complete inventory.'
Remove-Item Function:Get-ChildItem, Function:Get-ItemProperty
# Guard mapped drives and every ancestor before any descendants or ACL read.
$script:accessed = @(); $script:aclCalls = 0; $script:remoteDrive = $false
function Get-PSDrive { param($Name,$PSProvider,$ErrorAction) [pscustomobject]@{Root='C:\';DisplayRoot=$(if ($script:remoteDrive) {'\\server\share'} else {$null})} }
function Get-Item {
param($LiteralPath,[switch]$Force,$ErrorAction)
$script:accessed += $LiteralPath
if ($LiteralPath -like 'C:\Users\*') { throw 'Guard must not traverse the Users junction.' }
[pscustomobject]@{Attributes=$(if ($LiteralPath -eq 'C:\Users') {[IO.FileAttributes]::ReparsePoint} else {[IO.FileAttributes]::Directory})}
}
function Get-Acl { $script:aclCalls++; throw 'ACL reads must not cross redirect boundaries.' }
$guarded = Get-WelaSaclTargetObservation -Path 'C:\Users\One\AppData\Roaming\Signal' -Kind FileSystem
Assert ($guarded.PathState -eq 'ReparsePoint' -and $script:accessed.Count -eq 2 -and $script:aclCalls -eq 0) 'Ancestor junction stops before child resolution or Get-Acl.'
$script:accessed=@(); $script:remoteDrive=$true
$guarded = Get-WelaSaclTargetObservation -Path 'Z:\Startup' -Kind FileSystem
Assert ($guarded.PathState -eq 'RemoteNotInspected' -and $script:accessed.Count -eq 0) 'Mapped network drive never reaches Get-Item.'
Remove-Item Function:Get-Item, Function:Get-PSDrive, Function:Get-Acl
# Extract and execute only the option guard; never execute configure-sacl dispatch.
$tokens=$null; $errors=$null
$ast=[System.Management.Automation.Language.Parser]::ParseFile((Join-Path $PSScriptRoot '../WELA.ps1'),[ref]$tokens,[ref]$errors)
Assert ($errors.Count -eq 0) 'CLI must parse after adding explicit SaclMode command guard.'
$guard=$ast.EndBlock.Statements | Where-Object { $_ -is [System.Management.Automation.Language.IfStatementAst] -and $_.Extent.Text.StartsWith("if (`$PSBoundParameters.ContainsKey('SaclMode')") } | Select-Object -First 1
Assert ($null -ne $guard) 'Public CLI must reject ignored SaclMode before dispatch.'
$exercise=[scriptblock]::Create('param($SaclMode,$Cmd,$Profile)' + [Environment]::NewLine + $guard.Extent.Text)
$rejected=$false
try { & $exercise -SaclMode Skip -Cmd configure-sacl } catch { $rejected=$true }
Assert $rejected 'configure-sacl -SaclMode Skip must be rejected before any legacy SACL mutator.'
$rejected=$false
try { & $exercise -SaclMode Skip -Cmd configure } catch { $rejected=$true }
Assert $rejected 'Legacy configure without Profile cannot silently ignore SaclMode.'
& $exercise -SaclMode Skip -Cmd plan -Profile wela-2.2.0
& $exercise -SaclMode Skip -Cmd configure -Profile wela-2.2.0
# Real CLI offline JSON export exercises integration without changing Windows.
Remove-Item Function:Get-Item
$temp = Join-Path ([IO.Path]::GetTempPath()) ('wela-sacl-plan-' + [guid]::NewGuid().ToString('N') + '.json')
try {
# Different role/build deliberately prevents live probing even on Windows CI.