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)