From b861e86d3a9591afe7c1ad20ea665e8cbce92c21 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Sat, 19 Sep 2026 05:25:38 +0900 Subject: [PATCH 1/5] Plan targeted SACL prerequisites alongside audit profiles --- .github/workflows/targeted-sacl-planning.yml | 25 +++ CHANGELOG-Japanese.md | 3 + CHANGELOG.md | 3 + WELA.ps1 | 15 +- docs/audit-profiles.md | 2 + docs/targeted-sacl-planning.md | 28 +++ scripts/TargetedSaclPlanning.ps1 | 161 ++++++++++++++++++ .../IntegrationProfileConfiguration.Tests.ps1 | 2 + tests/TargetedSaclPlanning.Tests.ps1 | 76 +++++++++ tests/TargetedSaclPlanning.Windows.Tests.ps1 | 14 ++ website/docs/resources/changelog.ja.md | 3 + website/docs/resources/changelog.md | 3 + 12 files changed, 333 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/targeted-sacl-planning.yml create mode 100644 docs/targeted-sacl-planning.md create mode 100644 scripts/TargetedSaclPlanning.ps1 create mode 100644 tests/TargetedSaclPlanning.Tests.ps1 create mode 100644 tests/TargetedSaclPlanning.Windows.Tests.ps1 diff --git a/.github/workflows/targeted-sacl-planning.yml b/.github/workflows/targeted-sacl-planning.yml new file mode 100644 index 00000000..ab2fd002 --- /dev/null +++ b/.github/workflows/targeted-sacl-planning.yml @@ -0,0 +1,25 @@ +name: Targeted SACL planning tests +on: + push: + branches: ['**'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + targeted-sacl-planning: + runs-on: windows-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Planning fixtures and CLI on Windows PowerShell 5.1 + shell: powershell + run: ./tests/TargetedSaclPlanning.Tests.ps1 + - name: Planning fixtures and CLI on PowerShell 7 + shell: pwsh + run: ./tests/TargetedSaclPlanning.Tests.ps1 + - name: Native read-only observations on Windows PowerShell 5.1 + shell: powershell + run: ./tests/TargetedSaclPlanning.Windows.Tests.ps1 + - name: Native read-only observations on PowerShell 7 + shell: pwsh + run: ./tests/TargetedSaclPlanning.Windows.Tests.ps1 diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index c79f4a86..bf0c3e93 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -52,6 +52,9 @@ **新機能:** +- プロファイルの plan/audit/configure に対象を限定した SACL の読み取り専用計画を追加しました。オブジェクト監査ポリシー、ユーザーハイブ・フォルダーリダイレクトの未確認箇所、WEF Run/RunOnce の監査エントリを表示し、`-SaclMode Skip` による省略も明示します。SACL の書き込みや未検証の検知率向上は行いません。 (issue #373) (@Shirofune-Security) + + - MITRE ATT&CK Navigatorヒートマップに対応した。 (#11) (@fukusuket) - Windows設定を様々なベースラインに構成するための`configure`コマンドを追加した。 (#12) (@fukusuket) - Defender for Identityの必要なログに対応した。 (#114) (@fukusuket) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab71489f..0f1ccf45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,9 @@ **New Features:** +- Profile plan/audit/configure now include read-only targeted SACL prerequisites with object policy masks, per-user hive and redirected-folder gaps, exact WEF Run/RunOnce audit entries, and an explicit `-SaclMode Skip`. No SACL writes or unverified detection uplift are implied. (issue #373) (@Shirofune-Security) + + - Support for MITRE ATT&CK Navigator heatmaps. (#11) (@fukusuket) - Added a `configure` command to configure Windows settings to various baselines. (#12) (@fukusuket) - Support for Defender for Identity required logs. (#114) (@fukusuket) diff --git a/WELA.ps1 b/WELA.ps1 index c8196e0a..3043d1aa 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -11,6 +11,7 @@ [int]$Build, [string]$PlanPath, [switch]$IncludeOptional, + [ValidateSet('Plan', 'Skip')][string]$SaclMode = 'Plan', [switch]$Auto, [ValidateSet("PreserveOrAudit", "Audit", "Deny")] [string]$OutgoingNtlmMode = "PreserveOrAudit", @@ -42,6 +43,7 @@ Import-Module (Join-Path $ScriptRoot "modules/AuditProfiles.psm1") -ErrorAction Import-Module (Join-Path $ScriptRoot "modules/NativeProviders.psm1") -ErrorAction Stop Import-Module (Join-Path $ScriptRoot "modules/EventLogSettings.psm1") -ErrorAction Stop . (Join-Path $ScriptRoot "scripts/EventLogConfiguration.ps1") +. (Join-Path $ScriptRoot "scripts/TargetedSaclPlanning.ps1") # 64bit の PowerShell と GPO が読むのは Wow6432Node の無いパス。32bit 用に両方を扱う。 $PowerShellPolicyRoots = @( @@ -338,9 +340,10 @@ function Invoke-WelaProfileCommand { if (-not $script:Profile) { throw "Specify -Profile. Use './WELA.ps1 profiles' to list versioned profiles." } $context = Get-WelaSelectedContext $current = @{} + $saclLive = $false if (TestWindows) { $actual = Get-WelaHostContext - if ($actual.Role -eq $context.Role -and $actual.Build -eq $context.Build) { $current = Get-WelaEffectiveAuditPolicy } + if ($actual.Role -eq $context.Role -and $actual.Build -eq $context.Build) { $current = Get-WelaEffectiveAuditPolicy; $saclLive = $true } elseif ($Command -ne 'plan') { throw "Requested role/build does not match this Windows host." } else { Write-Host "Planning for another role/build: effective state remains Unknown." } } @@ -348,11 +351,15 @@ function Invoke-WelaProfileCommand { $plan = Get-WelaAuditProfilePlan -Profile $script:Profile -Role $context.Role -Build $context.Build -Current $current -IncludeOptional:$script:IncludeOptional $precedence = Get-WelaAuditPrecedenceState -Offline:($current.Count -eq 0) $plan | Add-Member NoteProperty AuditPrecedence $precedence + $saclPlan = Get-WelaTargetedSaclPlan -AuditPlan $plan -Mode $script:SaclMode -Live:$saclLive + $plan | Add-Member NoteProperty SaclPrerequisites $saclPlan Write-Host "Profile: $($plan.profile); role: $($plan.role); build: $($plan.build)" - Write-Host "Scope: advanced audit policy and its subcategory-precedence prerequisite. Channels, command-line capture, PowerShell, NTLM, SACLs, CA AuditFilter and forwarding are separate." + Write-Host "Scope: advanced audit policy and its subcategory-precedence prerequisite. Channels, command-line capture, PowerShell, NTLM, SACL writes, CA AuditFilter and forwarding are separate." Write-Host "Audit precedence: $($precedence.State); required SCENoApplyLegacyAuditPolicy=1 (DWORD). $($precedence.Diagnostic)" if ($precedence.PolicySource) { Write-Host $precedence.PolicySource.Description } Show-WelaAuditProfilePrerequisites -Plan $plan + Write-Host "Targeted SACL companion plan: $($saclPlan.Mode), $($saclPlan.Targets.Count) targets; $($saclPlan.TelemetryGap)" -ForegroundColor DarkYellow + $saclPlan.Targets | Select-Object Scope, Path, Rights, Inheritance, PolicyMode, @{Name='PathState';Expression={$_.Observation.PathState}} | Format-Table -AutoSize $result = $plan if ($Command -eq 'configure') { if (-not (TestAdministrator)) { throw "Configuring advanced audit policy requires Administrator privileges." } @@ -360,6 +367,8 @@ function Invoke-WelaProfileCommand { $configurationContext = New-WelaConfigurationContext -Auto:$script:Auto -DryRun:$script:DryRun -BackupPath $script:BackupPath Set-WelaProfileAuditControls -Context $configurationContext -Plan $plan $result = Complete-WelaConfiguration -Context $configurationContext -ResultsPath $script:ResultsPath -Plan $plan -Scope advanced-audit-policy-and-precedence + $result | Add-Member NoteProperty SaclPrerequisites $saclPlan + if ($script:ResultsPath) { $result | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $script:ResultsPath -Encoding UTF8 -ErrorAction Stop } $result.Results | Format-Table Id, Before, Desired, After, Status -AutoSize } else { $plan.policies | Format-Table id, mode, currentMask, requiredMask, action -AutoSize @@ -1688,6 +1697,8 @@ Usage: ./WELA.ps1 configure-eventlogs -LogProfile asd-collector-archive-2021-10 -ApplyLogMode # Explicit archive choice ./WELA.ps1 configure -Baseline YamatoSecurity # Configure audit settings based on the specified baseline ./WELA.ps1 configure -Baseline YamatoSecurity -Auto # Configure audit settings automatically without prompts + ./WELA.ps1 plan -Profile asd-native-2021-10 -Role Client -Build 26100 -IncludeOptional -SaclMode Plan + # Profile plan/audit/configure include read-only SACL prerequisites; -SaclMode Skip reports the telemetry gap. ./WELA.ps1 configure-sacl # Add targeted File System/Registry audit SACLs (ASEP keys + sensitive files) needed by the rules, without global auditing ./WELA.ps1 configure-sacl -Auto # ...automatically without prompts ./WELA.ps1 update-rules # Update rule config files from https://github.com/Yamato-Security/WELA diff --git a/docs/audit-profiles.md b/docs/audit-profiles.md index f60dcbdc..451d1504 100644 --- a/docs/audit-profiles.md +++ b/docs/audit-profiles.md @@ -84,3 +84,5 @@ See [Microsoft's precedence policy documentation](https://learn.microsoft.com/en For recovery, review the journal and restore the exact prior registry value/type (or remove only the value if it was previously absent), then restore reviewed subcategory settings. Never delete the Lsa key. In an isolated joined Windows VM, create a conflicting legacy category GPO, record `gpresult /scope computer /h before.html`, and capture `auditpol /get /category:* /r`. Apply WELA, explicitly refresh with `gpupdate /target:computer /force`, then rerun `audit-settings -Profile -PlanPath after.json` and the auditpol capture. Verify registry precedence, each effective mask and GPO provenance; retain the snapshots and benign event XML. This domain-refresh/event test remains pending; CI exercises injected failures/drift and read-only Windows observations. RSoP schema references: [registry policy](https://learn.microsoft.com/en-us/previous-versions/windows/desktop/policy/rsop-registrypolicysetting), [numeric security setting](https://learn.microsoft.com/en-us/previous-versions/aa375064(v=vs.85)), and [security registry value](https://learn.microsoft.com/en-us/previous-versions/aa375052(v=vs.85)). Tests use these actual property shapes; they do not substitute a shared synthetic schema. + +Targeted file/registry SACL prerequisites are included as a read-only companion plan. See [targeted SACL planning](targeted-sacl-planning.md) for per-user gaps, source distinctions and `-SaclMode Skip`. diff --git a/docs/targeted-sacl-planning.md b/docs/targeted-sacl-planning.md new file mode 100644 index 00000000..33cbd8a3 --- /dev/null +++ b/docs/targeted-sacl-planning.md @@ -0,0 +1,28 @@ +# Targeted SACL prerequisites in profile plans + +`plan`, `audit` and `configure -Profile` now include a read-only `SaclPrerequisites` companion plan. It links File System, Registry and Handle Manipulation policy rows to WELA's existing `config/audit_sacl_targets.json` definitions. It lists each path, SID, audit flags, rights, inheritance, selected/effective policy masks and observation. The 50 WELA definitions are companion targets, **not claims that Microsoft, CIS or ASD requires every path**. + +```powershell +# Offline: unknown host paths, hives, redirection and effective policy stay unknown. +./WELA.ps1 plan -Profile asd-native-2021-10 -Role Client -Build 26100 -IncludeOptional -PlanPath plan.json +# Local Windows: inspect paths and SACL readability before applying object policies. +./WELA.ps1 configure -Profile asd-native-2021-10 -IncludeOptional -DryRun -ResultsPath preview.json +# An explicit skip is retained as a telemetry gap in console and JSON results. +./WELA.ps1 configure -Profile wela-2.2.0 -SaclMode Skip -Auto -ResultsPath results.json +``` + +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. + +`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. + +## Source-specific distinctions + +[Microsoft WEF Appendix B](https://learn.microsoft.com/en-us/windows/security/operating-system-security/device-management/use-windows-event-forwarding-to-assist-in-intrusion-detection#appendix-b---recommended-minimum-registry-system-acl-policy) shows HKLM Run/RunOnce audit entries for **Authenticated Users**, Success, this key and subkeys. Run specifies SetValue/CreateSubKey; RunOnce adds Delete. A Microsoft WEF profile includes these exact audit-entry rows separately from WELA's Everyone, Success+Failure targets. The screenshots' DACL/owner settings are not proposed for copying. Appendix A leaves Registry Not Configured; the planner exposes that unresolved policy prerequisite rather than silently enabling it. + +[ASD native guidance](https://www.cyber.gov.au/business-government/detecting-responding-to-threats/event-logging/windows-event-logging-and-forwarding) makes File System and Registry success/failure auditing optional. `-IncludeOptional` selects those existing profile controls; otherwise the targets retain an explicit policy gap. Sysmon is outside this feature's scope. + +## Isolated Windows validation still required + +On a snapshot, save the plan and effective policy, apply an explicitly approved targeted SACL, perform a benign operation on a disposable registry key/file that inherits the selected rule, and match Security event XML (for example 4657/4663) to that object, subject and access mask. Check relevant 4656/4658 events separately if Handle Manipulation is needed. Verify forwarding at the collector where required. Repeat for loaded/unloaded users, redirected paths and relevant client/server roles. Preserve before/after ACLs and remove only disposable test objects. This PR's mocked tests and native read-only CI do not supply event-generation or forwarding evidence, so issue #373 remains open for that acceptance work. diff --git a/scripts/TargetedSaclPlanning.ps1 b/scripts/TargetedSaclPlanning.ps1 new file mode 100644 index 00000000..3d1c0ae2 --- /dev/null +++ b/scripts/TargetedSaclPlanning.ps1 @@ -0,0 +1,161 @@ +# Read-only companion planning for the existing configure-sacl targets. +# Never loads offline hives, enables privileges, changes audit policy or writes ACLs. +function Get-WelaSaclUserInventory { + $users = New-Object 'System.Collections.Generic.List[object]' + $diagnostics = New-Object 'System.Collections.Generic.List[string]' + $loaded = @{} + try { + foreach ($key in @(Get-ChildItem -LiteralPath 'Registry::HKEY_USERS' -ErrorAction Stop)) { + if ($key.PSChildName -match '^S-1-\d+(-\d+)+$') { $loaded[$key.PSChildName] = $true } + } + } catch { $diagnostics.Add("Loaded-hive inventory failed: $($_.Exception.Message)") } + $profileRoot = 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList' + try { + foreach ($key in @(Get-ChildItem -LiteralPath $profileRoot -ErrorAction Stop)) { + $sid = $key.PSChildName + if ($sid -notmatch '^S-1-\d+(-\d+)+$') { + $diagnostics.Add("Unresolved ProfileList entry: $sid (including backup/temporary profiles).") + 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)" } + $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) + $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) { + $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) } +} + +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. + 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 { + $folder = if ($RelativePath -like '*\Startup') { 'Startup' } else { 'AppData' } + $keyPath = "Registry::HKEY_USERS\$($User.Sid)\Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders" + $key = Get-Item -LiteralPath $keyPath -ErrorAction Stop + # DoNotExpandEnvironmentNames is essential when reading another user's hive. + $raw = [string]$key.GetValue($folder, $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + if (-not $raw) { throw "Known-folder value '$folder' is absent." } + $resolved = [regex]::Replace($raw, '(?i)%USERPROFILE%', [System.Text.RegularExpressions.MatchEvaluator]{ param($match) $User.ProfilePath }) + if ($resolved -match '%[^%]+%' -or $resolved -notmatch '^(?:[A-Za-z]:\\|\\\\)') { throw 'Known-folder path contains unresolved user variables or is not absolute.' } + if ($folder -eq 'AppData') { $resolved = $resolved.TrimEnd('\') + '\Signal' } + $expected = $User.ProfilePath.TrimEnd('\') + '\' + $RelativePath + $state = if ($resolved -ine $expected) { 'Redirected' } else { 'Resolved' } + [pscustomobject]@{ Path = $resolved; State = $state; Diagnostic = 'Resolved from this user hive; remote paths are reported without network access.' } + } catch { [pscustomobject]@{ Path = $null; State = 'UnresolvedUserPath'; Diagnostic = $_.Exception.Message } } +} + +function Get-WelaSaclTargetObservation { + param([string]$Path, [string]$Kind) + 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.' } + } + } catch { + $state = if ($_.CategoryInfo.Category -eq 'ObjectNotFound') { 'Missing' } else { 'Inaccessible' } + return [pscustomobject]@{ PathState = $state; SaclReadState = 'Unknown'; Diagnostic = $_.Exception.Message } + } + 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.' } + } catch { [pscustomobject]@{ PathState = 'Exists'; SaclReadState = 'Inaccessible'; Diagnostic = $_.Exception.Message } } +} + +function Get-WelaTargetedSaclPlan { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$AuditPlan, + [ValidateSet('Plan', 'Skip')][string]$Mode = 'Plan', + [switch]$Live, + [string]$TargetsPath = (Join-Path $PSScriptRoot '../config/audit_sacl_targets.json') + ) + $definitions = Get-Content -LiteralPath $TargetsPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop + $policyRows = @($AuditPlan.policies | Where-Object { $_.id -in @('File System', 'Registry', 'Handle Manipulation') }) + $inventory = [pscustomobject]@{ Users = @(); Diagnostics = @('Offline plan: user identities, hives and redirected folders are unknown.'); Complete = $false } + if ($Live -and $Mode -eq 'Plan') { $inventory = Get-WelaSaclUserInventory } + $users = @($inventory.Users) + if ($users.Count -eq 0) { + $users = @([pscustomobject]@{ Sid = ''; ProfilePath = ''; HiveLoaded = $false; Diagnostic = 'User inventory is unavailable; all-user coverage is unknown.' }) + } + $targets = New-Object 'System.Collections.Generic.List[object]' + foreach ($section in @('registry', 'files', 'user_registry', 'user_files')) { + $kind = if ($section -match 'registry') { 'Registry' } else { 'FileSystem' } + $policyName = if ($kind -eq 'Registry') { 'Registry' } else { 'File System' } + $policy = @($policyRows | Where-Object id -eq $policyName)[0] + foreach ($target in @($definitions.$section)) { + $instances = if ($section -like 'user_*') { $users } else { @([pscustomobject]@{ Sid = $null }) } + foreach ($user in $instances) { + $resolution = 'Resolved'; $detail = ''; $path = $null + if ($section -eq 'user_registry') { + $path = "Registry::HKEY_USERS\$($user.Sid)\$($target.key)" + 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)" + if ($Live -and $Mode -eq 'Plan') { + $resolved = Resolve-WelaSaclUserFile -User $user -RelativePath $target.relpath + $resolution = $resolved.State; $detail = $resolved.Diagnostic + if ($resolved.Path) { $path = $resolved.Path } + } else { $resolution = 'Unknown'; $detail = 'User folder redirection is unknown.' } + } else { + $path = ([string]$target.path).Replace('\\', '\') + if ($Live) { $path = [Environment]::ExpandEnvironmentVariables($path) } + } + $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) { $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.' } + elseif (-not $selected) { 'Profile leaves this object policy unchanged or optional; its effective setting and target SACL are still required.' } + elseif ($policy.requiredMask -eq 0) { 'Profile requests No Auditing for this object policy.' } + else { 'Object policy alone does not establish target SACL coverage; match a benign operation to actual Security event XML.' } + $targets.Add([pscustomobject][ordered]@{ + Origin = 'WELA existing targeted SACL definitions (companion targets, not a baseline requirement)' + Scope = $section; UserSid = if ($user) { $user.Sid } else { $null }; Path = $path; Kind = $kind + PrincipalSid = 'S-1-1-0'; AuditFlags = @('Success', 'Failure'); Rights = @($target.rights) + Inheritance = if ($target.inherit) { if ($kind -eq 'Registry') { 'ContainerInherit' } else { 'ContainerInherit, ObjectInherit (directories only)' } } else { 'None' } + Propagation = 'None'; Policy = $policyName; PolicyMode = $policy.mode; PolicySelected = [bool]$selected + RequiredPolicyMask = $policy.requiredMask; EffectivePolicyMask = $policy.currentMask + Resolution = $resolution; Observation = $observation; GenerationReadiness = 'Conditional'; TelemetryGap = $gap + }) + } + } + } + # Appendix B screenshots specify Authenticated Users / Success. Keep this + # distinct from WELA's wider Everyone / Success+Failure companion targets. + $wef = @($AuditPlan.provenance | Where-Object id -eq 'ms-wef').Count -gt 0 + if ($wef) { + foreach ($name in @('Run', 'RunOnce')) { + $original = @($targets | Where-Object { $_.Scope -eq 'registry' -and $_.Path -ieq "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\$name" })[0] + $row = $original.PSObject.Copy() + $row.Origin = 'Microsoft WEF Appendix B (screenshot audit entries only)' + $row.PrincipalSid = 'S-1-5-11'; $row.AuditFlags = @('Success') + $row.Rights = if ($name -eq 'Run') { @('SetValue', 'CreateSubKey') } else { @('SetValue', 'CreateSubKey', 'Delete') } + $targets.Add($row) + } + } + [pscustomobject][ordered]@{ + SchemaVersion = 1; Mode = $Mode; Scope = 'read-only-targeted-sacl-prerequisites'; LiveObservation = [bool]$Live + DefinitionSha256 = (Get-FileHash -LiteralPath $TargetsPath -Algorithm SHA256).Hash + ObjectPolicies = $policyRows; UserInventory = $inventory; Targets = @($targets.ToArray()) + GenerationReadiness = 'Conditional'; UsableRuleCredit = 0 + TelemetryGap = if ($Mode -eq 'Skip') { 'Target SACL assessment was explicitly skipped; object-policy success does not close this gap.' } else { 'Target SACL matching and benign Security event XML validation remain required. No Sigma uplift is claimed.' } + Guidance = 'Read-only companion plan. Existing configure-sacl is a separate, broader opt-in workflow; it applies all WELA targets and its own three object policies, not the selected profile. Review its scope before use. No hives are loaded and no permissions are changed by this plan.' + Sources = @( + 'https://learn.microsoft.com/en-us/windows/security/operating-system-security/device-management/use-windows-event-forwarding-to-assist-in-intrusion-detection#appendix-b---recommended-minimum-registry-system-acl-policy', + 'https://www.cyber.gov.au/business-government/detecting-responding-to-threats/event-logging/windows-event-logging-and-forwarding' + ) + } +} diff --git a/tests/IntegrationProfileConfiguration.Tests.ps1 b/tests/IntegrationProfileConfiguration.Tests.ps1 index 28aa7045..12530883 100644 --- a/tests/IntegrationProfileConfiguration.Tests.ps1 +++ b/tests/IntegrationProfileConfiguration.Tests.ps1 @@ -1,3 +1,5 @@ +. (Join-Path $PSScriptRoot '../scripts/TargetedSaclPlanning.ps1') +$script:SaclMode = 'Skip' # Profile command + verified configuration integration. No Windows policy is touched. $ErrorActionPreference = 'Stop' # Keep mocks in the same script scope as dot-sourced helpers/imported commands; diff --git a/tests/TargetedSaclPlanning.Tests.ps1 b/tests/TargetedSaclPlanning.Tests.ps1 new file mode 100644 index 00000000..e598bf3b --- /dev/null +++ b/tests/TargetedSaclPlanning.Tests.ps1 @@ -0,0 +1,76 @@ +$ErrorActionPreference = 'Stop' +Import-Module (Join-Path $PSScriptRoot '../modules/AuditProfiles.psm1') -Force +. (Join-Path $PSScriptRoot '../scripts/TargetedSaclPlanning.ps1') +$count = 0 +function Assert($Condition, $Message) { if (-not $Condition) { throw $Message }; $script:count++ } +$wef = Get-WelaAuditProfilePlan -Profile microsoft-wef-reviewed-2026-09 -Role Client -Build 26100 +$plan = Get-WelaTargetedSaclPlan -AuditPlan $wef +$reference = @($plan.Targets | Where-Object Origin -like 'Microsoft WEF*') +Assert ($reference.Count -eq 2) 'Both exact WEF Appendix B targets must be present.' +Assert (($reference.PrincipalSid | Select-Object -Unique) -eq 'S-1-5-11') 'WEF principal differs from WELA Everyone.' +Assert ($reference[0].Rights.Count -eq 2 -and $reference[1].Rights.Count -eq 3) 'WEF Run/RunOnce rights must stay distinct.' +Assert ($reference[0].AuditFlags.Count -eq 1 -and $reference[0].AuditFlags[0] -eq 'Success') 'WEF audits success only.' +Assert ($reference[0].PolicyMode -eq 'not-configured') 'WEF documentary Not Configured must not become registry auditing.' +Assert (@($plan.Targets | Where-Object { $_.Observation.PathState -ne 'Unknown' }).Count -eq 0) 'Offline planning must never credit live paths.' +Assert (-not $plan.UserInventory.Complete) 'Offline user inventory must remain incomplete.' +Assert ($plan.UsableRuleCredit -eq 0 -and $plan.GenerationReadiness -eq 'Conditional') 'No event evidence means no rule uplift.' +foreach ($role in @('Client', 'MemberServer', 'DomainController', 'ADCS')) { + $asd = Get-WelaAuditProfilePlan -Profile asd-native-2021-10 -Role $role -Build 26100 + $without = Get-WelaTargetedSaclPlan -AuditPlan $asd + Assert (@($without.Targets | Where-Object PolicySelected).Count -eq 0) "$role ASD optional targets should not be selected implicitly." + $asd = Get-WelaAuditProfilePlan -Profile asd-native-2021-10 -Role $role -Build 26100 -IncludeOptional + $with = Get-WelaTargetedSaclPlan -AuditPlan $asd + Assert (@($with.Targets | Where-Object { -not $_.PolicySelected }).Count -eq 0) "$role ASD optional selection must propagate." + Assert (@($with.Targets | Where-Object RequiredPolicyMask -ne 3).Count -eq 0) "$role ASD requires success and failure." +} +# Native boundary fixtures: loaded, unloaded, Default, unresolved and redirected users. +function Get-WelaSaclUserInventory { + [pscustomobject]@{ Complete = $false; Diagnostics = @('One profile could not be read.'); Users = @( + [pscustomobject]@{ Sid='S-1-5-21-1'; ProfilePath='C:\Users\One'; HiveLoaded=$true; Diagnostic='' }, + [pscustomobject]@{ Sid='S-1-5-21-2'; ProfilePath='D:\Two'; HiveLoaded=$false; Diagnostic='' }, + [pscustomobject]@{ Sid='Default'; ProfilePath='C:\Users\Default'; HiveLoaded=$false; Diagnostic='' } + ) } +} +$script:probeCalls = 0 +function Get-WelaSaclTargetObservation { + param($Path, $Kind) + $script:probeCalls++ + $state = if ($Path -like '*RunOnce') { 'Inaccessible' } elseif ($Path -like '*RunOnceEx') { 'Missing' } else { 'Exists' } + [pscustomobject]@{ PathState=$state; SaclReadState='Unknown'; Diagnostic='Fixture' } +} +function Resolve-WelaSaclUserFile { + param($User, $RelativePath) + if (-not $User.HiveLoaded) { return [pscustomobject]@{ Path=$null; State='UnloadedHive'; Diagnostic='No offline hive load.' } } + [pscustomobject]@{ Path='\\fileserver\redirected\Startup'; State='Redirected'; Diagnostic='Explicit redirected folder' } +} +$live = Get-WelaTargetedSaclPlan -AuditPlan $wef -Live +Assert ($script:probeCalls -gt 0) 'Matching live host should inspect paths.' +Assert (@($live.Targets | Where-Object { $_.UserSid -eq 'S-1-5-21-2' -and $_.Observation.PathState -eq 'UnloadedHive' }).Count -eq 13) 'Unloaded hive and unresolved known folders must be reported for every user target.' +Assert (@($live.Targets | Where-Object { $_.Resolution -eq 'Redirected' }).Count -eq 2) 'Redirected files must be explicit.' +Assert (@($live.Targets | Where-Object { $_.Observation.PathState -eq 'Inaccessible' }).Count -gt 0) 'Access denied is not missing or compliant.' +Assert (@($live.Targets | Where-Object { $_.Observation.PathState -eq 'Missing' }).Count -gt 0) 'Missing paths must be explicit.' +Assert (-not $live.UserInventory.Complete -and $live.UserInventory.Diagnostics.Count -gt 0) 'Partial inventory diagnostics must survive.' +$script:probeCalls = 0 +$skip = Get-WelaTargetedSaclPlan -AuditPlan $wef -Mode Skip -Live +Assert ($script:probeCalls -eq 0) 'Explicit skip must not inspect targets.' +Assert (@($skip.Targets | Where-Object { $_.Observation.PathState -ne 'Skipped' }).Count -eq 0) 'Every skipped target retains a gap.' +Assert ($skip.TelemetryGap -like '*explicitly skipped*') 'Skip gap must be visible in top-level report.' +# Reload native helpers. Network paths must never trigger Get-Item/authentication. +. (Join-Path $PSScriptRoot '../scripts/TargetedSaclPlanning.ps1') +function Get-Item { throw 'Unexpected path access.' } +$remote = Get-WelaSaclTargetObservation -Path '\\server\share\Startup' -Kind FileSystem +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.' +# 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. + & (Join-Path $PSScriptRoot '../WELA.ps1') plan -Profile asd-native-2021-10 -Role Client -Build 22001 -IncludeOptional -SaclMode Skip -PlanPath $temp | Out-Null + $json = Get-Content -LiteralPath $temp -Raw | ConvertFrom-Json + Assert ($json.SaclPrerequisites.Mode -eq 'Skip') 'Public CLI JSON must include selected mode.' + Assert ($json.SaclPrerequisites.Targets.Count -gt 40) 'Public CLI must export target details.' + Assert ($json.SaclPrerequisites.ObjectPolicies.Count -eq 3) 'Plan links File System, Registry and Handle Manipulation.' +} finally { Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue } +Write-Host "PASS: $count targeted SACL planning assertions. No audit policies or ACLs changed." diff --git a/tests/TargetedSaclPlanning.Windows.Tests.ps1 b/tests/TargetedSaclPlanning.Windows.Tests.ps1 new file mode 100644 index 00000000..be023f30 --- /dev/null +++ b/tests/TargetedSaclPlanning.Windows.Tests.ps1 @@ -0,0 +1,14 @@ +$ErrorActionPreference = 'Stop' +if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { throw 'Windows required.' } +. (Join-Path $PSScriptRoot '../scripts/TargetedSaclPlanning.ps1') +$inventory = Get-WelaSaclUserInventory +if ($null -eq $inventory.PSObject.Properties['Complete']) { throw 'User inventory did not report completeness.' } +# Read a real, existing local object. Lack of SACL read privilege stays explicit. +$observation = Get-WelaSaclTargetObservation -Path "$env:SystemRoot\System32\cmd.exe" -Kind FileSystem +if ($observation.PathState -ne 'Exists') { throw ($observation | ConvertTo-Json) } +if ($observation.SaclReadState -notin @('Readable', 'Inaccessible')) { throw 'Unexpected native SACL read state.' } +$missing = Get-WelaSaclTargetObservation -Path (Join-Path $env:TEMP ([guid]::NewGuid().ToString('N'))) -Kind FileSystem +if ($missing.PathState -ne 'Missing') { throw 'Missing native target misclassified.' } +$registry = Get-WelaSaclTargetObservation -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion' -Kind Registry +if ($registry.PathState -ne 'Exists') { throw ($registry | ConvertTo-Json) } +Write-Host 'PASS: read-only ProfileList/HKU and native file/registry observations. No policy, ACL or hive changes.' diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index 4d03a568..a899a4a3 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -55,6 +55,9 @@ **新機能:** +- プロファイルの plan/audit/configure に対象を限定した SACL の読み取り専用計画を追加しました。オブジェクト監査ポリシー、ユーザーハイブ・フォルダーリダイレクトの未確認箇所、WEF Run/RunOnce の監査エントリを表示し、`-SaclMode Skip` による省略も明示します。SACL の書き込みや未検証の検知率向上は行いません。 (issue #373) (@Shirofune-Security) + + - MITRE ATT&CK Navigatorヒートマップに対応した。 (#11) (@fukusuket) - Windows設定を様々なベースラインに構成するための`configure`コマンドを追加した。 (#12) (@fukusuket) - Defender for Identityの必要なログに対応した。 (#114) (@fukusuket) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index 50139bca..0bdb6fb5 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -57,6 +57,9 @@ **New Features:** +- Profile plan/audit/configure now include read-only targeted SACL prerequisites with object policy masks, per-user hive and redirected-folder gaps, exact WEF Run/RunOnce audit entries, and an explicit `-SaclMode Skip`. No SACL writes or unverified detection uplift are implied. (issue #373) (@Shirofune-Security) + + - Support for MITRE ATT&CK Navigator heatmaps. (#11) (@fukusuket) - Added a `configure` command to configure Windows settings to various baselines. (#12) (@fukusuket) - Support for Defender for Identity required logs. (#114) (@fukusuket) From 6489775d3014e1aab7ed525972560097a8ea6fcc Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Sat, 19 Sep 2026 05:30:50 +0900 Subject: [PATCH 2/5] Reference PR 398 in bilingual changelogs --- CHANGELOG-Japanese.md | 2 +- CHANGELOG.md | 2 +- website/docs/resources/changelog.ja.md | 2 +- website/docs/resources/changelog.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index bf0c3e93..6915a37e 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -52,7 +52,7 @@ **新機能:** -- プロファイルの plan/audit/configure に対象を限定した SACL の読み取り専用計画を追加しました。オブジェクト監査ポリシー、ユーザーハイブ・フォルダーリダイレクトの未確認箇所、WEF Run/RunOnce の監査エントリを表示し、`-SaclMode Skip` による省略も明示します。SACL の書き込みや未検証の検知率向上は行いません。 (issue #373) (@Shirofune-Security) +- プロファイルの plan/audit/configure に対象を限定した SACL の読み取り専用計画を追加しました。オブジェクト監査ポリシー、ユーザーハイブ・フォルダーリダイレクトの未確認箇所、WEF Run/RunOnce の監査エントリを表示し、`-SaclMode Skip` による省略も明示します。SACL の書き込みや未検証の検知率向上は行いません。 (#398) (@Shirofune-Security) - MITRE ATT&CK Navigatorヒートマップに対応した。 (#11) (@fukusuket) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f1ccf45..cadfe9af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,7 +54,7 @@ **New Features:** -- Profile plan/audit/configure now include read-only targeted SACL prerequisites with object policy masks, per-user hive and redirected-folder gaps, exact WEF Run/RunOnce audit entries, and an explicit `-SaclMode Skip`. No SACL writes or unverified detection uplift are implied. (issue #373) (@Shirofune-Security) +- Profile plan/audit/configure now include read-only targeted SACL prerequisites with object policy masks, per-user hive and redirected-folder gaps, exact WEF Run/RunOnce audit entries, and an explicit `-SaclMode Skip`. No SACL writes or unverified detection uplift are implied. (#398) (@Shirofune-Security) - Support for MITRE ATT&CK Navigator heatmaps. (#11) (@fukusuket) diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index a899a4a3..8805e76c 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -55,7 +55,7 @@ **新機能:** -- プロファイルの plan/audit/configure に対象を限定した SACL の読み取り専用計画を追加しました。オブジェクト監査ポリシー、ユーザーハイブ・フォルダーリダイレクトの未確認箇所、WEF Run/RunOnce の監査エントリを表示し、`-SaclMode Skip` による省略も明示します。SACL の書き込みや未検証の検知率向上は行いません。 (issue #373) (@Shirofune-Security) +- プロファイルの plan/audit/configure に対象を限定した SACL の読み取り専用計画を追加しました。オブジェクト監査ポリシー、ユーザーハイブ・フォルダーリダイレクトの未確認箇所、WEF Run/RunOnce の監査エントリを表示し、`-SaclMode Skip` による省略も明示します。SACL の書き込みや未検証の検知率向上は行いません。 (#398) (@Shirofune-Security) - MITRE ATT&CK Navigatorヒートマップに対応した。 (#11) (@fukusuket) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index 0bdb6fb5..cee66c42 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -57,7 +57,7 @@ **New Features:** -- Profile plan/audit/configure now include read-only targeted SACL prerequisites with object policy masks, per-user hive and redirected-folder gaps, exact WEF Run/RunOnce audit entries, and an explicit `-SaclMode Skip`. No SACL writes or unverified detection uplift are implied. (issue #373) (@Shirofune-Security) +- Profile plan/audit/configure now include read-only targeted SACL prerequisites with object policy masks, per-user hive and redirected-folder gaps, exact WEF Run/RunOnce audit entries, and an explicit `-SaclMode Skip`. No SACL writes or unverified detection uplift are implied. (#398) (@Shirofune-Security) - Support for MITRE ATT&CK Navigator heatmaps. (#11) (@fukusuket) From acde16f149a1431d0d2efb1e29a86b49a7c2c2fd Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Sat, 19 Sep 2026 05:35:23 +0900 Subject: [PATCH 3/5] Guard targeted SACL planning paths and ignored skip options --- WELA.ps1 | 6 +++ docs/targeted-sacl-planning.md | 4 +- scripts/TargetedSaclPlanning.ps1 | 50 ++++++++++++++++---- tests/TargetedSaclPlanning.Tests.ps1 | 68 +++++++++++++++++++++++++++- 4 files changed, 117 insertions(+), 11 deletions(-) diff --git a/WELA.ps1 b/WELA.ps1 index 3043d1aa..7c35e3a5 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -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 diff --git a/docs/targeted-sacl-planning.md b/docs/targeted-sacl-planning.md index 33cbd8a3..d2a23df7 100644 --- a/docs/targeted-sacl-planning.md +++ b/docs/targeted-sacl-planning.md @@ -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. diff --git a/scripts/TargetedSaclPlanning.ps1 b/scripts/TargetedSaclPlanning.ps1 index 3d1c0ae2..09723f6b 100644 --- a/scripts/TargetedSaclPlanning.ps1 +++ b/scripts/TargetedSaclPlanning.ps1 @@ -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 diff --git a/tests/TargetedSaclPlanning.Tests.ps1 b/tests/TargetedSaclPlanning.Tests.ps1 index e598bf3b..406f30e9 100644 --- a/tests/TargetedSaclPlanning.Tests.ps1 +++ b/tests/TargetedSaclPlanning.Tests.ps1 @@ -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. From 1acfec66a3a752735e0edc2b9db62ac9b55089ac Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Sat, 19 Sep 2026 05:36:43 +0900 Subject: [PATCH 4/5] Read unexpanded ProfileList paths before validation --- scripts/TargetedSaclPlanning.ps1 | 6 ++++-- tests/TargetedSaclPlanning.Tests.ps1 | 13 ++++++++----- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/scripts/TargetedSaclPlanning.ps1 b/scripts/TargetedSaclPlanning.ps1 index 09723f6b..2835669e 100644 --- a/scripts/TargetedSaclPlanning.ps1 +++ b/scripts/TargetedSaclPlanning.ps1 @@ -29,7 +29,8 @@ function Get-WelaSaclUserInventory { } $path = $null; $message = '' try { - $rawPath = [string](Get-ItemProperty -LiteralPath $key.PSPath -Name ProfileImagePath -ErrorAction Stop).ProfileImagePath + $profileKey = Get-Item -LiteralPath $key.PSPath -ErrorAction Stop + $rawPath = [string]$profileKey.GetValue('ProfileImagePath', $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) $path = Expand-WelaSaclProfilePath $rawPath } catch { $path = $null; $message = "Profile path unavailable: $($_.Exception.Message)" @@ -38,7 +39,8 @@ function Get-WelaSaclUserInventory { $users.Add([pscustomobject]@{ Sid = $sid; ProfilePath = $path; HiveLoaded = $loaded.ContainsKey($sid); Diagnostic = $message }) $loaded.Remove($sid) } - $default = Expand-WelaSaclProfilePath ([string](Get-ItemProperty -LiteralPath $profileRoot -Name Default -ErrorAction Stop).Default) + $profileListKey = Get-Item -LiteralPath $profileRoot -ErrorAction Stop + $default = Expand-WelaSaclProfilePath ([string]$profileListKey.GetValue('Default', $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)) $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) { diff --git a/tests/TargetedSaclPlanning.Tests.ps1 b/tests/TargetedSaclPlanning.Tests.ps1 index 406f30e9..7dc3fe4e 100644 --- a/tests/TargetedSaclPlanning.Tests.ps1 +++ b/tests/TargetedSaclPlanning.Tests.ps1 @@ -84,12 +84,15 @@ function Get-ChildItem { [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'} } +$script:profileKey = [pscustomobject]@{} +$script:profileKey | Add-Member ScriptMethod GetValue { + param($Name,$Default,$Options) + if ($Options -ne [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) { throw 'ProfileList read must preserve unexpanded tokens.' } + if ($Name -eq 'Default') { return 'C:\Users\Default' } if ($null -eq $script:profilePath) { throw 'Profile path denied' } - [pscustomobject]@{ProfileImagePath=$script:profilePath} + return $script:profilePath } +function Get-Item { param($LiteralPath,[switch]$Force,$ErrorAction) return $script:profileKey } $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' @@ -97,7 +100,7 @@ $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 +Remove-Item Function:Get-ChildItem # 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})} } From 521fe3b8262eac117df1b544df9092d6e65719bf Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Sat, 19 Sep 2026 06:21:18 +0900 Subject: [PATCH 5/5] Preserve configured user-file suffixes in SACL plans --- CHANGELOG-Japanese.md | 2 +- CHANGELOG.md | 2 +- docs/targeted-sacl-planning.md | 2 ++ scripts/TargetedSaclPlanning.ps1 | 19 ++++++++++-- tests/TargetedSaclPlanning.Tests.ps1 | 41 +++++++++++++++++++++++--- website/docs/resources/changelog.ja.md | 2 +- website/docs/resources/changelog.md | 2 +- 7 files changed, 60 insertions(+), 10 deletions(-) diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index 6915a37e..d8b9d0f3 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -52,7 +52,7 @@ **新機能:** -- プロファイルの plan/audit/configure に対象を限定した SACL の読み取り専用計画を追加しました。オブジェクト監査ポリシー、ユーザーハイブ・フォルダーリダイレクトの未確認箇所、WEF Run/RunOnce の監査エントリを表示し、`-SaclMode Skip` による省略も明示します。SACL の書き込みや未検証の検知率向上は行いません。 (#398) (@Shirofune-Security) +- プロファイルの plan/audit/configure に対象を限定した SACL の読み取り専用計画を追加しました。オブジェクト監査ポリシー、ユーザーハイブ・フォルダーリダイレクトの未確認箇所、WEF Run/RunOnce の監査エントリを表示し、`-SaclMode Skip` による省略も明示します。ユーザーファイルの対象は、そのユーザーの AppData または Startup 既知フォルダー配下の相対パスを保持し、未対応・曖昧なパスは未解決として扱います。SACL の書き込みや未検証の検知率向上は行いません。 (#398) (@Shirofune-Security) - MITRE ATT&CK Navigatorヒートマップに対応した。 (#11) (@fukusuket) diff --git a/CHANGELOG.md b/CHANGELOG.md index cadfe9af..4fa92aef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,7 +54,7 @@ **New Features:** -- Profile plan/audit/configure now include read-only targeted SACL prerequisites with object policy masks, per-user hive and redirected-folder gaps, exact WEF Run/RunOnce audit entries, and an explicit `-SaclMode Skip`. No SACL writes or unverified detection uplift are implied. (#398) (@Shirofune-Security) +- Profile plan/audit/configure now include read-only targeted SACL prerequisites with object policy masks, per-user hive and redirected-folder gaps, exact WEF Run/RunOnce audit entries, and an explicit `-SaclMode Skip`. User-file targets retain their configured suffix under the user's AppData or Startup known folder; unsupported or ambiguous paths remain unresolved. No SACL writes or unverified detection uplift are implied. (#398) (@Shirofune-Security) - Support for MITRE ATT&CK Navigator heatmaps. (#11) (@fukusuket) diff --git a/docs/targeted-sacl-planning.md b/docs/targeted-sacl-planning.md index d2a23df7..240a224c 100644 --- a/docs/targeted-sacl-planning.md +++ b/docs/targeted-sacl-planning.md @@ -17,6 +17,8 @@ No SACL is written by a profile command. Audit policy configuration retains its 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. +Configured user-file paths beneath `AppData\Roaming` retain their complete relative suffix under the user's AppData known folder. The exact `AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup` root and its descendants use the separate Startup known folder, including its redirection; an unrelated directory named `Startup` does not. Unsupported roots, dot segments, unresolved variables and ambiguous path components remain unresolved before any known-folder read. This resolution adds no targets and does not enumerate other application folders. + `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. ## Source-specific distinctions diff --git a/scripts/TargetedSaclPlanning.ps1 b/scripts/TargetedSaclPlanning.ps1 index 2835669e..a8236b0e 100644 --- a/scripts/TargetedSaclPlanning.ps1 +++ b/scripts/TargetedSaclPlanning.ps1 @@ -58,7 +58,22 @@ function Resolve-WelaSaclUserFile { 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 { - $folder = if ($RelativePath -like '*\Startup') { 'Startup' } else { 'AppData' } + # Match complete known-folder roots, not an arbitrary directory named + # Startup. Keep the configured suffix rather than substituting an app. + $parts = @($RelativePath -split '\\') + if ($RelativePath -match '[<>:"/|?*\x00-\x1F]' -or $RelativePath -match '%[^%]+%' -or + @($parts | Where-Object { -not $_ -or $_ -in @('.', '..') -or $_ -match '[ .]$' }).Count) { + throw 'User target contains unsupported or ambiguous path components.' + } + $startupRoot = 'AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup' + $appDataRoot = 'AppData\Roaming' + if ($RelativePath -ieq $startupRoot) { + $folder = 'Startup'; $suffix = '' + } elseif ($RelativePath.StartsWith($startupRoot + '\', [StringComparison]::OrdinalIgnoreCase)) { + $folder = 'Startup'; $suffix = $RelativePath.Substring($startupRoot.Length + 1) + } elseif ($RelativePath.StartsWith($appDataRoot + '\', [StringComparison]::OrdinalIgnoreCase)) { + $folder = 'AppData'; $suffix = $RelativePath.Substring($appDataRoot.Length + 1) + } else { throw 'User target must be the Startup known folder or a child of the supported AppData\Roaming known-folder root.' } $keyPath = "Registry::HKEY_USERS\$($User.Sid)\Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders" $key = Get-Item -LiteralPath $keyPath -ErrorAction Stop # DoNotExpandEnvironmentNames is essential when reading another user's hive. @@ -66,7 +81,7 @@ function Resolve-WelaSaclUserFile { if (-not $raw) { throw "Known-folder value '$folder' is absent." } $resolved = [regex]::Replace($raw, '(?i)%USERPROFILE%', [System.Text.RegularExpressions.MatchEvaluator]{ param($match) $User.ProfilePath }) if ($resolved -match '%[^%]+%' -or $resolved -notmatch '^(?:[A-Za-z]:\\|\\\\)') { throw 'Known-folder path contains unresolved user variables or is not absolute.' } - if ($folder -eq 'AppData') { $resolved = $resolved.TrimEnd('\') + '\Signal' } + if ($suffix) { $resolved = $resolved.TrimEnd('\') + '\' + $suffix } $expected = $User.ProfilePath.TrimEnd('\') + '\' + $RelativePath $state = if ($resolved -ine $expected) { 'Redirected' } else { 'Resolved' } [pscustomobject]@{ Path = $resolved; State = $state; Diagnostic = 'Resolved from this user hive; remote paths are reported without network access.' } diff --git a/tests/TargetedSaclPlanning.Tests.ps1 b/tests/TargetedSaclPlanning.Tests.ps1 index 7dc3fe4e..b8f1248f 100644 --- a/tests/TargetedSaclPlanning.Tests.ps1 +++ b/tests/TargetedSaclPlanning.Tests.ps1 @@ -64,18 +64,51 @@ $unloaded = Resolve-WelaSaclUserFile -User ([pscustomobject]@{HiveLoaded=$false} 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:knownFolderName = $null; $script:knownFolderReads = 0 $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 } +$script:key | Add-Member ScriptMethod GetValue { param($Name,$Default,$Options) if ($Options -ne [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) { throw 'Unsafe variable expansion mode' }; $script:knownFolderName = $Name; return $script:knownFolder } +function Get-Item { + param($LiteralPath,[switch]$Force,$ErrorAction) + if ($LiteralPath -ne 'Registry::HKEY_USERS\S-1-5-21-1\Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders') { throw 'Unexpected known-folder read scope.' } + $script:knownFolderReads++; 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.' +# Additional catalog-shaped targets retain their complete suffix under the +# selected user's known folder. Resolving a remote root never accesses it. +$userFileFixtures = @( + [pscustomobject]@{ relpath = 'AppData\\Roaming\\Foo'; suffix = 'Foo' }, + [pscustomobject]@{ relpath = 'appdata\roaming\Vendor\Cache'; suffix = 'Vendor\Cache' }, + [pscustomobject]@{ relpath = 'AppData\Roaming\Foo\Startup'; suffix = 'Foo\Startup' } +) +foreach ($fixture in $userFileFixtures) { + $script:knownFolder = '%USERPROFILE%\AppData\Roaming' + $result = Resolve-WelaSaclUserFile -User $user -RelativePath $fixture.relpath + Assert ($result.State -eq 'Resolved' -and $result.Path -ieq ('C:\Users\One\AppData\Roaming\' + $fixture.suffix) -and $script:knownFolderName -eq 'AppData') 'Additional AppData target must retain its suffix and use the AppData known folder.' + $script:knownFolder = '\\server\share\Roaming' + $result = Resolve-WelaSaclUserFile -User $user -RelativePath $fixture.relpath + Assert ($result.State -eq 'Redirected' -and $result.Path -eq ('\\server\share\Roaming\' + $fixture.suffix) -and $script:knownFolderName -eq 'AppData') 'Redirected AppData target must retain its own suffix.' + $readsBefore = $script:knownFolderReads + Assert ((Get-WelaSaclTargetObservation -Path $result.Path -Kind FileSystem).PathState -eq 'RemoteNotInspected' -and $script:knownFolderReads -eq $readsBefore) 'Remote AppData target must be reported without target access.' +} $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.' +Assert ($startup.State -eq 'Resolved' -and $script:knownFolderName -eq 'Startup') 'Actual Startup catalog path normalizes before comparison and uses its own known folder.' $script:knownFolder = '\\server\share\Startup' -Assert ((Resolve-WelaSaclUserFile -User $user -RelativePath $definitions.user_files[0].relpath).State -eq 'Redirected') 'Real redirected Startup remains distinguished.' +$startup = Resolve-WelaSaclUserFile -User $user -RelativePath $definitions.user_files[0].relpath +Assert ($startup.State -eq 'Redirected' -and $startup.Path -eq $script:knownFolder -and $script:knownFolderName -eq 'Startup') 'Real redirected Startup remains distinguished without appending its catalog suffix twice.' +$startupChild = Resolve-WelaSaclUserFile -User $user -RelativePath 'AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup\Child' +Assert ($startupChild.Path -eq '\\server\share\Startup\Child' -and $script:knownFolderName -eq 'Startup') 'A target below Startup must retain Startup redirection rather than fall back to AppData.' +foreach ($invalid in @('Desktop\Startup', 'AppData\Local\Foo', 'AppData\RoamingOther\Foo', 'AppData\Roaming', + 'AppData\Roaming\..\Local\Foo', 'AppData\Roaming\.\Foo', 'AppData\Roaming\Foo.\Bar', 'AppData\Roaming\Foo \Bar', + 'AppData\Roaming\Foo:stream', 'AppData/Roaming/Foo', 'AppData\Roaming\*', 'AppData\Roaming\Foo\', + 'AppData\Roaming\%APPDATA%', 'C:\Users\Other\AppData\Roaming\Foo')) { + $readsBefore = $script:knownFolderReads + $result = Resolve-WelaSaclUserFile -User $user -RelativePath $invalid + Assert ($result.State -eq 'UnresolvedUserPath' -and -not $result.Path -and $script:knownFolderReads -eq $readsBefore) "Unsupported user target must remain unresolved without path reads: $invalid" +} 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 { diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index 8805e76c..af4c86c9 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -55,7 +55,7 @@ **新機能:** -- プロファイルの plan/audit/configure に対象を限定した SACL の読み取り専用計画を追加しました。オブジェクト監査ポリシー、ユーザーハイブ・フォルダーリダイレクトの未確認箇所、WEF Run/RunOnce の監査エントリを表示し、`-SaclMode Skip` による省略も明示します。SACL の書き込みや未検証の検知率向上は行いません。 (#398) (@Shirofune-Security) +- プロファイルの plan/audit/configure に対象を限定した SACL の読み取り専用計画を追加しました。オブジェクト監査ポリシー、ユーザーハイブ・フォルダーリダイレクトの未確認箇所、WEF Run/RunOnce の監査エントリを表示し、`-SaclMode Skip` による省略も明示します。ユーザーファイルの対象は、そのユーザーの AppData または Startup 既知フォルダー配下の相対パスを保持し、未対応・曖昧なパスは未解決として扱います。SACL の書き込みや未検証の検知率向上は行いません。 (#398) (@Shirofune-Security) - MITRE ATT&CK Navigatorヒートマップに対応した。 (#11) (@fukusuket) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index cee66c42..1706369e 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -57,7 +57,7 @@ **New Features:** -- Profile plan/audit/configure now include read-only targeted SACL prerequisites with object policy masks, per-user hive and redirected-folder gaps, exact WEF Run/RunOnce audit entries, and an explicit `-SaclMode Skip`. No SACL writes or unverified detection uplift are implied. (#398) (@Shirofune-Security) +- Profile plan/audit/configure now include read-only targeted SACL prerequisites with object policy masks, per-user hive and redirected-folder gaps, exact WEF Run/RunOnce audit entries, and an explicit `-SaclMode Skip`. User-file targets retain their configured suffix under the user's AppData or Startup known folder; unsupported or ambiguous paths remain unresolved. No SACL writes or unverified detection uplift are implied. (#398) (@Shirofune-Security) - Support for MITRE ATT&CK Navigator heatmaps. (#11) (@fukusuket)