From 0f95115908c138a168d6dcd80f8756c4af475a85 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:49:31 +0900 Subject: [PATCH 01/12] test: establish owned redirected-profile native SACL fixture --- .gitattributes | 4 + .../workflows/filesystem-sacl-lifecycle.yml | 43 ++++++++++ tests/FileSaclLifecycle.Windows.Tests.ps1 | 54 ++++++++++++ tests/FileSaclProfileFixture.cs | 86 +++++++++++++++++++ 4 files changed, 187 insertions(+) create mode 100644 .github/workflows/filesystem-sacl-lifecycle.yml create mode 100644 tests/FileSaclLifecycle.Windows.Tests.ps1 create mode 100644 tests/FileSaclProfileFixture.cs 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;} + } + } +} From 8496b86b88d84bafbdd894bf098efec4a1dab1bd Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:54:37 +0900 Subject: [PATCH 02/12] test: exercise public redirected filesystem SACL lifecycle and native4663 --- tests/FileSaclLifecycle.Windows.Tests.ps1 | 127 ++++++++++++++++++++-- 1 file changed, 119 insertions(+), 8 deletions(-) diff --git a/tests/FileSaclLifecycle.Windows.Tests.ps1 b/tests/FileSaclLifecycle.Windows.Tests.ps1 index 10452ba8..72828744 100644 --- a/tests/FileSaclLifecycle.Windows.Tests.ps1 +++ b/tests/FileSaclLifecycle.Windows.Tests.ps1 @@ -15,26 +15,137 @@ $files=Join-Path $evidence 'owned-hive-files';$null=New-Item -ItemType Directory 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 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=@() +$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 - $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.' + 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 @@ -50,5 +161,5 @@ try { 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" +Write-Host "Passed $script:assertions actual public filesystem SACL lifecycle assertions; cleanup confirmed. Evidence: $evidence" $global:LASTEXITCODE=0 From 6feb0d8a33a43936ded21f322d7f6b01919e92ce Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:59:29 +0900 Subject: [PATCH 03/12] test: complete filesystem lifecycle fixture dependencies and evidence guide --- .github/workflows/release.yml | 2 +- CHANGELOG-Japanese.md | 2 ++ CHANGELOG.md | 2 ++ docs/native-filesystem-sacl-validation.md | 38 +++++++++++++++++++++++ docs/selected-sacl-configuration.md | 4 ++- tests/FileSaclLifecycle.Windows.Tests.ps1 | 21 ++++++++++--- website/docs/resources/changelog.ja.md | 2 ++ website/docs/resources/changelog.md | 2 ++ 8 files changed, 67 insertions(+), 6 deletions(-) create mode 100644 docs/native-filesystem-sacl-validation.md 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 index 72828744..d9f63dd0 100644 --- a/tests/FileSaclLifecycle.Windows.Tests.ps1 +++ b/tests/FileSaclLifecycle.Windows.Tests.ps1 @@ -4,14 +4,11 @@ $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'))} +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') -$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)} @@ -46,11 +43,27 @@ $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}) 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) From 54f9d6bf514265a06c0835c0c6634366174dce48 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:00:26 +0900 Subject: [PATCH 04/12] Add bounded native preflight for one selected WEF QueryList --- .github/workflows/wef-query.yml | 49 +++++++++ WELA.ps1 | 14 +++ modules/WefSubscriptions.psm1 | 7 +- scripts/WefQuery.ps1 | 175 ++++++++++++++++++++++++++++++ scripts/WefQueryNative.cs | 177 +++++++++++++++++++++++++++++++ scripts/WefQueryWorker.ps1 | 24 +++++ tests/WefQuery.Cli.Tests.ps1 | 17 +++ tests/WefQuery.Tests.ps1 | 60 +++++++++++ tests/WefQuery.Windows.Tests.ps1 | 105 ++++++++++++++++++ 9 files changed, 625 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/wef-query.yml create mode 100644 scripts/WefQuery.ps1 create mode 100644 scripts/WefQueryNative.cs create mode 100644 scripts/WefQueryWorker.ps1 create mode 100644 tests/WefQuery.Cli.Tests.ps1 create mode 100644 tests/WefQuery.Tests.ps1 create mode 100644 tests/WefQuery.Windows.Tests.ps1 diff --git a/.github/workflows/wef-query.yml b/.github/workflows/wef-query.yml new file mode 100644 index 00000000..2305a9f8 --- /dev/null +++ b/.github/workflows/wef-query.yml @@ -0,0 +1,49 @@ +name: Native WEF query preflight +on: + push: + paths: ['WELA.ps1', 'modules/WefSubscriptions.psm1', 'scripts/WefQuery*', 'tests/WefQuery*', '.github/workflows/wef-query.yml'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + wef-query: + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + engine: [powershell, pwsh] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - name: Query regressions in Windows PowerShell 5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: | + ./tests/WefQuery.Tests.ps1 + ./tests/WefQuery.Cli.Tests.ps1 + ./tests/WefDeployment.Tests.ps1 + - name: Query regressions in PowerShell 7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: | + ./tests/WefQuery.Tests.ps1 + ./tests/WefQuery.Cli.Tests.ps1 + ./tests/WefDeployment.Tests.ps1 + - name: Actual public query semantics in Windows PowerShell 5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: ./tests/WefQuery.Windows.Tests.ps1 -AllowDisposableAccount + - name: Actual public query semantics in PowerShell 7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: ./tests/WefQuery.Windows.Tests.ps1 -AllowDisposableAccount + - name: Retain native query evidence and exact fixture cleanup + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: wef-query-${{ matrix.os }}-${{ matrix.engine }} + path: ${{ runner.temp }}/wela-wef-query-* + if-no-files-found: warn + retention-days: 7 diff --git a/WELA.ps1 b/WELA.ps1 index 9aad3df0..94d3648b 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -47,6 +47,10 @@ [string]$ChannelReadOutputPath, [ValidateSet('Audit', 'Plan', 'Configure')][string]$WefAction = 'Audit', [string]$WefConfigPath, + [string]$WefQueryConfigPath, + [string]$WefQuerySubscriptionId, + [string]$WefQueryOutputPath, + [ValidateRange(1,64)][int]$WefQueryMaximumEvents = 16, [string]$RetentionConfigPath, [string]$RetentionPreviousPath, [ValidateSet('Audit', 'Plan', 'Import')][string]$AppLockerAction = 'Audit', @@ -275,6 +279,7 @@ Import-Module (Join-Path $ScriptRoot "modules/NativeChannelAccess.psm1") -ErrorA . (Join-Path $ScriptRoot "scripts/DnsAnalytical.ps1") Import-Module (Join-Path $ScriptRoot "modules/WefSubscriptions.psm1") -ErrorAction Stop . (Join-Path $ScriptRoot "scripts/WefDeployment.ps1") +. (Join-Path $ScriptRoot "scripts/WefQuery.ps1") . (Join-Path $ScriptRoot "scripts/WecUpdate.ps1") . (Join-Path $ScriptRoot "scripts/WecIngress.ps1") . (Join-Path $ScriptRoot "scripts/WecListener.ps1") @@ -2015,6 +2020,7 @@ Usage: ./WELA.ps1 provider-packs -ProviderAction List ./WELA.ps1 provider-packs -ProviderAction Plan -ProviderPack dns-client,capi2 -ResultsPath provider-plan.json + ./WELA.ps1 wef-query -Help # Execute one selected source QueryList locally ./WELA.ps1 wef-source -WefAction Plan -WefConfigPath source.json -ResultsPath source-plan.json ./WELA.ps1 wec-collector -WefAction Configure -WefConfigPath collector.json -DryRun @@ -2188,6 +2194,8 @@ if ($Cmd -eq 'outgoing-ntlm') { if ($OutgoingNtlmMode -eq 'Deny') {throw 'outgoing-ntlm configures auditing only; Deny enforcement is not accepted.'} if ($NtlmAction -ne 'Configure' -and ($Auto -or $DryRun -or $BackupPath)) {throw 'Consent, dry-run and backup options require NtlmAction Configure.'} } +if ($Cmd -ne 'wef-query' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'WefQuery*'}).Count) {throw 'WefQuery options require wef-query.'} +if ($Cmd -eq 'wef-query' -and ($args.Count -or @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','WefQueryConfigPath','WefQuerySubscriptionId','WefQueryOutputPath','WefQueryMaximumEvents','Help')}).Count)) {throw 'wef-query accepts only dedicated read-only options.'} if ($Cmd -ne 'wec-authorization' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'WecAuthorization*'}).Count) {throw 'WecAuthorization options require wec-authorization.'} if ($Cmd -eq 'wec-authorization' -and ($args.Count -or @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','WecAuthorizationAction','WecAuthorizationId','WecAuthorizationSourceSid','WecAuthorizationPlanPath','WecAuthorizationPlanHash','WecAuthorizationOutputPath','Help')}).Count)) {throw 'wec-authorization accepts only dedicated options.'} if ($Cmd -ne 'wec-state' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'WecState*'}).Count) {throw 'WecState options require wec-state.'} @@ -2469,6 +2477,12 @@ switch ($Cmd.ToLower()) { $report if ($report.ExitCode) {exit $report.ExitCode} } + 'wef-query' { + if ($Help) {Write-Host 'Usage: wef-query -WefQueryConfigPath source.json -WefQuerySubscriptionId exact-ID -WefQueryOutputPath new-directory [-WefQueryMaximumEvents 16]. Executes the exact selected local QueryList under the actual caller token. Strict query failures and separate partial diagnostics remain visible; empty reads differ from denied/missing/invalid/capped results. No configuration, NETWORK SERVICE access, forwarding or Sigma claim. See docs/wef-query.md.';return} + $report=Invoke-WelaWefQuery -ConfigPath $WefQueryConfigPath -SubscriptionId $WefQuerySubscriptionId -OutputPath $WefQueryOutputPath -MaximumEvents $WefQueryMaximumEvents + $report | ConvertTo-Json -Depth 32 | Write-Output + exit ([int]$report.ExitCode) + } 'wec-authorization' { if ($Help) {Write-Host 'Usage: wec-authorization [-WecAuthorizationAction Plan] -WecAuthorizationId ID -WecAuthorizationSourceSid desired-SID1,desired-SID2 -WecAuthorizationOutputPath new-directory; then Apply with -WecAuthorizationPlanPath plan.json -WecAuthorizationPlanHash SHA256 -WecAuthorizationOutputPath new-directory. Only the explicit source SID authorization of one already disabled subscription. No SID resolution or forwarding proof. See docs/wec-authorization.md.';return} $arguments=@{Action=$WecAuthorizationAction;OutputPath=$WecAuthorizationOutputPath} diff --git a/modules/WefSubscriptions.psm1 b/modules/WefSubscriptions.psm1 index 46ab3492..fbf15167 100644 --- a/modules/WefSubscriptions.psm1 +++ b/modules/WefSubscriptions.psm1 @@ -155,9 +155,10 @@ function Test-WelaWefFirewallAddressSet { } function Import-WelaWefConfig { - param([string]$Path, [ValidateSet('Source','Collector')][string]$Role) + param([string]$Path, [ValidateSet('Source','Collector')][string]$Role, [scriptblock]$ReadText) $full = (Resolve-Path -LiteralPath $Path -ErrorAction Stop).Path - $config = Get-Content -LiteralPath $full -Raw -Encoding UTF8 -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop + $configText=if($ReadText){ & $ReadText $full }else{Get-Content -LiteralPath $full -Raw -Encoding UTF8 -ErrorAction Stop} + $config = $configText | ConvertFrom-Json -ErrorAction Stop $known = @('SchemaVersion','Role','CollectorFqdn','CollectorUri','Authentication','SourceSids','SubscriptionFiles','Hardening','SubscriptionManagerSlot','RefreshSeconds','GrantNetworkServiceRead','ApplyChannelProfile','GrantCapi2Read','ListenerAddress','IngressRuleName','IngressLocalAddresses','IngressRemoteAddresses') foreach ($property in $config.PSObject.Properties) { if ($property.Name -cnotin $known) { throw "Unknown WEF config field: $($property.Name)" } } if ($config.SchemaVersion -ne 1 -or $config.Role -cne $Role) { throw "Expected schema 1 $Role configuration." } @@ -186,7 +187,7 @@ function Import-WelaWefConfig { $subscriptions = @(); $ids = @{} foreach ($file in $config.SubscriptionFiles) { $target = if ([IO.Path]::IsPathRooted($file)) { $file } else { Join-Path (Split-Path $full -Parent) $file } - $xml = Get-Content -LiteralPath $target -Raw -Encoding UTF8 -ErrorAction Stop + $xml = if($ReadText){ & $ReadText $target }else{Get-Content -LiteralPath $target -Raw -Encoding UTF8 -ErrorAction Stop} $subscription = ConvertFrom-WelaWefSubscription -Xml $xml -SourceSids @($config.SourceSids) if ($ids.ContainsKey($subscription.Id)) { throw 'Duplicate subscription ID in selected files.' } $ids[$subscription.Id] = $true; $subscriptions += $subscription diff --git a/scripts/WefQuery.ps1 b/scripts/WefQuery.ps1 new file mode 100644 index 00000000..5bfda262 --- /dev/null +++ b/scripts/WefQuery.ps1 @@ -0,0 +1,175 @@ +# Exact selected QueryList, current primary token, local read-only native execution. +function Get-WelaWefQueryKey { + param($Value) + (ConvertTo-Json -InputObject $Value -Depth 32 -Compress).Replace('<','\u003c').Replace('>','\u003e').Replace('&','\u0026').Replace("'",'\u0027') +} +function Initialize-WelaWefQueryNative { + $bytes=[IO.File]::ReadAllBytes((Join-Path $PSScriptRoot 'WefQueryNative.cs')) + if($bytes.Length -gt 131072){throw 'Native query source exceeds its bound.'};$hash=Get-WelaArrivalHash $bytes + if(-not('Wela.WefQuery.Native' -as [type])){ + $source=[Text.UTF8Encoding]::new($false,$true).GetString($bytes).TrimStart([char]0xfeff) + if([regex]::Matches($source,'__WELA_WEF_QUERY_SHA256__').Count -ne 1){throw 'Native query source marker is missing or ambiguous.'} + Add-Type -TypeDefinition $source.Replace('__WELA_WEF_QUERY_SHA256__',$hash) -ErrorAction Stop + } + if([Wela.WefQuery.Native]::SourceSha256 -cne $hash){throw 'Loaded query helper differs from current source.'} +} +function Get-WelaWefQuerySources { + $result=[ordered]@{} + foreach($name in @('WELA.ps1','scripts/WefQuery.ps1','scripts/WefQueryNative.cs','scripts/WefQueryWorker.ps1','scripts/ChannelRead.ps1','scripts/WefArrival.ps1','scripts/WecUpdate.ps1','scripts/CustomAuditProfiles.ps1','modules/WefSubscriptions.psm1','modules/AuditProfiles.psm1','modules/NativeProviders.psm1','config/native_channel_profile.json')){ + $result[$name]=(Get-FileHash -LiteralPath (Join-Path $script:ScriptRoot $name) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() + } + [pscustomobject]$result +} +function Get-WelaWefQueryToken { + Initialize-WelaWefQueryNative + [Wela.WefQueryToken.Native]::Snapshot() +} +function Get-WelaWefQueryTokenKey { + param($Token) + Assert-WelaArrivalObject $Token @('Sid','Name','AuthenticationId','AuthenticationType','ImpersonationLevel','TokenSource','Groups','Privileges') + foreach($name in @('Sid','Name','AuthenticationId','AuthenticationType','ImpersonationLevel','TokenSource')){if($Token.$name -isnot [string]){throw 'Mistyped query token text.'}} + if($Token.Sid -cnotmatch '^S-1-\d+(-\d+)+$' -or $Token.AuthenticationId -cnotmatch '^0x[0-9a-f]+$' -or -not $Token.Name -or $Token.TokenSource -cnotin @('Process','EquivalentSelfThread') -or $Token.Groups -isnot [array] -or -not $Token.Groups.Count -or $Token.Privileges -isnot [array]){throw 'Incomplete query token observation.'} + foreach($group in $Token.Groups){Assert-WelaArrivalObject $group @('Sid','Attributes');if($group.Sid -isnot [string] -or $group.Sid -cnotmatch '^S-1-\d+(-\d+)+$'){throw 'Invalid group SID.'};Assert-WelaWefQueryUInt $group.Attributes} + foreach($privilege in $Token.Privileges){Assert-WelaArrivalObject $privilege @('Luid','Attributes');if($privilege.Luid -isnot [string] -or $privilege.Luid -cnotmatch '^0x[0-9a-f]+$'){throw 'Invalid token privilege.'};Assert-WelaWefQueryUInt $privilege.Attributes} + Get-WelaWefQueryKey ([pscustomobject][ordered]@{Sid=$Token.Sid;Name=$Token.Name;AuthenticationId=$Token.AuthenticationId;AuthenticationType=$Token.AuthenticationType;Groups=$Token.Groups;Privileges=$Token.Privileges}) +} +function Assert-WelaWefQueryUInt {param($Value) if(($Value -isnot [int] -and $Value -isnot [long] -and $Value -isnot [uint32]) -or $Value -lt 0 -or $Value -gt [uint32]::MaxValue){throw 'Expected a native unsigned integer.'}} +function Import-WelaWefQuerySelection { + param([string]$ConfigPath,[string]$SubscriptionId) + if(-not $ConfigPath -or -not $SubscriptionId -or $SubscriptionId.Length -gt 256 -or $SubscriptionId -match '[\x00-\x1f]'){throw 'An exact source config path and subscription ID are required.'} + $capture=@{Files=[Collections.Generic.List[object]]::new();Bytes=0;Texts=[Collections.Generic.List[string]]::new()} + $reader={param($path) + $file=Read-WelaWecUpdateFile $path 1048576 + if($capture.Files.Path -contains $file.Path){throw 'Duplicate input file path.'} + if($capture.Files.Count -eq 0){$json=ConvertFrom-WelaArrivalJson $file.Text;if($json.SchemaVersion -isnot [int] -and $json.SchemaVersion -isnot [long]){throw 'WEF schema version must be an integer.'}} + $capture.Bytes+=[Text.Encoding]::UTF8.GetByteCount($file.Text);if($capture.Bytes -gt 4194304){throw 'WEF input text exceeds four MiB aggregate.'} + $capture.Files.Add([pscustomobject]@{Path=$file.Path;Sha256=$file.Hash});$capture.Texts.Add($file.Text) + $file.Text + }.GetNewClosure() + $model=Import-WelaWefConfig -Path $ConfigPath -Role Source -ReadText $reader + $selected=@($model.Subscriptions|Where-Object Id -CEQ $SubscriptionId) + if($selected.Count -ne 1){throw 'Select one exact subscription ID from the source config.'};$selected=$selected[0] + $doc=Read-WelaWefXml $selected.Xml;$query=[string]$doc.DocumentElement.SelectSingleNode('*[local-name()="Query"]').InnerText + $parsed=ConvertFrom-WelaWefQuery $query + if($query.Length -gt 65536 -or $parsed.Channels.Count -gt 16 -or $parsed.Filters.Count -gt 128){throw 'Selected QueryList exceeds 65536 characters, 16 channels or 128 filters.'} + $index=0;while($model.Subscriptions[$index].Id -cne $SubscriptionId){$index++} + [pscustomobject][ordered]@{Id=$SubscriptionId;RequestedEnabled=$selected.Definition.Enabled;CollectorFqdn=$model.Config.CollectorFqdn;CollectorUri=$model.Config.CollectorUri;Query=$query;QuerySha256=(Get-WelaArrivalHash ([Text.UTF8Encoding]::new($false).GetBytes($query)));Channels=@($parsed.Channels);Filters=@($parsed.Filters);Files=@($capture.Files.ToArray());ConfigText=$capture.Texts[0];SubscriptionText=$capture.Texts[$index+1]} +} +function Get-WelaWefQueryHost { + if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'Native 64-bit Windows is required.'} + foreach($service in @('Winmgmt','EventLog')){if((Get-Service -Name $service -ErrorAction Stop).Status -ne 'Running'){throw 'Observation services must already be running.'}} + $observed=Get-WelaChannelReadHost;$dns=[Net.NetworkInformation.IPGlobalProperties]::GetIPGlobalProperties() + $observed|Add-Member NoteProperty DnsHostName ([string]$dns.HostName) + $observed|Add-Member NoteProperty DnsSuffix ([string]$dns.DomainName) + $observed +} +function Get-WelaWefQueryChannelState { + param([string[]]$Channels) + foreach($channel in $Channels){Get-WelaNativeChannel -Name $channel} +} +function Assert-WelaWefQueryInputs { + param($Selection) + foreach($file in $Selection.Files){if((Read-WelaWecUpdateFile $file.Path 1048576).Hash -cne $file.Sha256){throw 'Original WEF configuration or subscription bytes changed.'}} +} +function Get-WelaWefQueryEngine { + $path=(Get-Process -Id $PID -ErrorAction Stop).Path + if([IO.Path]::GetFileName($path) -cnotin @('powershell.exe','pwsh.exe') -or $PSVersionTable.PSVersion.Major -notin @(5,7)){throw 'Native Windows PowerShell 5.1 or PowerShell 7 is required.'} + [pscustomobject]@{Path=$path;Sha256=(Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant();Version=$PSVersionTable.PSVersion.ToString();ModulePath=[IO.Path]::Combine($PSHOME,'Modules')} +} +function Close-WelaWefQueryWorker { + param($Process,$Result) + if($Result.Started){ + $exited=$false;try{$exited=$Process.HasExited}catch{$Result.Diagnostic+=' Exit observation failed: '+$_.Exception.Message} + if(-not $exited){try{$Process.Kill()}catch{$Result.Diagnostic+=' Termination request failed: '+$_.Exception.Message};try{$exited=$Process.WaitForExit(5000)}catch{$Result.Diagnostic+=' Termination wait failed: '+$_.Exception.Message}} + $Result.TerminationConfirmed=[bool]$exited;if(-not $exited){$Result.Diagnostic+=' Worker termination unconfirmed.'} + } + try{$Process.Dispose()}catch{$Result.Diagnostic+=' Process cleanup failed: '+$_.Exception.Message} +} +function Start-WelaWefQueryWorker { + param($Engine,[string]$RequestPath,[string]$RequestHash) + $worker=Join-Path $PSScriptRoot 'WefQueryWorker.ps1' + foreach($path in @($Engine.Path,$worker,$RequestPath)){if($path.Contains('"') -or $path.EndsWith('\') -or $path -match '[\x00-\x1f]'){throw 'Ambiguous query worker path.'}} + Initialize-WelaWefQueryNative + $info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$Engine.Path;$info.Arguments='-NoLogo -NoProfile -NonInteractive -File "'+$worker+'" -RequestPath "'+$RequestPath+'" -RequestHash '+$RequestHash + $info.EnvironmentVariables['PSModulePath']=$Engine.ModulePath;$info.UseShellExecute=$false;$info.CreateNoWindow=$true;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true;$info.StandardOutputEncoding=[Text.UTF8Encoding]::new($false);$info.StandardErrorEncoding=[Text.UTF8Encoding]::new($false) + $result=[pscustomobject]@{Started=$false;ProcessId=$null;ExitCode=$null;TimedOut=$false;TerminationConfirmed=$false;Receipt=$null;Diagnostic=''};$process=[Diagnostics.Process]::new();$process.StartInfo=$info + try{ + if(-not $process.Start()){throw 'Query worker did not start.'};$result.Started=$true;$result.ProcessId=$process.Id + $stdout=[Wela.WefQuery.Native]::ReadPipe($process.StandardOutput,33554432);$stderr=[Wela.WefQuery.Native]::ReadPipe($process.StandardError,65536) + if(-not $process.WaitForExit(45000)){$result.TimedOut=$true;throw 'Native query worker exceeded 45 seconds.'};$result.ExitCode=$process.ExitCode + if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),5000)){throw 'Native query output drain timed out.'} + if($stderr.Result){throw ('Query worker error output: '+$stderr.Result)} + $result.Receipt=ConvertFrom-WelaArrivalJson $stdout.Result + }catch{$result.Diagnostic=$_.Exception.Message}finally{Close-WelaWefQueryWorker $process $result} + $result +} +function Assert-WelaWefQueryNativeResult { + param($Result,[string[]]$Channels,[int]$MaximumEvents) + Assert-WelaArrivalObject $Result @('Opened','Complete','Capped','CleanupConfirmed','NativeError','Diagnostic','Channels','DiagnosticChannels','DiagnosticNativeError','Events') + foreach($name in @('Opened','Complete','Capped','CleanupConfirmed')){if($Result.$name -isnot [bool]){throw 'Mistyped native query outcome.'}} + if($Result.Diagnostic -isnot [string] -or $Result.Events -isnot [array] -or $Result.Events.Count -gt $MaximumEvents){throw 'Invalid native query evidence count or diagnostic.'} + foreach($name in @('NativeError','DiagnosticNativeError')){if($null -ne $Result.$name){Assert-WelaWefQueryUInt $Result.$name}} + foreach($field in @('Channels','DiagnosticChannels')){ + $entries=$Result.$field;if($entries -isnot [array] -or $entries.Count -gt 128){throw 'Invalid native query status list.'} + foreach($entry in $entries){Assert-WelaArrivalObject $entry @('Channel','Error');if($entry.Channel -isnot [string] -or $entry.Channel -cnotin $Channels){throw 'Native query status refers to an unselected channel.'};Assert-WelaWefQueryUInt $entry.Error} + } + if(-not $Result.Opened -and ($Result.Events.Count -or $Result.Channels.Count -or $Result.Complete -or $Result.Capped -or $null -eq $Result.NativeError)){throw 'An unopened strict query cannot have matching evidence.'} + if($Result.Opened -and ($Result.DiagnosticChannels.Count -or $null -ne $Result.DiagnosticNativeError)){throw 'Successful strict query has unexpected alternate diagnostic evidence.'} + if($Result.Complete -and ($Result.Capped -or -not $Result.CleanupConfirmed -or $null -ne $Result.NativeError -or $Result.Diagnostic)){throw 'Native completeness contradicts an error/cap/cleanup outcome.'} + if($Result.Opened){foreach($channel in $Channels){if(-not @($Result.Channels|Where-Object Channel -CEQ $channel).Count){throw 'Native query status omits a selected channel.'}}} + $bytes=0 + foreach($xml in $Result.Events){if($xml -isnot [string] -or $xml.Length -gt 524287){throw 'Invalid or oversized event XML.'};$bytes+=[Text.Encoding]::UTF8.GetByteCount($xml);if($bytes -gt 4194304){throw 'Matching event XML exceeds four MiB.'}} +} +function Read-WelaWefQueryEvent { + param([string]$Xml,[string[]]$Channels,$HostContext) + $doc=Read-WelaWefXml $Xml;$root=$doc.DocumentElement + if($root.LocalName -cne 'Event' -or $root.NamespaceURI -cne 'http://schemas.microsoft.com/win/2004/08/events/event'){throw 'Native result is not Windows Event XML.'} + $ns=[Xml.XmlNamespaceManager]::new($doc.NameTable);$ns.AddNamespace('e',$root.NamespaceURI) + $system=@($root.SelectNodes('e:System',$ns));if($system.Count -ne 1){throw 'Event System identity is missing or ambiguous.'} + foreach($name in @('Provider','EventID','EventRecordID','Channel','Computer','TimeCreated')){if(@($system[0].SelectNodes('e:'+$name,$ns)).Count -ne 1){throw 'Event identity is missing or duplicated.'}} + $channel=[string]$system[0].SelectSingleNode('e:Channel',$ns).InnerText;$machine=[string]$system[0].SelectSingleNode('e:Computer',$ns).InnerText;$record=[string]$system[0].SelectSingleNode('e:EventRecordID',$ns).InnerText;$provider=$system[0].SelectSingleNode('e:Provider',$ns).GetAttribute('Name');$eventId=[string]$system[0].SelectSingleNode('e:EventID',$ns).InnerText + $names=@([string]$HostContext.Computer);if($HostContext.DnsHostName){$names+=[string]$HostContext.DnsHostName;if($HostContext.DnsSuffix){$names+=([string]$HostContext.DnsHostName+'.'+[string]$HostContext.DnsSuffix)}} + if($channel -cnotin $Channels -or -not $machine -or $machine -inotIn $names -or $record -cnotmatch '^[1-9][0-9]{0,18}$' -or -not $provider -or $eventId -cnotmatch '^[0-9]{1,5}$'){throw 'Returned event identity differs from selected local provenance.'} + $time=ConvertTo-WelaArrivalUtc $system[0].SelectSingleNode('e:TimeCreated',$ns).GetAttribute('SystemTime') + [pscustomobject]@{Channel=$channel;Computer=$machine;RecordId=[long]$record;Provider=$provider;EventId=[int]$eventId;TimeCreatedUtc=$time.ToString('o')} +} +function Invoke-WelaWefQuery { + param([string]$ConfigPath,[string]$SubscriptionId,[string]$OutputPath,[ValidateRange(1,64)][int]$MaximumEvents=16) + $selection=Import-WelaWefQuerySelection $ConfigPath $SubscriptionId + $hostState=Get-WelaWefQueryHost;$sources=Get-WelaWefQuerySources;$engine=Get-WelaWefQueryEngine + if(-not $OutputPath){throw 'wef-query requires a new output directory.'};$output=New-WelaArrivalOutput $OutputPath $script:ScriptRoot + $report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaWefQueryPreflight';Status='Unverified';ExitCode=1;SubscriptionId=$selection.Id;RequestedEnabled=$selection.RequestedEnabled;CollectorFqdn=$selection.CollectorFqdn;CollectorUri=$selection.CollectorUri;QuerySha256=$selection.QuerySha256;MaximumEvents=$MaximumEvents;Sources=$sources;Inputs=$selection.Files;Host=$hostState;Engine=$engine;ReaderBefore=$null;ReaderAfter=$null;ChannelBefore=@();ChannelAfter=@();Worker=$null;Query=$null;Matches=@();Artifacts=@();Diagnostic='';ConfigurationChanges=0;ReadyRuleCredit=0;Forwarding='Not tested';SourceServiceTokenAccess='Not tested; actual caller token only';Scope='Exact selected local QueryList at observation time; disabled selection may read historical events.'} + try{ + $report.Artifacts+=Write-WelaWecUpdateArtifact $output 'source-config.json' $selection.ConfigText + $report.Artifacts+=Write-WelaWecUpdateArtifact $output 'subscription.xml' $selection.SubscriptionText + $report.Artifacts+=Write-WelaWecUpdateArtifact $output 'query.xml' $selection.Query + $report.ChannelBefore=@(Get-WelaWefQueryChannelState $selection.Channels) + $report.ReaderBefore=Get-WelaWefQueryToken;$tokenKey=Get-WelaWefQueryTokenKey $report.ReaderBefore + $request=[pscustomobject]@{SchemaVersion=1;Kind='WelaWefQueryRequest';Nonce=[guid]::NewGuid().ToString('N');Query=$selection.Query;QuerySha256=$selection.QuerySha256;Channels=$selection.Channels;MaximumEvents=$MaximumEvents;Sources=$sources;Host=$hostState;Reader=$report.ReaderBefore;Engine=$engine} + $artifact=Write-WelaWecUpdateArtifact $output 'request.json' (Get-WelaWefQueryKey $request);$report.Artifacts+=$artifact + Assert-WelaWefQueryInputs $selection + if((Get-WelaWefQueryKey (Get-WelaWefQuerySources)) -cne (Get-WelaWefQueryKey $sources) -or (Get-WelaWefQueryTokenKey (Get-WelaWefQueryToken)) -cne $tokenKey){throw 'Sources or actual reader changed before query.'} + $worker=Start-WelaWefQueryWorker $engine (Join-Path $output 'request.json') $artifact.Sha256;$report.Worker=$worker + if(-not $worker.Started -or -not $worker.TerminationConfirmed -or $worker.TimedOut -or $worker.Diagnostic -or $worker.ExitCode -ne 0 -or -not $worker.Receipt){throw ('Query worker did not complete verified observation. '+$worker.Diagnostic)} + $receipt=$worker.Receipt;Assert-WelaArrivalObject $receipt @('SchemaVersion','Kind','Nonce','ProcessId','Engine','ModulePath','StartedUtc','CompletedUtc','ReaderBefore','ReaderAfter','Host','Sources','QuerySha256','Result') + foreach($name in @('Kind','Nonce','ModulePath','StartedUtc','CompletedUtc','QuerySha256')){if($receipt.$name -isnot [string]){throw 'Mistyped worker receipt identity.'}} + if(($receipt.SchemaVersion -isnot [int] -and $receipt.SchemaVersion -isnot [long]) -or $receipt.SchemaVersion -ne 1 -or $receipt.Kind -cne 'WelaWefQueryWorker' -or $receipt.Nonce -cne $request.Nonce -or ($receipt.ProcessId -isnot [int] -and $receipt.ProcessId -isnot [long]) -or $receipt.ProcessId -ne $worker.ProcessId -or $receipt.ModulePath -cne $engine.ModulePath -or $receipt.QuerySha256 -cne $selection.QuerySha256 -or (Get-WelaWefQueryKey $receipt.Engine) -cne (Get-WelaWefQueryKey $engine) -or (Get-WelaWefQueryKey $receipt.Host) -cne (Get-WelaWefQueryKey $hostState) -or (Get-WelaWefQueryKey $receipt.Sources) -cne (Get-WelaWefQueryKey $sources)){throw 'Worker receipt differs from actual reviewed query/engine/host/source context.'} + if((Get-WelaWefQueryTokenKey $receipt.ReaderBefore) -cne $tokenKey -or (Get-WelaWefQueryTokenKey $receipt.ReaderAfter) -cne $tokenKey){throw 'Worker token differs from the actual caller or changed during query.'} + if((ConvertTo-WelaArrivalUtc $receipt.StartedUtc) -gt (ConvertTo-WelaArrivalUtc $receipt.CompletedUtc)){throw 'Worker time interval is invalid.'} + Assert-WelaWefQueryNativeResult $receipt.Result $selection.Channels $MaximumEvents + $report.Artifacts+=Write-WelaWecUpdateArtifact $output 'worker.json' (Get-WelaWefQueryKey $receipt) + $report.Query=$receipt.Result;$number=0;$seen=@{} + foreach($xml in $receipt.Result.Events){$metadata=Read-WelaWefQueryEvent $xml $selection.Channels $hostState;$key=$metadata.Channel+':'+$metadata.RecordId;if($seen[$key]){throw 'Duplicate native event identity.'};$seen[$key]=$true;$number++;$name='event-{0:d3}.xml' -f $number;$report.Artifacts+=Write-WelaWecUpdateArtifact $output $name $xml;$report.Matches+=[pscustomobject]@{Artifact=$name;Metadata=$metadata}} + # XML is retained in named artifacts/worker evidence, not repeated in the manifest. + $report.Query.Events=@();$worker.Receipt=$null + $report.ChannelAfter=@(Get-WelaWefQueryChannelState $selection.Channels);$report.ReaderAfter=Get-WelaWefQueryToken + Assert-WelaWefQueryInputs $selection + if((Get-WelaWefQueryKey (Get-WelaWefQueryHost)) -cne (Get-WelaWefQueryKey $hostState) -or (Get-WelaWefQueryKey (Get-WelaWefQuerySources)) -cne (Get-WelaWefQueryKey $sources) -or (Get-WelaWefQueryTokenKey $report.ReaderAfter) -cne $tokenKey -or (Get-WelaWefQueryKey (Get-WelaWefQueryEngine)) -cne (Get-WelaWefQueryKey $engine) -or (Get-WelaWefQueryKey $report.ChannelAfter) -cne (Get-WelaWefQueryKey $report.ChannelBefore)){throw 'Host/token/source/engine or channel configuration changed during query.'} + foreach($file in $report.Artifacts){if((Get-FileHash -LiteralPath (Join-Path $output $file.Name) -Algorithm SHA256).Hash.ToLowerInvariant() -cne $file.Sha256){throw 'Retained query evidence changed.'}} + $result=$report.Query + if($result.Opened -and $result.Complete -and $result.CleanupConfirmed -and -not $result.Capped -and $null -eq $result.NativeError -and -not $result.Diagnostic -and -not @($result.Channels|Where-Object Error -NE 0).Count){$report.Status=if($report.Matches.Count){'MatchesObserved'}else{'ReadAllowedEmpty'};$report.ExitCode=0} + elseif($result.Opened){$report.Status='Partial'}else{$report.Status='QueryFailed'} + }catch{$report.Diagnostic=$_.Exception.Message} + $null=Write-WelaWecUpdateArtifact $output 'manifest.json' (Get-WelaWefQueryKey $report) + $report +} diff --git a/scripts/WefQueryNative.cs b/scripts/WefQueryNative.cs new file mode 100644 index 00000000..a5b934a4 --- /dev/null +++ b/scripts/WefQueryNative.cs @@ -0,0 +1,177 @@ +// Read-only native Event Log query and bounded output helpers. +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using System.Security.Principal; +using System.Threading.Tasks; +namespace Wela.WefQuery { + public sealed class LogStatus { public string Channel; public uint Error; } + public sealed class Result { + public bool Opened, Complete, Capped, CleanupConfirmed=true; + public uint? NativeError; public string Diagnostic=""; + public LogStatus[] Channels=new LogStatus[0], DiagnosticChannels=new LogStatus[0]; + public uint? DiagnosticNativeError; + public string[] Events=new string[0]; + } + public static class Native { + public const string SourceSha256="__WELA_WEF_QUERY_SHA256__"; + const int MaximumBuffer=1048576; + [DllImport("wevtapi.dll",CharSet=CharSet.Unicode,ExactSpelling=true,SetLastError=true)] static extern IntPtr EvtQuery(IntPtr session,string path,string query,uint flags); + [DllImport("wevtapi.dll",ExactSpelling=true,SetLastError=true)] static extern bool EvtGetQueryInfo(IntPtr query,int property,uint size,IntPtr buffer,out uint used); + [DllImport("wevtapi.dll",ExactSpelling=true,SetLastError=true)] static extern bool EvtNext(IntPtr query,uint size,[Out] IntPtr[] events,uint timeout,uint flags,out uint returned); + [DllImport("wevtapi.dll",ExactSpelling=true,SetLastError=true)] static extern bool EvtRender(IntPtr context,IntPtr value,uint flags,uint size,IntPtr buffer,out uint used,out uint count); + [DllImport("wevtapi.dll",ExactSpelling=true,SetLastError=true)] static extern bool EvtClose(IntPtr value); + static int Offset(IntPtr buffer,int used,IntPtr value,long length) { + long offset=value.ToInt64()-buffer.ToInt64(); + if(value==IntPtr.Zero||offset<16||length<0||offset>used||length>used-offset)throw new InvalidDataException("Native pointer escapes its returned query buffer."); + return (int)offset; + } + static string Text(IntPtr buffer,int used,IntPtr value,int maximum) { + int offset=Offset(buffer,used,value,2);if((offset&1)!=0)throw new InvalidDataException("Unaligned native UTF16 string."); + int length=0;while(length<=maximum&&offset+2L*length+2<=used){if(Marshal.ReadInt16(buffer,offset+2*length)==0){byte[] bytes=new byte[length*2];Marshal.Copy(value,bytes,0,bytes.Length);return new UnicodeEncoding(false,false,true).GetString(bytes);}length++;} + throw new InvalidDataException("Unterminated or oversized native query name."); + } + static int Header(IntPtr buffer,int used,int expected) { + if(buffer==IntPtr.Zero||used<16||used>MaximumBuffer||Marshal.ReadInt32(buffer,12)!=expected)throw new InvalidDataException("Unexpected native query variant type or size."); + int count=Marshal.ReadInt32(buffer,8);if(count<0||count>128)throw new InvalidDataException("Native query status count exceeds 128.");return count; + } + // EVT (not EC) UInt32 is 8; arrays require the exact array bit. + public static string[] DecodeNames(IntPtr buffer,int used) { + int count=Header(buffer,used,129);IntPtr values=Marshal.ReadIntPtr(buffer);string[] result=new string[count]; + if(count>0){Offset(buffer,used,values,(long)count*IntPtr.Size);for(int i=0;i0){Offset(buffer,used,values,(long)count*4);for(int i=0;isize)throw new InvalidDataException("Native query returned an invalid used length.");return property==0?(object)DecodeNames(buffer,(int)used):DecodeStatuses(buffer,(int)used);} + if(error!=122)throw new Win32Exception(error);if(used<=size||used>MaximumBuffer)throw new InvalidDataException("Native query buffer bound exceeded.");size=used; + }finally{if(buffer!=IntPtr.Zero)Marshal.FreeHGlobal(buffer);} + }throw new InvalidDataException("Native query buffer did not stabilize."); + } + static LogStatus[] Statuses(IntPtr query) { + string[] names=(string[])Info(query,0);uint[] codes=(uint[])Info(query,1); + if(names.Length!=codes.Length||names.Length==0)throw new InvalidDataException("Incomplete native query channel status arrays."); + LogStatus[] result=new LogStatus[names.Length];for(int i=0;isize||(used&1)!=0||count!=0||Marshal.ReadInt16(buffer,(int)used-2)!=0)throw new InvalidDataException("Native event XML has an invalid UTF16 boundary.");byte[] bytes=new byte[used-2];Marshal.Copy(buffer,bytes,0,bytes.Length);string xml=new UnicodeEncoding(false,false,true).GetString(bytes);if(xml.IndexOf('\0')>=0)throw new InvalidDataException("Embedded NUL in event XML.");return xml;} + if(error!=122)throw new Win32Exception(error);if(used<=size||used>MaximumBuffer)throw new InvalidDataException("Native event XML exceeds one MiB.");size=used; + }finally{if(buffer!=IntPtr.Zero)Marshal.FreeHGlobal(buffer);} + }throw new InvalidDataException("Native event XML buffer did not stabilize."); + } + static void Close(IntPtr handle,Result result) {if(handle!=IntPtr.Zero&&!EvtClose(handle)){result.CleanupConfirmed=false;result.Complete=false;result.Diagnostic+=" Native query/event handle close failed.";}} + public static Result Read(string query,int maximum) { + if(String.IsNullOrEmpty(query)||query.Length>65536||maximum<1||maximum>64)throw new ArgumentException("Query text/event count exceeds the explicit bound."); + Result result=new Result();List events=new List();IntPtr handle=IntPtr.Zero; + try{ + // Local log query, reverse order. Never tolerate errors for matching evidence. + handle=EvtQuery(IntPtr.Zero,null,query,0x201); + if(handle==IntPtr.Zero){result.NativeError=unchecked((uint)Marshal.GetLastWin32Error()); + // Diagnostic-only alternate query. Windows may recover parts of invalid XPath. + IntPtr diagnostic=EvtQuery(IntPtr.Zero,null,query,0x1201); + if(diagnostic==IntPtr.Zero)result.DiagnosticNativeError=unchecked((uint)Marshal.GetLastWin32Error()); + else try{result.DiagnosticChannels=Statuses(diagnostic);}catch(Exception e){result.Diagnostic+=" Diagnostic status read failed: "+e.Message;}finally{Close(diagnostic,result);} + return result; + } + result.Opened=true;result.Channels=Statuses(handle);long bytes=0; + while(true){IntPtr[] next=new IntPtr[1];uint returned=0;bool ok=EvtNext(handle,1,next,5000,0,out returned);int error=Marshal.GetLastWin32Error(); + try{ + if(!ok){if(returned!=0||next[0]!=IntPtr.Zero)throw new InvalidDataException("Failed EvtNext returned an unexpected event.");if(error==259)result.Complete=true;else result.NativeError=unchecked((uint)error);break;} + if(returned!=1||next[0]==IntPtr.Zero)throw new InvalidDataException("EvtNext returned an invalid count or handle."); + if(events.Count==maximum){result.Capped=true;break;} + string xml=Render(next[0]);bytes+=Encoding.UTF8.GetByteCount(xml);if(bytes>4194304)throw new InvalidDataException("Native matching XML exceeds four MiB aggregate.");events.Add(xml); + }finally{Close(next[0],result);} + } + }catch(Win32Exception e){result.NativeError=unchecked((uint)e.NativeErrorCode);result.Complete=false;result.Diagnostic+=e.Message;} + catch(Exception e){result.Complete=false;result.Diagnostic+=e.Message;} + finally{Close(handle,result);if(!result.CleanupConfirmed)result.Complete=false;result.Events=events.ToArray();} + return result; + } + public static async Task ReadPipe(TextReader reader,int maximum) { + var text=new StringBuilder();var buffer=new char[2048];while(true){int count=await reader.ReadAsync(buffer,0,buffer.Length).ConfigureAwait(false);if(count==0)return text.ToString();if(count>maximum-text.Length)throw new InvalidDataException("Worker output exceeds its bound.");text.Append(buffer,0,count);} + } + } +} + +namespace Wela.WefQueryToken { + public sealed class Group { public string Sid; public uint Attributes; } + public sealed class Privilege { public string Luid; public uint Attributes; } + public sealed class Token { + public string Sid, Name, AuthenticationId, AuthenticationType, ImpersonationLevel, TokenSource; + public Group[] Groups; public Privilege[] Privileges; + } + public static class Native { + [DllImport("kernel32.dll",ExactSpelling=true)] static extern void GetSystemTimePreciseAsFileTime(out long value); + public static DateTime UtcNow() {long value;GetSystemTimePreciseAsFileTime(out value);return DateTime.FromFileTimeUtc(value);} + [StructLayout(LayoutKind.Sequential)] struct Luid {public uint Low; public int High;} + [StructLayout(LayoutKind.Sequential)] struct Statistics {public Luid TokenId,AuthenticationId;public long Expiration;public int Type,Level;public uint Charged,Available,Groups,Privileges;public Luid Modified;} + [StructLayout(LayoutKind.Sequential)] struct SidAndAttributes {public IntPtr Sid;public uint Attributes;} + [StructLayout(LayoutKind.Sequential)] struct TokenGroups {public uint Count;public SidAndAttributes First;} + [StructLayout(LayoutKind.Sequential)] struct LuidAndAttributes {public Luid Luid;public uint Attributes;} + [DllImport("kernel32.dll")] static extern IntPtr GetCurrentProcess(); + [DllImport("kernel32.dll")] static extern IntPtr GetCurrentThread(); + [DllImport("kernel32.dll",SetLastError=true)] static extern bool CloseHandle(IntPtr h); + [DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenProcessToken(IntPtr p,uint access,out IntPtr t); + [DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenThreadToken(IntPtr p,uint access,bool self,out IntPtr t); + [DllImport("advapi32.dll",SetLastError=true)] static extern bool GetTokenInformation(IntPtr t,int cls,IntPtr data,int length,out int needed); + static string Hex(Luid id) {return "0x"+(((ulong)(uint)id.High<<32)|id.Low).ToString("x");} + static IntPtr Read(IntPtr token,int cls,out int length) { + GetTokenInformation(token,cls,IntPtr.Zero,0,out length); + if(Marshal.GetLastWin32Error()!=122||length<4||length>65536)throw new InvalidOperationException("Unknown or oversized token information."); + IntPtr data=Marshal.AllocHGlobal(length); + if(!GetTokenInformation(token,cls,data,length,out length)){int error=Marshal.GetLastWin32Error();Marshal.FreeHGlobal(data);throw new Win32Exception(error);} + return data; + } + static Token ReadToken(IntPtr token,string source) { + Token result=new Token();result.TokenSource=source;using(WindowsIdentity identity=new WindowsIdentity(token)){result.Sid=identity.User.Value;result.Name=identity.Name;result.AuthenticationType=identity.AuthenticationType;result.ImpersonationLevel=identity.ImpersonationLevel.ToString();} + int length;IntPtr p=Read(token,10,out length); + try {if(length4096||offset+(long)count*size>length)throw new InvalidOperationException("Invalid token groups.");List groups=new List();for(int i=0;iString.CompareOrdinal(a.Sid,b.Sid));result.Groups=groups.ToArray();}finally{Marshal.FreeHGlobal(p);} + p=Read(token,3,out length); + try {int count=Marshal.ReadInt32(p),size=Marshal.SizeOf(typeof(LuidAndAttributes));if(count<0||count>4096||4+(long)count*size>length)throw new InvalidOperationException("Invalid token privileges.");List privileges=new List();for(int i=0;iString.CompareOrdinal(a.Luid,b.Luid));result.Privileges=privileges.ToArray();}finally{Marshal.FreeHGlobal(p);} + return result; + } + static bool Equivalent(Token a,Token b) { + if(a.Sid!=b.Sid||a.AuthenticationId!=b.AuthenticationId||a.Groups.Length!=b.Groups.Length||a.Privileges.Length!=b.Privileges.Length)return false; + for(int i=0;i&1|Out-String);$actual=$LASTEXITCODE;$ErrorActionPreference='Stop' + if($actual -ne $ExitCode -or $output -notmatch [regex]::Escape($Text)){throw "CLI regression ($actual expected $ExitCode): $($Arguments -join ' ')`n$output"};$script:count++ +} +Assert-Cli @('wef-query','-Help') 0 'exact selected local QueryList' +Assert-Cli @('version','-WefQueryConfigPath','source.json') 1 'WefQuery options require' +Assert-Cli @('wef-query','-Auto') 1 'dedicated read-only options' +Assert-Cli @('wef-query','-DryRun') 1 'dedicated read-only options' +Assert-Cli @('wef-query','-WhatIf') 1 'dedicated read-only options' +Assert-Cli @('wef-query','-ResultsPath','out.json') 1 'dedicated read-only options' +Assert-Cli @('wef-query','-WefAction','Configure') 1 'dedicated read-only options' +Assert-Cli @('wef-query','-WefQueryMaximumEvents','0') 1 'less than the minimum' +Assert-Cli @('wef-query','-WefQueryMaximumEvents','65') 1 'greater than the maximum' +Assert-Cli @('wef-query') 1 'exact source config path and subscription ID' +Write-Host "WefQuery.Cli.Tests: $script:count public CLI checks passed." +$global:LASTEXITCODE=0 diff --git a/tests/WefQuery.Tests.ps1 b/tests/WefQuery.Tests.ps1 new file mode 100644 index 00000000..2485fdae --- /dev/null +++ b/tests/WefQuery.Tests.ps1 @@ -0,0 +1,60 @@ +$ErrorActionPreference='Stop';$script:ScriptRoot=Split-Path $PSScriptRoot -Parent +Import-Module (Join-Path $script:ScriptRoot 'modules/AuditProfiles.psm1') -Force +Import-Module (Join-Path $script:ScriptRoot 'modules/WefSubscriptions.psm1') -Force +foreach($name in @('WefArrival','WecUpdate','ChannelRead','WefQuery')){. (Join-Path $script:ScriptRoot ('scripts/'+$name+'.ps1'))} +$script:count=0 +function Assert($value,[string]$message){if(-not $value){throw $message};$script:count++} +function Reject([scriptblock]$code,[string]$message){$caught=$false;try{& $code|Out-Null}catch{$caught=$true};Assert $caught $message} +function Clone($value){ConvertFrom-WelaArrivalJson (Get-WelaWefQueryKey $value)} +Initialize-WelaWefQueryNative +$buffer=[Runtime.InteropServices.Marshal]::AllocHGlobal(128) +try{ + function Reset-Buffer([int]$type,[int]$count){for($i=0;$i -lt 128;$i++){[Runtime.InteropServices.Marshal]::WriteByte($buffer,$i,0)};[Runtime.InteropServices.Marshal]::WriteInt32($buffer,12,$type);[Runtime.InteropServices.Marshal]::WriteInt32($buffer,8,$count);[Runtime.InteropServices.Marshal]::WriteIntPtr($buffer,[IntPtr]::Add($buffer,16))} + Reset-Buffer 136 2;[Runtime.InteropServices.Marshal]::WriteInt32($buffer,16,0);[Runtime.InteropServices.Marshal]::WriteInt32($buffer,20,-1) + $values=[Wela.WefQuery.Native]::DecodeStatuses($buffer,24);Assert ($values.Count -eq 2 -and $values[1] -eq [uint32]::MaxValue) 'Native EVT UInt32 status preserves unsigned errors.' + foreach($type in @(2,8,130,129,264)){Reset-Buffer $type 1;Reject {[Wela.WefQuery.Native]::DecodeStatuses($buffer,24)} "Reject wrong status variant $type"} + Reset-Buffer 136 129;Reject {[Wela.WefQuery.Native]::DecodeStatuses($buffer,128)} 'Status count cap.' + Reset-Buffer 136 2;Reject {[Wela.WefQuery.Native]::DecodeStatuses($buffer,20)} 'Status pointer cannot exceed used bytes.' + Reset-Buffer 136 1;[Runtime.InteropServices.Marshal]::WriteIntPtr($buffer,[IntPtr]::Add($buffer,8));Reject {[Wela.WefQuery.Native]::DecodeStatuses($buffer,24)} 'Status pointer cannot overlap header.' + Reset-Buffer 129 1;[Runtime.InteropServices.Marshal]::WriteIntPtr($buffer,16,[IntPtr]::Add($buffer,32));$text=[Text.Encoding]::Unicode.GetBytes('System'+[char]0);[Runtime.InteropServices.Marshal]::Copy($text,0,[IntPtr]::Add($buffer,32),$text.Length) + Assert ([Wela.WefQuery.Native]::DecodeNames($buffer,46)[0] -ceq 'System') 'Native string-array pointer and UTF16.' + Reject {[Wela.WefQuery.Native]::DecodeNames($buffer,44)} 'Unterminated names refuse.' + [Runtime.InteropServices.Marshal]::WriteInt16($buffer,32,[int16]-10240);Reject {[Wela.WefQuery.Native]::DecodeNames($buffer,46)} 'Unpaired Unicode surrogate refuses.' + foreach($used in @(0,15,1048577)){Reject {[Wela.WefQuery.Native]::DecodeNames($buffer,$used)} "Invalid buffer length $used"} +}finally{[Runtime.InteropServices.Marshal]::FreeHGlobal($buffer)} +$result=[pscustomobject]@{Opened=$true;Complete=$true;Capped=$false;CleanupConfirmed=$true;NativeError=$null;Diagnostic='';Channels=@([pscustomobject]@{Channel='System';Error=0});DiagnosticChannels=@();DiagnosticNativeError=$null;Events=@()} +Assert-WelaWefQueryNativeResult $result @('System') 16;Assert $true 'Complete empty strict result valid.' +foreach($field in @('Opened','Complete','Capped','CleanupConfirmed')){$copy=Clone $result;$copy.$field='true';Reject {Assert-WelaWefQueryNativeResult $copy @('System') 16} "Typed Boolean $field"} +foreach($field in @('NativeError','DiagnosticNativeError')){$copy=Clone $result;$copy.$field=$true;Reject {Assert-WelaWefQueryNativeResult $copy @('System') 16} "Typed native code $field"} +$copy=Clone $result;$copy.Channels=@();Reject {Assert-WelaWefQueryNativeResult $copy @('System') 16} 'Missing native per-channel provenance.' +$copy=Clone $result;$copy.Channels[0].Channel='Application';Reject {Assert-WelaWefQueryNativeResult $copy @('System') 16} 'Unexpected native channel.' +$copy=Clone $result;$copy.Channels[0].Error=$true;Reject {Assert-WelaWefQueryNativeResult $copy @('System') 16} 'Boolean error rejected.' +foreach($field in @('Capped','Diagnostic','NativeError','CleanupConfirmed')){$copy=Clone $result;switch($field){Capped{$copy.Capped=$true};Diagnostic{$copy.Diagnostic='failure'};NativeError{$copy.NativeError=5};CleanupConfirmed{$copy.CleanupConfirmed=$false}};Reject {Assert-WelaWefQueryNativeResult $copy @('System') 16} "Completeness cannot coexist with $field"} +$failure=Clone $result;$failure.Opened=$false;$failure.Complete=$false;$failure.NativeError=15001;$failure.Channels=@();$failure.DiagnosticChannels=@([pscustomobject]@{Channel='System';Error=15001}) +Assert-WelaWefQueryNativeResult $failure @('System') 16;Assert $true 'Failed strict query retains separate diagnostic errors.' +$failure.Events=@('');Reject {Assert-WelaWefQueryNativeResult $failure @('System') 16} 'Diagnostic records cannot become matches.' +$copy=Clone $result;$copy.Events=@($true);Reject {Assert-WelaWefQueryNativeResult $copy @('System') 16} 'Typed XML required.' +$copy=Clone $result;$copy.Events=@('x','y');Reject {Assert-WelaWefQueryNativeResult $copy @('System') 1} 'Event bound enforced.' +$xml='142SystemHost.example.test日本語 Ω & value' +$hostContext=[pscustomobject]@{Computer='Host';DnsHostName='Host';DnsSuffix='example.test'} +$event=Read-WelaWefQueryEvent $xml @('System') $hostContext;Assert ($event.RecordId -eq 42 -and $event.Channel -ceq 'System') 'Native event selected channel/local host provenance.' +foreach($bad in @($xml.Replace('System','Application'),$xml.Replace('Host.example.test','Other.example.test'),$xml.Replace('Host.example.test','Host.unrelated.test'),$xml.Replace('42',''),$xml.Replace('1','12'),$xml.Replace('2026-01-01T00:00:00.1234567Z','not-utc'))){Reject {Read-WelaWefQueryEvent $bad @('System') $hostContext} 'Native event malformed or mismatched provenance.'} +$temp=Join-Path ([IO.Path]::GetTempPath()) ('wela-query-tests-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $temp +try{ + Copy-Item (Join-Path $script:ScriptRoot 'config/wef-examples/*') $temp + $path=Join-Path $temp 'source.json';$subscription=Join-Path $temp 'native-security.xml';$original=[IO.File]::ReadAllText($path) + $selected=Import-WelaWefQuerySelection $path 'WELA Native Security Example' + Assert ($selected.Id -ceq 'WELA Native Security Example' -and $selected.Files.Count -eq 2 -and $selected.Channels -contains 'Security') 'Existing source config/parser used with exact bounded inputs.' + Assert ($selected.QuerySha256 -ceq (Get-WelaArrivalHash ([Text.Encoding]::UTF8.GetBytes($selected.Query)))) 'Exact extracted QueryList hashed.' + Assert-WelaWefQueryInputs $selected;Assert $true 'Unchanged original input hashes valid.' + Reject {Import-WelaWefQuerySelection $path 'wela Native Security Example'} 'Selected ID case exact.' + [IO.File]::WriteAllText($path,$original.Replace('"SchemaVersion": 1','"SchemaVersion": 1, "SchemaVersion": 1')) + Reject {Import-WelaWefQuerySelection $path 'WELA Native Security Example'} 'Duplicate config properties refused.' + [IO.File]::WriteAllText($path,$original.Replace('"SchemaVersion": 1','"SchemaVersion": true')) + Reject {Import-WelaWefQuerySelection $path 'WELA Native Security Example'} 'Boolean schema rejected.' + [IO.File]::WriteAllText($path,$original) + [IO.File]::AppendAllText($subscription,' ');Reject {Assert-WelaWefQueryInputs $selected} 'Original subscription byte drift invalidates evidence.' +}finally{Remove-Item -LiteralPath $temp -Recurse -Force} +$stream=[IO.StringReader]::new('abcdef');try{Reject {[Wela.WefQuery.Native]::ReadPipe($stream,5).GetAwaiter().GetResult()} 'Bounded pipe rejects excess before growing without limit.'}finally{$stream.Dispose()} +Write-Host "WefQuery.Tests: $script:count focused assertions passed." +$global:LASTEXITCODE=0 diff --git a/tests/WefQuery.Windows.Tests.ps1 b/tests/WefQuery.Windows.Tests.ps1 new file mode 100644 index 00000000..3c1fdb95 --- /dev/null +++ b/tests/WefQuery.Windows.Tests.ps1 @@ -0,0 +1,105 @@ +# Fixture-only owned account and temporary CAPI2 deny ACE. Product is read-only. +param([switch]$AllowDisposableAccount) +$ErrorActionPreference='Stop' +if(-not $AllowDisposableAccount -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 Windows fixture required.'} +$repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo +Import-Module (Join-Path $repo 'modules/AuditProfiles.psm1') -ErrorAction Stop +Import-Module (Join-Path $repo 'modules/WefSubscriptions.psm1') -ErrorAction Stop +foreach($name in @('Configuration','WefArrival','WecUpdate','ChannelRead','WefQuery')){. (Join-Path $repo ('scripts/'+$name+'.ps1'))} +$hostState=Get-WelaWefQueryHost +if($hostState.DomainJoined -or $hostState.DomainRole -ne 2 -or $hostState.ProductType -ne 3){throw 'Standalone disposable Server fixture required.'} +$nonce=[guid]::NewGuid().ToString('N');$root=New-WelaArrivalOutput (Join-Path $env:RUNNER_TEMP ('wela-wef-query-'+$nonce)) $repo +$code=Join-Path $root 'code';$null=New-Item -ItemType Directory $code +foreach($name in @('WELA.ps1','scripts','modules','config')){Copy-Item -LiteralPath (Join-Path $repo $name) -Destination $code -Recurse} +$engine=(Get-Process -Id $PID).Path;$channel='Microsoft-Windows-CAPI2/Operational';$userName='WelaQ'+$nonce.Substring(0,12);$ownedSid=$null;$aclChanged=$false;$passed=$false;$cleanupErrors=@();$script:assertions=0 +function Key($value){Get-WelaWefQueryKey $value} +function Assert($value,[string]$message){if(-not $value){throw $message};$script:assertions++} +function Save([string]$name,$value){[IO.File]::WriteAllText((Join-Path $root $name),(Key $value),[Text.UTF8Encoding]::new($false))} +function Services {@(Get-CimInstance Win32_Service -Filter "Name='WinRM' OR Name='Wecsvc' OR Name='Winmgmt' OR Name='EventLog'"|Sort-Object Name|Select-Object Name,State,StartMode)} +function NativeChannels {@('System','Security',$channel)|ForEach-Object {Get-WelaNativeChannel $_}} +function New-Case([string]$name,[string]$query){ + $inputDirectory=Join-Path $root ('input-'+$name);$null=New-Item -ItemType Directory $inputDirectory + $config=Get-Content -LiteralPath (Join-Path $repo 'config/wef-examples/source.json') -Raw|ConvertFrom-Json;$config.SubscriptionFiles=@('subscription.xml') + $xml=Read-WelaWefXml ([IO.File]::ReadAllText((Join-Path $repo 'config/wef-examples/native-security.xml'))) + $xml.DocumentElement.SelectSingleNode('*[local-name()="SubscriptionId"]').InnerText='Wela Query '+$nonce + $xml.DocumentElement.SelectSingleNode('*[local-name()="Enabled"]').InnerText='false' + $xml.DocumentElement.SelectSingleNode('*[local-name()="Description"]').InnerText='Native read-only query 日本語 Ω '+$nonce + $xml.DocumentElement.SelectSingleNode('*[local-name()="Query"]').InnerText=$query + [IO.File]::WriteAllText((Join-Path $inputDirectory 'source.json'),(Key $config),[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText((Join-Path $inputDirectory 'subscription.xml'),$xml.OuterXml,[Text.UTF8Encoding]::new($false)) + [pscustomobject]@{Name=$name;Config=(Join-Path $inputDirectory 'source.json');Id=('Wela Query '+$nonce)} +} +function Invoke-Public($case,[int]$expected,[int]$maximum=16,[switch]$AsUser){ + $parent=if($AsUser){$readerHome}else{$root};$output=Join-Path $parent ('result-'+$case.Name) + $all=@('-NoLogo','-NoProfile','-NonInteractive','-File',(Join-Path $code 'WELA.ps1'),'wef-query','-WefQueryConfigPath',$case.Config,'-WefQuerySubscriptionId',$case.Id,'-WefQueryOutputPath',$output,'-WefQueryMaximumEvents',[string]$maximum) + foreach($arg in $all){if($arg.Contains('"') -or $arg.EndsWith('\') -or $arg -match '[\x00-\x1f]'){throw 'Ambiguous fixture argument.'}} + $start=[Diagnostics.ProcessStartInfo]::new();$start.FileName=$engine;$start.Arguments=(@($all|ForEach-Object {'"'+$_+'"'}) -join ' ');$start.UseShellExecute=$false;$start.CreateNoWindow=$true;$start.RedirectStandardOutput=$true;$start.RedirectStandardError=$true + if($AsUser){$start.UserName=$userName;$start.Domain=[Environment]::MachineName;$start.Password=$password;$start.LoadUserProfile=$true;$start.WorkingDirectory=$readerHome;$start.EnvironmentVariables['TEMP']=$readerHome;$start.EnvironmentVariables['TMP']=$readerHome} + $process=[Diagnostics.Process]::new();$process.StartInfo=$start;$state=[pscustomobject]@{Started=$false;TerminationConfirmed=$false;Diagnostic=''} + try{ + if(-not $process.Start()){throw 'Public query command did not start.'};$state.Started=$true + $stdout=[Wela.WefQuery.Native]::ReadPipe($process.StandardOutput,50331648);$stderr=[Wela.WefQuery.Native]::ReadPipe($process.StandardError,1048576) + if(-not $process.WaitForExit(180000)){throw 'Public query exceeded three minutes.'} + if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),5000)){throw 'Public output drain timed out.'} + Save ($case.Name+'-stdout.json') $stdout.Result;Save ($case.Name+'-stderr.json') $stderr.Result + Assert ($process.ExitCode -eq $expected) ("Public $($case.Name) exit $($process.ExitCode), expected $expected. "+$stderr.Result+' '+$stdout.Result) + }finally{Close-WelaWefQueryWorker $process $state;if($state.Diagnostic -or -not $state.TerminationConfirmed){$script:cleanupErrors+='Public child cleanup: '+$state.Diagnostic}} + $manifest=ConvertFrom-WelaArrivalJson ([IO.File]::ReadAllText((Join-Path $output 'manifest.json'))) + Assert ($manifest.ConfigurationChanges -eq 0 -and $manifest.ReadyRuleCredit -eq 0 -and $manifest.RequestedEnabled -eq $false) 'Read-only/disabled selection boundary.' + Assert ($manifest.Worker.TerminationConfirmed -and -not $manifest.Worker.Diagnostic) 'Actual bounded worker completed.' + foreach($artifact in $manifest.Artifacts){$path=Join-Path $output $artifact.Name;Assert ((Get-Item -LiteralPath $path).Length -eq $artifact.Bytes -and (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() -ceq $artifact.Sha256) 'Actual artifact bytes/hash.'} + foreach($file in $manifest.Inputs){Assert ((Get-FileHash -LiteralPath $file.Path -Algorithm SHA256).Hash.ToLowerInvariant() -ceq $file.Sha256) 'Original retained native input bytes.'} + if($AsUser){Assert ($manifest.ReaderBefore.Sid -ceq $ownedSid -and $manifest.ReaderBefore.Groups.Sid -notcontains 'S-1-5-32-544' -and $manifest.ReaderBefore.Groups.Sid -notcontains 'S-1-5-32-573') 'Actual owned standard-user token.'} + $manifest +} +$before=[pscustomobject]@{Host=$hostState;Services=(Services);Channels=(NativeChannels);Masks=(Get-WelaEffectiveAuditPolicy);Precedence=(Get-WelaRegistryState 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' SCENoApplyLegacyAuditPolicy);Token=(Get-WelaWefQueryToken)} +Save 'original.json' $before +try{ + $record=Get-WinEvent -LogName System -MaxEvents 1 -ErrorAction Stop + try{$recordId=$record.RecordId;$originalXml=$record.ToXml()}finally{$record.Dispose()} + [IO.File]::WriteAllText((Join-Path $root 'original-event.xml'),$originalXml,[Text.UTF8Encoding]::new($false)) + $query='' + $match=Invoke-Public (New-Case 'match' $query) 0 + Assert ($match.Status -ceq 'MatchesObserved' -and $match.Matches.Count -eq 1 -and $match.Matches[0].Metadata.RecordId -eq $recordId) 'Actual exact System record selected.' + $found=[IO.File]::ReadAllText((Join-Path $root 'result-match/event-001.xml')) + Assert ((Get-WelaWefXmlKey (Read-WelaWefXml $found).DocumentElement) -ceq (Get-WelaWefXmlKey (Read-WelaWefXml $originalXml).DocumentElement)) 'Actual returned full event matches independent native XML.' + $suppressed=$query.Replace('','*[System[EventRecordID='+$recordId+']]') + $empty=Invoke-Public (New-Case 'suppress' $suppressed) 0 + Assert ($empty.Status -ceq 'ReadAllowedEmpty' -and $empty.Matches.Count -eq 0 -and $empty.Query.Complete) 'Actual Suppress excludes the selected event and ends empty.' + $invalid=Invoke-Public (New-Case 'invalid' '') 1 + Assert ($invalid.Status -ceq 'QueryFailed' -and -not $invalid.Query.Opened -and $invalid.Query.NativeError -ne 0 -and $invalid.Matches.Count -eq 0) 'Native invalid XPath cannot become successful evidence.' + $missing='Microsoft-Windows-WelaMissing-'+$nonce+'/Operational' + $mixedQuery=$query.Replace('','') + $mixed=Invoke-Public (New-Case 'missing' $mixedQuery) 1 + Assert ($mixed.Status -ceq 'QueryFailed' -and -not $mixed.Query.Opened -and $mixed.Matches.Count -eq 0) 'A missing selected channel fails the strict mixed query.' + Assert (@($mixed.Query.DiagnosticChannels|Where-Object {$_.Channel -ceq $missing -and $_.Error -ne 0}).Count -eq 1) 'Separate native diagnostics preserve missing-channel failure.' + $capped=Invoke-Public (New-Case 'capped' '') 1 1 + Assert ($capped.Status -ceq 'Partial' -and $capped.Query.Capped -and -not $capped.Query.Complete -and $capped.Matches.Count -eq 1) 'Actual second native record proves event cap.' + Assert ((Key (Services)) -ceq (Key $before.Services) -and (Key (NativeChannels)) -ceq (Key $before.Channels)) 'Admin public cases preserve services and all selected channel settings.' + $password=ConvertTo-SecureString ('Wela!9'+[guid]::NewGuid().ToString('N')+'rA#') -AsPlainText -Force + $user=New-LocalUser -Name $userName -Password $password -Description ('WELA query '+$nonce) -AccountNeverExpires;$ownedSid=$user.SID.Value + Add-LocalGroupMember -SID 'S-1-5-32-545' -Member $user + $readerHome=Join-Path $root 'reader';$null=New-Item -ItemType Directory $readerHome + $acl=Get-Acl -LiteralPath $root;$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($user.SID,'ReadAndExecute','ContainerInherit,ObjectInherit','None','Allow'));Set-Acl -LiteralPath $root -AclObject $acl + $acl=Get-Acl -LiteralPath $readerHome;$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($user.SID,'FullControl','ContainerInherit,ObjectInherit','None','Allow'));Set-Acl -LiteralPath $readerHome -AclObject $acl + $channelBefore=@($before.Channels|Where-Object Name -CEQ $channel)[0];$descriptor=[Security.AccessControl.RawSecurityDescriptor]::new($channelBefore.SecurityDescriptor) + $descriptor.DiscretionaryAcl.InsertAce(0,[Security.AccessControl.CommonAce]::new([Security.AccessControl.AceFlags]::None,[Security.AccessControl.AceQualifier]::AccessDenied,1,$user.SID,$false,$null));$deny=$descriptor.GetSddlForm([Security.AccessControl.AccessControlSections]::All) + $aclChanged=$true;& wevtutil.exe sl $channel ('/ca:'+$deny);if($LASTEXITCODE -ne 0){throw 'Fixture owned deny ACE setter failed.'};$global:LASTEXITCODE=0 + Assert ((Get-WelaNativeChannel $channel).SecurityDescriptor -ceq $deny) 'Actual fixture-only deny descriptor readback.' + $deniedQuery='' + $denied=Invoke-Public (New-Case 'denied' $deniedQuery) 1 16 -AsUser + Assert ($denied.Status -ceq 'QueryFailed' -and $denied.Query.NativeError -eq 5 -and $denied.Matches.Count -eq 0) 'Actual standard-user native access denied.' + Assert ((Get-WelaNativeChannel $channel).SecurityDescriptor -ceq $deny) 'Public denied read leaves prepared descriptor unchanged.' + $passed=$true +}catch{Save 'failure.json' ([pscustomobject]@{Message=$_.Exception.Message;Stack=$_.ScriptStackTrace});throw} +finally{ + if($aclChanged){try{& wevtutil.exe sl $channel ('/ca:'+$channelBefore.SecurityDescriptor);if($LASTEXITCODE -ne 0){throw 'Original descriptor restore failed.'};$global:LASTEXITCODE=0}catch{$cleanupErrors+=$_.Exception.Message}} + if($ownedSid){try{$current=Get-LocalUser -Name $userName -ErrorAction Stop;if($current.SID.Value -cne $ownedSid){throw 'Owned account changed identity.'};Remove-LocalUser -SID $ownedSid -ErrorAction Stop;if(Get-LocalUser -SID $ownedSid -ErrorAction SilentlyContinue){throw 'Owned account remains.'}}catch{$cleanupErrors+=$_.Exception.Message}} + $restored=$null;try{$restored=[pscustomobject]@{Host=(Get-WelaWefQueryHost);Services=(Services);Channels=(NativeChannels);Masks=(Get-WelaEffectiveAuditPolicy);Precedence=(Get-WelaRegistryState 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' SCENoApplyLegacyAuditPolicy);Token=(Get-WelaWefQueryToken)};Save 'restored.json' $restored}catch{$cleanupErrors+=$_.Exception.Message} + $channelsRestored=$restored -and (Key $restored.Channels) -ceq (Key $before.Channels);$servicesRestored=$restored -and (Key $restored.Services) -ceq (Key $before.Services);$policyRestored=$restored -and (Key $restored.Masks) -ceq (Key $before.Masks) -and (Key $restored.Precedence) -ceq (Key $before.Precedence);$tokenRestored=$restored -and (Get-WelaWefQueryTokenKey $restored.Token) -ceq (Get-WelaWefQueryTokenKey $before.Token) + if(-not $channelsRestored -or -not $servicesRestored -or -not $policyRestored -or -not $tokenRestored){$cleanupErrors+='Original channel/services/policy/token differ.'} + Save 'cleanup.json' ([pscustomobject]@{Passed=$passed;Assertions=$script:assertions;ChannelsRestored=[bool]$channelsRestored;ServicesRestored=[bool]$servicesRestored;PolicyRestored=[bool]$policyRestored;TokenRestored=[bool]$tokenRestored;AccountRemoved=[bool](-not $ownedSid -or -not(Get-LocalUser -SID $ownedSid -ErrorAction SilentlyContinue));Errors=$cleanupErrors;EventGeneration='Not tested';Forwarding='Not tested';Sources=(Get-WelaWefQuerySources)}) + if($cleanupErrors.Count){throw ('Fixture cleanup incomplete: '+($cleanupErrors -join '; '))} +} +Write-Host "WefQuery.Windows.Tests: $script:assertions actual native assertions passed; complete owned fixture cleanup." +exit 0 From b162c4e598d4d5c2fd351c15765903e69dc49194 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:03:11 +0900 Subject: [PATCH 05/12] feat: configure selected incoming and domain NTLM audit policies --- .gitattributes | 4 + .github/workflows/ntlm-auditing.yml | 46 ++++++++++++ .github/workflows/release.yml | 2 +- CHANGELOG-Japanese.md | 2 + CHANGELOG.md | 2 + WELA.ps1 | 19 ++++- docs/ntlm-auditing.md | 30 ++++++++ docs/outgoing-ntlm.md | 2 + scripts/Configuration.ps1 | 2 +- scripts/NtlmAudit.ps1 | 100 +++++++++++++++++++++++++ tests/NtlmAudit.Cli.Tests.ps1 | 21 ++++++ tests/NtlmAudit.Tests.ps1 | 97 ++++++++++++++++++++++++ tests/NtlmAudit.Windows.Tests.ps1 | 80 ++++++++++++++++++++ website/docs/resources/changelog.ja.md | 2 + website/docs/resources/changelog.md | 2 + 15 files changed, 408 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/ntlm-auditing.yml create mode 100644 docs/ntlm-auditing.md create mode 100644 scripts/NtlmAudit.ps1 create mode 100644 tests/NtlmAudit.Cli.Tests.ps1 create mode 100644 tests/NtlmAudit.Tests.ps1 create mode 100644 tests/NtlmAudit.Windows.Tests.ps1 diff --git a/.gitattributes b/.gitattributes index 7f4524b7..c8781566 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 + +# Scoped NTLM source and native evidence retain stable bytes. +/scripts/NtlmAudit.ps1 text eol=lf +/tests/NtlmAudit* text eol=lf diff --git a/.github/workflows/ntlm-auditing.yml b/.github/workflows/ntlm-auditing.yml new file mode 100644 index 00000000..c76057a9 --- /dev/null +++ b/.github/workflows/ntlm-auditing.yml @@ -0,0 +1,46 @@ +name: Scoped incoming and domain NTLM auditing +on: + push: + branches: ['**'] + paths: + - 'WELA.ps1' + - 'scripts/NtlmAudit.ps1' + - 'scripts/Configuration.ps1' + - 'tests/NtlmAudit*' + - '.github/workflows/ntlm-auditing.yml' + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + ntlm-auditing: + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + engine: [powershell, pwsh] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - name: Scoped audit tests in Windows PowerShell + if: matrix.engine == 'powershell' + shell: powershell + run: | + ./tests/NtlmAudit.Tests.ps1 + ./tests/NtlmAudit.Cli.Tests.ps1 + ./tests/NtlmAudit.Windows.Tests.ps1 -AllowDisposableAuditWrite + - name: Scoped audit tests in PowerShell7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: | + ./tests/NtlmAudit.Tests.ps1 + ./tests/NtlmAudit.Cli.Tests.ps1 + ./tests/NtlmAudit.Windows.Tests.ps1 -AllowDisposableAuditWrite + - name: Retain typed originals, results and cleanup + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: ntlm-auditing-${{ matrix.os }}-${{ matrix.engine }} + path: ${{ runner.temp }}/wela-incoming-domain-audit-*/ + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 52989c99..d3cd2ef9 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/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, ./docs/ntlm-auditing.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..75989969 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,6 +4,8 @@ **改善:** +- 受信・ドメイン監査を明示的に選択する `ntlm-auditing` Audit/Plan/Configure を追加しました。受信監査 DWORD2 と実際のDC上のドメイン監査 DWORD7 のみを設定し、旧値2の意味を推測せず識別します。不明な値・ホストや設定の変化を拒否し、型付き変更前記録・再読取・部分失敗を区別します。ネイティブテストで受信設定、非DCのスキップ、無関係な設定の保持と復元を検証します。(関連 #363) (@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..8bad02c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ **Improvements:** +- Add `ntlm-auditing` Audit/Plan/Configure with explicit Incoming/Domain/Both selection. Configure only incoming audit DWORD2 and actual-DC domain audit DWORD7, identify legacy domain2 without invented semantics, refuse unknown values and host/state drift, and retain separate typed journals/readback/partial outcomes. Native tests verify incoming changes, non-DC skips and exact preservation of unrelated settings. (Related #363) (@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/WELA.ps1 b/WELA.ps1 index 9aad3df0..b28d4184 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -17,6 +17,8 @@ [ValidateSet("PreserveOrAudit", "Audit", "Deny")] [string]$OutgoingNtlmMode = "PreserveOrAudit", [ValidateSet("Audit","Plan","Configure")][string]$NtlmAction = "Audit", + [ValidateSet("Audit","Plan","Configure")][string]$NtlmAuditAction = "Audit", + [ValidateSet("Incoming","Domain","Both")][string]$NtlmAuditScope = "Both", [switch]$DryRun, [string]$BackupPath, [string]$ResultsPath, @@ -237,6 +239,7 @@ $AuditpolTxtPath = Join-Path $ScriptRoot "auditpol.txt" $SaclTargetsPath = Join-Path $ScriptRoot "config/audit_sacl_targets.json" . (Join-Path $ScriptRoot "scripts/Configuration.ps1") . (Join-Path $ScriptRoot "scripts/OutgoingNtlmAudit.ps1") +. (Join-Path $ScriptRoot "scripts/NtlmAudit.ps1") . (Join-Path $ScriptRoot "scripts/AdcsAuditing.ps1") . (Join-Path $ScriptRoot "scripts/AdcsRestartResume.ps1") . (Join-Path $ScriptRoot "scripts/AuditIntegrity.ps1") @@ -2078,6 +2081,7 @@ Usage: ./WELA.ps1 eventlog-recovery -Help # Review restoration of one completed log size/mode write ./WELA.ps1 wec-listener -Help # Review one fixed-address native HTTP5985 listener ./WELA.ps1 wec-ingress -Help # Review scoped collector firewall rule creation + ./WELA.ps1 ntlm-auditing -Help # Configure selected incoming/domain NTLM auditing ./WELA.ps1 outgoing-ntlm -Help # Configure outgoing NTLM auditing independently ./WELA.ps1 wec-authorization -Help # Review source SID authorization on a disabled subscription ./WELA.ps1 wec-state -Help # Review enable/disable of one existing subscription @@ -2182,6 +2186,12 @@ if ($Cmd -ne 'wec-listener' -and @($PSBoundParameters.Keys | Where-Object {$_ -l if ($Cmd -eq 'wec-listener' -and ($args.Count -or @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','WecListenerAction','WecListenerComputerName','WecListenerLocalAddress','WecListenerPlanPath','WecListenerPlanHash','WecListenerOutputPath','Help')}).Count)) {throw 'wec-listener accepts only dedicated options.'} if ($Cmd -ne 'wec-ingress' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'WecIngress*'}).Count) {throw 'WecIngress options require wec-ingress.'} if ($Cmd -eq 'wec-ingress' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','WecIngressAction','WecIngressName','WecIngressLocalAddress','WecIngressRemoteAddress','WecIngressPlanPath','WecIngressPlanHash','WecIngressOutputPath','Help')}).Count) {throw 'wec-ingress accepts only dedicated options.'} +if ($Cmd -ne 'ntlm-auditing' -and @($PSBoundParameters.Keys | Where-Object {$_ -in @('NtlmAuditAction','NtlmAuditScope')}).Count) {throw 'NtlmAudit options require ntlm-auditing.'} +if ($Cmd -eq 'ntlm-auditing') { + if (@($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','NtlmAuditAction','NtlmAuditScope','Auto','DryRun','BackupPath','ResultsPath','Help')}).Count) {throw 'ntlm-auditing accepts only its dedicated options.'} + if ($NtlmAuditAction -ne 'Configure' -and ($Auto -or $DryRun -or $BackupPath)) {throw 'Consent, dry-run and backup options require NtlmAuditAction Configure.'} + if ($NtlmAuditAction -eq 'Configure' -and -not $PSBoundParameters.ContainsKey('NtlmAuditScope')) {throw 'Configure requires explicit NtlmAuditScope Incoming, Domain or Both.'} +} if ($Cmd -ne 'outgoing-ntlm' -and $PSBoundParameters.ContainsKey('NtlmAction')) {throw 'NtlmAction requires outgoing-ntlm.'} if ($Cmd -eq 'outgoing-ntlm') { if (@($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','NtlmAction','OutgoingNtlmMode','Auto','DryRun','BackupPath','ResultsPath','Help')}).Count) {throw 'outgoing-ntlm accepts only its dedicated options.'} @@ -2276,7 +2286,7 @@ if ($Cmd -ne 'ad-object-sacl' -and @($PSBoundParameters.Keys | Where-Object { }).Count) { throw 'AD object SACL options require the dedicated ad-object-sacl command. No command was run.' } -if ($DryRun -and -not ($Cmd -eq 'outgoing-ntlm' -and $NtlmAction -eq 'Configure') -and -not ($Cmd -eq 'transcription-recovery' -and $TranscriptRecoveryAction -eq 'Restore') -and -not ($Cmd -eq 'adcs-auditing' -and $AdcsAction -eq 'Configure') -and -not ($Cmd -eq 'adcs-resume' -and $AdcsResumeAction -eq 'Resume') -and -not ($Cmd -eq 'gpo-create' -and $GpoCreateAction -eq 'Create') -and -not ($Cmd -eq 'dns-analytical' -and $DnsAction -eq 'Configure') -and -not ($Cmd -eq 'targeted-sacl' -and $TargetSaclAction -eq 'Configure') -and -not ($Cmd -eq 'audit-recovery' -and $RecoveryAction -eq 'Restore') -and -not ($Cmd -eq 'gpo-package' -and $GpoAction -eq 'Export') -and -not ($Cmd -eq 'audit-integrity' -and $IntegrityAction -eq 'Configure') -and -not ($Cmd -eq 'audit-notifications' -and $NotificationAction -eq 'Configure') -and -not ($Cmd -eq 'ldap-diagnostics' -and $LdapAction -eq 'Configure') -and -not ($Cmd -eq 'applocker-readiness' -and $AppLockerAction -eq 'Import') -and $Cmd -notin @('configure', 'configure-eventlogs') -and +if ($DryRun -and -not ($Cmd -eq 'ntlm-auditing' -and $NtlmAuditAction -eq 'Configure') -and -not ($Cmd -eq 'outgoing-ntlm' -and $NtlmAction -eq 'Configure') -and -not ($Cmd -eq 'transcription-recovery' -and $TranscriptRecoveryAction -eq 'Restore') -and -not ($Cmd -eq 'adcs-auditing' -and $AdcsAction -eq 'Configure') -and -not ($Cmd -eq 'adcs-resume' -and $AdcsResumeAction -eq 'Resume') -and -not ($Cmd -eq 'gpo-create' -and $GpoCreateAction -eq 'Create') -and -not ($Cmd -eq 'dns-analytical' -and $DnsAction -eq 'Configure') -and -not ($Cmd -eq 'targeted-sacl' -and $TargetSaclAction -eq 'Configure') -and -not ($Cmd -eq 'audit-recovery' -and $RecoveryAction -eq 'Restore') -and -not ($Cmd -eq 'gpo-package' -and $GpoAction -eq 'Export') -and -not ($Cmd -eq 'audit-integrity' -and $IntegrityAction -eq 'Configure') -and -not ($Cmd -eq 'audit-notifications' -and $NotificationAction -eq 'Configure') -and -not ($Cmd -eq 'ldap-diagnostics' -and $LdapAction -eq 'Configure') -and -not ($Cmd -eq 'applocker-readiness' -and $AppLockerAction -eq 'Import') -and $Cmd -notin @('configure', 'configure-eventlogs') -and -not ($Cmd -eq 'provider-packs' -and $ProviderAction -eq 'Configure') -and -not ($Cmd -eq 'firewall-logging' -and $FirewallAction -eq 'Configure') -and -not ($Cmd -eq 'firewall-recovery' -and $FirewallRecoveryAction -eq 'Restore') -and @@ -2462,6 +2472,13 @@ switch ($Cmd.ToLower()) { $report=Invoke-WelaWecIngress @arguments;$report if($report.ExitCode){exit $report.ExitCode} } + 'ntlm-auditing' { + if ($Help) {Write-Host 'Usage: ntlm-auditing [-NtlmAuditAction Audit|Plan|Configure] [-NtlmAuditScope Incoming|Domain|Both] [-Auto] [-DryRun] [-BackupPath new-directory] [-ResultsPath report.json]. Configure requires explicit scope. Writes only incoming audit DWORD2 and/or actual-DC domain audit DWORD7; preserves all authentication restrictions. See docs/ntlm-auditing.md.';return} + if ($NtlmAuditAction -eq 'Configure' -and -not (TestAdministrator)) {throw 'NTLM audit configuration requires Administrator privileges.'} + $report=Invoke-WelaNtlmAuditCommand -Action $NtlmAuditAction -Selection $NtlmAuditScope -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath -ResultsPath $ResultsPath + $report|Format-List + exit $report.ExitCode + } 'outgoing-ntlm' { if ($Help) {Write-Host 'Usage: outgoing-ntlm [-NtlmAction Audit|Plan|Configure] [-OutgoingNtlmMode PreserveOrAudit|Audit] [-Auto] [-DryRun] [-BackupPath new-directory] [-ResultsPath report.json]. Changes only the outgoing audit DWORD. Existing deny is preserved by default; explicit Audit authorizes replacing it. See docs/outgoing-ntlm.md.';return} if ($NtlmAction -eq 'Configure' -and -not (TestAdministrator)) {throw 'Outgoing NTLM configuration requires Administrator privileges.'} diff --git a/docs/ntlm-auditing.md b/docs/ntlm-auditing.md new file mode 100644 index 00000000..68ca59e1 --- /dev/null +++ b/docs/ntlm-auditing.md @@ -0,0 +1,30 @@ +# Scoped incoming and domain NTLM auditing + +`ntlm-auditing` reads or configures two distinct audit values without invoking the broad `configure` workflow. It never writes an NTLM restriction or exception. Configure requires an explicit selection and elevated native 64-bit PowerShell on reviewed Windows 11 builds 22000/22621/22631/26100/26200 or Server 2022/2025 builds 20348/26100. Product type, domain role and join state must agree; role/build overrides are refused. + +| Selection | Exact value | Requested setting | Applicability | +| --- | --- | --- | --- | +| Incoming | `HKLM\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0\AuditReceivingNTLMTraffic` | DWORD 2, audit all accounts | Reviewed client and server roles | +| Domain | `HKLM\SYSTEM\CurrentControlSet\Services\Netlogon\Parameters\AuditNTLMInDomain` | DWORD 7, Enable all | Actual domain controller only | +| Both | Both rows above | Each applicable audit setting | Domain row remains NotApplicable on a non-DC | + +```powershell +./WELA.ps1 ntlm-auditing -NtlmAuditAction Audit -ResultsPath audit.json +./WELA.ps1 ntlm-auditing -NtlmAuditAction Plan -NtlmAuditScope Incoming -ResultsPath plan.json +./WELA.ps1 ntlm-auditing -NtlmAuditAction Configure -NtlmAuditScope Incoming -DryRun -ResultsPath preview.json +./WELA.ps1 ntlm-auditing -NtlmAuditAction Configure -NtlmAuditScope Incoming -Auto -BackupPath ./before-incoming -ResultsPath incoming.json +# Run on the reviewed domain controller itself: +./WELA.ps1 ntlm-auditing -NtlmAuditAction Configure -NtlmAuditScope Domain -BackupPath ./before-domain -ResultsPath domain.json +``` + +Incoming accepts native DWORD 0/1/2 or an absent value. Domain accepts absent or DWORD 0/1/3/5/7, plus historical WELA DWORD 2 for migration to7. The report labels2 `LegacyValue2`; it does not invent its undocumented meaning or credit it as full auditing. All other values/types are refused. Existing parent keys are required. An absent audit value does not imply a measured clean-image default. + +Audit and Plan are live read-only assessments. Plan is not an importable authorization file. Configure re-reads the actual host and typed selected state, writes an original `before.jsonl` receipt before mutation, checks for drift after consent, and verifies immediate and final readback. Applied, AlreadyCompliant, Skipped, Failed and Overridden remain distinct. Partial failures return nonzero even if another selected row succeeded. A skipped non-DC domain row can coexist with exit0; this means domain configuration was not applicable, not that domain auditing was enabled. RSoP matches are last-applied observations from `RSOP_RegistryValue`, may be stale or incomplete, and do not prove current ownership or persistence. The registry write is not atomic with GPO or another administrator. + +Outgoing policy is managed separately by [outgoing-ntlm](outgoing-ntlm.md). This command preserves outgoing/incoming/domain restrictions, NTLM exceptions, channels, services and advanced audit masks. It does not authenticate, create domain objects, restart services, refresh GPO, or generate NTLM events. Registry compliance alone supplies no forwarding or Sigma credit. Sysmon is out of scope. + +For manual recovery, retain the successful selected result and original journal. Review `Before.Policy` and current ownership/drift before restoring that exact typed value, or removing only that value if originally absent. Never remove the parent key, replay another control's receipt, or treat a failed/partial attempt as a confirmed configuration. No automatic rollback occurs. + +Native acceptance uses disposable unjoined Server 2022/2025 under Windows PowerShell 5.1 and PowerShell 7. It exercises actual incoming absence/disabled/domain-account-only→all-account auditing, dry run, original journals, repeated Configure, actual non-DC Domain/Both skips, and independent preservation/cleanup. Portable tests exercise DC transitions including historical2, unknown values/types, conflicting hosts, prompt drift, write/readback errors and partial outcomes. Windows 11, joined member/CA, actual DC application, domain authentication/events, policy persistence and collector delivery remain separate acceptance work for #363. + +Microsoft documents [incoming values0/1/2](https://learn.microsoft.com/en-us/windows/client-management/mdm/policy-csp-localpoliciessecurityoptions#networksecurity_restrictntlm_auditincomingntlmtraffic) and the [domain audit policy's DC applicability and separation from blocking](https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-10/security/threat-protection/security-policy-settings/network-security-restrict-ntlm-audit-ntlm-authentication-in-this-domain). Domain 7 follows the already reviewed WELA domain-audit correction and its pinned baseline evidence; this addition isolates that setting into a dedicated command. diff --git a/docs/outgoing-ntlm.md b/docs/outgoing-ntlm.md index 8ba701c7..17e42ace 100644 --- a/docs/outgoing-ntlm.md +++ b/docs/outgoing-ntlm.md @@ -20,3 +20,5 @@ For manual recovery, inspect the selected successful result and its original `be Native acceptance uses disposable unjoined Server2022/2025 hosts under PowerShell5.1/7, exercises actual absence/allow→audit, original journals, dry run, repeat, readback and exact cleanup. Existing enforcement and malformed values are never installed on a native runner merely for testing; portable regressions verify those preservation/refusal paths, prompt-time drift and failures. Native tests preserve incoming/domain policy, siblings/access descriptor, channels, service and all59 audit masks. Windows11/DC/ADCS acceptance, authentication behavior, representative NTLM events, GPO persistence and collector delivery remain separate work for #362. No Sigma credit is inferred. Built-in Windows only; Sysmon is excluded. Microsoft distinguishes outgoing audit from deny, describes GPO precedence and identifies the NTLM Operational log for validation: [outgoing NTLM policy](https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-10/security/threat-protection/security-policy-settings/network-security-restrict-ntlm-outgoing-ntlm-traffic-to-remote-servers). + +For separate incoming and actual-DC domain audit configuration, use [ntlm-auditing](ntlm-auditing.md). diff --git a/scripts/Configuration.ps1 b/scripts/Configuration.ps1 index c44d2de1..5202f504 100644 --- a/scripts/Configuration.ps1 +++ b/scripts/Configuration.ps1 @@ -108,7 +108,7 @@ function Invoke-WelaConfigurationControl { function Complete-WelaConfiguration { param($Context, [string]$ResultsPath, $Plan, - [ValidateSet("native-windows-configuration", "outgoing-ntlm-audit-policy-only", "advanced-audit-policy-only", "advanced-audit-policy-and-precedence", "firewall-text-logging-only", "event-log-size-and-mode-only", "smb-audit-policies-only", "native-channel-settings-only", "wmi-namespace-sacl-only", "ad-object-sacl-only", "windows-powershell-transcription-policy-only", "wef-source-configuration-only", "wec-collector-subscriptions-only", "audit-integrity-local-policy-only", "adcs-audit-settings-only", "disabled-unlinked-gpo-creation-only")] + [ValidateSet("native-windows-configuration", "outgoing-ntlm-audit-policy-only", "incoming-domain-ntlm-audit-policy-only", "advanced-audit-policy-only", "advanced-audit-policy-and-precedence", "firewall-text-logging-only", "event-log-size-and-mode-only", "smb-audit-policies-only", "native-channel-settings-only", "wmi-namespace-sacl-only", "ad-object-sacl-only", "windows-powershell-transcription-policy-only", "wef-source-configuration-only", "wec-collector-subscriptions-only", "audit-integrity-local-policy-only", "adcs-audit-settings-only", "disabled-unlinked-gpo-creation-only")] [string]$Scope = "native-windows-configuration", [string]$SuccessMessage = 'Configuration completed; all requested controls verified.') if ($Context.PSObject.Properties['CustomProfileGuard']) { diff --git a/scripts/NtlmAudit.ps1 b/scripts/NtlmAudit.ps1 new file mode 100644 index 00000000..10eadea9 --- /dev/null +++ b/scripts/NtlmAudit.ps1 @@ -0,0 +1,100 @@ +# Explicit incoming/domain audit values. Authentication restrictions are separate controls. +function Get-WelaNtlmAuditHost { + if($env:OS -ne 'Windows_NT' -or -not [Environment]::Is64BitProcess){throw 'Use native64-bit PowerShell on Windows.'} + $os=Get-CimInstance Win32_OperatingSystem -Property BuildNumber,ProductType -ErrorAction Stop + $computer=Get-CimInstance Win32_ComputerSystem -Property Name,Domain,DomainRole,PartOfDomain -ErrorAction Stop + if([string]$os.BuildNumber -notmatch '^\d+$' -or $os.ProductType -notin @(1,2,3) -or $computer.PartOfDomain -isnot [bool] -or $computer.DomainRole -notin @(0,1,2,3,4,5) -or [string]::IsNullOrWhiteSpace($computer.Name)){throw 'Incomplete Windows role/build identity.'} + $build=[int]$os.BuildNumber;$product=[int]$os.ProductType;$role=[int]$computer.DomainRole;$joined=$computer.PartOfDomain + $coherent=($product -eq 1 -and (($role -eq 0 -and -not $joined) -or ($role -eq 1 -and $joined))) -or ($product -eq 3 -and (($role -eq 2 -and -not $joined) -or ($role -eq 3 -and $joined))) -or ($product -eq 2 -and $role -in @(4,5) -and $joined) + if(-not $coherent){throw 'Conflicting native product/domain-role/join observations.'} + if(-not (($product -eq 1 -and $build -in @(22000,22621,22631,26100,26200)) -or ($product -in @(2,3) -and $build -in @(20348,26100)))){throw 'This Windows role/build has not been reviewed.'} + [pscustomobject][ordered]@{Computer=[string]$computer.Name;Domain=[string]$computer.Domain;Build=$build;ProductType=$product;DomainRole=$role;PartOfDomain=$joined} +} +function Get-WelaNtlmAuditDefinition { + param([ValidateSet('Incoming','Domain')][string]$Selection) + if($Selection -eq 'Incoming'){return [pscustomobject]@{Selection='Incoming';Path='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0';Name='AuditReceivingNTLMTraffic';Value=2;Known=@(0,1,2);Meaning='Enable auditing for all accounts'}} + [pscustomobject]@{Selection='Domain';Path='HKLM:\SYSTEM\CurrentControlSet\Services\Netlogon\Parameters';Name='AuditNTLMInDomain';Value=7;Known=@(0,1,2,3,5,7);Meaning='Enable all domain NTLM auditing on the observed domain controller'} +} +function Get-WelaNtlmAuditSnapshot { + param([ValidateSet('Incoming','Domain')][string]$Selection) + $observedHost=Get-WelaNtlmAuditHost + if($Selection -eq 'Domain' -and $observedHost.ProductType -ne 2){return [pscustomobject][ordered]@{Host=$observedHost;Applicable=$false;Policy=$null}} + $definition=Get-WelaNtlmAuditDefinition $Selection + $policy=Get-WelaRegistryState $definition.Path $definition.Name + if(-not $policy.KeyExists){throw 'The existing native policy key is required; no parent key will be created.'} + [pscustomobject][ordered]@{Host=$observedHost;Applicable=$true;Policy=$policy} +} +function Get-WelaNtlmAuditDisposition { + param($Snapshot,$Definition) + if(-not $Snapshot.Applicable){return 'NotApplicable'} + $p=$Snapshot.Policy + if($p.ValueExists -and ($p.Type -cne 'DWord' -or ($p.Value -isnot [int] -and $p.Value -isnot [long] -and $p.Value -isnot [uint32]) -or $p.Value -notin $Definition.Known)){return 'Unknown'} + if($p.ValueExists -and $p.Value -eq $Definition.Value){return 'AlreadyCompliant'} + if($Definition.Selection -eq 'Domain' -and $p.ValueExists -and $p.Value -eq 2){return 'LegacyValue2'} + return 'ChangeRequired' +} +function Get-WelaNtlmAuditPolicySource { + param($Definition) + $key='MACHINE\'+$Definition.Path.Substring(6) + try{ + $rows=@(Get-CimInstance -Namespace 'root\RSOP\Computer' -ClassName RSOP_RegistryValue -ErrorAction Stop|Where-Object {$_.KeyName -ieq $key -and $_.ValueName -ieq $Definition.Name}|Sort-Object precedence) + [pscustomobject]@{Status=$(if($rows.Count){'Observed'}else{'NotObserved'});Class='RSOP_RegistryValue';Matches=@($rows|Select-Object KeyName,ValueName,Type,Data,GPOID,precedence);Diagnostic='Last-applied RSoP may be stale or incomplete. This does not establish the current registry writer, local ownership or policy persistence.'} + }catch{[pscustomobject]@{Status='Unknown';Class='RSOP_RegistryValue';Matches=@();Diagnostic=$_.Exception.Message+' RSoP is potentially stale and is not current policy ownership evidence.'}} +} +function Get-WelaNtlmAuditPlan { + param([ValidateSet('Incoming','Domain','Both')][string]$Selection='Both') + $rows=@() + foreach($selected in @($(if($Selection -eq 'Both'){'Incoming';'Domain'}else{$Selection}))){ + $definition=Get-WelaNtlmAuditDefinition $selected + try{ + $snapshot=Get-WelaNtlmAuditSnapshot $selected;$status=Get-WelaNtlmAuditDisposition $snapshot $definition + $diagnostic=switch($status){ + NotApplicable {'Domain NTLM auditing is not applicable to this observed non-DC host. No domain policy value was read or selected for writing.'} + Unknown {'Unknown registry type/value is preserved. Inspect it before configuration.'} + LegacyValue2 {'Historical WELA value2 is not credited as Enable all. Its undocumented meaning is not inferred; selected Configure requests DWORD7.'} + AlreadyCompliant {'The requested audit value is configured. Actual authentication events and policy persistence are unverified.'} + default {$definition.Meaning} + } + $rows+=[pscustomobject]@{Selection=$selected;Definition=$definition;Status=$status;Before=$snapshot;Diagnostic=$diagnostic;PolicySource=$(if($snapshot.Applicable){Get-WelaNtlmAuditPolicySource $definition}else{$null})} + }catch{$rows+=[pscustomobject]@{Selection=$selected;Definition=$definition;Status='Unknown';Before=$null;Diagnostic=$_.ToString();PolicySource=$null}} + } + [pscustomobject]@{Selection=$Selection;Controls=$rows;Mode='Audit only';PlanKind='Live assessment; not an importable authorization file'} +} +function Invoke-WelaNtlmAuditCommand { + param([ValidateSet('Audit','Plan','Configure')][string]$Action='Audit',[ValidateSet('Incoming','Domain','Both')][string]$Selection='Both',[switch]$Auto,[switch]$DryRun,[string]$BackupPath,[string]$ResultsPath) + if($Action -ne 'Configure' -and ($Auto -or $DryRun -or $BackupPath)){throw 'Consent, dry-run and backup options require Configure.'} + if($Action -eq 'Configure' -and -not $PSBoundParameters.ContainsKey('Selection')){throw 'Configure requires an explicit Incoming, Domain or Both selection.'} + $plan=Get-WelaNtlmAuditPlan $Selection + if($Action -eq 'Configure'){ + $context=New-WelaConfigurationContext -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath + foreach($row in $plan.Controls){ + $definition=$row.Definition;$target=@{Path=$definition.Path;Name=$definition.Name};$desired=@{Value=$definition.Value;Type='DWord'};$id="Registry/$($definition.Path)/$($definition.Name)" + if($row.Status -in @('Unknown','NotApplicable')){ + $context.Results.Add([pscustomobject]@{Id=$id;Kind='Registry';Target=$target;Desired=$desired;Before=$row.Before;After=$null;Status=$(if($row.Status -eq 'Unknown'){'Failed'}else{'Skipped'});Diagnostic=$row.Diagnostic}) + continue + } + $state=@{Observed=$null;PlannedHost=($row.Before.Host|ConvertTo-Json -Compress);Definition=$definition} + $read={param($s) + $snapshot=Get-WelaNtlmAuditSnapshot $s.Definition.Selection + if(($snapshot.Host|ConvertTo-Json -Compress) -cne $s.PlannedHost -or -not $snapshot.Applicable){throw 'Native host role/context changed; review a new plan.'} + if((Get-WelaNtlmAuditDisposition $snapshot $s.Definition) -eq 'Unknown'){throw 'Unknown registry type/value is preserved.'} + $s.Observed=$snapshot;return $snapshot + } + $test={param($snapshot,$s) $snapshot.Applicable -and $snapshot.Policy.ValueExists -and $snapshot.Policy.Type -ceq 'DWord' -and $snapshot.Policy.Value -eq $s.Definition.Value} + $apply={param($s) + $fresh=Get-WelaNtlmAuditSnapshot $s.Definition.Selection + if(($fresh|ConvertTo-Json -Depth 8 -Compress) -cne ($s.Observed|ConvertTo-Json -Depth 8 -Compress)){throw 'NTLM audit state changed after the original journal snapshot; no write attempted.'} + if((Get-WelaNtlmAuditDisposition $fresh $s.Definition) -notin @('ChangeRequired','LegacyValue2')){throw 'The current state no longer authorizes this write.'} + Set-ItemProperty -LiteralPath $s.Definition.Path -Name $s.Definition.Name -Value $s.Definition.Value -Type DWord -ErrorAction Stop + 'Only the selected NTLM audit DWORD was requested. Authentication restrictions and exceptions were not changed.' + } + Invoke-WelaConfigurationControl -Context $context -Id $id -Kind Registry -Target $target -Desired $desired -Read $read -Compliant $test -Apply $apply -CallbackState $state -Description $row.Diagnostic + } + $report=Complete-WelaConfiguration -Context $context -Scope 'incoming-domain-ntlm-audit-policy-only' -SuccessMessage 'Selected NTLM audit results recorded; inspect failed/skipped controls separately.' + $report|Add-Member NoteProperty Plan $plan + }else{$report=[pscustomobject]@{ExitCode=$(if(@($plan.Controls|Where-Object Status -eq 'Unknown').Count){1}else{0});Scope='incoming-domain-ntlm-audit-policy-only';Action=$Action;Plan=$plan}} + $report|Add-Member NoteProperty EventGeneration 'Unverified. Audit registry values do not prove authentication, NTLM events, GPO persistence, forwarding or Sigma readiness.' + $report|Add-Member NoteProperty ReadyRuleCredit 0 + if($ResultsPath){try{$report|ConvertTo-Json -Depth 18|Set-Content -LiteralPath $ResultsPath -Encoding UTF8 -ErrorAction Stop}catch{$report.ExitCode=1;Write-Host "[Failed] Writing NTLM audit results: $_" -ForegroundColor Red}} + return $report +} diff --git a/tests/NtlmAudit.Cli.Tests.ps1 b/tests/NtlmAudit.Cli.Tests.ps1 new file mode 100644 index 00000000..14ae3b50 --- /dev/null +++ b/tests/NtlmAudit.Cli.Tests.ps1 @@ -0,0 +1,21 @@ +$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent;$engine=(Get-Process -Id $PID).Path;$count=0 +$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-ntlm-audit-cli-'+[guid]::NewGuid().ToString('N')) +$cases=@( + @{Args=@('ntlm-auditing','-Help');Code=0;Pattern='preserves all authentication restrictions'}, + @{Args=@('configure','-NtlmAuditAction','Configure');Code=1;Pattern='require ntlm-auditing'}, + @{Args=@('audit','-NtlmAuditScope','Incoming');Code=1;Pattern='require ntlm-auditing'}, + @{Args=@('ntlm-auditing','-NtlmAuditAction','Configure');Code=1;Pattern='requires explicit NtlmAuditScope'}, + @{Args=@('ntlm-auditing','-OutgoingNtlmMode','Deny');Code=1;Pattern='dedicated options'}, + @{Args=@('ntlm-auditing','-Role','DomainController');Code=1;Pattern='dedicated options'}, + @{Args=@('ntlm-auditing','-Build','26100');Code=1;Pattern='dedicated options'}, + @{Args=@('ntlm-auditing','-Profile','wela-2.2.0');Code=1;Pattern='dedicated options'}, + @{Args=@('ntlm-auditing','-Auto');Code=1;Pattern='require NtlmAuditAction Configure'}, + @{Args=@('ntlm-auditing','-DryRun');Code=1;Pattern='require NtlmAuditAction Configure'}, + @{Args=@('ntlm-auditing','-BackupPath',$root);Code=1;Pattern='require NtlmAuditAction Configure'}, + @{Args=@('ntlm-auditing','-Help','-NtlmAction','Configure');Code=1;Pattern='dedicated options'}, + @{Args=@('ntlm-auditing','-NtlmAuditScope','Incoming','-NtlmAuditAction','Configure','-Typo');Code=1;Pattern='Unsupported trailing arguments'} +) +foreach($case in $cases){$prior=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$text=@(&$engine -NoLogo -NoProfile -NonInteractive -File "$repo/WELA.ps1" @($case.Args) 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior};if(($code -eq 0) -ne ($case.Code -eq 0) -or ($text -join "`n") -notmatch $case.Pattern){throw "CLI failed: $($case.Args -join ' ') -> $code / $($text -join ' ')"};$count++} +if(Test-Path $root){throw 'Refused CLI input created unexpected output.'} +Write-Host "PASS: $count scoped NTLM CLI guards." +exit 0 diff --git a/tests/NtlmAudit.Tests.ps1 b/tests/NtlmAudit.Tests.ps1 new file mode 100644 index 00000000..2f66238b --- /dev/null +++ b/tests/NtlmAudit.Tests.ps1 @@ -0,0 +1,97 @@ +$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent +. (Join-Path $repo 'scripts/Configuration.ps1') +. (Join-Path $repo 'scripts/NtlmAudit.ps1') +$hostValidator=${function:Get-WelaNtlmAuditHost} +$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-ntlm-audit-tests-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root +$count=0;$sequence=0 +function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} +function Key($Value){ConvertTo-Json -InputObject $Value -Depth 18 -Compress} +function Copy-Fixture($Value){Key $Value|ConvertFrom-Json} +function Reset($Incoming=0,$Domain=0,[switch]$Dc){ + $script:hostState=[pscustomobject][ordered]@{Computer='fixture';Domain=$(if($Dc){'fixture.test'}else{'WORKGROUP'});Build=26100;ProductType=$(if($Dc){2}else{3});DomainRole=$(if($Dc){4}else{2});PartOfDomain=[bool]$Dc} + $script:policies=@{};foreach($pair in @(@('Incoming',$Incoming),@('Domain',$Domain))){$script:policies[$pair[0]]=[pscustomobject][ordered]@{KeyExists=$true;ValueExists=($null -ne $pair[1]);Value=$pair[1];Type=$(if($null -eq $pair[1]){$null}else{'DWord'})}} + $script:writes=@();$script:reads=@{Incoming=0;Domain=0};$script:readFail='';$script:writeFail='';$script:ignore='';$script:promptChange=$null;$script:onRead=$null +} +function Get-WelaNtlmAuditHost {Copy-Fixture $script:hostState} +function Get-WelaNtlmAuditPolicySource {[pscustomobject]@{Status='Unknown';Diagnostic='Fixture has no policy ownership evidence.'}} +function Get-WelaRegistryState {param($Path,$Name) + $selection=switch($Name){AuditReceivingNTLMTraffic{'Incoming'} AuditNTLMInDomain{'Domain'} default {throw 'Unexpected read'}} + $definition=Get-WelaNtlmAuditDefinition $selection;if($Path -cne $definition.Path){throw 'Unexpected registry path'} + $script:reads[$selection]++;if($script:onRead){& $script:onRead $selection} + if($script:readFail -eq $selection){throw 'Injected read denied'} + Copy-Fixture $script:policies[$selection] +} +function Set-ItemProperty {param($LiteralPath,$Name,$Value,$Type,$ErrorAction) + $selection=switch($Name){AuditReceivingNTLMTraffic{'Incoming'} AuditNTLMInDomain{'Domain'} default {throw 'Unexpected mutation'}} + $definition=Get-WelaNtlmAuditDefinition $selection + Assert ($LiteralPath -ceq $definition.Path -and $Value -eq $definition.Value -and $Type -ceq 'DWord') 'Only the selected exact audit value may be changed.' + $script:writes+=@($selection);if($script:writeFail -eq $selection){throw 'Injected write denied'} + if($script:ignore -ne $selection){$script:policies[$selection].ValueExists=$true;$script:policies[$selection].Value=$Value;$script:policies[$selection].Type='DWord'} +} +function Read-Host {param($Prompt) if($script:promptChange){& $script:promptChange};return 'Y'} +function Configure($Selection='Incoming',[switch]$DryRun,[switch]$Prompt){ + $script:sequence++;$script:backup=Join-Path $root ('case-'+$script:sequence) + Invoke-WelaNtlmAuditCommand -Action Configure -Selection $Selection -Auto:(-not $Prompt) -DryRun:$DryRun -BackupPath $script:backup +} +try{ + foreach($initial in @($null,0,1,2)){ + Reset -Incoming $initial;$old=Key $script:policies.Incoming;$r=Configure + Assert ($r.ExitCode -eq 0 -and $r.Scope -ceq 'incoming-domain-ntlm-audit-policy-only' -and $r.ReadyRuleCredit -eq 0) 'Success is explicitly limited to selected audit policies.' + Assert ($script:policies.Incoming.Value -eq 2 -and $script:writes.Count -eq $(if($initial -eq 2){0}else{1}) -and $script:reads.Domain -eq 0) 'Incoming scope preserves domain policy and enables only auditing.' + if($initial -ne 2){$j=@(Get-Content (Join-Path $backup 'before.jsonl')|ConvertFrom-Json);Assert ($j.Count -eq 1 -and (Key $j[0].Before.Policy) -ceq $old) 'Journal retains exact typed original before one write.'} + else{Assert ($r.Results[0].Status -ceq 'AlreadyCompliant' -and -not(Test-Path (Join-Path $backup 'before.jsonl'))) 'Existing all-account auditing is idempotent.'} + } + foreach($initial in @($null,0,1,2,3,5,7)){ + Reset -Domain $initial -Dc;$r=Configure Domain + Assert ($r.ExitCode -eq 0 -and $script:policies.Domain.Value -eq 7 -and $script:reads.Incoming -eq 0) 'Actual-DC domain selection requests only full domain auditing.' + if($initial -eq 2){Assert ($r.Plan.Controls[0].Status -ceq 'LegacyValue2' -and $r.Plan.Controls[0].Diagnostic -match 'undocumented') 'Legacy2 is identified without assigning it invented semantics.'} + } + foreach($selection in @('Incoming','Domain')){ + $values=if($selection -eq 'Incoming'){@(3,42,'1',$true)}else{@(4,6,8,'7',$true)} + foreach($invalid in $values){ + Reset -Dc;$script:policies[$selection].Value=$invalid;$r=Configure $selection + Assert ($r.ExitCode -eq 1 -and $r.Results[0].Status -ceq 'Failed' -and $script:writes.Count -eq 0) 'Unknown numeric values and coerced strings/bools fail without mutation.' + } + Reset -Dc;$script:policies[$selection].Type='String';$r=Configure $selection + Assert ($r.ExitCode -eq 1 -and $script:writes.Count -eq 0) 'Unknown registry type fails without mutation.' + Reset -Dc;$script:policies[$selection].KeyExists=$false;$r=Configure $selection + Assert ($r.ExitCode -eq 1 -and $script:writes.Count -eq 0) 'Missing policy parents are never created.' + Reset -Dc;$script:readFail=$selection;$r=Configure $selection + Assert ($r.ExitCode -eq 1 -and $script:writes.Count -eq 0) 'Access-denied is not fabricated absence.' + } + foreach($selection in @('Domain','Both')){ + Reset;$r=Configure $selection + $domain=@($r.Results|Where-Object {$_.Target.Name -eq 'AuditNTLMInDomain'}) + Assert ($r.ExitCode -eq 0 -and $domain.Count -eq 1 -and $domain[0].Status -ceq 'Skipped' -and $script:reads.Domain -eq 0 -and $script:writes -notcontains 'Domain') 'Non-DC domain audit is explicitly not applicable with no read or write.' + } + Reset -Dc;$r=Configure Both;Assert ($r.ExitCode -eq 0 -and $script:writes.Count -eq 2 -and @($r.Results|Where-Object Status -ne 'Applied').Count -eq 0) 'Both scopes produce separate applied rows on a coherent DC.' + Reset -Dc;$script:writeFail='Domain';$r=Configure Both;Assert ($r.ExitCode -eq 1 -and $r.Results[0].Status -ceq 'Applied' -and $r.Results[1].Status -ceq 'Failed') 'A partial failure preserves each distinct result and nonzero status.' + Reset -Dc;$r=Configure Both -DryRun;Assert ($script:writes.Count -eq 0 -and $r.DryRun -and -not(Test-Path $backup)) 'Dry-run creates neither policy mutations nor journal directory.' + Reset;$script:ignore='Incoming';$r=Configure;Assert ($r.ExitCode -eq 1 -and $r.Results[0].Status -ceq 'Failed') 'An ignored native write fails immediate verification.' + foreach($changed in @(1,2,42)){ + Reset;$script:changed=$changed;$script:promptChange={$script:policies.Incoming.Value=$script:changed};$r=Configure -Prompt + Assert ($r.ExitCode -eq 1 -and $script:writes.Count -eq 0) 'Prompt-time value drift refuses mutation after preserving the original snapshot.' + } + Reset;$script:promptChange={$script:hostState.Computer='different-host'};$r=Configure -Prompt + Assert ($r.ExitCode -eq 1 -and $script:writes.Count -eq 0) 'Prompt-time host drift refuses mutation.' + Reset;$script:onRead={param($s)if($s -eq 'Incoming' -and $script:reads.Incoming -eq 5){$script:policies.Incoming.Value=0}};$r=Configure + Assert ($r.ExitCode -eq 1 -and $r.Results[0].Status -ceq 'Overridden') 'Later policy override fails final readback.' + Reset;$r=Invoke-WelaNtlmAuditCommand -Action Plan;Assert ($r.Plan.Controls.Count -eq 2 -and $script:writes.Count -eq 0) 'Default assessment reports both distinct controls without mutation.' + $refused=$false;try{Invoke-WelaNtlmAuditCommand -Action Configure}catch{$refused=$true};Assert $refused 'The library requires explicit Configure selection.' + foreach($action in @('Audit','Plan')){foreach($option in @('Auto','DryRun','BackupPath')){ + $args=@{Action=$action};$args[$option]=$(if($option -eq 'BackupPath'){'unused'}else{$true});$refused=$false;try{Invoke-WelaNtlmAuditCommand @args}catch{$refused=$true};Assert $refused 'Read-only actions reject mutation-only options.' + }} + # Exercise actual CIM role validation independently of the policy fixture. + $savedOs=$env:OS;$env:OS='Windows_NT' + function Get-CimInstance {param($ClassName,$Property,$ErrorAction)if($ClassName -eq 'Win32_OperatingSystem'){$script:osFixture}else{$script:computerFixture}} + try{ + foreach($case in @(@(1,0,$false,26100),@(1,1,$true,26200),@(3,2,$false,20348),@(3,3,$true,26100),@(2,4,$true,20348),@(2,5,$true,26100))){ + $script:osFixture=[pscustomobject]@{ProductType=$case[0];BuildNumber=[string]$case[3]};$script:computerFixture=[pscustomobject]@{Name='fixture';Domain='fixture';DomainRole=$case[1];PartOfDomain=$case[2]};$h=&$hostValidator;Assert ($h.ProductType -eq $case[0]) 'Coherent native role/build accepted.' + } + foreach($case in @(@(2,2,$false,26100),@(3,4,$true,26100),@(1,1,$false,26100),@(3,3,$false,26100),@(2,5,$true,99999))){ + $script:osFixture=[pscustomobject]@{ProductType=$case[0];BuildNumber=[string]$case[3]};$script:computerFixture=[pscustomobject]@{Name='fixture';Domain='fixture';DomainRole=$case[1];PartOfDomain=$case[2]};$refused=$false;try{&$hostValidator}catch{$refused=$true};Assert $refused 'Conflicting or unsupported observed host is refused.' + } + }finally{$env:OS=$savedOs} +}finally{Remove-Item -LiteralPath $root -Recurse -Force} +Write-Host "PASS: $count scoped incoming/domain NTLM assertions." +exit 0 diff --git a/tests/NtlmAudit.Windows.Tests.ps1 b/tests/NtlmAudit.Windows.Tests.ps1 new file mode 100644 index 00000000..2d5bba86 --- /dev/null +++ b/tests/NtlmAudit.Windows.Tests.ps1 @@ -0,0 +1,80 @@ +param([switch]$AllowDisposableAuditWrite) +$ErrorActionPreference='Stop' +if(-not $AllowDisposableAuditWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit opt-in on a disposable GitHub-hosted Windows runner is required.'} +$repo=Split-Path $PSScriptRoot -Parent +. (Join-Path $repo 'scripts/Configuration.ps1') +. (Join-Path $repo 'scripts/NtlmAudit.ps1') +Import-Module (Join-Path $repo 'modules/AuditProfiles.psm1') -Force +Import-Module (Join-Path $repo 'modules/NativeProviders.psm1') -Force +$engine=(Get-Process -Id $PID).Path;$count=0;$failure=$null;$errors=@() +$root=Join-Path $env:RUNNER_TEMP ('wela-incoming-domain-audit-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root +$path='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0';$name='AuditReceivingNTLMTraffic' +function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} +function Key($Value){ConvertTo-Json -InputObject $Value -Depth 24 -Compress} +function Save($Name,$Value){ConvertTo-Json -InputObject $Value -Depth 24|Set-Content -LiteralPath (Join-Path $root $Name) -Encoding UTF8} +function Masks {$m=Get-WelaEffectiveAuditPolicy;@($m.Keys|Sort-Object|ForEach-Object{"$_=$($m[$_])"}) -join ';'} +function Other { + $base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64);$k=$null + try{ + $k=$base.OpenSubKey('SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0');if(-not $k){throw 'Existing MSV1_0 key required.'} + $values=@($k.GetValueNames()|Sort-Object|Where-Object {$_ -ine $name}|ForEach-Object{[pscustomobject][ordered]@{Name=$_;Type=$k.GetValueKind($_).ToString();Value=$k.GetValue($_,$null,[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)}}) + $children=@($k.GetSubKeyNames()|Sort-Object) + $security=if($PSVersionTable.PSVersion.Major -ge 6){[Microsoft.Win32.RegistryAclExtensions]::GetAccessControl($k)}else{$k.GetAccessControl()} + $acl=$security.GetSecurityDescriptorSddlForm([Security.AccessControl.AccessControlSections]::Access -bor [Security.AccessControl.AccessControlSections]::Owner -bor [Security.AccessControl.AccessControlSections]::Group) + }finally{if($k){$k.Dispose()};$base.Dispose()} + [pscustomobject][ordered]@{Values=$values;Children=$children;Access=$acl;DomainPolicy=Get-WelaRegistryState 'HKLM:\SYSTEM\CurrentControlSet\Services\Netlogon\Parameters' AuditNTLMInDomain;NtlmChannel=Get-WelaNativeChannel 'Microsoft-Windows-NTLM/Operational';SecurityChannel=Get-WelaNativeChannel Security;NetlogonService=[string](Get-Service Netlogon).Status} +} +function Public([string]$Label,[string[]]$Arguments){ + $prior=$ErrorActionPreference + try{$ErrorActionPreference='Continue';$output=& $engine -NoLogo -NoProfile -NonInteractive -File (Join-Path $repo 'WELA.ps1') ntlm-auditing @Arguments 2>&1|Out-String;$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior} + $output|Set-Content -LiteralPath (Join-Path $root ($Label+'.txt')) -Encoding UTF8 + Assert ($code -eq 0) "Public $Label exited $code : $output" + Get-Content -Raw -LiteralPath (Join-Path $root ($Label+'.json'))|ConvertFrom-Json +} +$original=Get-WelaNtlmAuditSnapshot Incoming +Assert ($original.Host.ProductType -eq 3 -and $original.Host.DomainRole -eq 2 -and -not $original.Host.PartOfDomain) 'Actual unjoined disposable Server is required.' +Assert (-not $original.Policy.ValueExists -or ($original.Policy.Type -ceq 'DWord' -and $original.Policy.Value -in @(0,1,2))) 'Fixture refuses unknown audit values/types and preserves all authentication restrictions.' +$other=Other;$masks=Masks +Save 'original.json' @{Snapshot=$original;Unselected=$other;Masks=$masks;UBR=(Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion').UBR;Engine=$PSVersionTable.PSVersion.ToString();Commit=$env:GITHUB_SHA;Sources=@(foreach($file in @('WELA.ps1','scripts/NtlmAudit.ps1','scripts/Configuration.ps1')){[pscustomobject]@{Name=$file;Sha256=(Get-FileHash (Join-Path $repo $file)).Hash.ToLowerInvariant()}})} +try{ + foreach($case in @('absent','disabled','domain-accounts')){ + if((Get-WelaRegistryState $path $name).ValueExists){Remove-ItemProperty -LiteralPath $path -Name $name -ErrorAction Stop} + if($case -ne 'absent'){$null=New-ItemProperty -LiteralPath $path -Name $name -PropertyType DWord -Value $(if($case -eq 'disabled'){0}else{1})} + $prepared=Get-WelaNtlmAuditSnapshot Incoming + $plan=Public ($case+'-plan') @('-NtlmAuditScope','Both','-NtlmAuditAction','Plan','-ResultsPath',(Join-Path $root ($case+'-plan.json'))) + Assert ($plan.Plan.Controls[0].Status -ceq 'ChangeRequired' -and (Key $plan.Plan.Controls[0].Before) -ceq (Key $prepared)) 'Public plan retains the exact native absence/allow state and actual host.' + $dryBackup=Join-Path $root ($case+'-dry-backup') + $dry=Public ($case+'-dry') @('-NtlmAuditScope','Both','-NtlmAuditAction','Configure','-Auto','-DryRun','-BackupPath',$dryBackup,'-ResultsPath',(Join-Path $root ($case+'-dry.json'))) + Assert ($dry.DryRun -and $dry.Results[0].Status -ceq 'Skipped' -and $dry.Results[1].Status -ceq 'Skipped' -and -not(Test-Path $dryBackup) -and (Key (Get-WelaNtlmAuditSnapshot Incoming)) -ceq (Key $prepared)) 'Dry run preserves policy and creates no journal directory.' + $backup=Join-Path $root ($case+'-backup') + $report=Public $case @('-NtlmAuditScope','Both','-NtlmAuditAction','Configure','-Auto','-BackupPath',$backup,'-ResultsPath',(Join-Path $root ($case+'.json'))) + $after=Get-WelaNtlmAuditSnapshot Incoming + Assert ($report.Scope -ceq 'incoming-domain-ntlm-audit-policy-only' -and $report.Results.Count -eq 2 -and $report.Results[0].Status -ceq 'Applied' -and $report.Results[1].Status -ceq 'Skipped' -and $report.Plan.Controls[1].Status -ceq 'NotApplicable') 'Exactly one native incoming audit value is applied; non-DC domain policy is skipped through the public CLI.' + Assert ($after.Policy.Type -ceq 'DWord' -and $after.Policy.Value -eq 2 -and (Key $report.Results[0].After) -ceq (Key $after)) 'Native audit-only readback matches the public result.' + $journal=@(Get-Content (Join-Path $backup 'before.jsonl')|ConvertFrom-Json) + Assert ($journal.Count -eq 1 -and (Key $journal[0].Before) -ceq (Key $prepared) -and $journal[0].Target.Path -ceq $path -and $journal[0].Target.Name -ceq $name) 'One original journal retains the actual typed policy and native context.' + $repeatBackup=Join-Path $root ($case+'-repeat-backup') + $repeat=Public ($case+'-repeat') @('-NtlmAuditScope','Both','-NtlmAuditAction','Configure','-Auto','-BackupPath',$repeatBackup,'-ResultsPath',(Join-Path $root ($case+'-repeat.json'))) + Assert ($repeat.Results[0].Status -ceq 'AlreadyCompliant' -and -not(Test-Path (Join-Path $repeatBackup 'before.jsonl'))) 'Repeated configuration is idempotent without another original journal.' + $audit=Public ($case+'-audit') @('-NtlmAuditScope','Both','-NtlmAuditAction','Audit','-ResultsPath',(Join-Path $root ($case+'-audit.json'))) + Assert ($audit.Plan.Controls[0].Status -ceq 'AlreadyCompliant' -and $audit.ReadyRuleCredit -eq 0 -and $audit.EventGeneration -like 'Unverified*') 'Audit distinguishes registry compliance from event or authentication proof.' + Assert ((Key (Other)) -ceq (Key $other) -and (Masks) -ceq $masks) 'Outgoing/domain policies, siblings, access descriptor, channels, service and all59 masks remain unchanged.' + } + $domainBackup=Join-Path $root 'domain-only-backup' + $domainOnly=Public 'domain-only' @('-NtlmAuditScope','Domain','-NtlmAuditAction','Configure','-Auto','-BackupPath',$domainBackup,'-ResultsPath',(Join-Path $root 'domain-only.json')) + Assert ($domainOnly.Results.Count -eq 1 -and $domainOnly.Results[0].Status -ceq 'Skipped' -and $domainOnly.Plan.Controls[0].Status -ceq 'NotApplicable' -and -not(Test-Path (Join-Path $domainBackup 'before.jsonl'))) 'Domain-only Configure has no original write journal on actual non-DC.' + Save 'completed.json' @{Status='Passed';Assertions=$count;NativeWrites=3;Scope='Only incoming audit DWORD2; non-DC domain policy skipped. No network authentication attempt, enforcement, event generation, GPO refresh or Sigma proof.'} +}catch{$failure=$_.ToString();throw}finally{ + try{ + if((Get-WelaRegistryState $path $name).ValueExists){Remove-ItemProperty -LiteralPath $path -Name $name -ErrorAction Stop} + if($original.Policy.ValueExists){$null=New-ItemProperty -LiteralPath $path -Name $name -Value $original.Policy.Value -PropertyType $original.Policy.Type} + }catch{$errors+=$_.ToString()} + $checks=[ordered]@{} + foreach($pair in @(@('Policy',{(Key (Get-WelaNtlmAuditSnapshot Incoming)) -ceq (Key $original)}),@('Unselected',{(Key (Other)) -ceq (Key $other)}),@('All59Masks',{(Masks) -ceq $masks}))){try{$checks[$pair[0]]=& $pair[1]}catch{$checks[$pair[0]]=$false;$errors+=$_.ToString()}} + $complete=$errors.Count -eq 0 -and @($checks.Values|Where-Object {-not $_}).Count -eq 0 + Save 'cleanup.json' @{Complete=$complete;Checks=$checks;Errors=$errors;Failure=$failure;Assertions=$count} + Save 'artifact-hashes.json' @(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).Hash.ToLowerInvariant()}}) + if(-not $complete){throw 'Incoming/domain NTLM native fixture cleanup failed.'} +} +Write-Host "PASS: $count native public incoming/domain NTLM assertions and exact cleanup." +exit 0 diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index 854679ee..2316cb62 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,6 +7,8 @@ **改善:** +- 受信・ドメイン監査を明示的に選択する `ntlm-auditing` Audit/Plan/Configure を追加しました。受信監査 DWORD2 と実際のDC上のドメイン監査 DWORD7 のみを設定し、旧値2の意味を推測せず識別します。不明な値・ホストや設定の変化を拒否し、型付き変更前記録・再読取・部分失敗を区別します。ネイティブテストで受信設定、非DCのスキップ、無関係な設定の保持と復元を検証します。(関連 #363) (@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..77990ffd 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,6 +7,8 @@ **Improvements:** +- Add `ntlm-auditing` Audit/Plan/Configure with explicit Incoming/Domain/Both selection. Configure only incoming audit DWORD2 and actual-DC domain audit DWORD7, identify legacy domain2 without invented semantics, refuse unknown values and host/state drift, and retain separate typed journals/readback/partial outcomes. Native tests verify incoming changes, non-DC skips and exact preservation of unrelated settings. (Related #363) (@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) From 6f713e7497d8dfbe0a1a2132d0100300d406b240 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:06:11 +0900 Subject: [PATCH 06/12] fix: require running WMI before scoped NTLM observations --- docs/ntlm-auditing.md | 2 +- scripts/NtlmAudit.ps1 | 1 + tests/NtlmAudit.Tests.ps1 | 5 ++++- tests/NtlmAudit.Windows.Tests.ps1 | 28 +++++++++++++++++++++++----- 4 files changed, 29 insertions(+), 7 deletions(-) diff --git a/docs/ntlm-auditing.md b/docs/ntlm-auditing.md index 68ca59e1..af0c7273 100644 --- a/docs/ntlm-auditing.md +++ b/docs/ntlm-auditing.md @@ -1,6 +1,6 @@ # Scoped incoming and domain NTLM auditing -`ntlm-auditing` reads or configures two distinct audit values without invoking the broad `configure` workflow. It never writes an NTLM restriction or exception. Configure requires an explicit selection and elevated native 64-bit PowerShell on reviewed Windows 11 builds 22000/22621/22631/26100/26200 or Server 2022/2025 builds 20348/26100. Product type, domain role and join state must agree; role/build overrides are refused. +`ntlm-auditing` reads or configures two distinct audit values without invoking the broad `configure` workflow. It never writes an NTLM restriction or exception. Configure requires an explicit selection and elevated native 64-bit PowerShell on reviewed Windows 11 builds 22000/22621/22631/26100/26200 or Server 2022/2025 builds 20348/26100. Product type, domain role and join state must agree; role/build overrides are refused. Winmgmt must already be running before any CIM observation. | Selection | Exact value | Requested setting | Applicability | | --- | --- | --- | --- | diff --git a/scripts/NtlmAudit.ps1 b/scripts/NtlmAudit.ps1 index 10eadea9..c7279d55 100644 --- a/scripts/NtlmAudit.ps1 +++ b/scripts/NtlmAudit.ps1 @@ -1,6 +1,7 @@ # Explicit incoming/domain audit values. Authentication restrictions are separate controls. function Get-WelaNtlmAuditHost { if($env:OS -ne 'Windows_NT' -or -not [Environment]::Is64BitProcess){throw 'Use native64-bit PowerShell on Windows.'} + if((Get-Service -Name Winmgmt -ErrorAction Stop).Status -ne 'Running'){throw 'Winmgmt must already be running; this command never starts services.'} $os=Get-CimInstance Win32_OperatingSystem -Property BuildNumber,ProductType -ErrorAction Stop $computer=Get-CimInstance Win32_ComputerSystem -Property Name,Domain,DomainRole,PartOfDomain -ErrorAction Stop if([string]$os.BuildNumber -notmatch '^\d+$' -or $os.ProductType -notin @(1,2,3) -or $computer.PartOfDomain -isnot [bool] -or $computer.DomainRole -notin @(0,1,2,3,4,5) -or [string]::IsNullOrWhiteSpace($computer.Name)){throw 'Incomplete Windows role/build identity.'} diff --git a/tests/NtlmAudit.Tests.ps1 b/tests/NtlmAudit.Tests.ps1 index 2f66238b..9adc90fd 100644 --- a/tests/NtlmAudit.Tests.ps1 +++ b/tests/NtlmAudit.Tests.ps1 @@ -83,7 +83,9 @@ try{ }} # Exercise actual CIM role validation independently of the policy fixture. $savedOs=$env:OS;$env:OS='Windows_NT' - function Get-CimInstance {param($ClassName,$Property,$ErrorAction)if($ClassName -eq 'Win32_OperatingSystem'){$script:osFixture}else{$script:computerFixture}} + $script:wmiStatus='Running';$script:cimReads=0 + function Get-Service {param($Name,$ErrorAction)if($Name -cne 'Winmgmt'){throw 'Unexpected service'};[pscustomobject]@{Status=$script:wmiStatus}} + function Get-CimInstance {param($ClassName,$Property,$ErrorAction)$script:cimReads++;if($ClassName -eq 'Win32_OperatingSystem'){$script:osFixture}else{$script:computerFixture}} try{ foreach($case in @(@(1,0,$false,26100),@(1,1,$true,26200),@(3,2,$false,20348),@(3,3,$true,26100),@(2,4,$true,20348),@(2,5,$true,26100))){ $script:osFixture=[pscustomobject]@{ProductType=$case[0];BuildNumber=[string]$case[3]};$script:computerFixture=[pscustomobject]@{Name='fixture';Domain='fixture';DomainRole=$case[1];PartOfDomain=$case[2]};$h=&$hostValidator;Assert ($h.ProductType -eq $case[0]) 'Coherent native role/build accepted.' @@ -91,6 +93,7 @@ try{ foreach($case in @(@(2,2,$false,26100),@(3,4,$true,26100),@(1,1,$false,26100),@(3,3,$false,26100),@(2,5,$true,99999))){ $script:osFixture=[pscustomobject]@{ProductType=$case[0];BuildNumber=[string]$case[3]};$script:computerFixture=[pscustomobject]@{Name='fixture';Domain='fixture';DomainRole=$case[1];PartOfDomain=$case[2]};$refused=$false;try{&$hostValidator}catch{$refused=$true};Assert $refused 'Conflicting or unsupported observed host is refused.' } + $script:wmiStatus='Stopped';$script:cimReads=0;$refused=$false;try{&$hostValidator}catch{$refused=$_.Exception.Message -match 'must already be running'};Assert ($refused -and $script:cimReads -eq 0) 'Stopped WMI is refused before a CIM observation can start its service.' }finally{$env:OS=$savedOs} }finally{Remove-Item -LiteralPath $root -Recurse -Force} Write-Host "PASS: $count scoped incoming/domain NTLM assertions." diff --git a/tests/NtlmAudit.Windows.Tests.ps1 b/tests/NtlmAudit.Windows.Tests.ps1 index 2d5bba86..620b3f3e 100644 --- a/tests/NtlmAudit.Windows.Tests.ps1 +++ b/tests/NtlmAudit.Windows.Tests.ps1 @@ -24,12 +24,30 @@ function Other { }finally{if($k){$k.Dispose()};$base.Dispose()} [pscustomobject][ordered]@{Values=$values;Children=$children;Access=$acl;DomainPolicy=Get-WelaRegistryState 'HKLM:\SYSTEM\CurrentControlSet\Services\Netlogon\Parameters' AuditNTLMInDomain;NtlmChannel=Get-WelaNativeChannel 'Microsoft-Windows-NTLM/Operational';SecurityChannel=Get-WelaNativeChannel Security;NetlogonService=[string](Get-Service Netlogon).Status} } +Add-Type -TypeDefinition @' +using System;using System.IO;using System.Text;using System.Threading.Tasks; +public static class WelaNtlmFixturePipe { + 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]$Label,[string[]]$Arguments){ - $prior=$ErrorActionPreference - try{$ErrorActionPreference='Continue';$output=& $engine -NoLogo -NoProfile -NonInteractive -File (Join-Path $repo 'WELA.ps1') ntlm-auditing @Arguments 2>&1|Out-String;$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior} - $output|Set-Content -LiteralPath (Join-Path $root ($Label+'.txt')) -Encoding UTF8 - Assert ($code -eq 0) "Public $Label exited $code : $output" - Get-Content -Raw -LiteralPath (Join-Path $root ($Label+'.json'))|ConvertFrom-Json + $all=@('-NoLogo','-NoProfile','-NonInteractive','-File',(Join-Path $repo 'WELA.ps1'),'ntlm-auditing')+$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=[WelaNtlmFixturePipe]::Read($process.StandardOutput);$stderr=[WelaNtlmFixturePipe]::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.'} + $output=$stdout.Result+"`n"+$stderr.Result + $output|Set-Content -LiteralPath (Join-Path $root ($Label+'.txt')) -Encoding UTF8 + Assert ($process.ExitCode -eq 0) "Public $Label exited $($process.ExitCode) : $output" + Get-Content -Raw -LiteralPath (Join-Path $root ($Label+'.json'))|ConvertFrom-Json + }finally{ + if($started){$exited=$false;try{$exited=$process.HasExited}catch{$script:errors+=$_.Exception.Message};if(-not $exited){try{$process.Kill()}catch{$script:errors+=$_.Exception.Message};try{$exited=$process.WaitForExit(5000)}catch{$script:errors+=$_.Exception.Message}};if(-not $exited){$script:errors+='Owned public process termination unconfirmed.'}} + $process.Dispose() + } } $original=Get-WelaNtlmAuditSnapshot Incoming Assert ($original.Host.ProductType -eq 3 -and $original.Host.DomainRole -eq 2 -and -not $original.Host.PartOfDomain) 'Actual unjoined disposable Server is required.' From f27238ebd588da848ee60155c5014fd0b3fd1b4d Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:10:03 +0900 Subject: [PATCH 07/12] Validate typed source inputs and preserve caller scope in query reader --- .github/workflows/release.yml | 2 +- CHANGELOG-Japanese.md | 2 ++ CHANGELOG.md | 2 ++ docs/wef-deployment.md | 2 +- docs/wef-query.md | 50 ++++++++++++++++++++++++++ scripts/WefQuery.ps1 | 14 ++++++-- tests/WefQuery.Tests.ps1 | 37 +++++++++++++++++++ website/docs/resources/changelog.ja.md | 2 ++ website/docs/resources/changelog.md | 2 ++ 9 files changed, 109 insertions(+), 4 deletions(-) create mode 100644 docs/wef-query.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 52989c99..8f2dbfb8 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/wef-query.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/ - name: Set Artifact Name if: contains(matrix.info.os, 'windows') == true diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index 3b6874eb..395546c7 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,6 +4,8 @@ **改善:** +- 読み取り専用の `wef-query` を追加しました。選択したソースのQueryListをそのままネイティブAPIで実行し、Select/Suppressの動作、チャネル別の失敗診断、上限付きの一致イベントXML、実際の操作者・ホスト・ソースの整合性を確認します。空の結果、アクセス拒否、未存在、不正クエリ、上限到達、状態変化を区別し、破棄可能なWindows環境で実イベントの選択・抑制と標準ユーザーの拒否、完全な後片付けを検証します。転送サービスのアクセス権、配送、Sigmaの準備完了は推定しません。 (Related #368) (@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..2326a7ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ **Improvements:** +- Added read-only `wef-query` preflight for one exact selected source QueryList, with strict native Select/Suppress execution, separate per-channel failure diagnostics, bounded matching XML and actual caller/host/source guards. Empty, denied, missing, invalid, capped and drifted results remain distinct; disposable native tests cover real record selection/suppression and standard-user denial with exact cleanup. No forwarding-service access, delivery or Sigma credit is inferred. (Related #368) (@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/wef-deployment.md b/docs/wef-deployment.md index 108558e7..516146cb 100644 --- a/docs/wef-deployment.md +++ b/docs/wef-deployment.md @@ -52,7 +52,7 @@ ForwardedEvents enablement preserves its size, retention mode and security descr [Native collector observations](wec-collector-observation.md) use complete bounded WEC name enumeration and strict Unicode XML reads. Failed or partial observations remain unknown; they never become permission to create a subscription. Raw Unicode descriptions/filters and actual disabled state are retained independently of the requested settings. -The supported input is the native Subscription namespace, SourceInitiated type, native EventLog URI, HTTP transport, ForwardedEvents destination and Normal/MinLatency/MinBandwidth delivery preset. Enabled, ReadExistingEvents, content format and locale must be explicit. QueryList uses unique numeric Query IDs, exact native channel paths and nonempty Select/Suppress XPath expressions. Wildcard/provider channel names, external-provider channels, Sysmon/EMET, DTDs, unknown settings and custom delivery are rejected. This checks supported structure, not Windows XPath execution; native `cs` remains the final syntax validator. +The supported input is the native Subscription namespace, SourceInitiated type, native EventLog URI, HTTP transport, ForwardedEvents destination and Normal/MinLatency/MinBandwidth delivery preset. Enabled, ReadExistingEvents, content format and locale must be explicit. QueryList uses unique numeric Query IDs, exact native channel paths and nonempty Select/Suppress XPath expressions. Wildcard/provider channel names, external-provider channels, Sysmon/EMET, DTDs, unknown settings and custom delivery are rejected. This checks supported structure, not Windows XPath execution; native `cs` remains the final syntax validator. Use the separate read-only [`wef-query` preflight](wef-query.md) to execute one exact selected QueryList on the local source under the actual caller token, with native Select/Suppress results and distinct empty/failure/partial evidence. It does not test the forwarding service token or remote delivery. An empty `AllowedSourceDomainComputers` input is filled from the explicit `SourceSids`; a nonempty value must match that authorization exactly. No empty authorization reaches `wecutil`, avoiding Windows' broader default authorization. Non-domain/certificate authorization is not supported. The example Security 4740 filter is illustrative and is not a complete baseline or a recommendation to lock an account for testing. diff --git a/docs/wef-query.md b/docs/wef-query.md new file mode 100644 index 00000000..cf0702a1 --- /dev/null +++ b/docs/wef-query.md @@ -0,0 +1,50 @@ +# Native source QueryList preflight + +`wef-query` executes the exact QueryList from one explicitly selected subscription in an existing WEF **Source** config against local Windows logs. It checks actual native query behavior and the current caller's read access, preserving matching event XML in a new private evidence directory. It changes no Windows settings, contacts no collector and creates no subscription or event. + +```powershell +./WELA.ps1 wef-query -WefQueryConfigPath C:\WEF\source.json ` + -WefQuerySubscriptionId 'WELA Native Security Example' ` + -WefQueryOutputPath C:\Evidence\query-001 ` + -WefQueryMaximumEvents 16 +``` + +Use native 64-bit Windows PowerShell 5.1 or PowerShell 7 under the intended reader's session. The observed host must be within WELA's reviewed Windows 11 / Server 2022/2025 build scope, with EventLog and Winmgmt already running. The command does not start services, elevate, impersonate another user or refresh a token. `-Auto`, `-DryRun`, `-WhatIf`, alternate credentials, remote query options and unrelated configuration arguments are rejected. A source's current domain membership does not authorize a remote operation because this command performs none. + +The source JSON and explicitly listed subscriptions use the existing [WEF deployment](wef-deployment.md) schema: collector FQDN/URI, domain-format source SIDs and supported built-in native subscription definitions. Select one exact, case-sensitive subscription ID from that config. The collector identity and requested enabled flag remain recorded operator inputs; neither becomes an observed collector setting or verified identity. An explicitly disabled subscription can still be preflighted against historical local records. + +## Exact query and separate error diagnostics + +The existing parser validates supported subscription structure, native channel paths, Select/Suppress clauses and excluded external providers. The extracted QueryList text is then passed directly to native `EvtQuery`; WELA does not rewrite XPath, remove suppressions, substitute a fixed query or enable tolerance for matching evidence. The query runs in reverse record order. Every native channel status must be attributable to a selected channel, and every selected channel must have a reported status before a complete result is possible. + +If strict query creation fails, its native error remains the primary outcome. A second, separately labeled native diagnostic query can return per-channel status codes with `EvtQueryTolerateQueryErrors`. Windows may recover only part of a malformed XPath under that flag, so **no records from the diagnostic query are read or accepted as matches**. Missing diagnostic details remain unknown. Numeric error codes are preserved without parsing localized message text. + +| Status | Meaning | +| --- | --- | +| `MatchesObserved` | Strict query completed, every selected channel status succeeded, and at least one native matching event was retained. | +| `ReadAllowedEmpty` | Strict query completed with successful channel statuses and no matching events. Empty is distinct from denial, missing logs or invalid syntax. | +| `QueryFailed` | Strict query creation failed; inspect its error and the separate diagnostic channel statuses. | +| `Partial` | The strict query opened, but a cap, read failure, channel error or incomplete cleanup prevented completeness. Retained samples remain individual observations. | +| `Unverified` | Input, worker, context, provenance or artifact checks failed. Inspect the diagnostic and available evidence. | + +Only the first two statuses return exit 0. A valid query can legitimately return no records, and a broad valid query can exceed the sample limit. A native success establishes behavior for the current local logs and actual caller at observation time; it does not prove that a future event, another account or the forwarding service will have the same result. + +## Bounds and evidence + +Each original input is limited to 1 MiB, with 4 MiB aggregate decoded text. The selected QueryList is limited to 65,536 UTF-16 characters, 16 distinct channels and 128 filters. `WefQueryMaximumEvents` accepts 1–64 (default 16). One extra native record is requested to distinguish an exact-sized result from a cap; the extra record is not rendered or retained. Native XML is bounded to 1 MiB of UTF-16 per record and 4 MiB of aggregate UTF-8 matching XML. + +The fixed worker uses the same installed PowerShell engine and actual caller context. Its native query handles remain on one thread. Each `EvtNext` uses a five-second timeout; the parent bounds the entire worker to 45 seconds, with bounded output draining and termination waits. A timeout or unconfirmed worker termination cannot earn a complete result. Bounded source, native query-status arrays and pipe buffers prevent unconstrained result allocation. + +The new output directory grants access to the current user, SYSTEM and local Administrators. Original inputs and parent ACLs are not changed. Paths must be ordinary local paths accepted by WELA's recovery artifact helpers; existing output directories and observed reparse paths are refused. Raw event payloads can contain sensitive operational data, so retain them as evidence under the intended reader's access policy. + +Outputs include decoded `source-config.json`, `subscription.xml`, exact `query.xml`, the worker `request.json`, `worker.json`, individual `event-NNN.xml` matches and a final `manifest.json`. The manifest records original file paths/hashes, source fingerprints, query hash, actual host/DNS context, engine hash/version, before/after reader and channel observations, strict/diagnostic query results and artifact hashes. The original byte hashes are distinct from the decoded text artifacts. Unsuccessful runs retain whatever evidence was available; a missing final manifest means the output is incomplete. + +The worker's SID, logon, group attributes and privileges must match the caller and remain stable. Host, input bytes, implementation, engine, channel configuration and saved hashes are rechecked before completeness. Returned event channel/record identity and exact observed local computer names must be consistent; no same-label arbitrary DNS suffix is accepted. These checks are observations rather than an atomic channel snapshot, and hashes establish consistency rather than authenticating an evidence author. + +`ConfigurationChanges` and `ReadyRuleCredit` remain zero. The result does not establish NETWORK SERVICE's effective token, source group membership, policy/SACL generation prerequisites, subscription delivery, origin of historical records, loss, forwarding latency, retention duration or Sigma readiness. Use [channel-read](channel-read.md) for a simple current-token channel read and [wef-arrival](wef-arrival.md) for the separate exact collector-presence workflow. Issue #368 still requires representative multi-host source/collector validation. + +## Native validation + +The disposable Server 2022/2025 workflow runs both PowerShell engines through the public command. It selects an independently read real System record, verifies complete XML equality, suppresses that same record to obtain a genuine empty result, exercises malformed XPath and a mixed missing-channel query, and proves the event cap with an extra native record. An owned standard user and temporary CAPI2 deny ACE exercise actual access denial. The fixture independently restores the original channel descriptor and removes its owned account, then compares selected channels, services, all audit masks, precedence and the operator token. These temporary fixture changes are absent from the product. No domain setup, event generation or forwarding is claimed by this native suite. + +Microsoft references: [EvtQuery](https://learn.microsoft.com/en-us/windows/win32/api/winevt/nf-winevt-evtquery), [query flags and partial XPath recovery](https://learn.microsoft.com/en-us/windows/win32/api/winevt/ne-winevt-evt_query_flags), [per-channel query information](https://learn.microsoft.com/en-us/windows/win32/api/winevt/nf-winevt-evtgetqueryinfo), [native property types](https://learn.microsoft.com/en-us/windows/win32/api/winevt/ne-winevt-evt_query_property_id), [EvtNext completeness and timeout](https://learn.microsoft.com/en-us/windows/win32/api/winevt/nf-winevt-evtnext), and [native event XML rendering](https://learn.microsoft.com/en-us/windows/win32/api/winevt/nf-winevt-evtrender). diff --git a/scripts/WefQuery.ps1 b/scripts/WefQuery.ps1 index 5bfda262..b2eb225e 100644 --- a/scripts/WefQuery.ps1 +++ b/scripts/WefQuery.ps1 @@ -34,18 +34,28 @@ function Get-WelaWefQueryTokenKey { Get-WelaWefQueryKey ([pscustomobject][ordered]@{Sid=$Token.Sid;Name=$Token.Name;AuthenticationId=$Token.AuthenticationId;AuthenticationType=$Token.AuthenticationType;Groups=$Token.Groups;Privileges=$Token.Privileges}) } function Assert-WelaWefQueryUInt {param($Value) if(($Value -isnot [int] -and $Value -isnot [long] -and $Value -isnot [uint32]) -or $Value -lt 0 -or $Value -gt [uint32]::MaxValue){throw 'Expected a native unsigned integer.'}} +function Assert-WelaWefQuerySourceConfig { + param($Config) + Assert-WelaArrivalObject $Config @('SchemaVersion','Role','CollectorFqdn','CollectorUri','Authentication','SourceSids','SubscriptionFiles','Hardening','SubscriptionManagerSlot','RefreshSeconds','GrantNetworkServiceRead','ApplyChannelProfile','GrantCapi2Read') + foreach($name in @('SchemaVersion','SubscriptionManagerSlot','RefreshSeconds')){if($Config.$name -isnot [int] -and $Config.$name -isnot [long]){throw 'Source config requires integer schema/slot/refresh fields.'}} + foreach($name in @('Role','CollectorFqdn','CollectorUri','Authentication','Hardening')){if($Config.$name -isnot [string]){throw 'Source config requires typed text fields.'}} + foreach($name in @('SourceSids','SubscriptionFiles')){if($Config.$name -isnot [array]){throw 'Source config requires explicit SID/file arrays.'};foreach($value in $Config.$name){if($value -isnot [string] -or -not $value){throw 'Source config requires nonempty SID/file strings.'}}} + foreach($name in @('GrantNetworkServiceRead','ApplyChannelProfile','GrantCapi2Read')){if($Config.$name -isnot [bool]){throw 'Source config requires explicit Boolean permission settings.'}} +} function Import-WelaWefQuerySelection { param([string]$ConfigPath,[string]$SubscriptionId) if(-not $ConfigPath -or -not $SubscriptionId -or $SubscriptionId.Length -gt 256 -or $SubscriptionId -match '[\x00-\x1f]'){throw 'An exact source config path and subscription ID are required.'} $capture=@{Files=[Collections.Generic.List[object]]::new();Bytes=0;Texts=[Collections.Generic.List[string]]::new()} + # This synchronous callback retains the caller's script scope. GetNewClosure + # creates a dynamic module that cannot see script-local artifact helpers. $reader={param($path) $file=Read-WelaWecUpdateFile $path 1048576 if($capture.Files.Path -contains $file.Path){throw 'Duplicate input file path.'} - if($capture.Files.Count -eq 0){$json=ConvertFrom-WelaArrivalJson $file.Text;if($json.SchemaVersion -isnot [int] -and $json.SchemaVersion -isnot [long]){throw 'WEF schema version must be an integer.'}} + if($capture.Files.Count -eq 0){$json=ConvertFrom-WelaArrivalJson $file.Text;Assert-WelaWefQuerySourceConfig $json} $capture.Bytes+=[Text.Encoding]::UTF8.GetByteCount($file.Text);if($capture.Bytes -gt 4194304){throw 'WEF input text exceeds four MiB aggregate.'} $capture.Files.Add([pscustomobject]@{Path=$file.Path;Sha256=$file.Hash});$capture.Texts.Add($file.Text) $file.Text - }.GetNewClosure() + } $model=Import-WelaWefConfig -Path $ConfigPath -Role Source -ReadText $reader $selected=@($model.Subscriptions|Where-Object Id -CEQ $SubscriptionId) if($selected.Count -ne 1){throw 'Select one exact subscription ID from the source config.'};$selected=$selected[0] diff --git a/tests/WefQuery.Tests.ps1 b/tests/WefQuery.Tests.ps1 index 2485fdae..9361b576 100644 --- a/tests/WefQuery.Tests.ps1 +++ b/tests/WefQuery.Tests.ps1 @@ -52,9 +52,46 @@ try{ Reject {Import-WelaWefQuerySelection $path 'WELA Native Security Example'} 'Duplicate config properties refused.' [IO.File]::WriteAllText($path,$original.Replace('"SchemaVersion": 1','"SchemaVersion": true')) Reject {Import-WelaWefQuerySelection $path 'WELA Native Security Example'} 'Boolean schema rejected.' + foreach($field in @('Role','Hardening','CollectorFqdn','CollectorUri','Authentication')){$config=ConvertFrom-WelaArrivalJson $original;$config.$field=$true;[IO.File]::WriteAllText($path,(Get-WelaWefQueryKey $config));Reject {Import-WelaWefQuerySelection $path 'WELA Native Security Example'} "Boolean text cannot pass source config $field"} + foreach($field in @('SourceSids','SubscriptionFiles')){$config=ConvertFrom-WelaArrivalJson $original;$config.$field=@($true);[IO.File]::WriteAllText($path,(Get-WelaWefQueryKey $config));Reject {Import-WelaWefQuerySelection $path 'WELA Native Security Example'} "Typed source array $field"} [IO.File]::WriteAllText($path,$original) [IO.File]::AppendAllText($subscription,' ');Reject {Assert-WelaWefQueryInputs $selected} 'Original subscription byte drift invalidates evidence.' }finally{Remove-Item -LiteralPath $temp -Recurse -Force} $stream=[IO.StringReader]::new('abcdef');try{Reject {[Wela.WefQuery.Native]::ReadPipe($stream,5).GetAwaiter().GetResult()} 'Bounded pipe rejects excess before growing without limit.'}finally{$stream.Dispose()} +# Exercise complete command outcomes and changed evidence through real local artifacts. +$script:lifecycle=@{Case='';HostReads=0;ChannelReads=0;Config='';Xml=$xml} +function Get-WelaWefQueryHost {$script:lifecycle.HostReads++;[pscustomobject]@{Computer=$(if($script:lifecycle.Case -eq 'HostDrift' -and $script:lifecycle.HostReads -gt 1){'Other'}else{'Host'});DnsHostName='Host';DnsSuffix='example.test'}} +function Get-WelaWefQueryEngine {[pscustomobject]@{Path='fixture-engine';Sha256=('a'*64);Version='7.0';ModulePath='fixture-modules'}} +function Get-WelaWefQueryToken {[pscustomobject]@{Sid='S-1-5-21-1-2-3-1001';Name='Host\Reader';AuthenticationId='0x123';AuthenticationType='Fixture';ImpersonationLevel='None';TokenSource='Process';Groups=@([pscustomobject]@{Sid='S-1-5-32-545';Attributes=7});Privileges=@()}} +function Get-WelaWefQueryChannelState {param($Channels) $script:lifecycle.ChannelReads++;[pscustomobject]@{Name='System';State=$(if($script:lifecycle.Case -eq 'ChannelDrift' -and $script:lifecycle.ChannelReads -gt 1){'Disabled'}else{'Enabled'})}} +function Start-WelaWefQueryWorker { + param($Engine,$RequestPath,$RequestHash) + $request=ConvertFrom-WelaArrivalJson ([IO.File]::ReadAllText($RequestPath));$result=[pscustomobject]@{Opened=$true;Complete=$true;Capped=$false;CleanupConfirmed=$true;NativeError=$null;Diagnostic='';Channels=@([pscustomobject]@{Channel='System';Error=0});DiagnosticChannels=@();DiagnosticNativeError=$null;Events=@($script:lifecycle.Xml)} + if($script:lifecycle.Case -eq 'Empty'){$result.Events=@()} + if($script:lifecycle.Case -eq 'Partial'){$result.Complete=$false;$result.Capped=$true} + if($script:lifecycle.Case -eq 'MissingStatus'){$result.Channels=@()} + if($script:lifecycle.Case -eq 'DuplicateEvents'){$result.Events=@($script:lifecycle.Xml,$script:lifecycle.Xml)} + if($script:lifecycle.Case -eq 'FailedQuery'){$result.Opened=$false;$result.Complete=$false;$result.NativeError=5;$result.Channels=@();$result.Events=@()} + $receipt=[pscustomobject]@{SchemaVersion=1;Kind='WelaWefQueryWorker';Nonce=$request.Nonce;ProcessId=4242;Engine=$Engine;ModulePath=$Engine.ModulePath;StartedUtc='2026-01-01T00:00:00Z';CompletedUtc='2026-01-01T00:00:01Z';ReaderBefore=(Get-WelaWefQueryToken);ReaderAfter=(Get-WelaWefQueryToken);Host=$request.Host;Sources=$request.Sources;QuerySha256=$request.QuerySha256;Result=$result} + if($script:lifecycle.Case -eq 'TokenDrift'){$receipt.ReaderAfter.AuthenticationId='0x999'} + if($script:lifecycle.Case -eq 'ReceiptBoolean'){$receipt.Kind=$true} + if($script:lifecycle.Case -eq 'InputDrift'){[IO.File]::AppendAllText($script:lifecycle.Config,' ')} + if($script:lifecycle.Case -eq 'ArtifactDrift'){[IO.File]::AppendAllText((Join-Path (Split-Path $RequestPath -Parent) 'query.xml'),' ')} + [pscustomobject]@{Started=$true;ProcessId=4242;ExitCode=0;TimedOut=$false;TerminationConfirmed=($script:lifecycle.Case -ne 'Termination');Receipt=$receipt;Diagnostic=''} +} +$temp=Join-Path ([IO.Path]::GetTempPath()) ('wela-query-lifecycle-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $temp +try{ + Copy-Item (Join-Path $script:ScriptRoot 'config/wef-examples/*') $temp + $script:lifecycle.Config=Join-Path $temp 'source.json';$originalConfig=[IO.File]::ReadAllText($script:lifecycle.Config) + $subscription=Join-Path $temp 'native-security.xml';$doc=Read-WelaWefXml ([IO.File]::ReadAllText($subscription));$doc.DocumentElement.SelectSingleNode('*[local-name()="Query"]').InnerText='';[IO.File]::WriteAllText($subscription,$doc.OuterXml) + foreach($case in @('Match','Empty','Partial','FailedQuery','MissingStatus','DuplicateEvents','TokenDrift','ReceiptBoolean','InputDrift','ArtifactDrift','Termination','HostDrift','ChannelDrift')){ + $script:lifecycle.Case=$case;$script:lifecycle.HostReads=0;$script:lifecycle.ChannelReads=0;[IO.File]::WriteAllText($script:lifecycle.Config,$originalConfig) + $report=Invoke-WelaWefQuery $script:lifecycle.Config 'WELA Native Security Example' (Join-Path $temp $case) + $expected=switch($case){Match{'MatchesObserved'};Empty{'ReadAllowedEmpty'};Partial{'Partial'};FailedQuery{'QueryFailed'};default{'Unverified'}} + Assert ($report.Status -ceq $expected) ("Public lifecycle $case expected $expected : "+$report.Diagnostic) + Assert ($report.ExitCode -eq $(if($case -in @('Match','Empty')){0}else{1}) -and $report.ReadyRuleCredit -eq 0 -and $report.ConfigurationChanges -eq 0) "Public lifecycle $case exit/credit boundaries." + Assert (Test-Path -LiteralPath (Join-Path (Join-Path $temp $case) 'manifest.json')) "Failure/complete manifest retained for $case." + } +}finally{Remove-Item -LiteralPath $temp -Recurse -Force} Write-Host "WefQuery.Tests: $script:count focused assertions passed." $global:LASTEXITCODE=0 diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index 854679ee..18617a44 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,6 +7,8 @@ **改善:** +- 読み取り専用の `wef-query` を追加しました。選択したソースのQueryListをそのままネイティブAPIで実行し、Select/Suppressの動作、チャネル別の失敗診断、上限付きの一致イベントXML、実際の操作者・ホスト・ソースの整合性を確認します。空の結果、アクセス拒否、未存在、不正クエリ、上限到達、状態変化を区別し、破棄可能なWindows環境で実イベントの選択・抑制と標準ユーザーの拒否、完全な後片付けを検証します。転送サービスのアクセス権、配送、Sigmaの準備完了は推定しません。 (Related #368) (@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..2ce18ac8 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,6 +7,8 @@ **Improvements:** +- Added read-only `wef-query` preflight for one exact selected source QueryList, with strict native Select/Suppress execution, separate per-channel failure diagnostics, bounded matching XML and actual caller/host/source guards. Empty, denied, missing, invalid, capped and drifted results remain distinct; disposable native tests cover real record selection/suppression and standard-user denial with exact cleanup. No forwarding-service access, delivery or Sigma credit is inferred. (Related #368) (@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) From ad7ddf4de710e98c4c86f1107328c7bd0a09168b Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:14:05 +0900 Subject: [PATCH 08/12] Handle native host casing and timestamp compatibility in query evidence --- docs/wef-query.md | 2 +- scripts/WefQuery.ps1 | 4 ++-- tests/WefQuery.Cli.Tests.ps1 | 4 ++-- tests/WefQuery.Tests.ps1 | 10 ++++++---- tests/WefQuery.Windows.Tests.ps1 | 3 ++- 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/docs/wef-query.md b/docs/wef-query.md index cf0702a1..b9efab22 100644 --- a/docs/wef-query.md +++ b/docs/wef-query.md @@ -15,7 +15,7 @@ The source JSON and explicitly listed subscriptions use the existing [WEF deploy ## Exact query and separate error diagnostics -The existing parser validates supported subscription structure, native channel paths, Select/Suppress clauses and excluded external providers. The extracted QueryList text is then passed directly to native `EvtQuery`; WELA does not rewrite XPath, remove suppressions, substitute a fixed query or enable tolerance for matching evidence. The query runs in reverse record order. Every native channel status must be attributable to a selected channel, and every selected channel must have a reported status before a complete result is possible. +The existing parser validates supported subscription structure, native channel paths, Select/Suppress clauses and excluded external providers. The extracted QueryList text is then passed directly to native `EvtQuery`; WELA does not rewrite XPath, remove suppressions, substitute a fixed query or enable tolerance for matching evidence. The query runs in reverse record order. Windows does not support reverse queries on Analytic/Debug channels; those native failures remain failures, without changing channel state or silently choosing another query mode. Every native channel status must be attributable to a selected channel, and every selected channel must have a reported status before a complete result is possible. If strict query creation fails, its native error remains the primary outcome. A second, separately labeled native diagnostic query can return per-channel status codes with `EvtQueryTolerateQueryErrors`. Windows may recover only part of a malformed XPath under that flag, so **no records from the diagnostic query are read or accepted as matches**. Missing diagnostic details remain unknown. Numeric error codes are preserved without parsing localized message text. diff --git a/scripts/WefQuery.ps1 b/scripts/WefQuery.ps1 index b2eb225e..66ab9d61 100644 --- a/scripts/WefQuery.ps1 +++ b/scripts/WefQuery.ps1 @@ -83,7 +83,7 @@ function Assert-WelaWefQueryInputs { } function Get-WelaWefQueryEngine { $path=(Get-Process -Id $PID -ErrorAction Stop).Path - if([IO.Path]::GetFileName($path) -cnotin @('powershell.exe','pwsh.exe') -or $PSVersionTable.PSVersion.Major -notin @(5,7)){throw 'Native Windows PowerShell 5.1 or PowerShell 7 is required.'} + if([IO.Path]::GetFileName($path) -notin @('powershell.exe','pwsh.exe') -or $PSVersionTable.PSVersion.Major -notin @(5,7)){throw 'Native Windows PowerShell 5.1 or PowerShell 7 is required.'} [pscustomobject]@{Path=$path;Sha256=(Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant();Version=$PSVersionTable.PSVersion.ToString();ModulePath=[IO.Path]::Combine($PSHOME,'Modules')} } function Close-WelaWefQueryWorker { @@ -162,7 +162,7 @@ function Invoke-WelaWefQuery { $worker=Start-WelaWefQueryWorker $engine (Join-Path $output 'request.json') $artifact.Sha256;$report.Worker=$worker if(-not $worker.Started -or -not $worker.TerminationConfirmed -or $worker.TimedOut -or $worker.Diagnostic -or $worker.ExitCode -ne 0 -or -not $worker.Receipt){throw ('Query worker did not complete verified observation. '+$worker.Diagnostic)} $receipt=$worker.Receipt;Assert-WelaArrivalObject $receipt @('SchemaVersion','Kind','Nonce','ProcessId','Engine','ModulePath','StartedUtc','CompletedUtc','ReaderBefore','ReaderAfter','Host','Sources','QuerySha256','Result') - foreach($name in @('Kind','Nonce','ModulePath','StartedUtc','CompletedUtc','QuerySha256')){if($receipt.$name -isnot [string]){throw 'Mistyped worker receipt identity.'}} + foreach($name in @('Kind','Nonce','ModulePath','QuerySha256')){if($receipt.$name -isnot [string]){throw 'Mistyped worker receipt identity.'}} if(($receipt.SchemaVersion -isnot [int] -and $receipt.SchemaVersion -isnot [long]) -or $receipt.SchemaVersion -ne 1 -or $receipt.Kind -cne 'WelaWefQueryWorker' -or $receipt.Nonce -cne $request.Nonce -or ($receipt.ProcessId -isnot [int] -and $receipt.ProcessId -isnot [long]) -or $receipt.ProcessId -ne $worker.ProcessId -or $receipt.ModulePath -cne $engine.ModulePath -or $receipt.QuerySha256 -cne $selection.QuerySha256 -or (Get-WelaWefQueryKey $receipt.Engine) -cne (Get-WelaWefQueryKey $engine) -or (Get-WelaWefQueryKey $receipt.Host) -cne (Get-WelaWefQueryKey $hostState) -or (Get-WelaWefQueryKey $receipt.Sources) -cne (Get-WelaWefQueryKey $sources)){throw 'Worker receipt differs from actual reviewed query/engine/host/source context.'} if((Get-WelaWefQueryTokenKey $receipt.ReaderBefore) -cne $tokenKey -or (Get-WelaWefQueryTokenKey $receipt.ReaderAfter) -cne $tokenKey){throw 'Worker token differs from the actual caller or changed during query.'} if((ConvertTo-WelaArrivalUtc $receipt.StartedUtc) -gt (ConvertTo-WelaArrivalUtc $receipt.CompletedUtc)){throw 'Worker time interval is invalid.'} diff --git a/tests/WefQuery.Cli.Tests.ps1 b/tests/WefQuery.Cli.Tests.ps1 index 2871c67e..d4ad6bf6 100644 --- a/tests/WefQuery.Cli.Tests.ps1 +++ b/tests/WefQuery.Cli.Tests.ps1 @@ -10,8 +10,8 @@ Assert-Cli @('wef-query','-DryRun') 1 'dedicated read-only options' Assert-Cli @('wef-query','-WhatIf') 1 'dedicated read-only options' Assert-Cli @('wef-query','-ResultsPath','out.json') 1 'dedicated read-only options' Assert-Cli @('wef-query','-WefAction','Configure') 1 'dedicated read-only options' -Assert-Cli @('wef-query','-WefQueryMaximumEvents','0') 1 'less than the minimum' -Assert-Cli @('wef-query','-WefQueryMaximumEvents','65') 1 'greater than the maximum' +Assert-Cli @('wef-query','-WefQueryMaximumEvents','0') 1 'WefQueryMaximumEvents' +Assert-Cli @('wef-query','-WefQueryMaximumEvents','65') 1 'WefQueryMaximumEvents' Assert-Cli @('wef-query') 1 'exact source config path and subscription ID' Write-Host "WefQuery.Cli.Tests: $script:count public CLI checks passed." $global:LASTEXITCODE=0 diff --git a/tests/WefQuery.Tests.ps1 b/tests/WefQuery.Tests.ps1 index 9361b576..54f1912c 100644 --- a/tests/WefQuery.Tests.ps1 +++ b/tests/WefQuery.Tests.ps1 @@ -1,4 +1,4 @@ -$ErrorActionPreference='Stop';$script:ScriptRoot=Split-Path $PSScriptRoot -Parent +$ErrorActionPreference='Stop';$script:ScriptRoot=Split-Path $PSScriptRoot -Parent Import-Module (Join-Path $script:ScriptRoot 'modules/AuditProfiles.psm1') -Force Import-Module (Join-Path $script:ScriptRoot 'modules/WefSubscriptions.psm1') -Force foreach($name in @('WefArrival','WecUpdate','ChannelRead','WefQuery')){. (Join-Path $script:ScriptRoot ('scripts/'+$name+'.ps1'))} @@ -73,6 +73,8 @@ function Start-WelaWefQueryWorker { if($script:lifecycle.Case -eq 'DuplicateEvents'){$result.Events=@($script:lifecycle.Xml,$script:lifecycle.Xml)} if($script:lifecycle.Case -eq 'FailedQuery'){$result.Opened=$false;$result.Complete=$false;$result.NativeError=5;$result.Channels=@();$result.Events=@()} $receipt=[pscustomobject]@{SchemaVersion=1;Kind='WelaWefQueryWorker';Nonce=$request.Nonce;ProcessId=4242;Engine=$Engine;ModulePath=$Engine.ModulePath;StartedUtc='2026-01-01T00:00:00Z';CompletedUtc='2026-01-01T00:00:01Z';ReaderBefore=(Get-WelaWefQueryToken);ReaderAfter=(Get-WelaWefQueryToken);Host=$request.Host;Sources=$request.Sources;QuerySha256=$request.QuerySha256;Result=$result} + if($script:lifecycle.Case -eq 'DateTimeReceipt'){$receipt.StartedUtc=[DateTime]::SpecifyKind([datetime]'2026-01-01T00:00:00',[DateTimeKind]::Utc);$receipt.CompletedUtc=$receipt.StartedUtc.AddSeconds(1)} + if($script:lifecycle.Case -eq 'InvalidTimeReceipt'){$receipt.StartedUtc=$true} if($script:lifecycle.Case -eq 'TokenDrift'){$receipt.ReaderAfter.AuthenticationId='0x999'} if($script:lifecycle.Case -eq 'ReceiptBoolean'){$receipt.Kind=$true} if($script:lifecycle.Case -eq 'InputDrift'){[IO.File]::AppendAllText($script:lifecycle.Config,' ')} @@ -84,12 +86,12 @@ try{ Copy-Item (Join-Path $script:ScriptRoot 'config/wef-examples/*') $temp $script:lifecycle.Config=Join-Path $temp 'source.json';$originalConfig=[IO.File]::ReadAllText($script:lifecycle.Config) $subscription=Join-Path $temp 'native-security.xml';$doc=Read-WelaWefXml ([IO.File]::ReadAllText($subscription));$doc.DocumentElement.SelectSingleNode('*[local-name()="Query"]').InnerText='';[IO.File]::WriteAllText($subscription,$doc.OuterXml) - foreach($case in @('Match','Empty','Partial','FailedQuery','MissingStatus','DuplicateEvents','TokenDrift','ReceiptBoolean','InputDrift','ArtifactDrift','Termination','HostDrift','ChannelDrift')){ + foreach($case in @('Match','DateTimeReceipt','InvalidTimeReceipt','Empty','Partial','FailedQuery','MissingStatus','DuplicateEvents','TokenDrift','ReceiptBoolean','InputDrift','ArtifactDrift','Termination','HostDrift','ChannelDrift')){ $script:lifecycle.Case=$case;$script:lifecycle.HostReads=0;$script:lifecycle.ChannelReads=0;[IO.File]::WriteAllText($script:lifecycle.Config,$originalConfig) $report=Invoke-WelaWefQuery $script:lifecycle.Config 'WELA Native Security Example' (Join-Path $temp $case) - $expected=switch($case){Match{'MatchesObserved'};Empty{'ReadAllowedEmpty'};Partial{'Partial'};FailedQuery{'QueryFailed'};default{'Unverified'}} + $expected=switch($case){Match{'MatchesObserved'};DateTimeReceipt{'MatchesObserved'};Empty{'ReadAllowedEmpty'};Partial{'Partial'};FailedQuery{'QueryFailed'};default{'Unverified'}} Assert ($report.Status -ceq $expected) ("Public lifecycle $case expected $expected : "+$report.Diagnostic) - Assert ($report.ExitCode -eq $(if($case -in @('Match','Empty')){0}else{1}) -and $report.ReadyRuleCredit -eq 0 -and $report.ConfigurationChanges -eq 0) "Public lifecycle $case exit/credit boundaries." + Assert ($report.ExitCode -eq $(if($case -in @('Match','DateTimeReceipt','Empty')){0}else{1}) -and $report.ReadyRuleCredit -eq 0 -and $report.ConfigurationChanges -eq 0) "Public lifecycle $case exit/credit boundaries." Assert (Test-Path -LiteralPath (Join-Path (Join-Path $temp $case) 'manifest.json')) "Failure/complete manifest retained for $case." } }finally{Remove-Item -LiteralPath $temp -Recurse -Force} diff --git a/tests/WefQuery.Windows.Tests.ps1 b/tests/WefQuery.Windows.Tests.ps1 index 3c1fdb95..f3d5c12a 100644 --- a/tests/WefQuery.Windows.Tests.ps1 +++ b/tests/WefQuery.Windows.Tests.ps1 @@ -1,10 +1,11 @@ -# Fixture-only owned account and temporary CAPI2 deny ACE. Product is read-only. +# Fixture-only owned account and temporary CAPI2 deny ACE. Product is read-only. param([switch]$AllowDisposableAccount) $ErrorActionPreference='Stop' if(-not $AllowDisposableAccount -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 Windows fixture required.'} $repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo Import-Module (Join-Path $repo 'modules/AuditProfiles.psm1') -ErrorAction Stop Import-Module (Join-Path $repo 'modules/WefSubscriptions.psm1') -ErrorAction Stop +Import-Module (Join-Path $repo 'modules/NativeProviders.psm1') -ErrorAction Stop foreach($name in @('Configuration','WefArrival','WecUpdate','ChannelRead','WefQuery')){. (Join-Path $repo ('scripts/'+$name+'.ps1'))} $hostState=Get-WelaWefQueryHost if($hostState.DomainJoined -or $hostState.DomainRole -ne 2 -or $hostState.ProductType -ne 3){throw 'Standalone disposable Server fixture required.'} From c18f971420e64cb4f54b13019546b89869c4c5a1 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:16:06 +0900 Subject: [PATCH 09/12] Avoid owned user profile creation and verify native fixture inventories --- docs/wef-query.md | 2 +- tests/WefQuery.Windows.Tests.ps1 | 16 +++++++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/wef-query.md b/docs/wef-query.md index b9efab22..5f945f2b 100644 --- a/docs/wef-query.md +++ b/docs/wef-query.md @@ -45,6 +45,6 @@ The worker's SID, logon, group attributes and privileges must match the caller a ## Native validation -The disposable Server 2022/2025 workflow runs both PowerShell engines through the public command. It selects an independently read real System record, verifies complete XML equality, suppresses that same record to obtain a genuine empty result, exercises malformed XPath and a mixed missing-channel query, and proves the event cap with an extra native record. An owned standard user and temporary CAPI2 deny ACE exercise actual access denial. The fixture independently restores the original channel descriptor and removes its owned account, then compares selected channels, services, all audit masks, precedence and the operator token. These temporary fixture changes are absent from the product. No domain setup, event generation or forwarding is claimed by this native suite. +The disposable Server 2022/2025 workflow runs both PowerShell engines through the public command. It selects an independently read real System record, verifies complete XML equality, suppresses that same record to obtain a genuine empty result, exercises malformed XPath and a mixed missing-channel query, and proves the event cap with an extra native record. An owned standard user and temporary CAPI2 deny ACE exercise actual access denial. The fixture independently restores the original channel descriptor and removes its owned account, then compares profile/loaded-hive inventories, selected channels, services, all audit masks, precedence and the operator token. The alternate-account process explicitly avoids loading a Windows user profile. These temporary fixture changes are absent from the product. No domain setup, event generation or forwarding is claimed by this native suite. Microsoft references: [EvtQuery](https://learn.microsoft.com/en-us/windows/win32/api/winevt/nf-winevt-evtquery), [query flags and partial XPath recovery](https://learn.microsoft.com/en-us/windows/win32/api/winevt/ne-winevt-evt_query_flags), [per-channel query information](https://learn.microsoft.com/en-us/windows/win32/api/winevt/nf-winevt-evtgetqueryinfo), [native property types](https://learn.microsoft.com/en-us/windows/win32/api/winevt/ne-winevt-evt_query_property_id), [EvtNext completeness and timeout](https://learn.microsoft.com/en-us/windows/win32/api/winevt/nf-winevt-evtnext), and [native event XML rendering](https://learn.microsoft.com/en-us/windows/win32/api/winevt/nf-winevt-evtrender). diff --git a/tests/WefQuery.Windows.Tests.ps1 b/tests/WefQuery.Windows.Tests.ps1 index f3d5c12a..bdc2c099 100644 --- a/tests/WefQuery.Windows.Tests.ps1 +++ b/tests/WefQuery.Windows.Tests.ps1 @@ -17,6 +17,11 @@ function Key($value){Get-WelaWefQueryKey $value} function Assert($value,[string]$message){if(-not $value){throw $message};$script:assertions++} function Save([string]$name,$value){[IO.File]::WriteAllText((Join-Path $root $name),(Key $value),[Text.UTF8Encoding]::new($false))} function Services {@(Get-CimInstance Win32_Service -Filter "Name='WinRM' OR Name='Wecsvc' OR Name='Winmgmt' OR Name='EventLog'"|Sort-Object Name|Select-Object Name,State,StartMode)} +function Profiles { + $base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64) + $key=$null;try{$key=$base.OpenSubKey('SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList',$false);if(-not $key){throw 'Profile inventory unavailable.'};@($key.GetSubKeyNames()|Sort-Object)}finally{if($key){$key.Dispose()};$base.Dispose()} +} +function Hives {@([Microsoft.Win32.Registry]::Users.GetSubKeyNames()|Sort-Object)} function NativeChannels {@('System','Security',$channel)|ForEach-Object {Get-WelaNativeChannel $_}} function New-Case([string]$name,[string]$query){ $inputDirectory=Join-Path $root ('input-'+$name);$null=New-Item -ItemType Directory $inputDirectory @@ -35,7 +40,7 @@ function Invoke-Public($case,[int]$expected,[int]$maximum=16,[switch]$AsUser){ $all=@('-NoLogo','-NoProfile','-NonInteractive','-File',(Join-Path $code 'WELA.ps1'),'wef-query','-WefQueryConfigPath',$case.Config,'-WefQuerySubscriptionId',$case.Id,'-WefQueryOutputPath',$output,'-WefQueryMaximumEvents',[string]$maximum) foreach($arg in $all){if($arg.Contains('"') -or $arg.EndsWith('\') -or $arg -match '[\x00-\x1f]'){throw 'Ambiguous fixture argument.'}} $start=[Diagnostics.ProcessStartInfo]::new();$start.FileName=$engine;$start.Arguments=(@($all|ForEach-Object {'"'+$_+'"'}) -join ' ');$start.UseShellExecute=$false;$start.CreateNoWindow=$true;$start.RedirectStandardOutput=$true;$start.RedirectStandardError=$true - if($AsUser){$start.UserName=$userName;$start.Domain=[Environment]::MachineName;$start.Password=$password;$start.LoadUserProfile=$true;$start.WorkingDirectory=$readerHome;$start.EnvironmentVariables['TEMP']=$readerHome;$start.EnvironmentVariables['TMP']=$readerHome} + if($AsUser){$start.UserName=$userName;$start.Domain=[Environment]::MachineName;$start.Password=$password;$start.LoadUserProfile=$false;$start.WorkingDirectory=$readerHome;$start.EnvironmentVariables['TEMP']=$readerHome;$start.EnvironmentVariables['TMP']=$readerHome} $process=[Diagnostics.Process]::new();$process.StartInfo=$start;$state=[pscustomobject]@{Started=$false;TerminationConfirmed=$false;Diagnostic=''} try{ if(-not $process.Start()){throw 'Public query command did not start.'};$state.Started=$true @@ -53,7 +58,7 @@ function Invoke-Public($case,[int]$expected,[int]$maximum=16,[switch]$AsUser){ if($AsUser){Assert ($manifest.ReaderBefore.Sid -ceq $ownedSid -and $manifest.ReaderBefore.Groups.Sid -notcontains 'S-1-5-32-544' -and $manifest.ReaderBefore.Groups.Sid -notcontains 'S-1-5-32-573') 'Actual owned standard-user token.'} $manifest } -$before=[pscustomobject]@{Host=$hostState;Services=(Services);Channels=(NativeChannels);Masks=(Get-WelaEffectiveAuditPolicy);Precedence=(Get-WelaRegistryState 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' SCENoApplyLegacyAuditPolicy);Token=(Get-WelaWefQueryToken)} +$before=[pscustomobject]@{Host=$hostState;Profiles=@(Profiles);Hives=@(Hives);Services=(Services);Channels=(NativeChannels);Masks=(Get-WelaEffectiveAuditPolicy);Precedence=(Get-WelaRegistryState 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' SCENoApplyLegacyAuditPolicy);Token=(Get-WelaWefQueryToken)} Save 'original.json' $before try{ $record=Get-WinEvent -LogName System -MaxEvents 1 -ErrorAction Stop @@ -96,10 +101,11 @@ try{ finally{ if($aclChanged){try{& wevtutil.exe sl $channel ('/ca:'+$channelBefore.SecurityDescriptor);if($LASTEXITCODE -ne 0){throw 'Original descriptor restore failed.'};$global:LASTEXITCODE=0}catch{$cleanupErrors+=$_.Exception.Message}} if($ownedSid){try{$current=Get-LocalUser -Name $userName -ErrorAction Stop;if($current.SID.Value -cne $ownedSid){throw 'Owned account changed identity.'};Remove-LocalUser -SID $ownedSid -ErrorAction Stop;if(Get-LocalUser -SID $ownedSid -ErrorAction SilentlyContinue){throw 'Owned account remains.'}}catch{$cleanupErrors+=$_.Exception.Message}} - $restored=$null;try{$restored=[pscustomobject]@{Host=(Get-WelaWefQueryHost);Services=(Services);Channels=(NativeChannels);Masks=(Get-WelaEffectiveAuditPolicy);Precedence=(Get-WelaRegistryState 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' SCENoApplyLegacyAuditPolicy);Token=(Get-WelaWefQueryToken)};Save 'restored.json' $restored}catch{$cleanupErrors+=$_.Exception.Message} + $restored=$null;try{$restored=[pscustomobject]@{Host=(Get-WelaWefQueryHost);Profiles=@(Profiles);Hives=@(Hives);Services=(Services);Channels=(NativeChannels);Masks=(Get-WelaEffectiveAuditPolicy);Precedence=(Get-WelaRegistryState 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' SCENoApplyLegacyAuditPolicy);Token=(Get-WelaWefQueryToken)};Save 'restored.json' $restored}catch{$cleanupErrors+=$_.Exception.Message} $channelsRestored=$restored -and (Key $restored.Channels) -ceq (Key $before.Channels);$servicesRestored=$restored -and (Key $restored.Services) -ceq (Key $before.Services);$policyRestored=$restored -and (Key $restored.Masks) -ceq (Key $before.Masks) -and (Key $restored.Precedence) -ceq (Key $before.Precedence);$tokenRestored=$restored -and (Get-WelaWefQueryTokenKey $restored.Token) -ceq (Get-WelaWefQueryTokenKey $before.Token) - if(-not $channelsRestored -or -not $servicesRestored -or -not $policyRestored -or -not $tokenRestored){$cleanupErrors+='Original channel/services/policy/token differ.'} - Save 'cleanup.json' ([pscustomobject]@{Passed=$passed;Assertions=$script:assertions;ChannelsRestored=[bool]$channelsRestored;ServicesRestored=[bool]$servicesRestored;PolicyRestored=[bool]$policyRestored;TokenRestored=[bool]$tokenRestored;AccountRemoved=[bool](-not $ownedSid -or -not(Get-LocalUser -SID $ownedSid -ErrorAction SilentlyContinue));Errors=$cleanupErrors;EventGeneration='Not tested';Forwarding='Not tested';Sources=(Get-WelaWefQuerySources)}) + $profilesRestored=$restored -and (Key $restored.Profiles) -ceq (Key $before.Profiles) -and (Key $restored.Hives) -ceq (Key $before.Hives) + if(-not $profilesRestored -or -not $channelsRestored -or -not $servicesRestored -or -not $policyRestored -or -not $tokenRestored){$cleanupErrors+='Original profile/hive/channel/services/policy/token differ.'} + Save 'cleanup.json' ([pscustomobject]@{Passed=$passed;Assertions=$script:assertions;ChannelsRestored=[bool]$channelsRestored;ServicesRestored=[bool]$servicesRestored;PolicyRestored=[bool]$policyRestored;TokenRestored=[bool]$tokenRestored;ProfilesAndHivesRestored=[bool]$profilesRestored;AccountRemoved=[bool](-not $ownedSid -or -not(Get-LocalUser -SID $ownedSid -ErrorAction SilentlyContinue));Errors=$cleanupErrors;EventGeneration='Not tested';Forwarding='Not tested';Sources=(Get-WelaWefQuerySources)}) if($cleanupErrors.Count){throw ('Fixture cleanup incomplete: '+($cleanupErrors -join '; '))} } Write-Host "WefQuery.Windows.Tests: $script:assertions actual native assertions passed; complete owned fixture cleanup." From 9692edcec6c5bf1231141688f0c2a9a40f4318ba Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:30:51 +0900 Subject: [PATCH 10/12] fix: normalize native WEF reader tokens before strict validation --- .gitattributes | 4 ++++ scripts/WefQuery.ps1 | 10 +++++++++- tests/WefQuery.Tests.ps1 | 9 +++++++++ tests/WefQuery.Windows.Tests.ps1 | 13 +++++++++---- 4 files changed, 31 insertions(+), 5 deletions(-) diff --git a/.gitattributes b/.gitattributes index 7f4524b7..ac5a1db3 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 + +# Native query receipts bind the same source bytes on every supported engine. +/scripts/WefQuery* text eol=lf +/tests/WefQuery* text eol=lf diff --git a/scripts/WefQuery.ps1 b/scripts/WefQuery.ps1 index 66ab9d61..9e071498 100644 --- a/scripts/WefQuery.ps1 +++ b/scripts/WefQuery.ps1 @@ -22,7 +22,15 @@ function Get-WelaWefQuerySources { } function Get-WelaWefQueryToken { Initialize-WelaWefQueryNative - [Wela.WefQueryToken.Native]::Snapshot() + ConvertTo-WelaWefQueryTokenObservation ([Wela.WefQueryToken.Native]::Snapshot()) +} +function ConvertTo-WelaWefQueryTokenObservation { + param($Token) + if($Token -isnot [Wela.WefQueryToken.Token]){throw 'Expected the native query token observation.'} + # Normalize native DTOs at the boundary, using the same strict shape as worker receipts. + $observed=ConvertFrom-WelaArrivalJson (Get-WelaWefQueryKey $Token) + $null=Get-WelaWefQueryTokenKey $observed + $observed } function Get-WelaWefQueryTokenKey { param($Token) diff --git a/tests/WefQuery.Tests.ps1 b/tests/WefQuery.Tests.ps1 index 54f1912c..79a9bbbf 100644 --- a/tests/WefQuery.Tests.ps1 +++ b/tests/WefQuery.Tests.ps1 @@ -7,6 +7,15 @@ function Assert($value,[string]$message){if(-not $value){throw $message};$script function Reject([scriptblock]$code,[string]$message){$caught=$false;try{& $code|Out-Null}catch{$caught=$true};Assert $caught $message} function Clone($value){ConvertFrom-WelaArrivalJson (Get-WelaWefQueryKey $value)} Initialize-WelaWefQueryNative +$nativeToken=[Wela.WefQueryToken.Token]::new();$nativeToken.Sid='S-1-5-21-1-2-3-1000';$nativeToken.Name='Host\reader';$nativeToken.AuthenticationId='0x123';$nativeToken.AuthenticationType='NTLM';$nativeToken.ImpersonationLevel='None';$nativeToken.TokenSource='Process' +$nativeGroup=[Wela.WefQueryToken.Group]::new();$nativeGroup.Sid='S-1-5-32-545';$nativeGroup.Attributes=[uint32]::MaxValue;$nativeToken.Groups=@($nativeGroup);$nativeToken.Privileges=@() +$observedToken=ConvertTo-WelaWefQueryTokenObservation $nativeToken +Assert ($observedToken -is [pscustomobject] -and $observedToken.Groups -is [array] -and $observedToken.Groups.Count -eq 1 -and $observedToken.Privileges -is [array] -and $observedToken.Privileges.Count -eq 0) 'Actual native DTO normalizes singleton groups and empty privileges for strict receipt validation.' +Assert ($observedToken.Groups[0].Attributes -eq [uint32]::MaxValue -and (Get-WelaWefQueryTokenKey $observedToken) -ceq (Get-WelaWefQueryTokenKey (Clone $nativeToken))) 'Native token normalization preserves every unsigned attribute and token comparison.' +$nativePrivilege=[Wela.WefQueryToken.Privilege]::new();$nativePrivilege.Luid='0x14';$nativePrivilege.Attributes=2;$nativeToken.Privileges=@($nativePrivilege) +Assert ((ConvertTo-WelaWefQueryTokenObservation $nativeToken).Privileges[0].Attributes -eq 2) 'Native privilege DTO normalizes without losing enabled attributes.' +$nativeToken.Sid='invalid';Reject {ConvertTo-WelaWefQueryTokenObservation $nativeToken} 'Invalid native token remains unverified.' +Reject {ConvertTo-WelaWefQueryTokenObservation $observedToken} 'Native boundary rejects a substituted arbitrary object.' $buffer=[Runtime.InteropServices.Marshal]::AllocHGlobal(128) try{ function Reset-Buffer([int]$type,[int]$count){for($i=0;$i -lt 128;$i++){[Runtime.InteropServices.Marshal]::WriteByte($buffer,$i,0)};[Runtime.InteropServices.Marshal]::WriteInt32($buffer,12,$type);[Runtime.InteropServices.Marshal]::WriteInt32($buffer,8,$count);[Runtime.InteropServices.Marshal]::WriteIntPtr($buffer,[IntPtr]::Add($buffer,16))} diff --git a/tests/WefQuery.Windows.Tests.ps1 b/tests/WefQuery.Windows.Tests.ps1 index bdc2c099..9d109e0c 100644 --- a/tests/WefQuery.Windows.Tests.ps1 +++ b/tests/WefQuery.Windows.Tests.ps1 @@ -102,10 +102,15 @@ finally{ if($aclChanged){try{& wevtutil.exe sl $channel ('/ca:'+$channelBefore.SecurityDescriptor);if($LASTEXITCODE -ne 0){throw 'Original descriptor restore failed.'};$global:LASTEXITCODE=0}catch{$cleanupErrors+=$_.Exception.Message}} if($ownedSid){try{$current=Get-LocalUser -Name $userName -ErrorAction Stop;if($current.SID.Value -cne $ownedSid){throw 'Owned account changed identity.'};Remove-LocalUser -SID $ownedSid -ErrorAction Stop;if(Get-LocalUser -SID $ownedSid -ErrorAction SilentlyContinue){throw 'Owned account remains.'}}catch{$cleanupErrors+=$_.Exception.Message}} $restored=$null;try{$restored=[pscustomobject]@{Host=(Get-WelaWefQueryHost);Profiles=@(Profiles);Hives=@(Hives);Services=(Services);Channels=(NativeChannels);Masks=(Get-WelaEffectiveAuditPolicy);Precedence=(Get-WelaRegistryState 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' SCENoApplyLegacyAuditPolicy);Token=(Get-WelaWefQueryToken)};Save 'restored.json' $restored}catch{$cleanupErrors+=$_.Exception.Message} - $channelsRestored=$restored -and (Key $restored.Channels) -ceq (Key $before.Channels);$servicesRestored=$restored -and (Key $restored.Services) -ceq (Key $before.Services);$policyRestored=$restored -and (Key $restored.Masks) -ceq (Key $before.Masks) -and (Key $restored.Precedence) -ceq (Key $before.Precedence);$tokenRestored=$restored -and (Get-WelaWefQueryTokenKey $restored.Token) -ceq (Get-WelaWefQueryTokenKey $before.Token) - $profilesRestored=$restored -and (Key $restored.Profiles) -ceq (Key $before.Profiles) -and (Key $restored.Hives) -ceq (Key $before.Hives) - if(-not $profilesRestored -or -not $channelsRestored -or -not $servicesRestored -or -not $policyRestored -or -not $tokenRestored){$cleanupErrors+='Original profile/hive/channel/services/policy/token differ.'} - Save 'cleanup.json' ([pscustomobject]@{Passed=$passed;Assertions=$script:assertions;ChannelsRestored=[bool]$channelsRestored;ServicesRestored=[bool]$servicesRestored;PolicyRestored=[bool]$policyRestored;TokenRestored=[bool]$tokenRestored;ProfilesAndHivesRestored=[bool]$profilesRestored;AccountRemoved=[bool](-not $ownedSid -or -not(Get-LocalUser -SID $ownedSid -ErrorAction SilentlyContinue));Errors=$cleanupErrors;EventGeneration='Not tested';Forwarding='Not tested';Sources=(Get-WelaWefQuerySources)}) + $channelsRestored=$false;$servicesRestored=$false;$policyRestored=$false;$tokenRestored=$false;$profilesRestored=$false;$accountRemoved=$false;$cleanupSources=$null + try{ + $channelsRestored=$restored -and (Key $restored.Channels) -ceq (Key $before.Channels);$servicesRestored=$restored -and (Key $restored.Services) -ceq (Key $before.Services);$policyRestored=$restored -and (Key $restored.Masks) -ceq (Key $before.Masks) -and (Key $restored.Precedence) -ceq (Key $before.Precedence);$tokenRestored=$restored -and (Get-WelaWefQueryTokenKey $restored.Token) -ceq (Get-WelaWefQueryTokenKey $before.Token) + $profilesRestored=$restored -and (Key $restored.Profiles) -ceq (Key $before.Profiles) -and (Key $restored.Hives) -ceq (Key $before.Hives) + }catch{$cleanupErrors+='Cleanup comparison: '+$_.Exception.Message} + try{$accountRemoved=-not $ownedSid -or -not(Get-LocalUser -SID $ownedSid -ErrorAction SilentlyContinue)}catch{$cleanupErrors+='Account observation: '+$_.Exception.Message} + try{$cleanupSources=Get-WelaWefQuerySources}catch{$cleanupErrors+='Source observation: '+$_.Exception.Message} + if(-not $profilesRestored -or -not $channelsRestored -or -not $servicesRestored -or -not $policyRestored -or -not $tokenRestored -or -not $accountRemoved){$cleanupErrors+='Original profile/hive/channel/services/policy/token or owned account differ.'} + Save 'cleanup.json' ([pscustomobject]@{Passed=$passed;Assertions=$script:assertions;ChannelsRestored=[bool]$channelsRestored;ServicesRestored=[bool]$servicesRestored;PolicyRestored=[bool]$policyRestored;TokenRestored=[bool]$tokenRestored;ProfilesAndHivesRestored=[bool]$profilesRestored;AccountRemoved=[bool]$accountRemoved;Errors=$cleanupErrors;EventGeneration='Not tested';Forwarding='Not tested';Sources=$cleanupSources}) if($cleanupErrors.Count){throw ('Fixture cleanup incomplete: '+($cleanupErrors -join '; '))} } Write-Host "WefQuery.Windows.Tests: $script:assertions actual native assertions passed; complete owned fixture cleanup." From c96ae4541c613c690b4bb8f2d90e7a52dfb1debe Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:33:44 +0900 Subject: [PATCH 11/12] test: retain precise native WEF XML boundary diagnostics --- scripts/WefQueryNative.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/WefQueryNative.cs b/scripts/WefQueryNative.cs index a5b934a4..067b1beb 100644 --- a/scripts/WefQueryNative.cs +++ b/scripts/WefQueryNative.cs @@ -66,7 +66,7 @@ namespace Wela.WefQuery { uint size=0;for(int attempt=0;attempt<4;attempt++){ IntPtr buffer=size==0?IntPtr.Zero:Marshal.AllocHGlobal((int)size); try{uint used,count;bool ok=EvtRender(IntPtr.Zero,value,1,size,buffer,out used,out count);int error=Marshal.GetLastWin32Error(); - if(ok){if(used<2||used>size||(used&1)!=0||count!=0||Marshal.ReadInt16(buffer,(int)used-2)!=0)throw new InvalidDataException("Native event XML has an invalid UTF16 boundary.");byte[] bytes=new byte[used-2];Marshal.Copy(buffer,bytes,0,bytes.Length);string xml=new UnicodeEncoding(false,false,true).GetString(bytes);if(xml.IndexOf('\0')>=0)throw new InvalidDataException("Embedded NUL in event XML.");return xml;} + if(ok){if(used<2||used>size||(used&1)!=0)throw new InvalidDataException("Native event XML byte boundary differs: used="+used+", allocated="+size+".");if(count!=0)throw new InvalidDataException("Native XML PropertyCount is "+count+", expected zero.");if(Marshal.ReadInt16(buffer,(int)used-2)!=0)throw new InvalidDataException("Native event XML lacks the final UTF16 terminator: used="+used+", allocated="+size+", finalWord="+Marshal.ReadInt16(buffer,(int)used-2)+".");byte[] bytes=new byte[used-2];Marshal.Copy(buffer,bytes,0,bytes.Length);string xml=new UnicodeEncoding(false,false,true).GetString(bytes);if(xml.IndexOf('\0')>=0)throw new InvalidDataException("Embedded NUL in event XML.");return xml;} if(error!=122)throw new Win32Exception(error);if(used<=size||used>MaximumBuffer)throw new InvalidDataException("Native event XML exceeds one MiB.");size=used; }finally{if(buffer!=IntPtr.Zero)Marshal.FreeHGlobal(buffer);} }throw new InvalidDataException("Native event XML buffer did not stabilize."); From 3fb0f8aa6cc4b5f249fc2fbeb23638ce6c27fff7 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:37:37 +0900 Subject: [PATCH 12/12] fix: decode native WEF XML independently of property count --- docs/wef-query.md | 2 +- scripts/WefQuery.ps1 | 3 ++- scripts/WefQueryNative.cs | 22 ++++++++++++++++------ tests/WefQuery.Tests.ps1 | 27 +++++++++++++++++++-------- tests/WefQuery.Windows.Tests.ps1 | 1 + 5 files changed, 39 insertions(+), 16 deletions(-) diff --git a/docs/wef-query.md b/docs/wef-query.md index 5f945f2b..07ef50b3 100644 --- a/docs/wef-query.md +++ b/docs/wef-query.md @@ -37,7 +37,7 @@ The fixed worker uses the same installed PowerShell engine and actual caller con The new output directory grants access to the current user, SYSTEM and local Administrators. Original inputs and parent ACLs are not changed. Paths must be ordinary local paths accepted by WELA's recovery artifact helpers; existing output directories and observed reparse paths are refused. Raw event payloads can contain sensitive operational data, so retain them as evidence under the intended reader's access policy. -Outputs include decoded `source-config.json`, `subscription.xml`, exact `query.xml`, the worker `request.json`, `worker.json`, individual `event-NNN.xml` matches and a final `manifest.json`. The manifest records original file paths/hashes, source fingerprints, query hash, actual host/DNS context, engine hash/version, before/after reader and channel observations, strict/diagnostic query results and artifact hashes. The original byte hashes are distinct from the decoded text artifacts. Unsuccessful runs retain whatever evidence was available; a missing final manifest means the output is incomplete. +Outputs include decoded `source-config.json`, `subscription.xml`, exact `query.xml`, the worker `request.json`, `worker.json`, individual `event-NNN.xml` matches and a final `manifest.json`. The manifest records original file paths/hashes, source fingerprints, query hash, actual host/DNS context, engine hash/version, before/after reader and channel observations, strict/diagnostic query results and artifact hashes. The worker retains each XML render’s native `PropertyCount` as information; the Server 2022/2025 validation runs observed 1 even though the API documentation specifies 0 for XML. XML parsing uses bounded UTF-16 byte length and its final terminator, following the string rendering contract, independently of that values-array count. The original byte hashes are distinct from the decoded text artifacts. Unsuccessful runs retain whatever evidence was available; a missing final manifest means the output is incomplete. The worker's SID, logon, group attributes and privileges must match the caller and remain stable. Host, input bytes, implementation, engine, channel configuration and saved hashes are rechecked before completeness. Returned event channel/record identity and exact observed local computer names must be consistent; no same-label arbitrary DNS suffix is accepted. These checks are observations rather than an atomic channel snapshot, and hashes establish consistency rather than authenticating an evidence author. diff --git a/scripts/WefQuery.ps1 b/scripts/WefQuery.ps1 index 9e071498..206e940d 100644 --- a/scripts/WefQuery.ps1 +++ b/scripts/WefQuery.ps1 @@ -123,9 +123,10 @@ function Start-WelaWefQueryWorker { } function Assert-WelaWefQueryNativeResult { param($Result,[string[]]$Channels,[int]$MaximumEvents) - Assert-WelaArrivalObject $Result @('Opened','Complete','Capped','CleanupConfirmed','NativeError','Diagnostic','Channels','DiagnosticChannels','DiagnosticNativeError','Events') + Assert-WelaArrivalObject $Result @('Opened','Complete','Capped','CleanupConfirmed','NativeError','Diagnostic','Channels','DiagnosticChannels','DiagnosticNativeError','Events','XmlPropertyCounts') foreach($name in @('Opened','Complete','Capped','CleanupConfirmed')){if($Result.$name -isnot [bool]){throw 'Mistyped native query outcome.'}} if($Result.Diagnostic -isnot [string] -or $Result.Events -isnot [array] -or $Result.Events.Count -gt $MaximumEvents){throw 'Invalid native query evidence count or diagnostic.'} + if($Result.XmlPropertyCounts -isnot [array] -or $Result.XmlPropertyCounts.Count -ne $Result.Events.Count){throw 'Native XML render observations do not match retained records.'};foreach($count in $Result.XmlPropertyCounts){Assert-WelaWefQueryUInt $count} foreach($name in @('NativeError','DiagnosticNativeError')){if($null -ne $Result.$name){Assert-WelaWefQueryUInt $Result.$name}} foreach($field in @('Channels','DiagnosticChannels')){ $entries=$Result.$field;if($entries -isnot [array] -or $entries.Count -gt 128){throw 'Invalid native query status list.'} diff --git a/scripts/WefQueryNative.cs b/scripts/WefQueryNative.cs index 067b1beb..4f3523b4 100644 --- a/scripts/WefQueryNative.cs +++ b/scripts/WefQueryNative.cs @@ -15,6 +15,7 @@ namespace Wela.WefQuery { public LogStatus[] Channels=new LogStatus[0], DiagnosticChannels=new LogStatus[0]; public uint? DiagnosticNativeError; public string[] Events=new string[0]; + public uint[] XmlPropertyCounts=new uint[0]; } public static class Native { public const string SourceSha256="__WELA_WEF_QUERY_SHA256__"; @@ -62,11 +63,20 @@ namespace Wela.WefQuery { if(names.Length!=codes.Length||names.Length==0)throw new InvalidDataException("Incomplete native query channel status arrays."); LogStatus[] result=new LogStatus[names.Length];for(int i=0;iMaximumBuffer||used<2||used>allocated||(used&1)!=0)throw new InvalidDataException("Native event XML byte boundary differs: used="+used+", allocated="+allocated+"."); + if(Marshal.ReadInt16(buffer,(int)used-2)!=0)throw new InvalidDataException("Native event XML lacks the final UTF16 terminator."); + byte[] bytes=new byte[used-2];Marshal.Copy(buffer,bytes,0,bytes.Length);string xml=new UnicodeEncoding(false,false,true).GetString(bytes); + if(xml.IndexOf('\0')>=0)throw new InvalidDataException("Embedded NUL in event XML.");return xml; + } + static string Render(IntPtr value,out uint propertyCount) { + propertyCount=0;uint size=0;for(int attempt=0;attempt<4;attempt++){ IntPtr buffer=size==0?IntPtr.Zero:Marshal.AllocHGlobal((int)size); try{uint used,count;bool ok=EvtRender(IntPtr.Zero,value,1,size,buffer,out used,out count);int error=Marshal.GetLastWin32Error(); - if(ok){if(used<2||used>size||(used&1)!=0)throw new InvalidDataException("Native event XML byte boundary differs: used="+used+", allocated="+size+".");if(count!=0)throw new InvalidDataException("Native XML PropertyCount is "+count+", expected zero.");if(Marshal.ReadInt16(buffer,(int)used-2)!=0)throw new InvalidDataException("Native event XML lacks the final UTF16 terminator: used="+used+", allocated="+size+", finalWord="+Marshal.ReadInt16(buffer,(int)used-2)+".");byte[] bytes=new byte[used-2];Marshal.Copy(buffer,bytes,0,bytes.Length);string xml=new UnicodeEncoding(false,false,true).GetString(bytes);if(xml.IndexOf('\0')>=0)throw new InvalidDataException("Embedded NUL in event XML.");return xml;} + // XML is a Unicode string, not an EVT_VARIANT array. Reviewed Server 2022/2025 runs returned + // PropertyCount=1 here despite the documented zero. Retain it as information; + // like .NET EventLogReader, never use it to size or interpret XML. + if(ok){propertyCount=count;return DecodeXml(buffer,size,used);} if(error!=122)throw new Win32Exception(error);if(used<=size||used>MaximumBuffer)throw new InvalidDataException("Native event XML exceeds one MiB.");size=used; }finally{if(buffer!=IntPtr.Zero)Marshal.FreeHGlobal(buffer);} }throw new InvalidDataException("Native event XML buffer did not stabilize."); @@ -74,7 +84,7 @@ namespace Wela.WefQuery { static void Close(IntPtr handle,Result result) {if(handle!=IntPtr.Zero&&!EvtClose(handle)){result.CleanupConfirmed=false;result.Complete=false;result.Diagnostic+=" Native query/event handle close failed.";}} public static Result Read(string query,int maximum) { if(String.IsNullOrEmpty(query)||query.Length>65536||maximum<1||maximum>64)throw new ArgumentException("Query text/event count exceeds the explicit bound."); - Result result=new Result();List events=new List();IntPtr handle=IntPtr.Zero; + Result result=new Result();List events=new List();List propertyCounts=new List();IntPtr handle=IntPtr.Zero; try{ // Local log query, reverse order. Never tolerate errors for matching evidence. handle=EvtQuery(IntPtr.Zero,null,query,0x201); @@ -91,12 +101,12 @@ namespace Wela.WefQuery { if(!ok){if(returned!=0||next[0]!=IntPtr.Zero)throw new InvalidDataException("Failed EvtNext returned an unexpected event.");if(error==259)result.Complete=true;else result.NativeError=unchecked((uint)error);break;} if(returned!=1||next[0]==IntPtr.Zero)throw new InvalidDataException("EvtNext returned an invalid count or handle."); if(events.Count==maximum){result.Capped=true;break;} - string xml=Render(next[0]);bytes+=Encoding.UTF8.GetByteCount(xml);if(bytes>4194304)throw new InvalidDataException("Native matching XML exceeds four MiB aggregate.");events.Add(xml); + uint propertyCount;string xml=Render(next[0],out propertyCount);bytes+=Encoding.UTF8.GetByteCount(xml);if(bytes>4194304)throw new InvalidDataException("Native matching XML exceeds four MiB aggregate.");events.Add(xml);propertyCounts.Add(propertyCount); }finally{Close(next[0],result);} } }catch(Win32Exception e){result.NativeError=unchecked((uint)e.NativeErrorCode);result.Complete=false;result.Diagnostic+=e.Message;} catch(Exception e){result.Complete=false;result.Diagnostic+=e.Message;} - finally{Close(handle,result);if(!result.CleanupConfirmed)result.Complete=false;result.Events=events.ToArray();} + finally{Close(handle,result);if(!result.CleanupConfirmed)result.Complete=false;result.Events=events.ToArray();result.XmlPropertyCounts=propertyCounts.ToArray();} return result; } public static async Task ReadPipe(TextReader reader,int maximum) { diff --git a/tests/WefQuery.Tests.ps1 b/tests/WefQuery.Tests.ps1 index 79a9bbbf..fcf38201 100644 --- a/tests/WefQuery.Tests.ps1 +++ b/tests/WefQuery.Tests.ps1 @@ -30,9 +30,20 @@ try{ Reject {[Wela.WefQuery.Native]::DecodeNames($buffer,44)} 'Unterminated names refuse.' [Runtime.InteropServices.Marshal]::WriteInt16($buffer,32,[int16]-10240);Reject {[Wela.WefQuery.Native]::DecodeNames($buffer,46)} 'Unpaired Unicode surrogate refuses.' foreach($used in @(0,15,1048577)){Reject {[Wela.WefQuery.Native]::DecodeNames($buffer,$used)} "Invalid buffer length $used"} + $rendered='日本語 Ω';$raw=[Text.Encoding]::Unicode.GetBytes($rendered+[char]0);[Runtime.InteropServices.Marshal]::Copy($raw,0,$buffer,$raw.Length) + Assert ([Wela.WefQuery.Native]::DecodeXml($buffer,128,$raw.Length) -ceq $rendered) 'Bounded native rendered XML preserves exact Unicode.' + foreach($used in @(0,1,3,130)){Reject {[Wela.WefQuery.Native]::DecodeXml($buffer,128,$used)} "Invalid rendered byte boundary $used"} + Reject {[Wela.WefQuery.Native]::DecodeXml([IntPtr]::Zero,128,$raw.Length)} 'Null render buffer refused.' + Reject {[Wela.WefQuery.Native]::DecodeXml($buffer,1048577,$raw.Length)} 'Render allocation cap enforced.' + Reject {[Wela.WefQuery.Native]::DecodeXml($buffer,128,$raw.Length-2)} 'Missing final XML terminator refused.' + [Runtime.InteropServices.Marshal]::WriteInt16($buffer,0,0);Reject {[Wela.WefQuery.Native]::DecodeXml($buffer,128,$raw.Length)} 'Embedded rendered XML NUL refused.' + [Runtime.InteropServices.Marshal]::WriteInt16($buffer,0,[int16]-10240);Reject {[Wela.WefQuery.Native]::DecodeXml($buffer,128,$raw.Length)} 'Invalid rendered UTF16 surrogate refused.' }finally{[Runtime.InteropServices.Marshal]::FreeHGlobal($buffer)} -$result=[pscustomobject]@{Opened=$true;Complete=$true;Capped=$false;CleanupConfirmed=$true;NativeError=$null;Diagnostic='';Channels=@([pscustomobject]@{Channel='System';Error=0});DiagnosticChannels=@();DiagnosticNativeError=$null;Events=@()} +$result=[pscustomobject]@{Opened=$true;Complete=$true;Capped=$false;CleanupConfirmed=$true;NativeError=$null;Diagnostic='';Channels=@([pscustomobject]@{Channel='System';Error=0});DiagnosticChannels=@();DiagnosticNativeError=$null;Events=@();XmlPropertyCounts=@()} Assert-WelaWefQueryNativeResult $result @('System') 16;Assert $true 'Complete empty strict result valid.' +$copy=Clone $result;$copy.Events=@('');$copy.XmlPropertyCounts=@(1);Assert-WelaWefQueryNativeResult $copy @('System') 16;Assert $true 'Observed XML PropertyCount=1 is informational, not a values-array requirement.' +$copy.XmlPropertyCounts=@();Reject {Assert-WelaWefQueryNativeResult $copy @('System') 16} 'Every retained XML has a corresponding render observation.' +$copy.XmlPropertyCounts=@($true);Reject {Assert-WelaWefQueryNativeResult $copy @('System') 16} 'Render observation must be an actual native unsigned count.' foreach($field in @('Opened','Complete','Capped','CleanupConfirmed')){$copy=Clone $result;$copy.$field='true';Reject {Assert-WelaWefQueryNativeResult $copy @('System') 16} "Typed Boolean $field"} foreach($field in @('NativeError','DiagnosticNativeError')){$copy=Clone $result;$copy.$field=$true;Reject {Assert-WelaWefQueryNativeResult $copy @('System') 16} "Typed native code $field"} $copy=Clone $result;$copy.Channels=@();Reject {Assert-WelaWefQueryNativeResult $copy @('System') 16} 'Missing native per-channel provenance.' @@ -41,9 +52,9 @@ $copy=Clone $result;$copy.Channels[0].Error=$true;Reject {Assert-WelaWefQueryNat foreach($field in @('Capped','Diagnostic','NativeError','CleanupConfirmed')){$copy=Clone $result;switch($field){Capped{$copy.Capped=$true};Diagnostic{$copy.Diagnostic='failure'};NativeError{$copy.NativeError=5};CleanupConfirmed{$copy.CleanupConfirmed=$false}};Reject {Assert-WelaWefQueryNativeResult $copy @('System') 16} "Completeness cannot coexist with $field"} $failure=Clone $result;$failure.Opened=$false;$failure.Complete=$false;$failure.NativeError=15001;$failure.Channels=@();$failure.DiagnosticChannels=@([pscustomobject]@{Channel='System';Error=15001}) Assert-WelaWefQueryNativeResult $failure @('System') 16;Assert $true 'Failed strict query retains separate diagnostic errors.' -$failure.Events=@('');Reject {Assert-WelaWefQueryNativeResult $failure @('System') 16} 'Diagnostic records cannot become matches.' -$copy=Clone $result;$copy.Events=@($true);Reject {Assert-WelaWefQueryNativeResult $copy @('System') 16} 'Typed XML required.' -$copy=Clone $result;$copy.Events=@('x','y');Reject {Assert-WelaWefQueryNativeResult $copy @('System') 1} 'Event bound enforced.' +$failure.Events=@('');$failure.XmlPropertyCounts=@(1);Reject {Assert-WelaWefQueryNativeResult $failure @('System') 16} 'Diagnostic records cannot become matches.' +$copy=Clone $result;$copy.Events=@($true);$copy.XmlPropertyCounts=@(1);Reject {Assert-WelaWefQueryNativeResult $copy @('System') 16} 'Typed XML required.' +$copy=Clone $result;$copy.Events=@('x','y');$copy.XmlPropertyCounts=@(1,1);Reject {Assert-WelaWefQueryNativeResult $copy @('System') 1} 'Event bound enforced.' $xml='142SystemHost.example.test日本語 Ω & value' $hostContext=[pscustomobject]@{Computer='Host';DnsHostName='Host';DnsSuffix='example.test'} $event=Read-WelaWefQueryEvent $xml @('System') $hostContext;Assert ($event.RecordId -eq 42 -and $event.Channel -ceq 'System') 'Native event selected channel/local host provenance.' @@ -75,12 +86,12 @@ function Get-WelaWefQueryToken {[pscustomobject]@{Sid='S-1-5-21-1-2-3-1001';Name function Get-WelaWefQueryChannelState {param($Channels) $script:lifecycle.ChannelReads++;[pscustomobject]@{Name='System';State=$(if($script:lifecycle.Case -eq 'ChannelDrift' -and $script:lifecycle.ChannelReads -gt 1){'Disabled'}else{'Enabled'})}} function Start-WelaWefQueryWorker { param($Engine,$RequestPath,$RequestHash) - $request=ConvertFrom-WelaArrivalJson ([IO.File]::ReadAllText($RequestPath));$result=[pscustomobject]@{Opened=$true;Complete=$true;Capped=$false;CleanupConfirmed=$true;NativeError=$null;Diagnostic='';Channels=@([pscustomobject]@{Channel='System';Error=0});DiagnosticChannels=@();DiagnosticNativeError=$null;Events=@($script:lifecycle.Xml)} - if($script:lifecycle.Case -eq 'Empty'){$result.Events=@()} + $request=ConvertFrom-WelaArrivalJson ([IO.File]::ReadAllText($RequestPath));$result=[pscustomobject]@{Opened=$true;Complete=$true;Capped=$false;CleanupConfirmed=$true;NativeError=$null;Diagnostic='';Channels=@([pscustomobject]@{Channel='System';Error=0});DiagnosticChannels=@();DiagnosticNativeError=$null;Events=@($script:lifecycle.Xml);XmlPropertyCounts=@(1)} + if($script:lifecycle.Case -eq 'Empty'){$result.Events=@();$result.XmlPropertyCounts=@()} if($script:lifecycle.Case -eq 'Partial'){$result.Complete=$false;$result.Capped=$true} if($script:lifecycle.Case -eq 'MissingStatus'){$result.Channels=@()} - if($script:lifecycle.Case -eq 'DuplicateEvents'){$result.Events=@($script:lifecycle.Xml,$script:lifecycle.Xml)} - if($script:lifecycle.Case -eq 'FailedQuery'){$result.Opened=$false;$result.Complete=$false;$result.NativeError=5;$result.Channels=@();$result.Events=@()} + if($script:lifecycle.Case -eq 'DuplicateEvents'){$result.Events=@($script:lifecycle.Xml,$script:lifecycle.Xml);$result.XmlPropertyCounts=@(1,1)} + if($script:lifecycle.Case -eq 'FailedQuery'){$result.Opened=$false;$result.Complete=$false;$result.NativeError=5;$result.Channels=@();$result.Events=@();$result.XmlPropertyCounts=@()} $receipt=[pscustomobject]@{SchemaVersion=1;Kind='WelaWefQueryWorker';Nonce=$request.Nonce;ProcessId=4242;Engine=$Engine;ModulePath=$Engine.ModulePath;StartedUtc='2026-01-01T00:00:00Z';CompletedUtc='2026-01-01T00:00:01Z';ReaderBefore=(Get-WelaWefQueryToken);ReaderAfter=(Get-WelaWefQueryToken);Host=$request.Host;Sources=$request.Sources;QuerySha256=$request.QuerySha256;Result=$result} if($script:lifecycle.Case -eq 'DateTimeReceipt'){$receipt.StartedUtc=[DateTime]::SpecifyKind([datetime]'2026-01-01T00:00:00',[DateTimeKind]::Utc);$receipt.CompletedUtc=$receipt.StartedUtc.AddSeconds(1)} if($script:lifecycle.Case -eq 'InvalidTimeReceipt'){$receipt.StartedUtc=$true} diff --git a/tests/WefQuery.Windows.Tests.ps1 b/tests/WefQuery.Windows.Tests.ps1 index 9d109e0c..936512c1 100644 --- a/tests/WefQuery.Windows.Tests.ps1 +++ b/tests/WefQuery.Windows.Tests.ps1 @@ -67,6 +67,7 @@ try{ $query='' $match=Invoke-Public (New-Case 'match' $query) 0 Assert ($match.Status -ceq 'MatchesObserved' -and $match.Matches.Count -eq 1 -and $match.Matches[0].Metadata.RecordId -eq $recordId) 'Actual exact System record selected.' + Assert-WelaWefQueryUInt $match.Query.XmlPropertyCounts[0];Assert ($match.Query.XmlPropertyCounts.Count -eq 1) 'Actual native XML PropertyCount is informational and retained.' $found=[IO.File]::ReadAllText((Join-Path $root 'result-match/event-001.xml')) Assert ((Get-WelaWefXmlKey (Read-WelaWefXml $found).DocumentElement) -ceq (Get-WelaWefXmlKey (Read-WelaWefXml $originalXml).DocumentElement)) 'Actual returned full event matches independent native XML.' $suppressed=$query.Replace('','*[System[EventRecordID='+$recordId+']]')