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/tests/FileSaclLifecycle.Windows.Tests.ps1 b/tests/FileSaclLifecycle.Windows.Tests.ps1 new file mode 100644 index 00000000..10452ba8 --- /dev/null +++ b/tests/FileSaclLifecycle.Windows.Tests.ps1 @@ -0,0 +1,54 @@ +# 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','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') +$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 +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)} +$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 +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=@() +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 + $context=Get-WelaSelectedSaclContext + $catalog=Get-WelaSelectedSaclCatalog -Profile 'asd-native-2021-10' -IncludeOptional -Context $context + Save 'catalog.json' $catalog;Save 'owned-profile.json' ([pscustomobject]@{Sid=$hive.Sid;Nonce=$nonce;Root=$targetRoot;ProfilePath=$profile.ProfilePath;AppDataPath=$profile.AppDataPath;SelectedPath=$signal}) + $selected=@($catalog.Rows|Where-Object {$_.Definition.UserSid -ceq $hive.Sid -and $_.Definition.Kind -ceq 'FileSystem' -and $_.Definition.Path -ieq $signal}) + Assert ($selected.Count -eq 1 -and $selected[0].Definition.Resolution -ceq 'Redirected') 'Real built-in catalog resolves exactly the owned redirected Signal folder.' + Assert ($selected[0].Definition.PrincipalSid -ceq 'S-1-1-0' -and @($selected[0].Definition.Rights).Count -eq 1 -and $selected[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.' +}catch{$failure=$_}finally{ + 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 owned native profile substrate 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;} + } + } +}