diff --git a/.gitattributes b/.gitattributes index d4d2d2bb..ecc3e97a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -73,3 +73,6 @@ tests/SelectedSaclFixtureProtection.cs text eol=lf # Existing-file read receipts bind identical native/worker source bytes. /scripts/FileAccessProbe* text eol=lf /tests/FileAccessProbe* text eol=lf + +# Disposable public registry lifecycle fixture bytes are retained in evidence. +/tests/RegistrySacl* text eol=lf diff --git a/.github/workflows/registry-sacl-lifecycle.yml b/.github/workflows/registry-sacl-lifecycle.yml index 0dc1a4cb..da05c7fd 100644 --- a/.github/workflows/registry-sacl-lifecycle.yml +++ b/.github/workflows/registry-sacl-lifecycle.yml @@ -23,6 +23,9 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - name: Strict event attribution fixtures + shell: ${{ matrix.engine }} + run: ./tests/RegistrySaclLifecycle.Tests.ps1 - name: Actual public registry lifecycle in Windows PowerShell 5.1 if: matrix.engine == 'powershell' shell: powershell diff --git a/tests/RegistrySaclFixtureNative.cs b/tests/RegistrySaclFixtureNative.cs index e5fe4118..f4d39905 100644 --- a/tests/RegistrySaclFixtureNative.cs +++ b/tests/RegistrySaclFixtureNative.cs @@ -24,9 +24,15 @@ namespace Wela.RegistrySaclFixture { } public void Dispose(){if(token==IntPtr.Zero)return;try{Privileges ignored;uint needed;bool ok=AdjustTokenPrivileges(token,false,ref previous,(uint)Marshal.SizeOf(typeof(Privileges)),out ignored,out needed);int error=Marshal.GetLastWin32Error();if(!ok||error!=0)throw new Win32Exception(error,"Fixture privilege restoration failed.");}finally{CloseHandle(token);token=IntPtr.Zero;}} } + public sealed class WriteReceipt {public string StartedUtc,ReturnedUtc,CompletedUtc,HandleId,ValueName,Value;public int Calls;public bool Success;} public sealed class Hive : IDisposable { + [DllImport("kernel32.dll",ExactSpelling=true)] static extern void GetSystemTimePreciseAsFileTime(out long value); + static DateTime UtcNow(){long value;GetSystemTimePreciseAsFileTime(out value);return DateTime.FromFileTimeUtc(value);} static readonly IntPtr HKCU=new IntPtr(unchecked((int)0x80000001)),HKU=new IntPtr(unchecked((int)0x80000003)); [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",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegOpenKeyExW(IntPtr root,string path,uint options,uint access,out IntPtr key); + [DllImport("advapi32.dll",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegSetValueExW(IntPtr key,string name,uint reserved,uint type,byte[] data,uint size); + [DllImport("advapi32.dll",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegQueryValueExW(IntPtr key,string name,IntPtr reserved,out uint type,byte[] data,ref uint size); [DllImport("advapi32.dll")] static extern int RegCloseKey(IntPtr key); [DllImport("advapi32.dll",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegSaveKeyExW(IntPtr key,string file,IntPtr security,uint flags); [DllImport("advapi32.dll",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegLoadKeyW(IntPtr root,string name,string file); @@ -53,7 +59,21 @@ namespace Wela.RegistrySaclFixture { AssertOwned(); } public void AssertOwned(){using(RegistryKey key=Registry.Users.OpenSubKey(Sid)){if(!Loaded||key==null||key.GetValueKind("WelaFixtureOwner")!=RegistryValueKind.String||!String.Equals(key.GetValue("WelaFixtureOwner") as string,Nonce,StringComparison.Ordinal))throw new InvalidOperationException("Owned hive marker changed.");}} - public void CreateRunOnce(){AssertOwned();IntPtr key;uint disposition;Check(RegCreateKeyExW(HKU,Sid+"\\Software\\Microsoft\\Windows\\CurrentVersion\\RunOnce",0,null,0,0xF003F,IntPtr.Zero,out key,out disposition),"Create owned catalog RunOnce target");try{if(disposition!=1)throw new InvalidOperationException("Owned target unexpectedly exists.");}finally{RegCloseKey(key);}} + public void CreateRunOnce(){AssertOwned();IntPtr key;uint disposition;Check(RegCreateKeyExW(HKU,Sid+"\\Software\\Microsoft\\Windows\\CurrentVersion\\RunOnce",0,null,0,0xF003F,IntPtr.Zero,out key,out disposition),"Create owned catalog RunOnce target");try{if(disposition!=1)throw new InvalidOperationException("Owned target unexpectedly exists.");}finally{RegCloseKey(key);} + using(RegistryKey target=Registry.Users.OpenSubKey(Sid+"\\Software\\Microsoft\\Windows\\CurrentVersion\\RunOnce",true)){target.SetValue("KeepTypedDword",321,RegistryValueKind.DWord);} + } + public void AssertValues(bool probe){using(RegistryKey key=Registry.Users.OpenSubKey(Sid+"\\Software\\Microsoft\\Windows\\CurrentVersion\\RunOnce")){if(key==null||key.ValueCount!=(probe?2:1)||key.GetValueKind("KeepTypedDword")!=RegistryValueKind.DWord||!(key.GetValue("KeepTypedDword") is int)||(int)key.GetValue("KeepTypedDword")!=321)throw new InvalidOperationException("Unrelated owned typed values changed.");if(probe&&(key.GetValueKind("WelaProbe_"+Nonce)!=RegistryValueKind.String||!String.Equals(key.GetValue("WelaProbe_"+Nonce) as string,Nonce,StringComparison.Ordinal)))throw new InvalidOperationException("Owned nonce value mismatch.");}} + public WriteReceipt WriteProbe(){ + AssertOwned();AssertValues(false);string name="WelaProbe_"+Nonce;IntPtr key; + Check(RegOpenKeyExW(HKU,Sid+"\\Software\\Microsoft\\Windows\\CurrentVersion\\RunOnce",0,0x103,out key),"Open owned value writer"); + try{ + byte[] bytes=System.Text.Encoding.Unicode.GetBytes(Nonce+"\0");string handle="0x"+unchecked((ulong)key.ToInt64()).ToString("x"); + DateTime start=UtcNow();Check(RegSetValueExW(key,name,0,1,bytes,(uint)bytes.Length),"Write one owned nonce REG_SZ");DateTime returned=UtcNow(); + uint type,size=(uint)bytes.Length;byte[] actual=new byte[size];Check(RegQueryValueExW(key,name,IntPtr.Zero,out type,actual,ref size),"Read back same-handle owned nonce"); + if(type!=1||size!=bytes.Length||Convert.ToBase64String(actual)!=Convert.ToBase64String(bytes))throw new InvalidOperationException("Probe write readback failed.");DateTime completed=UtcNow(); + return new WriteReceipt{StartedUtc=start.ToString("o"),ReturnedUtc=returned.ToString("o"),CompletedUtc=completed.ToString("o"),HandleId=handle,ValueName=name,Value=Nonce,Calls=1,Success=true}; + }finally{RegCloseKey(key);} + } public void Dispose(){ if(Loaded){AssertOwned();using(new Privilege("SeBackupPrivilege"))using(new Privilege("SeRestorePrivilege")){Check(RegUnLoadKeyW(HKU,Sid),"Unload owned fixture hive");Loaded=false;}} if(SeedCreated){using(RegistryKey seed=Registry.CurrentUser.OpenSubKey(SeedPath)){if(seed==null||seed.SubKeyCount!=0||seed.ValueCount!=1||seed.GetValueKind("WelaFixtureOwner")!=RegistryValueKind.String||!String.Equals(seed.GetValue("WelaFixtureOwner") as string,Nonce,StringComparison.Ordinal))throw new InvalidOperationException("Owned seed changed; refuse deletion.");}Registry.CurrentUser.DeleteSubKey(SeedPath,true);SeedCreated=false;} diff --git a/tests/RegistrySaclLifecycle.Tests.ps1 b/tests/RegistrySaclLifecycle.Tests.ps1 new file mode 100644 index 00000000..406893d1 --- /dev/null +++ b/tests/RegistrySaclLifecycle.Tests.ps1 @@ -0,0 +1,13 @@ +$ErrorActionPreference='Stop';$root=Split-Path $PSScriptRoot -Parent +. (Join-Path $root 'scripts/WefArrival.ps1');. (Join-Path $PSScriptRoot 'RegistrySaclLifecycleEvidence.ps1') +$operation=[pscustomobject]@{Computer='FIXTURE';ProcessId=1234;Engine='C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe';NativePath='\REGISTRY\USER\S-1-5-21-1-2-3-1001\Software\Microsoft\Windows\CurrentVersion\RunOnce';RecordIdBefore=10;Token=[pscustomobject]@{Sid='S-1-5-21-4-5-6-500';AuthenticationId='0x1234'};Write=[pscustomobject]@{ValueName='WelaProbe_nonce';Value='nonce';HandleId='0x456';StartedUtc='2026-09-22T00:00:00.0001000Z';ReturnedUtc='2026-09-22T00:00:00.0001500Z';CompletedUtc='2026-09-22T00:00:00.0002000Z'}} +$xml=@" +46570128010x8020000000000000SecurityFIXTURE11S-1-5-21-4-5-6-500ReaderFIXTURE0x1234$($operation.NativePath)WelaProbe_nonce0x456%%1904--%%1873nonce0x4d2$($operation.Engine) +"@ +$count=0 +function Assert($value,$message){if(-not $value){throw $message};$script:count++} +Assert (Test-WelaRegistrySaclFixtureEvent $xml $operation) 'Exact native-schema creation must match the measured write/readback phase.' +foreach($change in @(@('4657','4663'),@('%%1904','%%1905'),@('%%1873','%%1874'),@('0x1234','0x1235'),@('0x456','0x457'),@('0x4d2','0x4d3'),@('6-500','6-501'),@('RunOnce','Other'),@('>nonce<','>other<'),@('0001800Z','0000999Z'),@('0001800Z','0002001Z'),@('EventRecordID>11','EventRecordID>10'),@('12801','12800'),@('8020000000000000','8010000000000000'))){Assert (-not(Test-WelaRegistrySaclFixtureEvent $xml.Replace($change[0],$change[1]) $operation)) ('Changed attribution must fail: '+$change[0])} +Assert (-not(Test-WelaRegistrySaclFixtureEvent $xml.Replace('','0x4d2') $operation)) 'Duplicate identity fields must fail.' +Assert (-not(Test-WelaRegistrySaclFixtureEvent (']>'+$xml) $operation)) 'DTD input must fail.' +Write-Host "Passed $count registry SACL fixture evidence assertions." diff --git a/tests/RegistrySaclLifecycle.Windows.Tests.ps1 b/tests/RegistrySaclLifecycle.Windows.Tests.ps1 index ad95ae05..4a1669fe 100644 --- a/tests/RegistrySaclLifecycle.Windows.Tests.ps1 +++ b/tests/RegistrySaclLifecycle.Windows.Tests.ps1 @@ -1,40 +1,143 @@ +# Mutating test fixture only: public WELA never loads hives or prepares audit policy. param([switch]$AllowDisposableHiveWrite) $ErrorActionPreference='Stop' if(-not $AllowDisposableHiveWrite -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 GitHub-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')){. (Join-Path $script:ScriptRoot ('scripts/'+$name+'.ps1'))} +foreach($name in @('Configuration','WefArrival','WmiProbe','ChannelRead','SelectedSaclConfiguration')){. (Join-Path $script:ScriptRoot ('scripts/'+$name+'.ps1'))} +. (Join-Path $PSScriptRoot 'RegistrySaclLifecycleEvidence.ps1') Initialize-WelaWmiProbeNative Add-Type -Path (Join-Path $PSScriptRoot 'RegistrySaclFixtureNative.cs') -ErrorAction Stop $root=New-WelaArrivalOutput (Join-Path $env:RUNNER_TEMP ('wela-registry-sacl-'+[guid]::NewGuid().ToString('N'))) $script:ScriptRoot $files=Join-Path $root 'owned-hive-files';$null=New-Item -ItemType Directory $files function Save([string]$Name,$Value){[IO.File]::WriteAllText((Join-Path $root $Name),($Value|ConvertTo-Json -Depth 28),[Text.UTF8Encoding]::new($false))} +function Read-Receipt([string]$Name){ConvertFrom-WelaArrivalJson ([IO.File]::ReadAllText((Join-Path $root $Name)))} function Hives {@([Microsoft.Win32.Registry]::Users.GetSubKeyNames()|Sort-Object)} -function Key($Value){ConvertTo-Json -InputObject $Value -Depth 20 -Compress} -$beforeHives=Hives;$beforeToken=[Wela.WmiProbe.Native]::Snapshot();$beforeMasks=Get-WelaEffectiveAuditPolicy;$beforePrecedence=Get-WelaRegistryState 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' SCENoApplyLegacyAuditPolicy -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([guid]::NewGuid().ToString('N'),(Join-Path $files 'owned.dat'));$failure='';$errors=@();$assertions=0 -try { - $hive.Prepare();$hive.CreateRunOnce();$hive.AssertOwned() - $mounted=Hives;if((Key $mounted) -cne (Key (@($beforeHives)+$hive.Sid|Sort-Object))){throw 'Unexpected HKU namespace change.'};$assertions++ - Save 'mounted.json' ([pscustomobject]@{Sid=$hive.Sid;File=$hive.FilePath;Seed=$hive.SeedPath;Loaded=$hive.Loaded;Target=('Registry::HKEY_USERS\'+$hive.Sid+'\Software\Microsoft\Windows\CurrentVersion\RunOnce')}) - if((Key ([Wela.WmiProbe.Native]::Snapshot())) -cne (Key $beforeToken)){throw 'Fixture preparation did not restore the primary token.'};$assertions++ - $engine=(Get-Process -Id $PID).Path;$old=$ErrorActionPreference - try{$ErrorActionPreference='Continue';$out=@(& $engine -NoLogo -NoProfile -NonInteractive -File (Join-Path $script:ScriptRoot 'WELA.ps1') targeted-sacl -TargetSaclProfile asd-native-2021-10 -IncludeOptional -ResultsPath (Join-Path $root 'catalog.json') 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$old;$global:LASTEXITCODE=0} - Save 'catalog-output.json' $out - if($code -ne 0){throw "Actual public catalog exited $code : $($out -join ' ')"} - $catalog=ConvertFrom-WelaArrivalJson ([IO.File]::ReadAllText((Join-Path $root 'catalog.json'))) - $matches=@($catalog.Catalog|Where-Object {$_.Definition.UserSid -ceq $hive.Sid -and $_.Definition.Path -ieq ('Registry::HKEY_USERS\'+$hive.Sid+'\Software\Microsoft\Windows\CurrentVersion\RunOnce')}) - if($matches.Count -ne 1 -or $matches[0].Id -cnotmatch '^sacl-[a-f0-9]{24}$' -or $matches[0].Definition.Resolution -cne 'Resolved'){throw 'The unchanged actual public catalog did not resolve exactly one owned registry target.'};$assertions++ - Save 'selected.json' $matches[0] -} catch {$failure=$_.Exception.Message;throw} finally { - try{$hive.Dispose()}catch{$errors+=$_.Exception.Message} - $hivesOk=(Key (Hives)) -ceq (Key $beforeHives);$tokenOk=(Key ([Wela.WmiProbe.Native]::Snapshot())) -ceq (Key $beforeToken) - $masks=Get-WelaEffectiveAuditPolicy;$masksOk=(Key ($masks.GetEnumerator()|Sort-Object Key)) -ceq (Key ($beforeMasks.GetEnumerator()|Sort-Object Key));$precedenceOk=(Key (Get-WelaRegistryState 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' SCENoApplyLegacyAuditPolicy)) -ceq (Key $beforePrecedence) - if(-not $hive.Loaded -and $hivesOk){try{Remove-Item -LiteralPath $files -Recurse -Force -ErrorAction Stop}catch{$errors+=$_.Exception.Message}} - $cleanup=[pscustomobject]@{Complete=($hivesOk -and $tokenOk -and $masksOk -and $precedenceOk -and -not $hive.SeedCreated -and -not(Test-Path $files) -and $errors.Count -eq 0);HivesRestored=$hivesOk;TokenRestored=$tokenOk;AuditMasksRestored=$masksOk;PrecedenceRestored=$precedenceOk;HiveUnloaded=(-not $hive.Loaded);SeedRemoved=(-not $hive.SeedCreated);FilesRemoved=(-not(Test-Path $files));Errors=$errors;Failure=$failure;Assertions=$assertions;AfterHives=(Hives);AfterToken=[Wela.WmiProbe.Native]::Snapshot();AfterMasks=$masks;AfterPrecedence=(Get-WelaRegistryState 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' SCENoApplyLegacyAuditPolicy)} - Save 'cleanup.json' $cleanup - if(-not $cleanup.Complete){throw ('Owned registry fixture cleanup incomplete: '+(Key $cleanup))} +function Key($Value){ConvertTo-Json -InputObject $Value -Depth 24 -Compress} +$script:assertions=0 +function Assert($Condition,[string]$Message){if(-not $Condition){throw $Message};$script:assertions++} +function Invoke-PublicFixture([string]$Name,[string[]]$Arguments,[int]$ExpectedExit=0,[string]$Diagnostic=''){ + $old=$ErrorActionPreference + try{$ErrorActionPreference='Continue';$output=@(& $engine -NoLogo -NoProfile -NonInteractive -File (Join-Path $script:ScriptRoot 'WELA.ps1') targeted-sacl @Arguments -ResultsPath (Join-Path $root ($Name+'.json')) 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$old;$global:LASTEXITCODE=0} + Save ($Name+'-output.json') @($output|ForEach-Object {[string]$_}) + Assert ($code -eq $ExpectedExit) ("Public $Name exited $code : "+($output -join ' ')) + if($Diagnostic){Assert (($output -join ' ') -match $Diagnostic) ("Public $Name did not report the expected refusal: "+($output -join ' '))} + if($ExpectedExit -eq 0){Read-Receipt ($Name+'.json')} } -Write-Host "Passed $assertions owned real hive/catalog checkpoint assertions; all cleanup confirmed. Evidence: $root" +function Assert-SelectedRow($Plan,[string]$Status){ + Assert ($Plan.Kind -is [string] -and $Plan.Kind -ceq 'WelaSelectedSaclPlan' -and @($Plan.Rows).Count -eq 1 -and $Plan.Rows[0].Id -is [string] -and $Plan.Rows[0].Id -ceq $selected.Id -and $Plan.Rows[0].Status -is [string] -and $Plan.Rows[0].Status -ceq $Status) ('Exact public selected row must be '+$Status) + Assert ($Plan.Rows[0].Definition.UserSid -ceq $hive.Sid -and $Plan.Rows[0].Definition.Path -ieq $providerPath -and $Plan.GenerationReadiness -ceq 'Conditional' -and $Plan.UsableRuleCredit -eq 0) 'Public plan must remain bound to the owned target without generation/Sigma credit.' +} +function Assert-PreparedState { + Assert ((Key ((Get-WelaEffectiveAuditPolicy).GetEnumerator()|Sort-Object Key)) -ceq $preparedMasks) 'Public selected-SACL calls must preserve all 59 prepared audit masks.' + Assert ((Key (Get-WelaRegistryState $precedencePath $precedenceName)) -ceq $preparedPrecedence) 'Public selected-SACL calls must preserve typed precedence.' + Assert ((Key ([Wela.WmiProbe.Native]::Snapshot())) -ceq (Key $beforeToken)) 'Current process token groups and privilege attributes must remain exact.' + $hive.AssertValues($false) +} +$engine=(Get-Process -Id $PID).Path;$guid='0CCE921E-69AE-11D9-BED3-505054503030' +$precedencePath='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa';$precedenceName='SCENoApplyLegacyAuditPolicy' +$beforeHives=Hives;$beforeToken=[Wela.WmiProbe.Native]::Snapshot();$beforeMasks=Get-WelaEffectiveAuditPolicy;$beforePrecedence=Get-WelaRegistryState $precedencePath $precedenceName +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([guid]::NewGuid().ToString('N'),(Join-Path $files 'owned.dat'));$failure=$null;$cleanupErrors=@();$policyTouched=$false +try { + Assert ($beforeMasks.Count -eq 59) 'All 59 native audit subcategories must be observed before fixture mutation.' + $hive.Prepare();$hive.CreateRunOnce();$hive.AssertOwned();$hive.AssertValues($false) + Assert ((Key (Hives)) -ceq (Key (@($beforeHives)+$hive.Sid|Sort-Object))) 'Only the fresh owned SID hive may appear in HKU.' + $providerPath='Registry::HKEY_USERS\'+$hive.Sid+'\Software\Microsoft\Windows\CurrentVersion\RunOnce' + Save 'mounted.json' ([pscustomobject]@{Sid=$hive.Sid;File=$hive.FilePath;Seed=$hive.SeedPath;Loaded=$hive.Loaded;Target=$providerPath}) + Assert ((Key ([Wela.WmiProbe.Native]::Snapshot())) -ceq (Key $beforeToken)) 'Fixture save/load must restore existing backup/restore privilege attributes.' + $catalog=Invoke-PublicFixture 'catalog' @('-TargetSaclProfile','asd-native-2021-10','-IncludeOptional') + $selectedRows=@($catalog.Catalog|Where-Object {$_.Definition.UserSid -ceq $hive.Sid -and $_.Definition.Path -ieq $providerPath}) + Assert ($selectedRows.Count -eq 1 -and $selectedRows[0].Id -cmatch '^sacl-[a-f0-9]{24}$' -and $selectedRows[0].Definition.Resolution -ceq 'Resolved') 'Actual public catalog must resolve exactly one owned registry target.' + $selected=$selectedRows[0];Save 'selected.json' $selected + # Seed a distinct explicit SYSTEM QueryValue audit ACE to prove additive preservation. + Initialize-WelaSelectedSaclNative;$privilege=[Wela.SelectedSacl.Privilege]::new();$target=$null + try{$target=[Wela.SelectedSacl.Target]::new('Registry',(Resolve-WelaSelectedSaclNativePath $selected.Definition));$original=$target.Read();$seeded=$target.Add($original.Identity,$original.DescriptorBase64,'S-1-5-18',1,64)}finally{if($target){$target.Dispose()};$privilege.Dispose()} + Save 'before-public-snapshot.json' $seeded + $policyTouched=$true + Set-ItemProperty -LiteralPath $precedencePath -Name $precedenceName -Type DWord -Value 1 + Set-WelaEffectiveAuditPolicy -Guid $guid -Mask 3 -Mode exact + $preparedMasks=Key ((Get-WelaEffectiveAuditPolicy).GetEnumerator()|Sort-Object Key);$preparedPrecedence=Key (Get-WelaRegistryState $precedencePath $precedenceName) + $selection=@('-TargetSaclProfile','asd-native-2021-10','-TargetSaclId',$selected.Id,'-IncludeOptional') + $noConsent=Invoke-PublicFixture 'no-consent-plan' ($selection+@('-TargetSaclAction','Plan')) + Assert-SelectedRow $noConsent 'Blocked' + $refusedBackup=Join-Path $root 'refused-no-consent' + Invoke-PublicFixture 'no-consent-configure' ($selection+@('-TargetSaclAction','Configure','-TargetSaclPlanPath',(Join-Path $root 'no-consent-plan.json'),'-BackupPath',$refusedBackup,'-Auto')) 1 'inheritance requires explicit' + Assert (-not(Test-Path -LiteralPath $refusedBackup)) 'Missing inheritance consent must fail before journal creation.' + $selection+=@('-TargetSaclIncludeChildren') + $plan=Invoke-PublicFixture 'plan' ($selection+@('-TargetSaclAction','Plan')) + Assert-SelectedRow $plan 'ChangeRequired' + Assert ($plan.Rows[0].DescendantsBefore.Status -ceq 'Complete' -and @($plan.Rows[0].DescendantsBefore.Entries).Count -eq 0) 'Owned RunOnce must have a complete empty descendant capture.' + Assert ((Get-WelaSelectedSaclSnapshotKey $plan.Rows[0].Before) -ceq (Get-WelaSelectedSaclSnapshotKey $seeded)) 'Read-only planning must preserve the entire native target descriptor/identity.' + $configure=$selection+@('-TargetSaclAction','Configure','-TargetSaclPlanPath',(Join-Path $root 'plan.json'),'-Auto') + $dry=Invoke-PublicFixture 'dry-run' ($configure+@('-DryRun')) + Assert ($dry.Kind -ceq 'WelaSelectedSaclResult' -and $dry.DryRun -is [bool] -and $dry.DryRun -and $dry.Results[0].Status -is [string] -and $dry.Results[0].Status -ceq 'Skipped' -and $null -eq $dry.BackupPath) 'Public DryRun must skip mutation and journal creation.' + Assert ((Get-WelaSelectedSaclSnapshotKey (Get-WelaSelectedSaclSnapshot $selected.Definition)) -ceq (Get-WelaSelectedSaclSnapshotKey $seeded)) 'DryRun must leave the exact native descriptor unchanged.' + Assert-PreparedState + $backup=Join-Path $root 'applied-journal' + $applied=Invoke-PublicFixture 'applied' ($configure+@('-BackupPath',$backup)) + Assert ($applied.Results.Count -eq 1 -and $applied.Results[0].Status -is [string] -and $applied.Results[0].Status -ceq 'Applied' -and $applied.GenerationReadiness -ceq 'Conditional' -and $applied.UsableRuleCredit -eq 0) 'Exactly the owned target must be Applied without event or rule credit.' + $after=Get-WelaSelectedSaclSnapshot $selected.Definition;Save 'after-public-snapshot.json' $after + Assert-WelaSelectedSaclPreserved $seeded $after $plan.Rows[0].Ace + Assert ($after.Aces.Count -eq $seeded.Aces.Count+1) 'Public Configure must append exactly one ACE and preserve the unrelated explicit audit ACE.' + Assert ((Get-WelaSelectedSaclSnapshotKey $after) -ceq (Get-WelaSelectedSaclSnapshotKey $applied.Results[0].After)) 'Public result must match independent native final readback.' + $pending=Read-Receipt ('applied-journal/'+$selected.Id+'.pending.json');$confirmed=Read-Receipt ('applied-journal/'+$selected.Id+'.confirmed.json');$desc=Read-Receipt ('applied-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' -and $pending.Id -ceq $selected.Id -and $confirmed.Id -ceq $selected.Id -and $desc.Verification.Status -ceq 'Observed') 'Durable pending/confirmed and descendant receipts must name the exact selected target.' + Assert ((Get-WelaSelectedSaclSnapshotKey $pending.Before) -ceq (Get-WelaSelectedSaclSnapshotKey $seeded) -and (Get-WelaSelectedSaclSnapshotKey $confirmed.After) -ceq (Get-WelaSelectedSaclSnapshotKey $after)) 'Journal snapshots must agree with both independent native observations.' + Assert-PreparedState + $staleBackup=Join-Path $root 'refused-stale' + Invoke-PublicFixture 'stale-configure' ($configure+@('-BackupPath',$staleBackup)) 1 'changed; review a new plan' + Assert (-not(Test-Path -LiteralPath $staleBackup)) 'Replaying the old descriptor must fail before another journal.' + $fresh=Invoke-PublicFixture 'idempotent-plan' ($selection+@('-TargetSaclAction','Plan')) + Assert-SelectedRow $fresh 'AlreadyCompliant' + $idemBackup=Join-Path $root 'idempotent-journal' + $idempotent=Invoke-PublicFixture 'idempotent' ($selection+@('-TargetSaclAction','Configure','-TargetSaclPlanPath',(Join-Path $root 'idempotent-plan.json'),'-BackupPath',$idemBackup,'-Auto')) + Assert ($idempotent.Results[0].Status -is [string] -and $idempotent.Results[0].Status -ceq 'AlreadyCompliant' -and @(Get-ChildItem -LiteralPath $idemBackup -Force).Count -eq 0) 'Fresh idempotent public Configure must make no write receipts.' + Assert ((Get-WelaSelectedSaclSnapshotKey (Get-WelaSelectedSaclSnapshot $selected.Definition)) -ceq (Get-WelaSelectedSaclSnapshotKey $after)) 'Stale refusal and idempotent Configure must preserve exact native state.' + Set-WelaEffectiveAuditPolicy -Guid $guid -Mask 0 -Mode exact + $blocked=Invoke-PublicFixture 'missing-policy-plan' ($selection+@('-TargetSaclAction','Plan')) + Assert-SelectedRow $blocked 'Blocked' + $policyBackup=Join-Path $root 'refused-policy' + Invoke-PublicFixture 'missing-policy-configure' ($selection+@('-TargetSaclAction','Configure','-TargetSaclPlanPath',(Join-Path $root 'missing-policy-plan.json'),'-BackupPath',$policyBackup,'-Auto')) 1 'outcomes are not already effective' + Assert (-not(Test-Path -LiteralPath $policyBackup) -and (Get-WelaEffectiveAuditPolicy)[$guid] -eq 0) 'Public Configure must refuse ineffective auditing without preparing policy or a journal.' + Set-WelaEffectiveAuditPolicy -Guid $guid -Mask 3 -Mode exact + Assert-PreparedState + $boundary=Read-WelaChannelLatest 'Security';Save 'security-boundary.json' $boundary + Assert ($boundary.Status -ceq 'EventObserved' -and $boundary.Event.RecordId -gt 0) 'Actual Security watermark must be observed before the single value write.' + $operation=[pscustomobject]@{Phase='OneRegSetValueAndSameHandleTypedReadback';Computer=$boundary.Event.Computer;ProcessId=$PID;Engine=$engine;NativePath=('\REGISTRY\USER\'+$hive.Sid+'\Software\Microsoft\Windows\CurrentVersion\RunOnce');RecordIdBefore=$boundary.Event.RecordId;Token=[Wela.WmiProbe.Native]::Snapshot();Write=$hive.WriteProbe();ObservedUtc=[DateTime]::UtcNow.ToString('o')} + Save 'operation.json' $operation + Assert ($operation.Write.Calls -eq 1 -and $operation.Write.Success -is [bool] -and $operation.Write.Success -and (ConvertTo-WelaArrivalUtc $operation.Write.StartedUtc) -le (ConvertTo-WelaArrivalUtc $operation.Write.ReturnedUtc) -and (ConvertTo-WelaArrivalUtc $operation.Write.ReturnedUtc) -le (ConvertTo-WelaArrivalUtc $operation.Write.CompletedUtc) -and (ConvertTo-WelaArrivalUtc $operation.Write.CompletedUtc) -le (ConvertTo-WelaArrivalUtc $operation.ObservedUtc)) 'Exactly one native write and its same-handle typed readback must have ordered measured times.' + $hive.AssertValues($true) + $found=@{};$candidates=@{};$deadline=[DateTime]::UtcNow.AddSeconds(20) + $xpath="*[System[Provider[@Name='Microsoft-Windows-Security-Auditing'] and EventID=4657 and EventRecordID > $($boundary.Event.RecordId)]] and *[EventData[Data[@Name='ObjectName']='$($operation.NativePath)']]" + do { + $events=@();try{$events=@(Get-WinEvent -LogName Security -FilterXPath $xpath -MaxEvents 256 -ErrorAction Stop)}catch{if($_.FullyQualifiedErrorId -notmatch 'NoMatchingEventsFound'){throw}} + try { + if($events.Count -ge 256){throw 'Owned-target native event query reached its bound.'} + foreach($event in $events){$xml=$event.ToXml();if($xml.Length -gt 131072){throw 'Owned-target event exceeds its XML bound.'};$candidates[[string]$event.RecordId]=$xml;if(Test-WelaRegistrySaclFixtureEvent $xml $operation){$found[[string]$event.RecordId]=$xml}} + }finally{foreach($event in $events){if($event -is [IDisposable]){$event.Dispose()}}} + if($found.Count -eq 0){Start-Sleep -Milliseconds 250} + }while($found.Count -eq 0 -and [DateTime]::UtcNow -lt $deadline) + Save 'event-candidates.json' $candidates + Assert ($found.Count -eq 1) ('Expected exactly one attributable native4657; candidates='+$candidates.Count+' matches='+$found.Count) + [IO.File]::WriteAllText((Join-Path $root 'event.xml'),[string]@($found.Values)[0],[Text.UTF8Encoding]::new($false)) + Assert ((Get-WelaSelectedSaclSnapshot $selected.Definition).DescriptorBase64 -ceq $after.DescriptorBase64) 'The value operation must preserve every native descriptor section.' + Assert ((Key ((Get-WelaEffectiveAuditPolicy).GetEnumerator()|Sort-Object Key)) -ceq $preparedMasks -and (Key (Get-WelaRegistryState $precedencePath $precedenceName)) -ceq $preparedPrecedence) 'Observed event delivery must not alter prepared auditing.' + Assert ((Key ([Wela.WmiProbe.Native]::Snapshot())) -ceq (Key $beforeToken)) 'The actual native value operation must preserve the full primary token.' +}catch{$failure=$_}finally { + if($policyTouched){ + try{Set-WelaEffectiveAuditPolicy -Guid $guid -Mask $beforeMasks[$guid] -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{$hive.Dispose()}catch{$cleanupErrors+='Hive unload/seed removal: '+$_.Exception.Message} + $hivesOk=(Key (Hives)) -ceq (Key $beforeHives);$tokenOk=(Key ([Wela.WmiProbe.Native]::Snapshot())) -ceq (Key $beforeToken) + $masks=Get-WelaEffectiveAuditPolicy;$masksOk=(Key ($masks.GetEnumerator()|Sort-Object Key)) -ceq (Key ($beforeMasks.GetEnumerator()|Sort-Object Key));$precedenceOk=(Key (Get-WelaRegistryState $precedencePath $precedenceName)) -ceq (Key $beforePrecedence) + if(-not $hive.Loaded -and $hivesOk){try{Remove-Item -LiteralPath $files -Recurse -Force -ErrorAction Stop}catch{$cleanupErrors+='Owned file removal: '+$_.Exception.Message}} + $cleanup=[pscustomobject]@{Complete=($hivesOk -and $tokenOk -and $masksOk -and $precedenceOk -and -not $hive.SeedCreated -and -not(Test-Path -LiteralPath $files) -and $cleanupErrors.Count -eq 0);HivesRestored=$hivesOk;TokenRestored=$tokenOk;AuditMasksCompared=$beforeMasks.Count;AuditMasksRestored=$masksOk;PrecedenceRestored=$precedenceOk;HiveUnloaded=(-not $hive.Loaded);SeedRemoved=(-not $hive.SeedCreated);FilesRemoved=(-not(Test-Path -LiteralPath $files));Errors=$cleanupErrors;Failure=$(if($failure){$failure.Exception.Message}else{$null});Assertions=$script:assertions;AfterHives=(Hives);AfterToken=[Wela.WmiProbe.Native]::Snapshot();AfterMasks=$masks;AfterPrecedence=(Get-WelaRegistryState $precedencePath $precedenceName)} + Save 'cleanup.json' $cleanup + $artifacts=@(Get-ChildItem -LiteralPath $root -Recurse -File |Where-Object Name -ne 'artifact-hashes.json'|Sort-Object FullName|ForEach-Object {[pscustomobject]@{Name=$_.FullName.Substring($root.Length+1).Replace('\','/');Sha256=(Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant()}}) + Save 'artifact-hashes.json' $artifacts +} +if($failure){throw $failure};if(-not $cleanup.Complete){throw ('Owned registry fixture cleanup incomplete: '+(Key $cleanup))} +Write-Host "Passed $script:assertions actual public registry SACL lifecycle assertions and one exact4657; all cleanup confirmed. Evidence: $root" $global:LASTEXITCODE=0 diff --git a/tests/RegistrySaclLifecycleEvidence.ps1 b/tests/RegistrySaclLifecycleEvidence.ps1 new file mode 100644 index 00000000..3adcc2f7 --- /dev/null +++ b/tests/RegistrySaclLifecycleEvidence.ps1 @@ -0,0 +1,21 @@ +# Test-only attribution for the single fixture-owned REG_SZ creation. +function Test-WelaRegistrySaclFixtureEvent { + param([string]$Xml,$Operation) + $reader=$null + try { + if($Xml.Length -gt 131072){return $false} + $settings=[Xml.XmlReaderSettings]::new();$settings.DtdProcessing=[Xml.DtdProcessing]::Prohibit;$settings.XmlResolver=$null;$settings.MaxCharactersInDocument=131072 + $reader=[Xml.XmlReader]::Create([IO.StringReader]::new($Xml),$settings);$doc=[Xml.XmlDocument]::new();$doc.XmlResolver=$null;$doc.Load($reader) + $ns=[Xml.XmlNamespaceManager]::new($doc.NameTable);$ns.AddNamespace('e','http://schemas.microsoft.com/win/2004/08/events/event') + if($doc.DocumentElement.LocalName -cne 'Event' -or $doc.DocumentElement.NamespaceURI -cne $ns.LookupNamespace('e') -or $doc.SelectNodes('/e:Event/e:System',$ns).Count -ne 1 -or $doc.SelectNodes('/e:Event/e:EventData',$ns).Count -ne 1){return $false} + $system=@{};foreach($name in @('Provider','EventID','Version','Task','Keywords','Channel','Computer','EventRecordID','TimeCreated')){$nodes=$doc.SelectNodes("/e:Event/e:System/e:$name",$ns);if($nodes.Count -ne 1){return $false};$system[$name]=$nodes[0]} + if($system.Provider.GetAttribute('Name') -cne 'Microsoft-Windows-Security-Auditing' -or $system.Provider.GetAttribute('Guid').Trim('{}') -ine '54849625-5478-4994-a5ba-3e3b0328c30d' -or $system.EventID.InnerText -cne '4657' -or $system.Version.InnerText -cne '0' -or $system.Task.InnerText -cne '12801' -or $system.Keywords.InnerText -ine '0x8020000000000000' -or $system.Channel.InnerText -cne 'Security' -or $system.Computer.InnerText -ine $Operation.Computer -or [long]$system.EventRecordID.InnerText -le $Operation.RecordIdBefore){return $false} + $time=ConvertTo-WelaArrivalUtc $system.TimeCreated.GetAttribute('SystemTime');if($time -lt (ConvertTo-WelaArrivalUtc $Operation.Write.StartedUtc) -or $time -gt (ConvertTo-WelaArrivalUtc $Operation.Write.CompletedUtc)){return $false} + $fields=@{};foreach($node in $doc.SelectSingleNode('/e:Event/e:EventData',$ns).ChildNodes){if($node.NodeType -eq 'Whitespace'){continue};if($node.NodeType -ne 'Element' -or $node.LocalName -cne 'Data' -or $node.NamespaceURI -cne $ns.LookupNamespace('e') -or @($node.ChildNodes|Where-Object NodeType -eq Element).Count){return $false};$name=$node.GetAttribute('Name');if(-not $name -or $fields.ContainsKey($name)){return $false};$fields[$name]=$node.InnerText} + if($fields.Count -ne 14){return $false};foreach($name in @('SubjectUserSid','SubjectUserName','SubjectDomainName','SubjectLogonId','ObjectName','ObjectValueName','HandleId','OperationType','OldValueType','OldValue','NewValueType','NewValue','ProcessId','ProcessName')){if(-not $fields.ContainsKey($name)){return $false}} + if($fields.SubjectUserSid -cne $Operation.Token.Sid -or $fields.ObjectName -ine $Operation.NativePath -or $fields.ObjectValueName -cne $Operation.Write.ValueName -or $fields.OperationType -cne '%%1904' -or $fields.NewValueType -cne '%%1873' -or $fields.NewValue -cne $Operation.Write.Value -or $fields.ProcessName -ine $Operation.Engine){return $false} + foreach($name in @('SubjectLogonId','ProcessId','HandleId')){if($fields[$name] -cnotmatch '^0x[0-9a-fA-F]+$'){return $false}} + if([Convert]::ToUInt64($fields.SubjectLogonId.Substring(2),16) -ne [Convert]::ToUInt64($Operation.Token.AuthenticationId.Substring(2),16) -or [Convert]::ToUInt64($fields.ProcessId.Substring(2),16) -ne $Operation.ProcessId -or [Convert]::ToUInt64($fields.HandleId.Substring(2),16) -ne [Convert]::ToUInt64($Operation.Write.HandleId.Substring(2),16)){return $false} + $true + }catch{$false}finally{if($reader){$reader.Dispose()}} +}