diff --git a/.gitattributes b/.gitattributes index 7f4524b7..f4896249 100644 --- a/.gitattributes +++ b/.gitattributes @@ -96,3 +96,7 @@ tests/NativeProviderConfigure.Windows.Tests.ps1 text eol=lf /modules/WecSubscriptionInventory.cs text eol=lf /tests/WecCollectorObservation* text eol=lf /tests/WecSubscriptionInventory* text eol=lf + +# Public filesystem-SACL disposable lifecycle evidence. +tests/FileSaclProfileFixture.cs text eol=lf +tests/FileSaclLifecycle.Windows.Tests.ps1 text eol=lf diff --git a/.github/workflows/filesystem-sacl-lifecycle.yml b/.github/workflows/filesystem-sacl-lifecycle.yml new file mode 100644 index 00000000..7ce9b882 --- /dev/null +++ b/.github/workflows/filesystem-sacl-lifecycle.yml @@ -0,0 +1,43 @@ +name: Native public filesystem SACL lifecycle +on: + push: + branches: ['**'] + paths: + - 'tests/FileSacl*' + - 'tests/RegistrySaclFixtureNative.cs' + - 'tests/SelectedSaclFixtureProtection.cs' + - 'scripts/SelectedSacl*' + - 'scripts/TargetedSaclPlanning.ps1' + - 'scripts/FileAccessProbe*' + - 'WELA.ps1' + - '.github/workflows/filesystem-sacl-lifecycle.yml' + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + filesystem-sacl-lifecycle: + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + engine: [powershell, pwsh] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - name: Owned public filesystem lifecycle in Windows PowerShell 5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: ./tests/FileSaclLifecycle.Windows.Tests.ps1 -AllowDisposableProfileWrite + - name: Owned public filesystem lifecycle in PowerShell 7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: ./tests/FileSaclLifecycle.Windows.Tests.ps1 -AllowDisposableProfileWrite + - name: Retain public receipts and exact owned-fixture cleanup + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: filesystem-sacl-${{ matrix.os }}-${{ matrix.engine }} + path: ${{ runner.temp }}/wela-filesystem-lifecycle-*/ + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 52989c99..20d22f1c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,7 +41,7 @@ jobs: Copy-Item -Recurse -Path ./scripts -Destination release-binaries/ Copy-Item -Recurse -Path ./modules -Destination release-binaries/ New-Item -ItemType Directory -Path release-binaries/docs -Force | Out-Null - Copy-Item -Path ./docs/gpo-audit-packages.md, ./docs/gpo-package-deployment.md, ./docs/gpo-creation.md, ./docs/wmi-probe.md, ./docs/firewall-logging.md, ./docs/firewall-logging-recovery.md, ./docs/ipsec-prerequisites.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-ingress.md, ./docs/capi2-probe.md, ./docs/powershell-transcription.md, ./docs/transcription-recovery.md, ./docs/eventlog-recovery.md, ./docs/failed-logon-probe.md, ./docs/wec-listener.md, ./docs/file-access-probe.md, ./docs/applocker-script-probe.md, ./docs/applocker-probe.md, ./docs/selected-sacl-configuration.md, ./docs/registry-sacl-recovery.md, ./docs/targeted-sacl-planning.md, ./docs/native-registry-sacl-validation.md, ./docs/wec-authorization.md, ./docs/channel-recovery.md, ./docs/outgoing-ntlm.md, ./docs/wec-collector-observation.md, ./docs/native-provider-packs.md, ./docs/native-provider-acceptance.md -Destination release-binaries/docs/ + Copy-Item -Path ./docs/gpo-audit-packages.md, ./docs/gpo-package-deployment.md, ./docs/gpo-creation.md, ./docs/wmi-probe.md, ./docs/firewall-logging.md, ./docs/firewall-logging-recovery.md, ./docs/ipsec-prerequisites.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-ingress.md, ./docs/capi2-probe.md, ./docs/powershell-transcription.md, ./docs/transcription-recovery.md, ./docs/eventlog-recovery.md, ./docs/failed-logon-probe.md, ./docs/wec-listener.md, ./docs/file-access-probe.md, ./docs/applocker-script-probe.md, ./docs/applocker-probe.md, ./docs/selected-sacl-configuration.md, ./docs/registry-sacl-recovery.md, ./docs/targeted-sacl-planning.md, ./docs/native-registry-sacl-validation.md, ./docs/native-filesystem-sacl-validation.md, ./docs/wec-authorization.md, ./docs/channel-recovery.md, ./docs/outgoing-ntlm.md, ./docs/wec-collector-observation.md, ./docs/native-provider-packs.md, ./docs/native-provider-acceptance.md -Destination release-binaries/docs/ - name: Set Artifact Name if: contains(matrix.info.os, 'windows') == true diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index 3b6874eb..e893c489 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,6 +4,8 @@ **改善:** +- Server 2022/2025 と PowerShell 5.1/7 の使い捨て環境で、リダイレクトされたユーザーフォルダーの実カタログを使う公開ファイルシステム SACL 設定を検証します。Plan/DryRun/Configure、子の変化による拒否、再実行時の無変更を確認し、無関係なセキュリティ情報と保護された子孫を保持します。継承された末端ファイルの SACL を公開プローブの正確な Security4663 と照合し、型付きプロファイル、ハイブ、トークン、監査ポリシー、所有ファイルの後始末を記録とハッシュで確認します。本番のプロファイル読み込み、遠隔ユーザー、将来の子孫、Sigma の保証は行いません。 (関連 #373) (@Shirofune-Security) + - 明示的な `registry-sacl-recovery` を追加しました。整合する4つの元記録、レビュー済み計画のハッシュ、過去・現在ともに空の子キー観測、監査縮小と継承への個別同意を必須とし、追加が証明されたレジストリルートの明示的な監査ACEを1つだけ削除します。SACLのみのネイティブ書き込みで他の記述子フィールドとACEの順序を保持し、部分的な書き込みの証跡と元の記述子バイトとの一致を別々に報告します。過去のキー・操作者の同一性認証、ツリー全体の原子性、イベント生成、Sigmaの準備完了は保証しません。 (Related #373) (@Shirofune-Security) - Server 2022/2025とPowerShell 5.1/7でSMBポリシー設定の公開CLIを検証します。対応ホストで6項目の適用・再読取・再実行、非対応ホストのスキップ、元の型付き記録、無関係な設定の維持と完全な復元を確認します。イベント生成と実行時の有効化は別途検証します。(関連 #377) (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7129758..dc7ac96f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ **Improvements:** +- Added native public filesystem SACL lifecycle validation for one genuine redirected per-user catalog target on disposable Server 2022/2025 with PowerShell 5.1/7. Actual Plan/DryRun/Configure, stale-child refusal and idempotence preserve unrelated security and protected descendants; an inherited leaf SACL is checked against an exact public-probe Security4663. Retained receipts and hashes verify full typed profile, hive, token, audit-policy and owned-file cleanup without production profile loading, remote-user, future-child or Sigma claims. (Related #373) (@Shirofune-Security) + - Added opt-in `registry-sacl-recovery` for one proven explicit registry-root audit ACE, requiring four matching original records, a reviewed plan hash, empty historical/current descendants and separate audit-reduction/inheritance consent. Native SACL-only removal preserves unrelated descriptor fields and ACE order, retains partial-write evidence and reports original-byte equality separately; no authenticated historical key/operator identity, atomic-tree, event or Sigma claim. (Related #373) (@Shirofune-Security) - Add native public SMB policy configuration acceptance on Server 2022/2025 and PowerShell 5.1/7: six-policy apply/readback and idempotence on supported hosts, unsupported-host skips, typed original journals, unrelated-state preservation and exact cleanup. Event generation and runtime activation remain separate. (Related #377) (@Shirofune-Security) diff --git a/docs/native-filesystem-sacl-validation.md b/docs/native-filesystem-sacl-validation.md new file mode 100644 index 00000000..42d4347a --- /dev/null +++ b/docs/native-filesystem-sacl-validation.md @@ -0,0 +1,38 @@ +# Public filesystem SACL lifecycle validation + +The `Native public filesystem SACL lifecycle` workflow validates the public `targeted-sacl` command against its real built-in per-user Signal directory definition. It runs on disposable Server 2022/2025 hosts with Windows PowerShell 5.1 and PowerShell 7. It uses an owned redirected folder, its existing ordinary descendants and a protected subtree, then runs the public `file-access-probe` against one inherited leaf SACL. + +## Owned fixture boundary + +The test creates a fresh private directory on the system volume, a newly saved hive mounted under a nonce-derived synthetic SID, and a matching new `ProfileList` entry. The entry contains only its ownership marker and typed `ProfileImagePath`. Its loaded hive supplies a redirected `AppData` known-folder value. The ordinary catalog must independently discover exactly that SID's Signal directory and classify it `Redirected`; no alternate catalog, arbitrary target switch or mocked resolver supplies selection authority. + +The synthetic SID is a fixture identity, not a created Windows account or proof of another user's effective access. The read worker uses the actual elevated runner account. Existing users, offline hives and system catalog targets are not modified. Profile registration, hive loading, initial unrelated ACE/protection setup and temporary audit policy are test-only operations; public WELA commands do not perform them. + +The test alone prepares File System success/failure auditing and typed advanced-audit precedence. It requires a complete observation of all 59 masks, the original full process token and the complete bounded `ProfileList` key/value inventory. Profile values retain their registry types and unexpanded data. The test never restores a whole saved system registry tree over current state. + +## Public operations and retained proof + +The fixture exercises this sequence with bounded, separately launched public WELA processes: + +1. Discover the actual redirected catalog target. Plan without child consent must block inheritance. +2. Plan with explicit child consent must capture the exact parent and all four existing descendants: one ordinary directory/leaf pair and one protected directory/leaf pair. +3. DryRun must leave every descriptor unchanged and create no recovery directory. +4. Create one owned unreviewed child. Configure using the earlier plan must refuse before journaling or writing. Remove that fixture child and generate a fresh plan. +5. Configure the fresh selection. A successful result must contain one `Applied` row and matching distinct Pending, Confirmed and descendant-observation records. +6. Independently read the parent and children. Exactly one required root ACE is added; its unrelated ACE, owner, group, DACL and other observed descriptor components remain. Two ordinary descendants show the inherited ACE, while both protected descendants retain their original security. +7. A fresh Plan/Configure reports `AlreadyCompliant`, adds no duplicate ACE or receipt, and preserves the complete observed tree. +8. Public file-probe Plan/Run on the ordinary leaf must observe the existing inherited ReadData SACL and exactly one attributable local Security 4663. The protected leaf must remain uncovered and its read probe must refuse before a read operation. + +The probe retains raw XML and binds the actual worker PID, handle, subject SID/logon, native file identity/path, access mask and measured one-byte-read/held-identity-readback phase. It reads exactly one byte and retains no file content. Only the fixture hashes its known harmless files to check byte preservation. The public configuration still reports `GenerationReadiness=Conditional` and `UsableRuleCredit=0`; the probe grants no Sigma credit. + +Review `fresh-plan.json`, `results.json`, `journal/`, the independent before/after/final descendant snapshots, `probe-result.json`, `probe/event.xml`, `cleanup.json` and `artifact-hashes.json` together. A process exit or printed status alone is insufficient. A failed run can retain partial evidence and is not a successful lifecycle result. + +## Cleanup and limits + +Cleanup restores the original selected audit mask and exact typed precedence, then independently compares every original audit mask, full token, `ProfileList` inventory/data and loaded-hive names. The ProfileList adapter removes only its exact unchanged two-value, childless, marker-owned entry. Changed ownership or partial setup prevents unproven deletion and is retained as a cleanup error. The owned hive is unloaded, its original seed removed, and the private hive files/target tree removed only after profile and hive restoration is verified. Each independent verification is guarded so one failure does not hide other cleanup observations. Registry parent last-write metadata is not restored or claimed unchanged. + +This proves the observed fixture cases on the tested builds. It does not establish arbitrary redirected-user access, remote shares, offline profiles, future children, an atomic tree transaction, Windows 11, domain/DC/CA behavior, forwarding, retention or backend Sigma execution. No receipt authorizes removing inherited ACEs from production descendants. The selected command's existing concurrency and partial-write limits still apply. + +The system-volume fixture is deliberate: some hosted data volumes emit the Removable Storage task even when `DriveInfo` reports Fixed. The existing probe accepts File System task 12800 only. Neither a protected branch nor another volume receives event credit from the successful ordinary leaf. + +See [selected SACL configuration](selected-sacl-configuration.md), [file-access probe](file-access-probe.md) and the separate [public registry lifecycle](native-registry-sacl-validation.md). Microsoft documents [SetSecurityInfo inheritance behavior](https://learn.microsoft.com/en-us/windows/win32/api/aclapi/nf-aclapi-setsecurityinfo) and the [4663 access-use event fields](https://learn.microsoft.com/en-us/windows/security/threat-protection/auditing/event-4663). diff --git a/docs/selected-sacl-configuration.md b/docs/selected-sacl-configuration.md index f9f7f3a4..af105b77 100644 --- a/docs/selected-sacl-configuration.md +++ b/docs/selected-sacl-configuration.md @@ -88,7 +88,9 @@ Mocked tests cover selection, source-specific masks, unsupported consent, source The separate [public registry lifecycle fixture](native-registry-sacl-validation.md) mounts a newly saved, fixture-owned hive under a fresh synthetic user SID. The unchanged public catalog resolves its RunOnce key, then actual CLI Plan/DryRun/Configure calls exercise the reviewed lifecycle and one exact local 4657. Only the fixture loads/unloads hives and prepares auditing; the product behavior above is unchanged. This leaf fixture does not replace populated-tree inheritance validation. -Native CI results must be reviewed before claiming those test cases passed. Windows 11, DC/CA, user redirection, large/changing production trees, forwarding and actual Sigma/backend execution remain separate acceptance work. Every report remains `GenerationReadiness=Conditional` with `UsableRuleCredit=0`. +The [public filesystem lifecycle fixture](native-filesystem-sacl-validation.md) resolves a genuine built-in Signal target through an owned synthetic profile and redirected known folder. It exercises actual public selection, Plan/DryRun/Configure, stale-child refusal and idempotence on a populated tree, checks protected descendants, and matches one public leaf-read probe to local4663 XML. Only the disposable fixture registers its profile and mounts its hive. + +Native CI results must be reviewed before claiming those test cases passed. Windows 11, DC/CA, other user-redirection/access scenarios, large/changing production trees, forwarding and actual Sigma/backend execution remain separate acceptance work. Every report remains `GenerationReadiness=Conditional` with `UsableRuleCredit=0`. Primary API references: [GetSecurityInfo](https://learn.microsoft.com/en-us/windows/win32/api/aclapi/nf-aclapi-getsecurityinfo), [SetSecurityInfo and inheritance](https://learn.microsoft.com/en-us/windows/win32/api/aclapi/nf-aclapi-setsecurityinfo), [registry open/link behavior](https://learn.microsoft.com/en-us/windows/win32/api/winreg/nf-winreg-regopenkeyexw), [file handle and sharing flags](https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilew). diff --git a/tests/FileSaclLifecycle.Windows.Tests.ps1 b/tests/FileSaclLifecycle.Windows.Tests.ps1 new file mode 100644 index 00000000..d9f63dd0 --- /dev/null +++ b/tests/FileSaclLifecycle.Windows.Tests.ps1 @@ -0,0 +1,178 @@ +# Mutating fixture only: public WELA never registers profiles or loads hives. +param([switch]$AllowDisposableProfileWrite) +$ErrorActionPreference='Stop' +if(-not $AllowDisposableProfileWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted' -or [Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'Explicit disposable hosted native Windows fixture only.'} +$script:ScriptRoot=Split-Path $PSScriptRoot -Parent +Import-Module (Join-Path $script:ScriptRoot 'modules/AuditProfiles.psm1') -ErrorAction Stop +foreach($name in @('Configuration','WefArrival','WmiProbe','ChannelRead','ControlApplicability','TargetedSaclPlanning','SelectedSaclConfiguration','FileAccessProbe')){. (Join-Path $script:ScriptRoot ('scripts/'+$name+'.ps1'))} +Initialize-WelaWmiProbeNative +Add-Type -Path (Join-Path $PSScriptRoot 'RegistrySaclFixtureNative.cs') -ErrorAction Stop +Add-Type -Path (Join-Path $PSScriptRoot 'FileSaclProfileFixture.cs') -ErrorAction Stop +$nonce=[guid]::NewGuid().ToString('N') +function Save([string]$Name,$Value){[IO.File]::WriteAllText((Join-Path $evidence $Name),(ConvertTo-Json -InputObject $Value -Depth 32),[Text.UTF8Encoding]::new($false))} +function Key($Value){ConvertTo-Json -InputObject $Value -Depth 32 -Compress} +function Hives {@([Microsoft.Win32.Registry]::Users.GetSubKeyNames()|Sort-Object)} +function Read-Receipt([string]$Name){ConvertFrom-WelaArrivalJson ([IO.File]::ReadAllText((Join-Path $evidence $Name)))} +function Read-PublicReport([string]$Name){$text=Read-Receipt ($Name+'-output.json');$start=$text.IndexOf('{');if($start -lt 0){throw 'Public probe JSON is missing.'};ConvertFrom-WelaArrivalJson $text.Substring($start)} +$engine=(Get-Process -Id $PID).Path +Add-Type -TypeDefinition @' +using System;using System.IO;using System.Text;using System.Threading.Tasks; +public static class WelaFileSaclLifecyclePipe { + public static async Task Read(TextReader reader){var text=new StringBuilder();var buffer=new char[1024];while(true){int n=await reader.ReadAsync(buffer,0,buffer.Length).ConfigureAwait(false);if(n==0)return text.ToString();if(n>1048576-text.Length)throw new InvalidDataException("Fixture output exceeds one Mi character bound.");text.Append(buffer,0,n);}} +} +'@ +function Public([string]$Name,[string[]]$Arguments,[int]$Expected=0){ + $all=@('-NoLogo','-NoProfile','-NonInteractive','-File',(Join-Path $script:ScriptRoot 'WELA.ps1'))+$Arguments + foreach($a in $all){if($a.Contains('"') -or $a.EndsWith('\') -or $a -match '[\x00-\x1f]'){throw 'Ambiguous fixture argument.'}} + $info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$engine;$info.Arguments=(@($all|ForEach-Object {'"'+$_+'"'}) -join ' ');$info.UseShellExecute=$false;$info.CreateNoWindow=$true;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true + $process=[Diagnostics.Process]::new();$process.StartInfo=$info;$started=$false + try{ + if(-not $process.Start()){throw 'Public process did not start.'};$started=$true + $stdout=[WelaFileSaclLifecyclePipe]::Read($process.StandardOutput);$stderr=[WelaFileSaclLifecyclePipe]::Read($process.StandardError) + if(-not $process.WaitForExit(180000)){throw 'Public command exceeded three minutes.'} + if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),5000)){throw 'Public output drain timed out.'} + $text=$stdout.Result+"`n"+$stderr.Result;Save ($Name+'-output.json') $text + Assert ($process.ExitCode -eq $Expected) ("Public $Name exited $($process.ExitCode), expected $Expected : "+$text) + }finally{ + if($started){$exited=$false;try{$exited=$process.HasExited}catch{$script:cleanupErrors+=$_.Exception.Message};if(-not $exited){try{$process.Kill()}catch{$script:cleanupErrors+=$_.Exception.Message};try{$exited=$process.WaitForExit(5000)}catch{$script:cleanupErrors+=$_.Exception.Message}};if(-not $exited){$script:cleanupErrors+='Owned public process termination unconfirmed.'}} + $process.Dispose() + } +} + +$script:assertions=0 +function Assert($Condition,[string]$Message){if(-not $Condition){throw $Message};$script:assertions++} +$beforeProfiles=[Wela.FileSaclFixture.Profile]::Snapshot();$beforeHives=Hives;$beforeToken=[Wela.WmiProbe.Native]::Snapshot() +$beforeMasks=Get-WelaEffectiveAuditPolicy;$precedencePath='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa';$precedenceName='SCENoApplyLegacyAuditPolicy';$beforePrecedence=Get-WelaRegistryState $precedencePath $precedenceName +$evidence=New-WelaArrivalOutput (Join-Path $env:RUNNER_TEMP ('wela-filesystem-lifecycle-'+$nonce)) $script:ScriptRoot +$targetRoot=New-WelaArrivalOutput (Join-Path (Join-Path $env:SystemRoot 'Temp') ('wela-filesystem-sacl-'+$nonce)) $script:ScriptRoot +$files=Join-Path $evidence 'owned-hive-files';$null=New-Item -ItemType Directory $files +Save 'before-profiles.json' $beforeProfiles;Save 'before-hives.json' $beforeHives;Save 'before-token.json' $beforeToken;Save 'before-masks.json' $beforeMasks;Save 'before-precedence.json' $beforePrecedence +$hive=[Wela.RegistrySaclFixture.Hive]::new($nonce,(Join-Path $files 'owned.dat'));$profile=$null;$failure=$null;$cleanupErrors=@();$policyTouched=$false;$auditGuid='0CCE921D-69AE-11D9-BED3-505054503030' +try { + $hive.Prepare();$profile=[Wela.FileSaclFixture.Profile]::new($nonce,$hive.Sid,$targetRoot);$profile.Prepare() + $signal=Join-Path $profile.AppDataPath 'Signal';$null=New-Item -ItemType Directory $signal + $preparedProfiles=Key ([Wela.FileSaclFixture.Profile]::Snapshot()) + $collision=[Wela.FileSaclFixture.Profile]::new($nonce,$hive.Sid,$targetRoot);$refusal='' + try{$collision.Prepare()}catch{$refusal=$_.Exception.Message}finally{$collision.Dispose()} + Assert ($refusal -match 'already exists' -and -not $collision.Created -and (Key ([Wela.FileSaclFixture.Profile]::Snapshot())) -ceq $preparedProfiles) 'A real colliding ProfileList entry is never claimed, altered or removed by a new fixture owner.' + $ownedProfilePath='Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\'+$hive.Sid + try{ + Set-ItemProperty -LiteralPath $ownedProfilePath -Name ProfileImagePath -Type String -Value $profile.ProfilePath + $refusal='';try{$profile.Dispose()}catch{$refusal=$_.Exception.Message} + Assert ($refusal -match 'changed' -and $profile.Created -and (Test-Path -LiteralPath $ownedProfilePath)) 'Typed ownership drift refuses profile deletion despite identical text.' + }finally{Set-ItemProperty -LiteralPath $ownedProfilePath -Name ProfileImagePath -Type ExpandString -Value $profile.ProfilePath} + $profile.AssertOwned() + Assert ((Key ([Wela.FileSaclFixture.Profile]::Snapshot())) -ceq $preparedProfiles) 'Fixture-only ownership refusal test restores its exact registered profile tuple.' + + Public 'catalog' @('targeted-sacl','-TargetSaclProfile','asd-native-2021-10','-IncludeOptional','-ResultsPath',(Join-Path $evidence 'catalog.json')) + $catalog=Read-Receipt 'catalog.json' + Save 'owned-profile.json' ([pscustomobject]@{Sid=$hive.Sid;Nonce=$nonce;Root=$targetRoot;ProfilePath=$profile.ProfilePath;AppDataPath=$profile.AppDataPath;SelectedPath=$signal}) + $selectedRows=@($catalog.Catalog|Where-Object {$_.Definition.UserSid -ceq $hive.Sid -and $_.Definition.Kind -ceq 'FileSystem' -and $_.Definition.Path -ieq $signal}) + Assert ($selectedRows.Count -eq 1 -and $selectedRows[0].Definition.Resolution -ceq 'Redirected') 'Real built-in catalog resolves exactly the owned redirected Signal folder.' + Assert ($selectedRows[0].Definition.PrincipalSid -ceq 'S-1-1-0' -and @($selectedRows[0].Definition.Rights).Count -eq 1 -and $selectedRows[0].Definition.Rights[0] -ceq 'ReadData') 'Owned fixture uses the unchanged built-in read target.' + Assert ((Key (Hives)) -ceq (Key (@($beforeHives)+$hive.Sid|Sort-Object))) 'Only the owned hive was mounted.' + $profile.AssertOwned();$hive.AssertOwned() + Assert (([Wela.FileSaclFixture.Profile]::Snapshot()).Children.Count -eq $beforeProfiles.Children.Count+1) 'Exactly one marker-owned profile entry was registered.' + Assert ((Key ([Wela.WmiProbe.Native]::Snapshot())) -ceq (Key $beforeToken)) 'Fixture profile/hive preparation restores full token state.' + $selected=$selectedRows[0];Save 'selected.json' $selected + $policyTouched=$true;Set-ItemProperty -LiteralPath $precedencePath -Name $precedenceName -Type DWord -Value 1;Set-WelaEffectiveAuditPolicy -Guid $auditGuid -Mask 3 -Mode exact + $preparedMasks=Key ((Get-WelaEffectiveAuditPolicy).GetEnumerator()|Sort-Object Key);$preparedPrecedence=Key (Get-WelaRegistryState $precedencePath $precedenceName) + $open=Join-Path $signal 'open';$protected=Join-Path $signal 'protected';$null=New-Item -ItemType Directory $open,$protected + Add-Type -Path (Join-Path $PSScriptRoot 'SelectedSaclFixtureProtection.cs') -ErrorAction Stop + Initialize-WelaSelectedSaclNative;$privilege=[Wela.SelectedSacl.Privilege]::new();$target=$null + try{ + $protectedBefore=Get-WelaSelectedSaclSnapshot ([pscustomobject]@{Kind='FileSystem';Path=$protected;Resolution='Resolved'}) + [Wela.SelectedSaclFixture.Protection]::Protect('FileSystem',$protected,$protectedBefore.DescriptorBase64,$nonce) + $target=[Wela.SelectedSacl.Target]::new('FileSystem',$signal);$before=$target.Read();$null=$target.Add($before.Identity,$before.DescriptorBase64,'S-1-5-18',2,64) + }finally{if($target){$target.Dispose()};$privilege.Dispose()} + $leaf=Join-Path $open 'ReadLeaf.bin';$protectedLeaf=Join-Path $protected 'ReadLeaf.bin' + foreach($path in @($leaf,$protectedLeaf)){[IO.File]::WriteAllBytes($path,[byte[]]@(87,69,76,65))} + $contentBefore=@(foreach($path in @($leaf,$protectedLeaf)){[pscustomobject]@{Path=$path;Sha256=(Get-FileHash -LiteralPath $path).Hash.ToLowerInvariant()}}) + Save 'owned-content-before.json' $contentBefore + $before=Get-WelaSelectedSaclSnapshot $selected.Definition;$children=Get-WelaSelectedSaclStableDescendants $selected.Definition $before + Save 'before-public.json' $before;Save 'before-descendants.json' $children + Assert ($before.Aces.Count -eq 1 -and $before.Aces[0].Sid -ceq 'S-1-5-18' -and $before.Aces[0].Mask -eq 2) 'Fixture seeds only its unrelated root audit ACE.' + Assert ($children.Status -ceq 'Complete' -and $children.Entries.Count -eq 4 -and @($children.Entries|Where-Object ProtectedBarrier).Count -eq 2) 'Before-state captures exactly four owned descendants and the protected branch.' + $selection=@('targeted-sacl','-TargetSaclProfile','asd-native-2021-10','-TargetSaclId',$selected.Id,'-IncludeOptional') + Public 'no-child-consent' ($selection+@('-TargetSaclAction','Plan','-ResultsPath',(Join-Path $evidence 'no-child-plan.json'))) + Assert ((Read-Receipt 'no-child-plan.json').Rows[0].Status -ceq 'Blocked' -and (Read-Receipt 'no-child-plan.json').Rows[0].Diagnostic -match 'IncludeChildren') 'Actual public Plan refuses inherited scope without explicit child consent.' + $selection+='-TargetSaclIncludeChildren' + $planPath=Join-Path $evidence 'reviewed-plan.json';Public 'plan' ($selection+@('-TargetSaclAction','Plan','-ResultsPath',$planPath)) + $plan=Read-Receipt 'reviewed-plan.json';$row=$plan.Rows[0] + Assert ($plan.Rows.Count -eq 1 -and $row.Status -is [string] -and $row.Status -ceq 'ChangeRequired' -and $row.Definition.Resolution -ceq 'Redirected' -and $row.Ace.Mask -eq 1 -and $row.Ace.Flags -eq 195) 'Public reviewed plan selects exactly the redirected catalog root and explicit ReadData inheritance.' + Assert ((Get-WelaSelectedSaclSnapshotKey $row.Before) -ceq (Get-WelaSelectedSaclSnapshotKey $before) -and (Get-WelaSelectedSaclDescendantKey $row.DescendantsBefore) -ceq (Get-WelaSelectedSaclDescendantKey $children)) 'Public review binds independently observed full native root and descendants.' + $dryBackup=Join-Path $evidence 'dry-journal' + Public 'dry-run' ($selection+@('-TargetSaclAction','Configure','-TargetSaclPlanPath',$planPath,'-DryRun','-BackupPath',$dryBackup,'-ResultsPath',(Join-Path $evidence 'dry-results.json'))) + $dry=Read-Receipt 'dry-results.json' + Assert ($dry.DryRun -is [bool] -and $dry.DryRun -and $dry.Results[0].Status -ceq 'Skipped' -and -not(Test-Path $dryBackup)) 'Actual public DryRun writes no recovery directory or ACE.' + Assert ((Get-WelaSelectedSaclDescendantKey (Get-WelaSelectedSaclStableDescendants $selected.Definition (Get-WelaSelectedSaclSnapshot $selected.Definition))) -ceq (Get-WelaSelectedSaclDescendantKey $children)) 'Plan and DryRun preserve all native parent/child state.' + $appeared=Join-Path $signal 'Appeared.bin';[IO.File]::WriteAllBytes($appeared,[byte[]]@(1)) + $staleBefore=Get-WelaSelectedSaclStableDescendants $selected.Definition (Get-WelaSelectedSaclSnapshot $selected.Definition);Save 'stale-before.json' $staleBefore + $staleBackup=Join-Path $evidence 'stale-journal' + Public 'stale' ($selection+@('-TargetSaclAction','Configure','-TargetSaclPlanPath',$planPath,'-BackupPath',$staleBackup,'-ResultsPath',(Join-Path $evidence 'stale-results.json'),'-Auto')) 1 + $staleAfter=Get-WelaSelectedSaclStableDescendants $selected.Definition (Get-WelaSelectedSaclSnapshot $selected.Definition);Save 'stale-after.json' $staleAfter + Assert (-not(Test-Path $staleBackup) -and (Read-Receipt 'stale-output.json') -match 'descendants changed' -and (Get-WelaSelectedSaclDescendantKey $staleBefore) -ceq (Get-WelaSelectedSaclDescendantKey $staleAfter)) 'A real unreviewed child refuses public Configure before journal/write and preserves all observed state.' + Remove-Item -LiteralPath $appeared -Force -ErrorAction Stop + $freshPlan=Join-Path $evidence 'fresh-plan.json';Public 'fresh-plan' ($selection+@('-TargetSaclAction','Plan','-ResultsPath',$freshPlan)) + $journal=Join-Path $evidence 'journal';$resultsPath=Join-Path $evidence 'results.json' + Public 'configure' ($selection+@('-TargetSaclAction','Configure','-TargetSaclPlanPath',$freshPlan,'-BackupPath',$journal,'-ResultsPath',$resultsPath,'-Auto')) + $result=Read-Receipt 'results.json';$applied=$result.Results[0] + Assert ($result.ExitCode -eq 0 -and $result.DryRun -is [bool] -and -not $result.DryRun -and $result.Results.Count -eq 1 -and $applied.Status -is [string] -and $applied.Status -ceq 'Applied') 'Actual public Configure reports exactly one completed selected root addition.' + $after=Get-WelaSelectedSaclSnapshot $selected.Definition;$afterChildren=Get-WelaSelectedSaclStableDescendants $selected.Definition $after + Save 'after-public.json' $after;Save 'after-descendants.json' $afterChildren + Assert-WelaSelectedSaclPreserved $before $after $row.Ace + Assert ($after.Aces.Count -eq $before.Aces.Count+1 -and $after.Aces[0].Binary -ceq $before.Aces[0].Binary) 'Independent native readback proves one appended root audit ACE and unchanged unrelated ACE.' + $outcome=Test-WelaSelectedSaclDescendantOutcomes $children $afterChildren $row.Ace + Save 'independent-descendant-outcomes.json' $outcome + Assert ($outcome.Status -ceq 'Observed' -and @($outcome.Outcomes|Where-Object Status -CEQ 'InheritedAceObserved').Count -eq 2 -and @($outcome.Outcomes|Where-Object Status -CEQ 'ProtectedUnchanged').Count -eq 2) 'Actual propagation is observed on the open branch while both protected descendants retain exact security.' + Assert ((Get-WelaSelectedSaclSnapshotKey $applied.After) -ceq (Get-WelaSelectedSaclSnapshotKey $after) -and (Get-WelaSelectedSaclDescendantKey $applied.DescendantsAfter) -ceq (Get-WelaSelectedSaclDescendantKey $afterChildren)) 'Public Applied evidence agrees with independent native parent and descendant readback.' + $pending=Read-Receipt ('journal/'+$selected.Id+'.pending.json');$confirmed=Read-Receipt ('journal/'+$selected.Id+'.confirmed.json');$observed=Read-Receipt ('journal/'+$selected.Id+'.descendants-observed.json') + Assert ($pending.State -is [string] -and $pending.State -ceq 'Pending' -and $null -eq $pending.After -and $confirmed.State -is [string] -and $confirmed.State -ceq 'Confirmed') 'Distinct original Pending and Confirmed receipts establish actual intent and completion.' + Assert ((Get-WelaSelectedSaclSnapshotKey $pending.Before) -ceq (Get-WelaSelectedSaclSnapshotKey $before) -and (Get-WelaSelectedSaclSnapshotKey $confirmed.After) -ceq (Get-WelaSelectedSaclSnapshotKey $after) -and (Get-WelaSelectedSaclDescendantKey $observed.After) -ceq (Get-WelaSelectedSaclDescendantKey $afterChildren)) 'Retained original receipt bytes bind the exact actual root/child transition.' + Assert ($result.GenerationReadiness -ceq 'Conditional' -and $result.UsableRuleCredit -eq 0) 'Root configuration remains conditional without a coverage or Sigma claim.' + $againPlan=Join-Path $evidence 'idempotent-plan.json';Public 'idempotent-plan' ($selection+@('-TargetSaclAction','Plan','-ResultsPath',$againPlan)) + $againJournal=Join-Path $evidence 'idempotent-journal';Public 'idempotent-configure' ($selection+@('-TargetSaclAction','Configure','-TargetSaclPlanPath',$againPlan,'-BackupPath',$againJournal,'-ResultsPath',(Join-Path $evidence 'idempotent-results.json'),'-Auto')) + $again=Read-Receipt 'idempotent-results.json' + Assert ($again.Results[0].Status -ceq 'AlreadyCompliant' -and @(Get-ChildItem -LiteralPath $againJournal -Force).Count -eq 0 -and (Get-WelaSelectedSaclDescendantKey (Get-WelaSelectedSaclStableDescendants $selected.Definition (Get-WelaSelectedSaclSnapshot $selected.Definition))) -ceq (Get-WelaSelectedSaclDescendantKey $afterChildren)) 'Public second Configure adds no duplicate ACE or receipt and preserves full native descendant state.' + Public 'probe-plan' @('file-access-probe','-FileProbePath',$leaf.ToLowerInvariant()) + $probePlan=Read-PublicReport 'probe-plan';Save 'probe-plan.json' $probePlan + Assert ($probePlan.Status -ceq 'PrerequisitesObserved' -and @($probePlan.Before.File.Aces|Where-Object {($_.Flags -band 16) -and $_.Sid -ceq 'S-1-1-0' -and ($_.Mask -band 1)}).Count -eq 1) 'Public read-probe Plan observes the actual inherited ReadData SACL on the owned leaf.' + $probeOutput=Join-Path $evidence 'probe';Public 'probe' @('file-access-probe','-FileProbeAction','Run','-FileProbePath',$leaf.ToLowerInvariant(),'-FileProbeOutputPath',$probeOutput) + $probe=Read-PublicReport 'probe';Save 'probe-result.json' $probe + Assert ($probe.Status -ceq 'FileReadObserved' -and $probe.Matches -eq 1 -and $probe.Operation.Read.ReadCalls -eq 1 -and $probe.Operation.Read.BytesRead -eq 1 -and $probe.RetainedContentBytes -eq 0) 'Actual one-byte public leaf read produces exactly one attributable4663 without retaining content.' + Assert (Test-WelaFileProbeEvent ([IO.File]::ReadAllText((Join-Path $probeOutput 'event.xml'))) $probe.Operation $probe.Before) 'Retained native4663 matches exact worker PID/handle/token/path/right and measured operation phase.' + foreach($artifact in $probe.Artifacts){Assert ((Get-FileHash -LiteralPath (Join-Path $probeOutput $artifact.Name)).Hash.ToLowerInvariant() -ceq $artifact.Sha256) 'Public probe artifact hash matches retained bytes.'} + Assert ($probe.ConfigurationChanges -eq 0 -and $probe.FileDataWrites -eq 0 -and $probe.SigmaEvtxCredit -eq 0) 'Observed read grants no configuration, file-write or Sigma credit.' + Public 'protected-probe' @('file-access-probe','-FileProbePath',$protectedLeaf) 1 + $protectedProbe=Read-PublicReport 'protected-probe';Save 'protected-probe.json' $protectedProbe + Assert ($protectedProbe.Status -ceq 'Unverified' -and $null -eq $protectedProbe.Operation -and $protectedProbe.Diagnostic -match 'No existing ordinary success ReadData') 'Protected leaf receives no inherited coverage and its public read probe is refused.' + $contentAfter=@(foreach($path in @($leaf,$protectedLeaf)){[pscustomobject]@{Path=$path;Sha256=(Get-FileHash -LiteralPath $path).Hash.ToLowerInvariant()}});Save 'owned-content-after.json' $contentAfter + Assert ((Key $contentAfter) -ceq (Key $contentBefore)) 'Fixture-owned content remains byte-identical.' + $finalChildren=Get-WelaSelectedSaclStableDescendants $selected.Definition (Get-WelaSelectedSaclSnapshot $selected.Definition);Save 'final-descendants.json' $finalChildren + Assert ((Get-WelaSelectedSaclDescendantKey $finalChildren) -ceq (Get-WelaSelectedSaclDescendantKey $afterChildren)) 'Final public probe outcomes preserve full root/descendant security and membership.' + $profile.AssertOwned();$hive.AssertOwned() + Assert ((Key ((Get-WelaEffectiveAuditPolicy).GetEnumerator()|Sort-Object Key)) -ceq $preparedMasks -and (Key (Get-WelaRegistryState $precedencePath $precedenceName)) -ceq $preparedPrecedence) 'Every public operation preserves prepared auditing and typed precedence.' + Assert ((Key ([Wela.WmiProbe.Native]::Snapshot())) -ceq (Key $beforeToken)) 'All fixture and public operations restore full token groups/privileges.' + +}catch{$failure=$_}finally{ + if($policyTouched){ + try{Set-WelaEffectiveAuditPolicy -Guid $auditGuid -Mask $beforeMasks[$auditGuid] -Mode exact}catch{$cleanupErrors+='Audit restore: '+$_.Exception.Message} + try{if($beforePrecedence.ValueExists){Set-ItemProperty -LiteralPath $precedencePath -Name $precedenceName -Type $beforePrecedence.Type -Value $beforePrecedence.Value}else{Remove-ItemProperty -LiteralPath $precedencePath -Name $precedenceName -ErrorAction Stop}}catch{$cleanupErrors+='Precedence restore: '+$_.Exception.Message} + } + try{if($profile){$profile.Dispose()}}catch{$cleanupErrors+='Profile removal: '+$_.Exception.Message} + try{$hive.Dispose()}catch{$cleanupErrors+='Hive unload/seed removal: '+$_.Exception.Message} + $afterProfiles=$null;$afterHives=$null;$afterToken=$null;$afterMasks=$null;$afterPrecedence=$null + $profilesOk=$false;$hivesOk=$false;$tokenOk=$false;$masksOk=$false;$precedenceOk=$false + try{$afterProfiles=[Wela.FileSaclFixture.Profile]::Snapshot();$profilesOk=(Key $afterProfiles) -ceq (Key $beforeProfiles)}catch{$cleanupErrors+='Profile verification: '+$_.Exception.Message} + try{$afterHives=Hives;$hivesOk=(Key $afterHives) -ceq (Key $beforeHives)}catch{$cleanupErrors+='Hive verification: '+$_.Exception.Message} + try{$afterToken=[Wela.WmiProbe.Native]::Snapshot();$tokenOk=(Key $afterToken) -ceq (Key $beforeToken)}catch{$cleanupErrors+='Token verification: '+$_.Exception.Message} + try{$afterMasks=Get-WelaEffectiveAuditPolicy;$masksOk=(Key ($afterMasks.GetEnumerator()|Sort-Object Key)) -ceq (Key ($beforeMasks.GetEnumerator()|Sort-Object Key))}catch{$cleanupErrors+='Audit verification: '+$_.Exception.Message} + try{$afterPrecedence=Get-WelaRegistryState $precedencePath $precedenceName;$precedenceOk=(Key $afterPrecedence) -ceq (Key $beforePrecedence)}catch{$cleanupErrors+='Precedence verification: '+$_.Exception.Message} + if($profilesOk -and $hivesOk -and -not $hive.Loaded){try{Remove-Item -LiteralPath $targetRoot -Recurse -Force -ErrorAction Stop;Remove-Item -LiteralPath $files -Recurse -Force -ErrorAction Stop}catch{$cleanupErrors+='Owned file removal: '+$_.Exception.Message}} + $cleanup=[pscustomobject]@{Complete=($profilesOk -and $hivesOk -and $tokenOk -and $masksOk -and $precedenceOk -and -not $hive.Loaded -and -not $hive.SeedCreated -and -not(Test-Path $targetRoot) -and -not(Test-Path $files) -and $cleanupErrors.Count -eq 0);ProfilesRestored=$profilesOk;HivesRestored=$hivesOk;TokenRestored=$tokenOk;AuditMasksCompared=$beforeMasks.Count;AuditMasksRestored=$masksOk;PrecedenceRestored=$precedenceOk;HiveUnloaded=(-not $hive.Loaded);SeedRemoved=(-not $hive.SeedCreated);FilesRemoved=(-not(Test-Path $targetRoot) -and -not(Test-Path $files));Errors=$cleanupErrors;Failure=$(if($failure){$failure.Exception.Message}else{$null});Assertions=$script:assertions;AfterProfiles=$afterProfiles;AfterHives=$afterHives;AfterToken=$afterToken;AfterMasks=$afterMasks;AfterPrecedence=$afterPrecedence} + Save 'cleanup.json' $cleanup + Save 'artifact-hashes.json' @(Get-ChildItem -LiteralPath $evidence -Recurse -File|Where-Object Name -ne 'artifact-hashes.json'|Sort-Object FullName|ForEach-Object {[pscustomobject]@{Name=$_.FullName.Substring($evidence.Length+1).Replace('\','/');Sha256=(Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant()}}) +} +if($failure){throw $failure};if(-not $cleanup.Complete){throw ('Owned profile fixture cleanup incomplete: '+(Key $cleanup))} +Write-Host "Passed $script:assertions actual public filesystem SACL lifecycle assertions; cleanup confirmed. Evidence: $evidence" +$global:LASTEXITCODE=0 diff --git a/tests/FileSaclProfileFixture.cs b/tests/FileSaclProfileFixture.cs new file mode 100644 index 00000000..894ac80c --- /dev/null +++ b/tests/FileSaclProfileFixture.cs @@ -0,0 +1,86 @@ +// Disposable hosted-test setup only. Never imported by WELA product commands. +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; +using Microsoft.Win32; +using Microsoft.Win32.SafeHandles; +namespace Wela.FileSaclFixture { + public sealed class ValueState {public string Name,Kind;public object Value;} + public sealed class KeyState {public string Name;public ValueState[] Values;public KeyState[] Children;} + public sealed class Profile : IDisposable { + const string ProfileList=@"SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList"; + const string ShellFolders=@"Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders"; + [DllImport("advapi32.dll",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegCreateKeyExW(IntPtr root,string path,int reserved,string cls,uint options,uint access,IntPtr security,out IntPtr result,out uint disposition); + [DllImport("advapi32.dll")] static extern int RegCloseKey(IntPtr key); + public readonly string Nonce,Sid,Root,ProfilePath,AppDataPath; + public bool Created {get;private set;} + public Profile(string nonce,string sid,string root) { + if(Environment.OSVersion.Platform!=PlatformID.Win32NT||!Environment.Is64BitProcess||Environment.GetEnvironmentVariable("GITHUB_ACTIONS")!="true"||Environment.GetEnvironmentVariable("RUNNER_ENVIRONMENT")!="github-hosted")throw new InvalidOperationException("Disposable native hosted Windows fixture only."); + if(!System.Text.RegularExpressions.Regex.IsMatch(nonce??"","^[a-f0-9]{32}$"))throw new InvalidOperationException("Exact owned nonce required."); + string expectedSid="S-1-5-21-"+Convert.ToUInt32(nonce.Substring(0,8),16)+"-"+Convert.ToUInt32(nonce.Substring(8,8),16)+"-"+Convert.ToUInt32(nonce.Substring(16,8),16)+"-1001"; + if(sid!=expectedSid)throw new InvalidOperationException("Profile must use the matching owned hive SID."); + string expectedRoot=Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows),"Temp","wela-filesystem-sacl-"+nonce); + if(!String.Equals(Path.GetFullPath(root),expectedRoot,StringComparison.OrdinalIgnoreCase))throw new InvalidOperationException("Only the nonce-owned system-volume fixture tree is supported."); + AssertOrdinary(root);Nonce=nonce;Sid=sid;Root=Path.GetFullPath(root);ProfilePath=Path.Combine(Root,"Profile");AppDataPath=Path.Combine(Root,"RedirectedRoaming"); + } + static void AssertOrdinary(string path) { + for(DirectoryInfo directory=new DirectoryInfo(path);directory!=null;directory=directory.Parent) + if(!directory.Exists||(directory.Attributes&FileAttributes.ReparsePoint)!=0)throw new InvalidOperationException("Fixture tree or ancestor is absent or a reparse point."); + } + public static KeyState Snapshot() { + int count=0;using(RegistryKey machine=RegistryKey.OpenBaseKey(RegistryHive.LocalMachine,RegistryView.Registry64)) + using(RegistryKey root=machine.OpenSubKey(ProfileList,false)){if(root==null)throw new InvalidOperationException("Actual ProfileList is missing.");return Read(root,"ProfileList",0,ref count);} + } + static KeyState Read(RegistryKey key,string name,int depth,ref int count) { + if(depth>8||++count>4096)throw new InvalidOperationException("Profile inventory exceeds its bounded scope."); + string[] names=key.GetValueNames();Array.Sort(names,StringComparer.Ordinal);if(names.Length>256)throw new InvalidOperationException("Profile values exceed fixture bound."); + var values=new List();foreach(string valueName in names){ + RegistryValueKind kind=key.GetValueKind(valueName);object value=key.GetValue(valueName,null,RegistryValueOptions.DoNotExpandEnvironmentNames); + if(value==null||kind==RegistryValueKind.Unknown||kind==RegistryValueKind.None)throw new InvalidOperationException("Unknown typed profile value."); + if(value is string&&((string)value).Length>1048576||value is byte[]&&((byte[])value).Length>1048576)throw new InvalidOperationException("Profile value exceeds fixture bound."); + values.Add(new ValueState{Name=valueName,Kind=kind.ToString(),Value=value}); + } + string[] children=key.GetSubKeyNames();Array.Sort(children,StringComparer.Ordinal);var result=new List(); + foreach(string child in children)using(RegistryKey opened=key.OpenSubKey(child,false)){if(opened==null)throw new InvalidOperationException("Profile inventory changed.");result.Add(Read(opened,child,depth+1,ref count));} + return new KeyState{Name=name,Values=values.ToArray(),Children=result.ToArray()}; + } + void AssertHive() { + using(RegistryKey hive=Registry.Users.OpenSubKey(Sid,false)) + if(hive==null||hive.GetValueKind("WelaFixtureOwner")!=RegistryValueKind.String||!String.Equals(hive.GetValue("WelaFixtureOwner") as string,Nonce,StringComparison.Ordinal))throw new InvalidOperationException("Owned hive marker differs."); + } + public void Prepare() { + if(Created)throw new InvalidOperationException("Profile was already prepared.");AssertHive();AssertOrdinary(Root); + Directory.CreateDirectory(ProfilePath);Directory.CreateDirectory(AppDataPath); + IntPtr handle;uint disposition;int error=RegCreateKeyExW(new IntPtr(unchecked((int)0x80000002)),ProfileList+"\\"+Sid,0,null,0,0xF013F,IntPtr.Zero,out handle,out disposition); + if(error!=0)throw new Win32Exception(error,"Create owned ProfileList entry"); + try{ + if(disposition!=1)throw new InvalidOperationException("ProfileList identity already exists.");Created=true; + using(var safe=new SafeRegistryHandle(handle,false))using(RegistryKey key=RegistryKey.FromHandle(safe,RegistryView.Registry64)){ + key.SetValue("WelaFixtureOwner",Nonce,RegistryValueKind.String); + key.SetValue("ProfileImagePath",ProfilePath,RegistryValueKind.ExpandString);key.Flush(); + } + }finally{RegCloseKey(handle);} + AssertHive(); + using(RegistryKey hive=Registry.Users.OpenSubKey(Sid,true))using(RegistryKey shell=hive.CreateSubKey(ShellFolders)){ + if(shell.ValueCount!=0||shell.SubKeyCount!=0)throw new InvalidOperationException("Owned known-folder key unexpectedly contains data."); + shell.SetValue("AppData",AppDataPath,RegistryValueKind.ExpandString); + shell.SetValue("Startup",@"%USERPROFILE%\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup",RegistryValueKind.ExpandString);shell.Flush(); + } + AssertOwned(); + } + public void AssertOwned() { + AssertHive(); + using(RegistryKey machine=RegistryKey.OpenBaseKey(RegistryHive.LocalMachine,RegistryView.Registry64)) + using(RegistryKey key=machine.OpenSubKey(ProfileList+"\\"+Sid,false)){ + if(!Created||key==null||key.SubKeyCount!=0||key.ValueCount!=2||key.GetValueKind("WelaFixtureOwner")!=RegistryValueKind.String||!String.Equals(key.GetValue("WelaFixtureOwner") as string,Nonce,StringComparison.Ordinal)||key.GetValueKind("ProfileImagePath")!=RegistryValueKind.ExpandString||!String.Equals(key.GetValue("ProfileImagePath",null,RegistryValueOptions.DoNotExpandEnvironmentNames) as string,ProfilePath,StringComparison.Ordinal))throw new InvalidOperationException("Owned ProfileList entry changed; removal is refused."); + } + } + public void Dispose() { + if(!Created)return;AssertOwned(); + using(RegistryKey machine=RegistryKey.OpenBaseKey(RegistryHive.LocalMachine,RegistryView.Registry64)) + using(RegistryKey root=machine.OpenSubKey(ProfileList,true)){root.DeleteSubKey(Sid,true);Created=false;} + } + } +} diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index 854679ee..c2099cb1 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,6 +7,8 @@ **改善:** +- Server 2022/2025 と PowerShell 5.1/7 の使い捨て環境で、リダイレクトされたユーザーフォルダーの実カタログを使う公開ファイルシステム SACL 設定を検証します。Plan/DryRun/Configure、子の変化による拒否、再実行時の無変更を確認し、無関係なセキュリティ情報と保護された子孫を保持します。継承された末端ファイルの SACL を公開プローブの正確な Security4663 と照合し、型付きプロファイル、ハイブ、トークン、監査ポリシー、所有ファイルの後始末を記録とハッシュで確認します。本番のプロファイル読み込み、遠隔ユーザー、将来の子孫、Sigma の保証は行いません。 (関連 #373) (@Shirofune-Security) + - 明示的な `registry-sacl-recovery` を追加しました。整合する4つの元記録、レビュー済み計画のハッシュ、過去・現在ともに空の子キー観測、監査縮小と継承への個別同意を必須とし、追加が証明されたレジストリルートの明示的な監査ACEを1つだけ削除します。SACLのみのネイティブ書き込みで他の記述子フィールドとACEの順序を保持し、部分的な書き込みの証跡と元の記述子バイトとの一致を別々に報告します。過去のキー・操作者の同一性認証、ツリー全体の原子性、イベント生成、Sigmaの準備完了は保証しません。 (Related #373) (@Shirofune-Security) - Server 2022/2025とPowerShell 5.1/7でSMBポリシー設定の公開CLIを検証します。対応ホストで6項目の適用・再読取・再実行、非対応ホストのスキップ、元の型付き記録、無関係な設定の維持と完全な復元を確認します。イベント生成と実行時の有効化は別途検証します。(関連 #377) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index 6b533916..648293c2 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,6 +7,8 @@ **Improvements:** +- Added native public filesystem SACL lifecycle validation for one genuine redirected per-user catalog target on disposable Server 2022/2025 with PowerShell 5.1/7. Actual Plan/DryRun/Configure, stale-child refusal and idempotence preserve unrelated security and protected descendants; an inherited leaf SACL is checked against an exact public-probe Security4663. Retained receipts and hashes verify full typed profile, hive, token, audit-policy and owned-file cleanup without production profile loading, remote-user, future-child or Sigma claims. (Related #373) (@Shirofune-Security) + - Added opt-in `registry-sacl-recovery` for one proven explicit registry-root audit ACE, requiring four matching original records, a reviewed plan hash, empty historical/current descendants and separate audit-reduction/inheritance consent. Native SACL-only removal preserves unrelated descriptor fields and ACE order, retains partial-write evidence and reports original-byte equality separately; no authenticated historical key/operator identity, atomic-tree, event or Sigma claim. (Related #373) (@Shirofune-Security) - Add native public SMB policy configuration acceptance on Server 2022/2025 and PowerShell 5.1/7: six-policy apply/readback and idempotence on supported hosts, unsupported-host skips, typed original journals, unrelated-state preservation and exact cleanup. Event generation and runtime activation remain separate. (Related #377) (@Shirofune-Security)