mirror of
https://github.com/Yamato-Security/WELA.git
synced 2026-10-02 20:44:42 +02:00
Integrate current dev with intended-reader EVTX verification
This commit is contained in:
commit
4598bb36f7
175 files changed
+11391
-70
No files matched your search
@@ -0,0 +1,79 @@
|
||||
// Read-only process-token observations. No privilege or authorization changes.
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Principal;
|
||||
namespace Wela.AppLockerScript {
|
||||
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, TokenId, ModifiedId, AuthenticationId, AuthenticationType, ImpersonationLevel, TokenSource;
|
||||
public Group[] Groups; public Privilege[] Privileges;
|
||||
}
|
||||
public static class Native {
|
||||
public const string SourceSha256="__WELA_SOURCE_SHA256__";
|
||||
[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(length<Marshal.SizeOf(typeof(Statistics)))throw new InvalidOperationException("Truncated token statistics.");Statistics stats=(Statistics)Marshal.PtrToStructure(p,typeof(Statistics));if(stats.Type!=1)throw new InvalidOperationException("A primary token is required.");result.AuthenticationId=Hex(stats.AuthenticationId);result.TokenId=Hex(stats.TokenId);result.ModifiedId=Hex(stats.Modified);}finally{Marshal.FreeHGlobal(p);}
|
||||
p=Read(token,2,out length);
|
||||
try {int count=Marshal.ReadInt32(p),offset=(int)Marshal.OffsetOf(typeof(TokenGroups),"First"),size=Marshal.SizeOf(typeof(SidAndAttributes));if(count<0||count>4096||offset+(long)count*size>length)throw new InvalidOperationException("Invalid token groups.");List<Group> groups=new List<Group>();for(int i=0;i<count;i++){SidAndAttributes g=(SidAndAttributes)Marshal.PtrToStructure(IntPtr.Add(p,offset+i*size),typeof(SidAndAttributes));groups.Add(new Group{Sid=new SecurityIdentifier(g.Sid).Value,Attributes=g.Attributes});}groups.Sort((a,b)=>String.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<Privilege> privileges=new List<Privilege>();for(int i=0;i<count;i++){LuidAndAttributes v=(LuidAndAttributes)Marshal.PtrToStructure(IntPtr.Add(p,4+i*size),typeof(LuidAndAttributes));privileges.Add(new Privilege{Luid=Hex(v.Luid),Attributes=v.Attributes});}privileges.Sort((a,b)=>String.CompareOrdinal(a.Luid,b.Luid));result.Privileges=privileges.ToArray();}finally{Marshal.FreeHGlobal(p);}
|
||||
return result;
|
||||
}
|
||||
public static Token Child(IntPtr handle) {
|
||||
IntPtr token=IntPtr.Zero;
|
||||
if(!OpenProcessToken(handle,8,out token))throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
try {if(IsTokenRestricted(token))throw new InvalidOperationException("Restricted child token unsupported.");return ReadToken(token,"ChildProcess");}finally{CloseHandle(token);}
|
||||
}
|
||||
[DllImport("advapi32.dll")] static extern bool IsTokenRestricted(IntPtr token);
|
||||
public static Token Snapshot() {
|
||||
IntPtr thread=IntPtr.Zero,process=IntPtr.Zero;
|
||||
if(OpenThreadToken(GetCurrentThread(),8,true,out thread)){CloseHandle(thread);throw new InvalidOperationException("Impersonated callers are unsupported.");}
|
||||
int error=Marshal.GetLastWin32Error();if(error!=1008)throw new Win32Exception(error);
|
||||
if(!OpenProcessToken(GetCurrentProcess(),8,out process))throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
try {if(IsTokenRestricted(process))throw new InvalidOperationException("Restricted caller unsupported.");return ReadToken(process,"Process");}finally{CloseHandle(process);}
|
||||
}
|
||||
[StructLayout(LayoutKind.Sequential)] struct FileInformation {
|
||||
public uint Attributes;public System.Runtime.InteropServices.ComTypes.FILETIME Creation,Access,Write;
|
||||
public uint Volume,SizeHigh,SizeLow,Links,IndexHigh,IndexLow;
|
||||
}
|
||||
[DllImport("kernel32.dll",SetLastError=true)] static extern bool GetFileInformationByHandle(IntPtr file,out FileInformation info);
|
||||
public static string FileId(IntPtr handle) {
|
||||
FileInformation info;if(!GetFileInformationByHandle(handle,out info))throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
if((info.Attributes&0x410)!=0)throw new InvalidOperationException("One ordinary non-reparse file identity is required.");
|
||||
return info.Volume.ToString("x8")+":"+info.IndexHigh.ToString("x8")+info.IndexLow.ToString("x8");
|
||||
}
|
||||
public static async System.Threading.Tasks.Task<string> ReadLineBoundedAsync(System.IO.StreamReader reader,int limit) {
|
||||
char[] buffer=new char[1];System.Text.StringBuilder text=new System.Text.StringBuilder();
|
||||
while(true){int count=await reader.ReadAsync(buffer,0,1).ConfigureAwait(false);if(count==0)throw new InvalidOperationException("Owned child ended before its ready marker.");if(buffer[0]=='\n')return text.ToString().TrimEnd('\r');if(text.Length>=limit)throw new InvalidOperationException("Owned child line exceeds the bound.");text.Append(buffer[0]);}
|
||||
}
|
||||
public static async System.Threading.Tasks.Task<string> ReadBoundedAsync(System.IO.StreamReader reader,int limit) {
|
||||
char[] buffer=new char[256];System.Text.StringBuilder text=new System.Text.StringBuilder();
|
||||
while(true){int count=await reader.ReadAsync(buffer,0,buffer.Length).ConfigureAwait(false);if(count==0)return text.ToString();if(text.Length+count>limit)throw new InvalidOperationException("Owned child output exceeds the bound.");text.Append(buffer,0,count);}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
# One fixed native Windows PowerShell script. Never prepares policy/services/channels.
|
||||
function Get-WelaAppLockerScriptKey { param($Value) ConvertTo-Json -InputObject $Value -Depth 30 -Compress }
|
||||
function Get-WelaAppLockerScriptSources {
|
||||
$sources=[ordered]@{}
|
||||
foreach($path in @('WELA.ps1','scripts/AppLockerScriptProbe.ps1','scripts/AppLockerScriptNative.cs','scripts/AppLockerScriptWorker.ps1','scripts/AppLockerReadiness.ps1','scripts/WefArrival.ps1')) {
|
||||
$sources[$path]=(Get-FileHash -LiteralPath (Join-Path $script:ScriptRoot $path) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()
|
||||
}
|
||||
[pscustomobject]$sources
|
||||
}
|
||||
function Initialize-WelaAppLockerScriptNative {
|
||||
if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'A native 64-bit Windows process is required.'}
|
||||
$bytes=[IO.File]::ReadAllBytes((Join-Path $script:ScriptRoot 'scripts/AppLockerScriptNative.cs'))
|
||||
$hash=Get-WelaArrivalHash $bytes
|
||||
if(-not ('Wela.AppLockerScript.Native' -as [type])) {
|
||||
$text=[Text.UTF8Encoding]::new($false,$true).GetString($bytes).TrimStart([char]0xfeff)
|
||||
if(([regex]::Matches($text,'__WELA_SOURCE_SHA256__')).Count -ne 1){throw 'Unexpected native source fingerprint placeholder.'}
|
||||
Add-Type -TypeDefinition $text.Replace('__WELA_SOURCE_SHA256__',$hash) -ErrorAction Stop
|
||||
}
|
||||
if([Wela.AppLockerScript.Native]::SourceSha256 -cne $hash){throw 'Loaded helper differs from current source; start a fresh PowerShell process.'}
|
||||
}
|
||||
function Get-WelaAppLockerScriptReader { [Wela.AppLockerScript.Native]::Snapshot() }
|
||||
function Get-WelaAppLockerScriptUtcNow { [Wela.AppLockerScript.Native]::UtcNow() }
|
||||
function Get-WelaAppLockerScriptExecutionPolicy {
|
||||
$values=[ordered]@{InheritedProcessValue=$env:PSExecutionPolicyPreference;Machine=@();User=@()}
|
||||
foreach($scope in @('Machine','User')) {
|
||||
$base=if($scope -eq 'Machine'){[Microsoft.Win32.Registry]::LocalMachine}else{[Microsoft.Win32.Registry]::CurrentUser}
|
||||
foreach($path in @('SOFTWARE\Policies\Microsoft\Windows\PowerShell','SOFTWARE\Microsoft\PowerShell\1\ShellIds\Microsoft.PowerShell')) {
|
||||
$key=$base.OpenSubKey($path,$false)
|
||||
try {
|
||||
foreach($name in @('EnableScripts','ExecutionPolicy')) {
|
||||
$present=$null -ne $key -and $name -cin @($key.GetValueNames())
|
||||
$values[$scope]+=[pscustomobject]@{Path=$path;Name=$name;Present=[bool]$present;Kind=$(if($present){[string]$key.GetValueKind($name)}else{$null});Value=$(if($present){$key.GetValue($name,$null,[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)}else{$null})}
|
||||
}
|
||||
}finally{if($key){$key.Dispose()}}
|
||||
}
|
||||
}
|
||||
[pscustomobject]$values
|
||||
}
|
||||
function Get-WelaAppLockerScriptServices {
|
||||
# Direct SCM observations precede every CIM connection; observation must not
|
||||
# implicitly start stopped WMI or AppLocker generation dependencies.
|
||||
$rows=@()
|
||||
foreach($name in @('Winmgmt','EventLog','AppIDSvc')) {
|
||||
$service=Get-Service -Name $name -ErrorAction Stop
|
||||
if($null -eq $service -or $service.Name -ine $name -or $service.Status -ne [ServiceProcess.ServiceControllerStatus]::Running){throw ($name+' must already be running; no CIM connection or child was started.')}
|
||||
$rows+=[pscustomobject]@{Name=$name;Status=[string]$service.Status}
|
||||
}
|
||||
$rows
|
||||
}
|
||||
function Get-WelaAppLockerScriptState {
|
||||
$services=@(Get-WelaAppLockerScriptServices)
|
||||
$hostState=Get-WelaAppLockerHost
|
||||
$computer=Get-CimInstance Win32_ComputerSystem -ErrorAction Stop
|
||||
$version=Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -ErrorAction Stop
|
||||
$machine=Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Cryptography' -Name MachineGuid -ErrorAction Stop
|
||||
$channel=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('Microsoft-Windows-AppLocker/MSI and Script')
|
||||
try {$log=[ordered]@{Name=$channel.LogName;Enabled=$channel.IsEnabled;SecurityDescriptor=$channel.SecurityDescriptor;MaximumSize=$channel.MaximumSizeInBytes;Mode=[string]$channel.LogMode;LogFilePath=$channel.LogFilePath}}finally{$channel.Dispose()}
|
||||
$source=Resolve-WelaArrivalPath (Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe')
|
||||
[pscustomobject][ordered]@{Services=$services;Host=$hostState;MachineGuid=$machine.MachineGuid;UBR=$version.UBR;Computer=[Environment]::MachineName;Domain=[string]$computer.Domain;LocalPolicy=(Get-WelaAppLockerPolicySnapshot Local);EffectivePolicy=(Get-WelaAppLockerPolicySnapshot Effective);Management=(Get-WelaAppLockerManagement);Service=(Get-WelaAppLockerService);Channel=$log;ExecutionPolicy=(Get-WelaAppLockerScriptExecutionPolicy);Source=$source;SourceHash=(Get-FileHash -LiteralPath $source -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()}
|
||||
}
|
||||
function Get-WelaAppLockerScriptStateKey {
|
||||
param($State)
|
||||
foreach($status in @($State.Host.Status,$State.LocalPolicy.Status,$State.EffectivePolicy.Status,$State.Service.Status,$State.Service.State,$State.Management.Status)){if($status -isnot [string]){throw 'Native context status must be a typed string.'}}
|
||||
$services=@($State.Services)
|
||||
if($services.Count -ne 3 -or @($services|Where-Object{$_.Name -isnot [string] -or $_.Status -isnot [string] -or $_.Status -cne 'Running'}).Count -or (($services.Name -join ',') -cne 'Winmgmt,EventLog,AppIDSvc')){throw 'Complete running-service preflight evidence is required.'}
|
||||
if($State.Host.Status -cne 'Candidate' -or $State.Host.Is64BitProcess -isnot [bool] -or -not $State.Host.Is64BitProcess -or $State.Host.ProductType -notin @(1,3) -or ($State.Host.ProductType -eq 1 -and $State.Host.Build -notin @(22000,22621,22631,26100,26200)) -or ($State.Host.ProductType -eq 3 -and $State.Host.Build -notin @(20348,26100))){throw 'A reviewed native Windows 11 or Server 2022/2025 member host is required; DCs are excluded.'}
|
||||
foreach($policy in @($State.LocalPolicy,$State.EffectivePolicy)){if($policy.Status -cne 'Observed' -or $policy.Policy.HasUnknownPolicyData -isnot [bool] -or $policy.Policy.HasUnknownPolicyData){throw 'Local and effective GP policy must be readable and understood.'}}
|
||||
$collection=@($State.EffectivePolicy.Policy.Collections|Where-Object Type -CEQ 'Script')
|
||||
if($collection.Count -ne 1 -or $collection[0].EnforcementMode -isnot [string] -or $collection[0].EnforcementMode -cne 'AuditOnly' -or ($collection[0].RuleCount -isnot [int] -and $collection[0].RuleCount -isnot [long]) -or $collection[0].RuleCount -lt 1){throw 'An existing nonempty effective Script AuditOnly collection is required.'}
|
||||
if($State.Service.Status -cne 'Observed' -or $State.Service.State -cne 'Running'){throw 'AppIDSvc must already be running.'}
|
||||
if($State.Channel.Enabled -isnot [bool] -or -not $State.Channel.Enabled -or $State.Channel.Name -cne 'Microsoft-Windows-AppLocker/MSI and Script' -or -not $State.Channel.SecurityDescriptor){throw 'The native MSI and Script channel must already be enabled and readable.'}
|
||||
if($State.Management.Status -cne 'Observed' -or $State.SourceHash -cnotmatch '^[a-f0-9]{64}$' -or -not $State.MachineGuid -or -not $State.Computer){throw 'Incomplete management, machine or source observation.'}
|
||||
Get-WelaAppLockerScriptKey $State
|
||||
}
|
||||
function Get-WelaAppLockerScriptAuthorizationKey {
|
||||
param($Token)
|
||||
if($Token.Sid -cnotmatch '^S-1-\d+(-\d+)+$' -or $Token.AuthenticationId -cnotmatch '^0x[0-9a-f]+$' -or $null -eq $Token.Groups -or $null -eq $Token.Privileges){throw 'Incomplete actual token evidence.'}
|
||||
Get-WelaAppLockerScriptKey ([ordered]@{Sid=$Token.Sid;AuthenticationId=$Token.AuthenticationId;Groups=$Token.Groups;Privileges=$Token.Privileges})
|
||||
}
|
||||
function New-WelaAppLockerScriptText {
|
||||
param([string]$Template,[string]$Nonce)
|
||||
if($Nonce -cnotmatch '^[a-f0-9]{32}$' -or ([regex]::Matches($Template,'__WELA_SCRIPT_NONCE__')).Count -ne 3){throw 'Unexpected fixed worker template or nonce.'}
|
||||
$Template.Replace('__WELA_SCRIPT_NONCE__',$Nonce)
|
||||
}
|
||||
function Read-WelaAppLockerScriptBoundary {
|
||||
$reader=$null;$record=$null
|
||||
try {
|
||||
$query=[Diagnostics.Eventing.Reader.EventLogQuery]::new('Microsoft-Windows-AppLocker/MSI and Script',[Diagnostics.Eventing.Reader.PathType]::LogName,'*');$query.ReverseDirection=$true;$query.TolerateQueryErrors=$false
|
||||
$reader=[Diagnostics.Eventing.Reader.EventLogReader]::new($query);$reader.BatchSize=1
|
||||
$record=$reader.ReadEvent([TimeSpan]::FromSeconds(5))
|
||||
$status=@($reader.LogStatus)
|
||||
if($status.Count -ne 1 -or $status[0].LogName -cne 'Microsoft-Windows-AppLocker/MSI and Script' -or $status[0].StatusCode -ne 0){throw 'Incomplete native channel query status.'}
|
||||
if($null -eq $record){return [long]0}
|
||||
if($record.LogName -cne $status[0].LogName -or $record.RecordId -le 0){throw 'Invalid native record boundary.'}
|
||||
[long]$record.RecordId
|
||||
}finally{if($record){$record.Dispose()};if($reader){$reader.Dispose()}}
|
||||
}
|
||||
function Start-WelaAppLockerScriptProcess {
|
||||
param([string]$Root,$State,$Reader,[string]$SourcesKey)
|
||||
$nonce=[guid]::NewGuid().ToString('N');$path=Join-Path $Root ('wela-script-'+$nonce+'.ps1')
|
||||
$template=[IO.File]::ReadAllText((Join-Path $script:ScriptRoot 'scripts/AppLockerScriptWorker.ps1'))
|
||||
$scriptBytes=[Text.UTF8Encoding]::new($false).GetBytes((New-WelaAppLockerScriptText $template $nonce))
|
||||
$artifact=Write-WelaArrivalArtifact $Root ([IO.Path]::GetFileName($path)) ([Text.UTF8Encoding]::new($false).GetString($scriptBytes))
|
||||
$source=$null;$scriptFile=$null;$process=$null;$started=$false;$stderr=$null
|
||||
try {
|
||||
$source=[IO.File]::Open($State.Source,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read)
|
||||
$scriptFile=[IO.File]::Open($path,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read)
|
||||
$sourceId=[Wela.AppLockerScript.Native]::FileId($source.SafeFileHandle.DangerousGetHandle());$scriptId=[Wela.AppLockerScript.Native]::FileId($scriptFile.SafeFileHandle.DangerousGetHandle())
|
||||
if((Get-FileHash -LiteralPath $State.Source -Algorithm SHA256).Hash.ToLowerInvariant() -cne $State.SourceHash -or (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() -cne $artifact.Sha256){throw 'The held native source or generated script differs before launch.'}
|
||||
if((Get-WelaAppLockerScriptKey (Get-WelaAppLockerScriptSources)) -cne $SourcesKey){throw 'Source changed before script launch.'}
|
||||
$readerKey=Get-WelaAppLockerScriptKey $Reader
|
||||
if((Get-WelaAppLockerScriptKey ((Get-WelaAppLockerScriptReader))) -cne $readerKey){throw 'Caller token changed before launch.'}
|
||||
$info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$State.Source;$info.Arguments='-NoLogo -NoProfile -NonInteractive -File "'+$path+'"';$info.UseShellExecute=$false;$info.CreateNoWindow=$true;$info.RedirectStandardInput=$true;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true;$info.WorkingDirectory=$Root
|
||||
$process=[Diagnostics.Process]::new();$process.StartInfo=$info
|
||||
$start=(Get-WelaAppLockerScriptUtcNow)
|
||||
$started=$process.Start();if(-not $started){throw 'The fixed script process did not start.'}
|
||||
$stderr=[Wela.AppLockerScript.Native]::ReadBoundedAsync($process.StandardError,4096)
|
||||
$ready=[Wela.AppLockerScript.Native]::ReadLineBoundedAsync($process.StandardOutput,256)
|
||||
if(-not $ready.Wait(30000)){throw 'The fixed script did not reach its ready marker within thirty seconds.'}
|
||||
$line=$ready.GetAwaiter().GetResult()
|
||||
if($line -cnotmatch ('^WELA_SCRIPT_READY_'+$nonce+'\|(FullLanguage|ConstrainedLanguage)\|5\.1\.[0-9.]+$')){throw ('Unexpected fixed script ready marker: '+$line)}
|
||||
$child=[Wela.AppLockerScript.Native]::Child($process.Handle)
|
||||
if((Get-WelaAppLockerScriptAuthorizationKey $child) -cne (Get-WelaAppLockerScriptAuthorizationKey $Reader)){throw 'The actual child primary/logon authorization differs from the caller.'}
|
||||
if((Get-WelaAppLockerScriptKey ((Get-WelaAppLockerScriptReader))) -cne $readerKey){throw 'Caller token changed while the fixed child started.'}
|
||||
$stdout=[Wela.AppLockerScript.Native]::ReadBoundedAsync($process.StandardOutput,4096)
|
||||
$process.StandardInput.WriteLine('WELA_SCRIPT_GO_'+$nonce);$process.StandardInput.Close()
|
||||
if(-not $process.WaitForExit(10000)){throw 'The fixed child did not complete within ten seconds of release.'}
|
||||
if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),5000)){throw 'The fixed child output did not complete within five seconds.'}
|
||||
$out=$stdout.GetAwaiter().GetResult();$err=$stderr.GetAwaiter().GetResult();$end=(Get-WelaAppLockerScriptUtcNow)
|
||||
if($process.ExitCode -ne 0 -or $out.TrimEnd("`r","`n") -cne ('WELA_SCRIPT_COMPLETE_'+$nonce) -or $err){throw ('The fixed script did not complete correctly. Exit='+$process.ExitCode+' Error='+$err)}
|
||||
if((Get-WelaAppLockerScriptKey ((Get-WelaAppLockerScriptReader))) -cne $readerKey -or [Wela.AppLockerScript.Native]::FileId($source.SafeFileHandle.DangerousGetHandle()) -cne $sourceId -or [Wela.AppLockerScript.Native]::FileId($scriptFile.SafeFileHandle.DangerousGetHandle()) -cne $scriptId){throw 'Reader or held file identity changed during script execution.'}
|
||||
[pscustomobject][ordered]@{ProcessId=$process.Id;UserSid=$Reader.Sid;ChildToken=$child;NativePowerShell=$State.Source;NativePowerShellSha256=$State.SourceHash;NativePowerShellFileId=$sourceId;ScriptPath=$path;ScriptSha256=$artifact.Sha256;ScriptFileId=$scriptId;ScriptArtifact=$artifact;Nonce=$nonce;Arguments=$info.Arguments;StartedUtc=$start.ToString('o');CompletedUtc=$end.ToString('o');Clock='GetSystemTimePreciseAsFileTime';Ready=$line;Marker=$out.TrimEnd("`r","`n");ExitCode=$process.ExitCode}
|
||||
}catch{
|
||||
$message=$_.Exception.Message
|
||||
if($stderr -and $stderr.Status -eq [Threading.Tasks.TaskStatus]::RanToCompletion){$message+=' Native stderr: '+$stderr.GetAwaiter().GetResult()}
|
||||
throw $message
|
||||
}finally{
|
||||
try{if($process){try{if($started -and -not $process.HasExited){$process.Kill();if(-not $process.WaitForExit(5000)){throw 'Owned script process termination is unconfirmed.'}}}finally{$process.Dispose()}}}
|
||||
finally{if($scriptFile){$scriptFile.Dispose()};if($source){$source.Dispose()}}
|
||||
}
|
||||
}
|
||||
function Read-WelaAppLockerScriptEvents {
|
||||
param([long]$Boundary)
|
||||
$query="*[System[Provider[@Name='Microsoft-Windows-AppLocker'] and (EventID=8005 or EventID=8006) and EventRecordID>$Boundary]]"
|
||||
$reader=$null;$record=$null;$xml=@();$bytes=0
|
||||
try {
|
||||
$nativeQuery=[Diagnostics.Eventing.Reader.EventLogQuery]::new('Microsoft-Windows-AppLocker/MSI and Script',[Diagnostics.Eventing.Reader.PathType]::LogName,$query);$nativeQuery.ReverseDirection=$false;$nativeQuery.TolerateQueryErrors=$false
|
||||
$reader=[Diagnostics.Eventing.Reader.EventLogReader]::new($nativeQuery);$reader.BatchSize=16
|
||||
while($null -ne ($record=$reader.ReadEvent([TimeSpan]::FromSeconds(2)))) {
|
||||
try{$text=$record.ToXml();$bytes+=[Text.Encoding]::UTF8.GetByteCount($text);if($xml.Count -ge 255 -or $bytes -gt 1048576){throw 'Native script query reached its 256-event/one-MiB cap.'};$xml+=$text}finally{$record.Dispose();$record=$null}
|
||||
}
|
||||
$status=@($reader.LogStatus);if($status.Count -ne 1 -or $status[0].LogName -cne 'Microsoft-Windows-AppLocker/MSI and Script' -or $status[0].StatusCode -ne 0){throw 'Incomplete native script query status.'}
|
||||
[pscustomobject]@{Xml=$xml;Query=$query;Bytes=$bytes;Complete=$true}
|
||||
}finally{if($record){$record.Dispose()};if($reader){$reader.Dispose()}}
|
||||
}
|
||||
|
||||
function Test-WelaAppLockerScriptEvent {
|
||||
param([string]$Xml,$Process,$State,[long]$Boundary)
|
||||
$reader=$null
|
||||
try {
|
||||
$settings=New-Object Xml.XmlReaderSettings;$settings.DtdProcessing=[Xml.DtdProcessing]::Prohibit;$settings.XmlResolver=$null;$settings.MaxCharactersInDocument=4194304
|
||||
$reader=[Xml.XmlReader]::Create([IO.StringReader]::new($Xml),$settings);$doc=New-Object Xml.XmlDocument;$doc.XmlResolver=$null;$doc.Load($reader)
|
||||
$ns=New-Object Xml.XmlNamespaceManager($doc.NameTable);$ns.AddNamespace('e','http://schemas.microsoft.com/win/2004/08/events/event');$ns.AddNamespace('a','http://schemas.microsoft.com/schemas/event/Microsoft.Windows/1.0.0.0')
|
||||
if ($doc.DocumentElement.LocalName -cne 'Event' -or $doc.DocumentElement.NamespaceURI -cne $ns.LookupNamespace('e') -or $doc.SelectNodes('/e:Event/e:System',$ns).Count -ne 1 -or $doc.SelectNodes('/e:Event/e:UserData/a:RuleAndFileData',$ns).Count -ne 1 -or $doc.SelectNodes('/e:Event/e:EventData',$ns).Count) {return $false}
|
||||
$system=@{};foreach ($name in @('Provider','EventID','Version','EventRecordID','Channel','Computer','TimeCreated')) {$nodes=$doc.SelectNodes("/e:Event/e:System/e:$name",$ns);if($nodes.Count -ne 1){return $false};$system[$name]=$nodes[0]}
|
||||
if ($system.Provider.GetAttribute('Name') -cne 'Microsoft-Windows-AppLocker' -or $system.Provider.GetAttribute('Guid').Trim('{}') -ine 'cbda4dbf-8d5d-4f69-9578-be14aa540d22' -or $system.EventID.InnerText -cnotin @('8005','8006') -or $system.Version.InnerText -cne '0' -or $system.EventRecordID.InnerText -notmatch '^[1-9][0-9]*$' -or $system.Channel.InnerText -cne 'Microsoft-Windows-AppLocker/MSI and Script') {return $false}
|
||||
$computers=@($State.Computer);if($State.Host.PartOfDomain){$computers+=$State.Computer+'.'+$State.Domain};if($system.Computer.InnerText -notin $computers){return $false}
|
||||
$time=ConvertTo-WelaArrivalUtc $system.TimeCreated.GetAttribute('SystemTime')
|
||||
if($time.UtcDateTime -lt ([DateTimeOffset]::Parse($Process.StartedUtc)).UtcDateTime -or $time.UtcDateTime -gt (ConvertTo-WelaArrivalUtc $Process.CompletedUtc).UtcDateTime -or [long]$system.EventRecordID.InnerText -le $Boundary){return $false}
|
||||
$data=@{};foreach($node in $doc.SelectSingleNode('/e:Event/e:UserData/a:RuleAndFileData',$ns).ChildNodes){if($node.NodeType -eq 'Whitespace'){continue};if($node.NodeType -ne 'Element' -or $node.NamespaceURI -cne $ns.LookupNamespace('a') -or $data.ContainsKey($node.LocalName) -or @($node.ChildNodes|Where-Object NodeType -eq Element).Count){return $false};$data[$node.LocalName]=$node.InnerText}
|
||||
if($data.PolicyName -cne 'SCRIPT' -or $data.TargetUser -cne $Process.UserSid -or $data.TargetProcessId -notmatch '^[1-9][0-9]*$' -or [long]$data.TargetProcessId -ne $Process.ProcessId){return $false}
|
||||
# AppLocker may render the exact Windows directory through this documented path variable.
|
||||
$eventPath=$data.FilePath
|
||||
if($eventPath -imatch '^%OSDRIVE%\\'){$eventPath=[IO.Path]::GetPathRoot($State.Source).TrimEnd('\')+$eventPath.Substring(9)}
|
||||
return $eventPath -ieq $Process.ScriptPath
|
||||
} catch {return $false} finally {if($reader){$reader.Dispose()}}
|
||||
}
|
||||
function Invoke-WelaAppLockerScriptProbe {
|
||||
param([ValidateSet('Plan','Run')][string]$Action='Plan',[string]$OutputPath,[ValidateRange(1,30)][int]$TimeoutSeconds=15)
|
||||
if(($Action -eq 'Run') -ne (-not [string]::IsNullOrWhiteSpace($OutputPath))){throw 'Run requires a new AppLockerScriptOutputPath; Plan does not write files.'}
|
||||
Initialize-WelaAppLockerScriptNative
|
||||
$sources=Get-WelaAppLockerScriptSources;$sourceKey=Get-WelaAppLockerScriptKey $sources
|
||||
$report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaAppLockerScriptProbe';Action=$Action;Status='Unverified';ExitCode=1;RecordedUtc=(Get-WelaAppLockerScriptUtcNow).ToString('o');Sources=$sources;Before=$null;After=$null;ReaderBefore=$null;ReaderAfter=$null;ReaderInterval='After output/context preparation, through child execution and actual event queries; final metadata is checked separately';Boundary=$null;Process=$null;EventId=$null;Decision=$null;Artifacts=@();Diagnostic='';OutputPath=$null;PolicyChanges=0;ReadyRuleCredit=0;CspPolicyState='Unknown';Scope='One fixed native Windows PowerShell5.1 Script-collection event. Other engines, collections, forwarding and Sigma/backend validation are not tested. Sysmon excluded.'}
|
||||
try {
|
||||
$before=Get-WelaAppLockerScriptState;$report.Before=$before;$key=Get-WelaAppLockerScriptStateKey $before
|
||||
if($Action -eq 'Plan'){$report.ReaderBefore=(Get-WelaAppLockerScriptReader);$report.Status='PrerequisitesObserved';$report.ExitCode=0;return $report}
|
||||
$report.OutputPath=New-WelaArrivalOutput -Path $OutputPath -SourcePath $script:ScriptRoot
|
||||
if((Get-WelaAppLockerScriptStateKey (Get-WelaAppLockerScriptState)) -cne $key){throw 'Context changed during output preparation.'}
|
||||
$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'before.json' ($before|ConvertTo-Json -Depth 24)
|
||||
$reader=(Get-WelaAppLockerScriptReader);$report.ReaderBefore=$reader;$readerKey=Get-WelaAppLockerScriptKey $reader
|
||||
$report.Boundary=Read-WelaAppLockerScriptBoundary
|
||||
if((Get-WelaAppLockerScriptKey ((Get-WelaAppLockerScriptReader))) -cne $readerKey){throw 'Reader changed during the actual boundary query.'}
|
||||
$process=Start-WelaAppLockerScriptProcess -Root $report.OutputPath -State $before -Reader $reader -SourcesKey $sourceKey;$report.Process=$process
|
||||
$report.Artifacts+= $process.ScriptArtifact
|
||||
$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'process.json' ($process|ConvertTo-Json -Depth 16)
|
||||
$timer=[Diagnostics.Stopwatch]::StartNew();$matches=@();$batch=$null
|
||||
do {
|
||||
if((Get-WelaAppLockerScriptKey ((Get-WelaAppLockerScriptReader))) -cne $readerKey){throw 'Reader changed before script event query.'}
|
||||
$batch=Read-WelaAppLockerScriptEvents $report.Boundary
|
||||
if($batch.Complete -isnot [bool] -or -not $batch.Complete){throw 'Script query completeness is unknown.'}
|
||||
if((Get-WelaAppLockerScriptKey ((Get-WelaAppLockerScriptReader))) -cne $readerKey){throw 'Reader changed during script event query.'}
|
||||
$matches=@($batch.Xml|Where-Object{Test-WelaAppLockerScriptEvent $_ $process $before $report.Boundary})
|
||||
if($matches.Count -gt 1){throw 'Multiple exact script records make the result ambiguous.'}
|
||||
if($matches.Count -eq 1){break}
|
||||
Start-Sleep -Milliseconds 250
|
||||
}while($timer.Elapsed.TotalSeconds -lt $TimeoutSeconds)
|
||||
$report.ReaderAfter=(Get-WelaAppLockerScriptReader)
|
||||
if((Get-WelaAppLockerScriptKey $report.ReaderAfter) -cne $readerKey){throw 'Reader changed before completion of the actual query interval.'}
|
||||
if($matches.Count -ne 1){
|
||||
# Retain only bounded candidates bearing the owned unique script name.
|
||||
$owned=@($batch.Xml|Where-Object{$_ -like ('*wela-script-'+$process.Nonce+'.ps1*')}|Select-Object -First 4)
|
||||
for($i=0;$i -lt $owned.Count;$i++){$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath ('candidate-'+$i+'.xml') $owned[$i]}
|
||||
throw 'No exact native Script8005/8006 event arrived within the timeout.'
|
||||
}
|
||||
$report.After=Get-WelaAppLockerScriptState
|
||||
if((Get-WelaAppLockerScriptStateKey $report.After) -cne $key -or (Get-WelaAppLockerScriptKey (Get-WelaAppLockerScriptSources)) -cne $sourceKey -or (Get-FileHash -LiteralPath $process.ScriptPath -Algorithm SHA256).Hash.ToLowerInvariant() -cne $process.ScriptSha256){throw 'Host, policy, service, channel, execution-policy observation or implementation changed.'}
|
||||
$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'after.json' ($report.After|ConvertTo-Json -Depth 24)
|
||||
$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'event.xml' $matches[0]
|
||||
$event=[xml]$matches[0];$report.EventId=[int]$event.Event.System.EventID
|
||||
$report.Decision=if($report.EventId -eq 8005){'Allowed'}else{'AllowedWouldBlockIfEnforced'}
|
||||
$report.Status='NativeScriptEventObserved';$report.ExitCode=0
|
||||
}catch{$report.Diagnostic=$_.Exception.Message}
|
||||
if($report.OutputPath){$json=$report|ConvertTo-Json -Depth 32;if([Text.Encoding]::UTF8.GetByteCount($json) -gt 2097152){throw 'The script probe report exceeded its two-MiB bound.'};$null=Write-WelaArrivalArtifact $report.OutputPath 'manifest.json' $json}
|
||||
$report
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
# Fixed locally generated script; no external inputs or configuration writes.
|
||||
$ErrorActionPreference = 'Stop'
|
||||
[Console]::Out.WriteLine(('WELA_SCRIPT_READY___WELA_SCRIPT_NONCE__|' + $ExecutionContext.SessionState.LanguageMode + '|' + $PSVersionTable.PSVersion))
|
||||
# .NET Framework's redirected-input writer may emit an encoding preamble.
|
||||
# Read the owned pipe through a BOM-aware reader, without changing console state.
|
||||
$pipeReader = [IO.StreamReader]::new([Console]::OpenStandardInput(), [Text.UTF8Encoding]::new($false, $true), $true, 128, $true)
|
||||
try { $release = $pipeReader.ReadLine() } finally { $pipeReader.Dispose() }
|
||||
if ($release -cne 'WELA_SCRIPT_GO___WELA_SCRIPT_NONCE__') { exit 17 }
|
||||
[Console]::Out.WriteLine('WELA_SCRIPT_COMPLETE___WELA_SCRIPT_NONCE__')
|
||||
exit 0
|
||||
@@ -0,0 +1,166 @@
|
||||
# Explicit fixed local CAPI2 source measurement. No channel, key-store or trust-policy writes.
|
||||
function Initialize-WelaCapi2ProbeNative {
|
||||
Initialize-WelaWmiProbeNative
|
||||
$source=Join-Path $PSScriptRoot 'Capi2ProbeNative.cs';$hash=(Get-FileHash -LiteralPath $source -Algorithm SHA256).Hash
|
||||
if(-not ('Wela.Capi2Probe.Native' -as [type])){Add-Type -Path $source -ErrorAction Stop;$script:WelaCapi2ProbeNativeHash=$hash}
|
||||
if($script:WelaCapi2ProbeNativeHash -cne $hash){throw 'Loaded CAPI2 helper differs from its source; start a fresh session.'}
|
||||
}
|
||||
function Get-WelaCapi2ProbeSources {
|
||||
$sources=[ordered]@{}
|
||||
foreach($name in @('WELA.ps1','scripts/Capi2Probe.ps1','scripts/Capi2ProbeWorker.ps1','scripts/Capi2ProbeNative.cs','scripts/WmiProbe.ps1','scripts/WmiProbeNative.cs','scripts/ChannelRead.ps1','scripts/WefArrival.ps1','modules/AuditProfiles.psm1','scripts/CustomAuditProfiles.ps1')){$sources[$name]=(Get-FileHash -LiteralPath (Join-Path $PSScriptRoot ('../'+$name)) -Algorithm SHA256).Hash.ToLowerInvariant()}
|
||||
$sources|ConvertTo-Json -Compress
|
||||
}
|
||||
function Get-WelaCapi2ProbeChannel {
|
||||
$channel=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('Microsoft-Windows-CAPI2/Operational')
|
||||
try{[pscustomobject][ordered]@{Name=$channel.LogName;Enabled=$channel.IsEnabled;SecurityDescriptor=$channel.SecurityDescriptor;MaximumSize=$channel.MaximumSizeInBytes;Mode=[string]$channel.LogMode;Type=[string]$channel.LogType;Provider=$channel.OwningProviderName}}finally{$channel.Dispose()}
|
||||
}
|
||||
function Get-WelaCapi2ProbeState {
|
||||
Initialize-WelaCapi2ProbeNative
|
||||
$services=@(Get-Service -Name Winmgmt,CryptSvc,EventLog -ErrorAction Stop|Sort-Object Name|ForEach-Object {[pscustomobject]@{Name=$_.Name;Status=[string]$_.Status}})
|
||||
if($services.Count -ne 3 -or @($services|Where-Object Status -ne 'Running').Count){throw 'Winmgmt, CryptSvc and EventLog must already be running; the probe starts no service.'}
|
||||
$token=[Wela.WmiProbe.Native]::Snapshot();$hostState=Get-WelaChannelReadHost
|
||||
$provider=[Diagnostics.Eventing.Reader.ProviderMetadata]::new('Microsoft-Windows-CAPI2')
|
||||
try{$event=@($provider.Events|Where-Object Id -eq 11);$metadata=[pscustomobject]@{Name=$provider.Name;Guid=$provider.Id.ToString();Event11Versions=@($event|ForEach-Object Version);LogNames=@($provider.LogLinks|ForEach-Object LogName|Sort-Object)}}finally{$provider.Dispose()}
|
||||
$engine=(Get-Process -Id $PID).Path
|
||||
$state=[pscustomobject][ordered]@{Computer=[Environment]::MachineName;Host=$hostState;Services=$services;Token=$token;Channel=(Get-WelaCapi2ProbeChannel);Provider=$metadata;Engine=$engine;EngineHash=(Get-FileHash -LiteralPath $engine -Algorithm SHA256).Hash.ToLowerInvariant();Sources=(Get-WelaCapi2ProbeSources)}
|
||||
if((Get-WelaWmiProbeTokenKey $token) -cne (Get-WelaWmiProbeTokenKey ([Wela.WmiProbe.Native]::Snapshot()))){throw 'Token changed during CAPI2 prerequisite observation.'}
|
||||
$state
|
||||
}
|
||||
function Get-WelaCapi2ProbeStateKey {
|
||||
param($State)
|
||||
if(@($State.Services).Count -ne 3 -or (@($State.Services.Name|Sort-Object) -join ',') -cne 'CryptSvc,EventLog,Winmgmt' -or @($State.Services|Where-Object Status -cne 'Running').Count){throw 'Required native services must already be running.'}
|
||||
if($State.Host.Build -notin @(20348,26100) -or $State.Host.ProductType -notin @(2,3) -or -not $State.Host.UBR -or $State.Host.Computer -cne $State.Computer){throw 'CAPI2 probe requires an observed Server 2022/2025 build and patch context.'}
|
||||
if($State.Channel.Enabled -isnot [bool] -or -not $State.Channel.Enabled -or $State.Channel.Name -cne 'Microsoft-Windows-CAPI2/Operational' -or $State.Channel.Type -cne 'Operational' -or $State.Channel.Provider -cne 'Microsoft-Windows-CAPI2' -or -not $State.Channel.SecurityDescriptor){throw 'CAPI2 Operational must already be enabled with an observed descriptor.'}
|
||||
if($State.Provider.Name -cne 'Microsoft-Windows-CAPI2' -or $State.Provider.Guid -ine '5bbca4a8-b209-48dc-a8c7-b23d3e5216fb' -or @($State.Provider.Event11Versions).Count -ne 1 -or $State.Provider.Event11Versions[0] -ne 0 -or $State.Channel.Name -cnotin $State.Provider.LogNames){throw 'Unreviewed CAPI2 provider or event 11 schema version.'}
|
||||
$null=Get-WelaWmiProbeTokenKey $State.Token
|
||||
$State|ConvertTo-Json -Depth 16 -Compress
|
||||
}
|
||||
function Get-WelaCapi2ProbeWatermark {
|
||||
$latest=Read-WelaChannelLatest 'Microsoft-Windows-CAPI2/Operational'
|
||||
if($latest.Status -eq 'ReadAllowedEmpty'){return [long]0}
|
||||
if($latest.Status -ne 'EventObserved'){throw ('CAPI2 is not readable: '+$latest.Status+' '+$latest.Diagnostic)}
|
||||
[long]$latest.Event.RecordId
|
||||
}
|
||||
function Assert-WelaCapi2ProbeCertificate {
|
||||
param($Operation,[string]$Nonce)
|
||||
if($Nonce -cnotmatch '^[a-f0-9]{32}$' -or $Operation.Nonce -cne $Nonce -or $Operation.KeyEphemeral -isnot [bool] -or -not $Operation.KeyEphemeral -or $Operation.CertificateDerBase64 -isnot [string] -or $Operation.CertificateDerBase64.Length -gt 12000){throw 'Unexpected generated certificate identity.'}
|
||||
$der=[Convert]::FromBase64String($Operation.CertificateDerBase64)
|
||||
if($der.Length -lt 128 -or $der.Length -gt 8192){throw 'Certificate DER exceeds its evidence bound.'}
|
||||
$certificate=[Security.Cryptography.X509Certificates.X509Certificate2]::new($der)
|
||||
try{
|
||||
if([Convert]::ToBase64String($certificate.RawData) -cne $Operation.CertificateDerBase64){throw 'Public certificate evidence must contain exactly one canonical DER object.'}
|
||||
if($certificate.Subject -cne ('CN=WelaCapi2Probe_'+$Nonce) -or $certificate.Issuer -cne $certificate.Subject -or $Operation.Subject -cne $certificate.Subject -or $Operation.Thumbprint -cne $certificate.Thumbprint -or $certificate.Extensions.Count -ne 0 -or $certificate.HasPrivateKey -or $certificate.SignatureAlgorithm.Value -cne '1.2.840.113549.1.1.11' -or $certificate.PublicKey.Oid.Value -cne '1.2.840.113549.1.1.1'){throw 'Certificate DER does not describe the fixed ephemeral self-signed probe.'}
|
||||
$rsa=[Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPublicKey($certificate)
|
||||
try{if($rsa.get_KeySize() -ne 2048){throw 'Unexpected probe RSA key size.'}}finally{$rsa.Dispose()}
|
||||
$start=ConvertTo-WelaArrivalUtc $Operation.StartedUtc;$end=ConvertTo-WelaArrivalUtc $Operation.CompletedUtc
|
||||
if($certificate.NotBefore.ToUniversalTime() -gt $start.UtcDateTime -or $certificate.NotAfter.ToUniversalTime() -lt $end.UtcDateTime -or ($certificate.NotAfter-$certificate.NotBefore).TotalMinutes -gt 11){throw 'Certificate validity does not cover the bounded operation.'}
|
||||
if($Operation.Chain.Flags -ne 2147492100 -or $Operation.Chain.ErrorStatus -ne 32 -or $Operation.Chain.Chains -ne 1 -or $Operation.Chain.Elements -ne 1){throw 'Expected one offline untrusted self-signed native chain.'}
|
||||
}finally{$certificate.Dispose()}
|
||||
,$der
|
||||
}
|
||||
function Start-WelaCapi2ProbeBuild {
|
||||
param($State)
|
||||
if((Get-WelaCapi2ProbeStateKey (Get-WelaCapi2ProbeState)) -cne (Get-WelaCapi2ProbeStateKey $State)){throw 'CAPI2 prerequisites changed before the operation.'}
|
||||
$watermark=Get-WelaCapi2ProbeWatermark;$nonce=[guid]::NewGuid().ToString('N')
|
||||
$worker=Join-Path $PSScriptRoot 'Capi2ProbeWorker.ps1'
|
||||
$info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$State.Engine;$info.Arguments='-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "'+$worker+'" -Nonce '+$nonce
|
||||
$info.UseShellExecute=$false;$info.CreateNoWindow=$true;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true
|
||||
$info.StandardOutputEncoding=[Text.UTF8Encoding]::new($false,$true);$info.StandardErrorEncoding=$info.StandardOutputEncoding
|
||||
$process=$null
|
||||
try{
|
||||
$launch=[DateTimeOffset][Wela.WmiProbe.Native]::UtcNow();$process=[Diagnostics.Process]::Start($info);$output=$process.StandardOutput.ReadToEndAsync();$errors=$process.StandardError.ReadToEndAsync()
|
||||
if(-not $process.WaitForExit(20000)){$process.Kill();$null=$process.WaitForExit(1000);throw 'Fixed CAPI2 worker exceeded twenty seconds.'}
|
||||
if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($output,$errors),1000)){throw 'Fixed worker output did not complete.'}
|
||||
if($output.Result.Length -gt 262144 -or $errors.Result.Length -gt 65536){throw 'Worker output exceeded its evidence bound.'}
|
||||
if($process.ExitCode -ne 0 -or $errors.Result){throw ('Fixed CAPI2 worker failed: '+$errors.Result)}
|
||||
$operation=ConvertFrom-WelaArrivalJson $output.Result
|
||||
if($operation.ProcessId -ne $process.Id -or $operation.ProcessName -ine [IO.Path]::GetFileName($State.Engine)){throw 'Worker process identity differs.'}
|
||||
$interval=Assert-WelaWmiProbeInterval $operation $launch ([DateTimeOffset][Wela.WmiProbe.Native]::UtcNow())
|
||||
$operation.StartedUtc=$interval.Start.UtcDateTime.ToString('o');$operation.CompletedUtc=$interval.End.UtcDateTime.ToString('o')
|
||||
$der=Assert-WelaCapi2ProbeCertificate $operation $nonce
|
||||
if((Get-WelaWmiProbeTokenKey $operation.BeforeToken) -cne (Get-WelaWmiProbeTokenKey $operation.AfterToken) -or (Get-WelaWmiProbeTokenKey $operation.BeforeToken -AuthorizationOnly) -cne (Get-WelaWmiProbeTokenKey $State.Token -AuthorizationOnly)){throw 'Worker token differs from caller or changed during operation.'}
|
||||
$operation|Add-Member NoteProperty RecordIdBefore $watermark
|
||||
$operation|Add-Member NoteProperty CertificateSha256 (Get-WelaArrivalHash $der)
|
||||
$operation
|
||||
}finally{if($process){try{if(-not $process.HasExited){$process.Kill();$null=$process.WaitForExit(1000)}}finally{$process.Dispose()}}}
|
||||
}
|
||||
function Read-WelaCapi2ProbeEvents {
|
||||
param($Operation)
|
||||
$query="*[System[Provider[@Name='Microsoft-Windows-CAPI2'] and EventID=11 and EventRecordID>$($Operation.RecordIdBefore) and Execution[@ProcessID='$($Operation.ProcessId)'] and TimeCreated[@SystemTime>='$($Operation.StartedUtc)' and @SystemTime<='$($Operation.CompletedUtc)']]]"
|
||||
$records=@();$xml=@()
|
||||
try{try{$records=@(Get-WinEvent -LogName 'Microsoft-Windows-CAPI2/Operational' -FilterXPath $query -MaxEvents 64 -ErrorAction Stop)}catch{if($_.FullyQualifiedErrorId -notlike 'NoMatchingEventsFound*'){throw}}
|
||||
foreach($record in $records){$text=[string]$record.ToXml();if($text.Length -gt 131072){throw 'CAPI2 event exceeds 128 KiB characters.'};$xml+=$text}
|
||||
[pscustomobject]@{Xml=$xml;Capped=($records.Count -ge 64);Query=$query;MaximumEvents=64}
|
||||
}finally{foreach($record in $records){$record.Dispose()}}
|
||||
}
|
||||
function Test-WelaCapi2XmlChildren {
|
||||
param($Node,[string[]]$Names)
|
||||
$children=@($Node.ChildNodes|Where-Object NodeType -eq Element)
|
||||
if($children.Count -ne $Names.Count -or @($Node.ChildNodes|Where-Object {$_.NodeType -notin @('Element','Whitespace')}).Count){return $false}
|
||||
foreach($name in $Names){if(@($children|Where-Object {$_.LocalName -ceq $name -and $_.NamespaceURI -ceq 'http://schemas.microsoft.com/win/2004/08/events/event'}).Count -ne 1){return $false}}
|
||||
$true
|
||||
}
|
||||
function Test-WelaCapi2ProbeEvent {
|
||||
param([string]$Xml,$Operation,$State)
|
||||
$reader=$null
|
||||
try{
|
||||
if($Xml.Length -gt 131072){return $false}
|
||||
$settings=[Xml.XmlReaderSettings]::new();$settings.DtdProcessing=[Xml.DtdProcessing]::Prohibit;$settings.XmlResolver=$null;$settings.MaxCharactersInDocument=131072
|
||||
$reader=[Xml.XmlReader]::Create([IO.StringReader]::new($Xml),$settings);$doc=[Xml.XmlDocument]::new();$doc.XmlResolver=$null;$doc.Load($reader)
|
||||
$ns=[Xml.XmlNamespaceManager]::new($doc.NameTable);$ns.AddNamespace('e','http://schemas.microsoft.com/win/2004/08/events/event')
|
||||
if($doc.DocumentElement.LocalName -cne 'Event' -or $doc.DocumentElement.NamespaceURI -cne $ns.LookupNamespace('e') -or $doc.SelectNodes('/e:Event/e:System',$ns).Count -ne 1 -or $doc.SelectNodes('/e:Event/e:UserData',$ns).Count -ne 1 -or $doc.SelectNodes('/e:Event/e:EventData',$ns).Count){return $false}
|
||||
$system=@{};foreach($name in @('Provider','EventID','Version','Level','Task','Opcode','Keywords','EventRecordID','Channel','Computer','TimeCreated','Execution','Security')){$nodes=$doc.SelectNodes("/e:Event/e:System/e:$name",$ns);if($nodes.Count -ne 1){return $false};$system[$name]=$nodes[0]}
|
||||
if($system.Provider.GetAttribute('Name') -cne 'Microsoft-Windows-CAPI2' -or $system.Provider.GetAttribute('Guid').Trim('{}') -ine '5bbca4a8-b209-48dc-a8c7-b23d3e5216fb' -or $system.EventID.InnerText -cne '11' -or $system.Version.InnerText -cne '0' -or $system.Level.InnerText -cne '2' -or $system.Task.InnerText -cne '11' -or $system.Opcode.InnerText -cne '2' -or $system.Keywords.InnerText -ine '0x4000000000000003' -or $system.Channel.InnerText -cne 'Microsoft-Windows-CAPI2/Operational' -or $system.EventRecordID.InnerText -cnotmatch '^[1-9][0-9]*$' -or [long]$system.EventRecordID.InnerText -le $Operation.RecordIdBefore){return $false}
|
||||
$computers=@($State.Computer);if($State.Host.DomainJoined){$computers+=$State.Computer+'.'+$State.Host.Domain}
|
||||
if($system.Computer.InnerText -notin $computers -or $system.Execution.GetAttribute('ProcessID') -cne [string]$Operation.ProcessId -or $system.Security.GetAttribute('UserID') -cne $Operation.BeforeToken.Sid){return $false}
|
||||
$time=ConvertTo-WelaArrivalUtc $system.TimeCreated.GetAttribute('SystemTime');if($time -lt (ConvertTo-WelaArrivalUtc $Operation.StartedUtc) -or $time -gt (ConvertTo-WelaArrivalUtc $Operation.CompletedUtc)){return $false}
|
||||
$data=$doc.SelectSingleNode('/e:Event/e:UserData',$ns)
|
||||
# Namespace and exact paths are pinned to native event 11, never a recursive name search.
|
||||
if(@($data.ChildNodes|Where-Object NodeType -eq Element).Count -ne 1){return $false}
|
||||
$chain=$data.SelectNodes('e:CertGetCertificateChain',$ns);if($chain.Count -ne 1){return $false};$chain=$chain[0]
|
||||
$names=@('Certificate','ExtendedKeyUsage','URLRetrievalTimeout','Flags','ChainEngineInfo','CertificateChain','EventAuxInfo','CorrelationAuxInfo','Result')
|
||||
if(-not(Test-WelaCapi2XmlChildren $chain $names)){return $false}
|
||||
$fields=@{};foreach($name in $names){$nodes=$chain.SelectNodes("e:$name",$ns);if($nodes.Count -ne 1){return $false};$fields[$name]=$nodes[0]}
|
||||
if($fields.ExtendedKeyUsage.HasChildNodes -or $fields.URLRetrievalTimeout.InnerText -cne 'PT1S' -or -not(Test-WelaCapi2XmlChildren $fields.CertificateChain @('TrustStatus','ChainElement'))){return $false}
|
||||
foreach($flag in @('CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL','CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY','CERT_CHAIN_DISABLE_AUTH_ROOT_AUTO_UPDATE','CERT_CHAIN_DISABLE_AIA')){if($fields.Flags.GetAttribute($flag) -cne 'true'){return $false}}
|
||||
if($fields.EventAuxInfo.HasAttribute('impersonateToken') -and $fields.EventAuxInfo.GetAttribute('impersonateToken') -cne $Operation.BeforeToken.Sid){return $false}
|
||||
$cert=$fields.Certificate
|
||||
if($cert.GetAttribute('fileRef') -cne ($Operation.Thumbprint+'.cer') -or $cert.GetAttribute('subjectName') -cne ('WelaCapi2Probe_'+$Operation.Nonce) -or $fields.Flags.GetAttribute('value') -ine '80002104' -or $fields.ChainEngineInfo.GetAttribute('context') -cne 'user' -or $fields.EventAuxInfo.GetAttribute('ProcessName') -ine $Operation.ProcessName -or $fields.Result.GetAttribute('value') -ine '800B0109'){return $false}
|
||||
$error=$fields.CertificateChain.SelectNodes('e:TrustStatus/e:ErrorStatus',$ns);$elements=$fields.CertificateChain.SelectNodes('e:ChainElement',$ns)
|
||||
if($error.Count -ne 1 -or $error[0].GetAttribute('value') -cne '20' -or $elements.Count -ne 1){return $false}
|
||||
$elementCert=$elements[0].SelectNodes('e:Certificate',$ns);$elementError=$elements[0].SelectNodes('e:TrustStatus/e:ErrorStatus',$ns)
|
||||
if($elementCert.Count -ne 1 -or $elementCert[0].GetAttribute('fileRef') -cne $cert.GetAttribute('fileRef') -or $elementCert[0].GetAttribute('subjectName') -cne $cert.GetAttribute('subjectName') -or $elementError.Count -ne 1 -or $elementError[0].GetAttribute('value') -cne '20'){return $false}
|
||||
if(-not(Test-WelaCapi2XmlChildren $elements[0] @('Certificate','SignatureAlgorithm','PublicKeyAlgorithm','TrustStatus','ApplicationUsage','IssuanceUsage'))){return $false}
|
||||
$signature=$elements[0].SelectSingleNode('e:SignatureAlgorithm',$ns);$publicKey=$elements[0].SelectSingleNode('e:PublicKeyAlgorithm',$ns)
|
||||
if($signature.GetAttribute('oid') -cne '1.2.840.113549.1.1.11' -or $signature.GetAttribute('hashName') -cne 'SHA256' -or $signature.GetAttribute('publicKeyName') -cne 'RSA' -or $publicKey.GetAttribute('oid') -cne '1.2.840.113549.1.1.1' -or $publicKey.GetAttribute('publicKeyLength') -cne '2048'){return $false}
|
||||
return $true
|
||||
}catch{return $false}finally{if($reader){$reader.Dispose()}}
|
||||
}
|
||||
function Invoke-WelaCapi2Probe {
|
||||
param([ValidateSet('Plan','Run')][string]$Action='Plan',[string]$OutputPath,[ValidateRange(1,30)][int]$TimeoutSeconds=15)
|
||||
if(($Action -eq 'Run') -ne (-not [string]::IsNullOrWhiteSpace($OutputPath))){throw 'Run requires a new Capi2ProbeOutputPath; Plan creates no files.'}
|
||||
$report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaOfflineCapi2ChainProbe';Action=$Action;Status='Unverified';ExitCode=1;Before=$null;After=$null;Operation=$null;Query=$null;Candidates=0;Matches=0;Artifacts=@();Diagnostic='';OutputPath=$null;ChannelChanges=0;StoreChanges=0;TrustPolicyChanges=0;ReadyRuleCredit=0;Scope='One fixed local ephemeral certificate-chain build and matching CAPI2 event 11 only. Untrusted self-signed outcome expected; no TLS, revocation, remote, forwarding, catalog event70 or Sigma/backend validation. Sysmon excluded.'}
|
||||
if($Action -eq 'Run'){$report.OutputPath=New-WelaArrivalOutput $OutputPath $PSScriptRoot}
|
||||
try{
|
||||
$before=Get-WelaCapi2ProbeState;$report.Before=$before;$key=Get-WelaCapi2ProbeStateKey $before;$null=Get-WelaCapi2ProbeWatermark
|
||||
if($Action -eq 'Plan'){$report.After=$before;$report.Status='PrerequisitesObserved';$report.ExitCode=0;return $report}
|
||||
$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'before.json' ($before|ConvertTo-Json -Depth 20)
|
||||
$operation=Start-WelaCapi2ProbeBuild $before;$report.Operation=$operation
|
||||
$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'operation.json' ($operation|ConvertTo-Json -Depth 16)
|
||||
$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'certificate.pem' ("-----BEGIN CERTIFICATE-----`n"+$operation.CertificateDerBase64+"`n-----END CERTIFICATE-----`n")
|
||||
$timer=[Diagnostics.Stopwatch]::StartNew();$matches=@()
|
||||
do{$batch=Read-WelaCapi2ProbeEvents $operation;$report.Query=$batch.Query;$report.Candidates=@($batch.Xml).Count
|
||||
if($batch.Capped -isnot [bool] -or $batch.Capped){throw 'The 64-event query cap was reached or completeness is unknown.'}
|
||||
$matches=@($batch.Xml|Where-Object {Test-WelaCapi2ProbeEvent $_ $operation $before});if($matches.Count){break};Start-Sleep -Milliseconds 250
|
||||
}while($timer.Elapsed.TotalSeconds -lt $TimeoutSeconds)
|
||||
$report.Matches=$matches.Count
|
||||
if($matches.Count -ne 1){$i=0;foreach($xml in @($batch.Xml|Select-Object -First 4)){$i++;$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath ('candidate-'+$i+'.xml') $xml};throw 'Expected exactly one matching CAPI2 event 11 in the fixed operation interval.'}
|
||||
$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'event.xml' $matches[0]
|
||||
if((Get-WelaCapi2ProbeWatermark) -lt $operation.RecordIdBefore){throw 'CAPI2 record boundary moved backwards; continuity is unknown.'}
|
||||
$after=Get-WelaCapi2ProbeState;$report.After=$after;if((Get-WelaCapi2ProbeStateKey $after) -cne $key){throw 'Host, token, provider, channel or implementation changed during collection.'}
|
||||
$report.Status='LocalChainEventObserved';$report.ExitCode=0
|
||||
}catch{$report.Diagnostic=$_.Exception.Message}
|
||||
finally{if($report.Before -and -not $report.After){try{$report.After=Get-WelaCapi2ProbeState}catch{$report.Diagnostic+=' Final observation failed: '+$_.Exception.Message}};if($report.OutputPath -and $report.After){$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'after.json' ($report.After|ConvertTo-Json -Depth 20)}}
|
||||
if($report.OutputPath){$null=Write-WelaArrivalArtifact $report.OutputPath 'manifest.json' ($report|ConvertTo-Json -Depth 24)}
|
||||
$report
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Fixed offline chain build. No certificate/key store or policy writes.
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography;
|
||||
namespace Wela.Capi2Probe {
|
||||
public sealed class ChainResult { public uint Flags, ErrorStatus, InfoStatus, Chains, Elements; }
|
||||
public static class Native {
|
||||
public static CngKey CreateEphemeralRsa() {
|
||||
CngKeyCreationParameters parameters=new CngKeyCreationParameters();
|
||||
parameters.Provider=CngProvider.MicrosoftSoftwareKeyStorageProvider;
|
||||
parameters.Parameters.Add(new CngProperty("Length",BitConverter.GetBytes(2048),CngPropertyOptions.None));
|
||||
// Literal null is essential: PowerShell converts a null string argument to empty.
|
||||
return CngKey.Create(CngAlgorithm.Rsa,null,parameters);
|
||||
}
|
||||
public const uint OfflineFlags=0x80002104; // cache-only URL/revocation, no AIA, no auth-root auto-update
|
||||
[StructLayout(LayoutKind.Sequential)] struct Usage { public uint Count; public IntPtr Oids; }
|
||||
[StructLayout(LayoutKind.Sequential)] struct Match { public uint Type; public Usage Usage; }
|
||||
[StructLayout(LayoutKind.Sequential)] struct Parameters {
|
||||
public uint Size; public Match RequestedUsage,RequestedIssuancePolicy;
|
||||
public uint UrlTimeout; public int CheckFreshness; public uint Freshness;
|
||||
public IntPtr CacheResync,StrongSign; public uint StrongFlags;
|
||||
}
|
||||
// Both CERT_CHAIN_CONTEXT and CERT_SIMPLE_CHAIN have this documented prefix.
|
||||
[StructLayout(LayoutKind.Sequential)] struct ChainPrefix { public uint Size,Error,Info,Count; public IntPtr Entries; }
|
||||
[DllImport("crypt32.dll",ExactSpelling=true,SetLastError=true)] static extern IntPtr CertCreateCertificateContext(uint encoding,byte[] encoded,uint length);
|
||||
[DllImport("crypt32.dll",ExactSpelling=true,SetLastError=true)] [return:MarshalAs(UnmanagedType.Bool)] static extern bool CertGetCertificateChain(IntPtr engine,IntPtr certificate,IntPtr time,IntPtr additionalStore,ref Parameters parameters,uint flags,IntPtr reserved,out IntPtr chain);
|
||||
[DllImport("crypt32.dll",ExactSpelling=true)] static extern void CertFreeCertificateChain(IntPtr chain);
|
||||
[DllImport("crypt32.dll",ExactSpelling=true)] [return:MarshalAs(UnmanagedType.Bool)] static extern bool CertFreeCertificateContext(IntPtr certificate);
|
||||
public static ChainResult Build(byte[] der) {
|
||||
if(IntPtr.Size!=8 || Marshal.SizeOf(typeof(Parameters))!=96 || Marshal.SizeOf(typeof(ChainPrefix))!=24)throw new InvalidOperationException("Unsupported native chain structure layout.");
|
||||
if(der==null || der.Length<128 || der.Length>8192)throw new ArgumentException("Certificate DER exceeds the fixed bound.");
|
||||
IntPtr certificate=CertCreateCertificateContext(1,der,(uint)der.Length),chain=IntPtr.Zero;
|
||||
if(certificate==IntPtr.Zero)throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
try {
|
||||
Parameters p=new Parameters();p.Size=(uint)Marshal.SizeOf(typeof(Parameters));p.UrlTimeout=1000;
|
||||
// No revocation-check request, additional store, custom trust engine, or caching of the end certificate.
|
||||
if(!CertGetCertificateChain(IntPtr.Zero,certificate,IntPtr.Zero,IntPtr.Zero,ref p,OfflineFlags,IntPtr.Zero,out chain))throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
if(chain==IntPtr.Zero)throw new InvalidOperationException("Native chain context is absent.");
|
||||
ChainPrefix c=(ChainPrefix)Marshal.PtrToStructure(chain,typeof(ChainPrefix));
|
||||
if(c.Size<24 || c.Count!=1 || c.Entries==IntPtr.Zero)throw new InvalidOperationException("Unexpected native chain shape.");
|
||||
IntPtr simple=Marshal.ReadIntPtr(c.Entries);if(simple==IntPtr.Zero)throw new InvalidOperationException("Native simple chain is absent.");
|
||||
ChainPrefix s=(ChainPrefix)Marshal.PtrToStructure(simple,typeof(ChainPrefix));
|
||||
if(s.Size<24 || s.Count!=1 || s.Entries==IntPtr.Zero || s.Error!=c.Error)throw new InvalidOperationException("Unexpected native simple chain shape.");
|
||||
return new ChainResult {Flags=OfflineFlags,ErrorStatus=c.Error,InfoStatus=c.Info,Chains=c.Count,Elements=s.Count};
|
||||
} finally {if(chain!=IntPtr.Zero)CertFreeCertificateChain(chain);CertFreeCertificateContext(certificate);}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
# Fixed local operation. The parent bounds this process to twenty seconds.
|
||||
param([Parameter(Mandatory)][ValidatePattern('^[a-f0-9]{32}$')][string]$Nonce)
|
||||
$ErrorActionPreference='Stop'
|
||||
[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)
|
||||
. (Join-Path $PSScriptRoot 'WmiProbe.ps1')
|
||||
. (Join-Path $PSScriptRoot 'Capi2Probe.ps1')
|
||||
Initialize-WelaCapi2ProbeNative
|
||||
$before=[Wela.WmiProbe.Native]::Snapshot()
|
||||
$key=$null;$rsa=$null;$certificate=$null
|
||||
try {
|
||||
$key=[Wela.Capi2Probe.Native]::CreateEphemeralRsa()
|
||||
if(-not $key.IsEphemeral -or $key.KeyName){throw 'The generated CNG key is not ephemeral.'}
|
||||
$rsa=[Security.Cryptography.RSACng]::new($key)
|
||||
$request=[Security.Cryptography.X509Certificates.CertificateRequest]::new(('CN=WelaCapi2Probe_'+$Nonce),$rsa,[Security.Cryptography.HashAlgorithmName]::SHA256,[Security.Cryptography.RSASignaturePadding]::Pkcs1)
|
||||
$now=[DateTimeOffset][Wela.WmiProbe.Native]::UtcNow()
|
||||
$generator=[Security.Cryptography.X509Certificates.X509SignatureGenerator]::CreateForRSA($rsa,[Security.Cryptography.RSASignaturePadding]::Pkcs1)
|
||||
$certificate=$request.Create($request.SubjectName,$generator,$now.AddMinutes(-5),$now.AddMinutes(5),[guid]::NewGuid().ToByteArray())
|
||||
if($certificate.HasPrivateKey){throw 'Only a public certificate is expected.'}
|
||||
$der=$certificate.Export([Security.Cryptography.X509Certificates.X509ContentType]::Cert)
|
||||
$started=[Wela.WmiProbe.Native]::UtcNow()
|
||||
$chain=[Wela.Capi2Probe.Native]::Build($der)
|
||||
$completed=[Wela.WmiProbe.Native]::UtcNow()
|
||||
$after=[Wela.WmiProbe.Native]::Snapshot()
|
||||
if((Get-WelaWmiProbeTokenKey $before) -cne (Get-WelaWmiProbeTokenKey $after)){throw 'Worker token changed during the chain build.'}
|
||||
[pscustomobject]@{Nonce=$Nonce;CertificateDerBase64=[Convert]::ToBase64String($der);Thumbprint=$certificate.Thumbprint;Subject=$certificate.Subject;KeyEphemeral=$key.IsEphemeral;ProcessId=$PID;ProcessName=[IO.Path]::GetFileName((Get-Process -Id $PID).Path);StartedUtc=$started.ToString('o');CompletedUtc=$completed.ToString('o');Clock='GetSystemTimePreciseAsFileTime';BeforeToken=$before;AfterToken=$after;Chain=$chain}|ConvertTo-Json -Depth 12 -Compress
|
||||
}finally{if($certificate){$certificate.Dispose()};if($rsa){$rsa.Dispose()};if($key){$key.Dispose()}}
|
||||
@@ -0,0 +1,187 @@
|
||||
# Restore one completed canonical channel-settings operation; never replay arbitrary arguments.
|
||||
function Get-WelaChannelRecoveryKey {param($Value) ConvertTo-Json -InputObject $Value -Depth 24 -Compress}
|
||||
function Assert-WelaChannelRecoveryText {param($Value,[string[]]$Names) foreach($name in $Names){if($Value.$name -isnot [string]){throw "Missing or mistyped channel recovery text: $name"}}}
|
||||
function Get-WelaChannelRecoveryDescriptorKey {
|
||||
param([string]$Sddl)
|
||||
if(-not $Sddl -or $Sddl.Length -gt 131072){throw 'A bounded full channel descriptor is required.'}
|
||||
$descriptor=[Security.AccessControl.RawSecurityDescriptor]::new($Sddl)
|
||||
$bytes=New-Object byte[] $descriptor.BinaryLength;$descriptor.GetBinaryForm($bytes,0)
|
||||
$round=[Security.AccessControl.RawSecurityDescriptor]::new($descriptor.GetSddlForm('All'))
|
||||
$other=New-Object byte[] $round.BinaryLength;$round.GetBinaryForm($other,0)
|
||||
if([Convert]::ToBase64String($bytes) -cne [Convert]::ToBase64String($other)){throw 'Channel descriptor cannot round-trip losslessly.'}
|
||||
[Convert]::ToBase64String($bytes)
|
||||
}
|
||||
function Get-WelaChannelRecoveryTuple {
|
||||
param($Value)
|
||||
[pscustomobject][ordered]@{IsEnabled=$Value.IsEnabled;MaximumSizeInBytes=$Value.MaximumSizeInBytes;LogMode=$Value.LogMode;SecurityDescriptor=$Value.SecurityDescriptor}
|
||||
}
|
||||
function Get-WelaChannelRecoveryTupleKey {
|
||||
param($Value)
|
||||
Get-WelaChannelRecoveryKey ([ordered]@{IsEnabled=$Value.IsEnabled;MaximumSizeInBytes=$Value.MaximumSizeInBytes;LogMode=$Value.LogMode;Descriptor=(Get-WelaChannelRecoveryDescriptorKey $Value.SecurityDescriptor)})
|
||||
}
|
||||
function Assert-WelaChannelRecoverySnapshot {
|
||||
param($Value,[string]$Channel)
|
||||
Assert-WelaArrivalObject $Value @('Name','State','IsEnabled','LogMode','SecurityDescriptor','MaximumSizeInBytes','ProviderNames','MetadataErrors','Error')
|
||||
Assert-WelaChannelRecoveryText $Value @('Name','State','LogMode','SecurityDescriptor')
|
||||
if($Value.Name -cne $Channel -or $Value.IsEnabled -isnot [bool] -or $Value.State -cne $(if($Value.IsEnabled){'Enabled'}else{'Disabled'}) -or
|
||||
($Value.MaximumSizeInBytes -isnot [int] -and $Value.MaximumSizeInBytes -isnot [long]) -or $Value.MaximumSizeInBytes -lt 1048576 -or $Value.MaximumSizeInBytes -gt 2199023255552 -or
|
||||
$Value.LogMode -cnotin @('Circular','Retain','AutoBackup') -or $null -ne $Value.Error -or $null -eq $Value.MetadataErrors -or @($Value.MetadataErrors.PSObject.Properties).Count -ne 0 -or ($Value.ProviderNames -isnot [array] -and $Value.ProviderNames -isnot [string]) -or @($Value.ProviderNames).Count -gt 64){throw 'Original channel snapshot is incomplete, mistyped or unsupported.'}
|
||||
foreach($name in $Value.ProviderNames){if($name -isnot [string] -or -not $name -or $name.Length -gt 512){throw 'Invalid original provider name.'}}
|
||||
$null=Get-WelaChannelRecoveryDescriptorKey $Value.SecurityDescriptor
|
||||
}
|
||||
function Get-WelaChannelRecoverySources {
|
||||
$sources=[ordered]@{}
|
||||
foreach($name in @('WELA.ps1','scripts/ChannelRecovery.ps1','scripts/NativeChannelConfiguration.ps1','modules/NativeChannelAccess.psm1','modules/NativeProviders.psm1','modules/EventLogSettings.psm1','config/native_channel_profile.json','scripts/Configuration.ps1','scripts/AuditRecovery.ps1','scripts/ControlApplicability.ps1','scripts/ChannelRead.ps1','scripts/ChannelReadNative.cs','scripts/WefArrival.ps1','scripts/WecUpdate.ps1','modules/AuditProfiles.psm1','scripts/CustomAuditProfiles.ps1')){
|
||||
$sources[$name]=(Get-FileHash -LiteralPath (Join-Path $script:ScriptRoot $name) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()
|
||||
}
|
||||
if([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT){foreach($path in @((Join-Path ([Environment]::SystemDirectory) 'wevtutil.exe'),[Diagnostics.Eventing.Reader.EventLogConfiguration].Assembly.Location)){$sources[$path]=(Get-FileHash -LiteralPath $path -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()}}
|
||||
Get-WelaChannelRecoveryKey $sources
|
||||
}
|
||||
function Get-WelaChannelRecoveryContext {
|
||||
foreach($name in @('EventLog','Winmgmt')){if((Get-Service -Name $name -ErrorAction Stop).Status -ne 'Running'){throw 'EventLog and Winmgmt must already be running; recovery starts no services.'}}
|
||||
$reader=Get-WelaChannelReader
|
||||
if(-not $reader.ElevatedAdministrator){throw 'The actual non-impersonated elevated administrator is required.'}
|
||||
[pscustomobject][ordered]@{Host=(Get-WelaRecoveryHost);ReviewedHost=(Get-WelaChannelReadHost);Reader=[ordered]@{Sid=$reader.UserSid;Logon=$reader.AuthenticationId;Groups=$reader.GroupSids};Engine=$PSVersionTable.PSVersion.ToString()}
|
||||
}
|
||||
function Read-WelaChannelRecoveryState {
|
||||
param([string]$Channel)
|
||||
$native=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new($Channel)
|
||||
try {
|
||||
if($native.LogName -cne $Channel -or [string]$native.LogType -cnotin @('Administrative','Operational')){throw 'An exact built-in administrative or operational channel is required.'}
|
||||
$guard=[ordered]@{}
|
||||
foreach($name in @('LogType','LogIsolation','LogFilePath','OwningProviderName','IsClassicLog','ProviderLevel','ProviderKeywords','ProviderBufferSize','ProviderMinimumNumberOfBuffers','ProviderMaximumNumberOfBuffers','ProviderLatency','ProviderControlGuid')){
|
||||
$value=$native.$name;$guard[$name]=if($null -eq $value){$null}else{[string]$value}
|
||||
}
|
||||
$tuple=[pscustomobject][ordered]@{IsEnabled=[bool]$native.IsEnabled;MaximumSizeInBytes=[long]$native.MaximumSizeInBytes;LogMode=[string]$native.LogMode;SecurityDescriptor=[string]$native.SecurityDescriptor}
|
||||
$null=Get-WelaChannelRecoveryDescriptorKey $tuple.SecurityDescriptor
|
||||
[pscustomobject][ordered]@{Channel=$native.LogName;Settings=$tuple;Guard=[pscustomobject]$guard}
|
||||
}finally{$native.Dispose()}
|
||||
}
|
||||
function Get-WelaChannelRecoveryDefinition {
|
||||
param([string]$JournalPath,[string]$OriginalResultsPath,[string]$Channel)
|
||||
$profile=Get-WelaNativeChannelProfile;$controls=@($profile.controls|Where-Object channel -ceq $Channel)
|
||||
if($controls.Count -ne 1){throw 'Select one exact channel from the bundled Microsoft WEF Appendix C profile.'};$control=$controls[0]
|
||||
$context=Get-WelaChannelRecoveryContext
|
||||
$journal=Read-WelaWecUpdateFile $JournalPath;$file=Read-WelaWecUpdateFile $OriginalResultsPath
|
||||
$entries=@($journal.Text -split '\r?\n'|Where-Object {$_ -match '\S'}|ForEach-Object {ConvertFrom-WelaArrivalJson $_})
|
||||
if($entries.Count -lt 1 -or $entries.Count -gt 1024){throw 'Expected 1-1024 bounded journal entries.'}
|
||||
$result=ConvertFrom-WelaArrivalJson $file.Text
|
||||
Assert-WelaChannelRecoveryText $result @('Scope','Action','ChannelProfile')
|
||||
if($result.Scope -cne 'native-channel-settings-only' -or $result.Action -cne 'Configure' -or $result.ChannelProfile -cne $profile.id -or $result.DryRun -isnot [bool] -or $result.DryRun -or $result.GrantEventLogReadersRequested -isnot [bool] -or $result.Results -isnot [array] -or $result.Results.Count -gt 64){throw 'Expected original non-dry-run public channel-settings Configure results.'}
|
||||
foreach($name in @('ExitCode','Failed','Skipped')){if(($result.$name -isnot [int] -and $result.$name -isnot [long]) -or $result.$name -lt 0){throw 'Original result counters must be nonnegative integers.'}}
|
||||
$id='NativeChannel/'+$Channel+'/Settings';$seen=@{}
|
||||
foreach($row in $result.Results){Assert-WelaChannelRecoveryText $row @('Id');if($seen.ContainsKey($row.Id)){throw 'Duplicate original result ID.'};$seen[$row.Id]=$true}
|
||||
$rows=@($result.Results|Where-Object Id -ceq $id);$matching=@($entries|Where-Object Id -ceq $id)
|
||||
if($rows.Count -ne 1 -or $matching.Count -ne 1){throw 'Exactly one completed result and its original journal entry are required.'}
|
||||
$row=$rows[0];$entry=$matching[0]
|
||||
Assert-WelaChannelRecoveryText $row @('Id','Kind','Status','Diagnostic');Assert-WelaChannelRecoveryText $entry @('ComputerName','Id','Kind')
|
||||
if($row.Kind -cne 'NativeChannel' -or $row.Status -cne 'Applied' -or $entry.Kind -cne 'NativeChannel' -or $entry.PSObject.Properties['Phase'] -or
|
||||
($entry.Version -isnot [int] -and $entry.Version -isnot [long]) -or $entry.Version -ne 1 -or $entry.ComputerName -ine $context.Host.Computer){throw 'Only one completed Applied native-channel operation on this named host is recoverable.'}
|
||||
if((ConvertTo-WelaArrivalUtc $entry.RecordedUtc) -gt [DateTimeOffset]::UtcNow.AddMinutes(1)){throw 'Future journal timestamp.'}
|
||||
foreach($field in @('Before','Desired','Target')){if((Get-WelaChannelRecoveryKey $entry.$field) -cne (Get-WelaChannelRecoveryKey $row.$field)){throw "Original journal/result $field differs."}}
|
||||
Assert-WelaArrivalObject $row.Target @('Channel','Profile');Assert-WelaChannelRecoveryText $row.Target @('Channel','Profile')
|
||||
if($row.Target.Channel -cne $Channel -or $row.Target.Profile -cne $profile.id){throw 'Original target does not match the canonical channel/profile.'}
|
||||
foreach($state in @($row.Before,$row.After)){Assert-WelaChannelRecoverySnapshot $state $Channel}
|
||||
$desired=$row.Desired;Assert-WelaArrivalObject $desired @('IsEnabled','SourceExampleBytes','RoundedMinimumBytes','MaximumSizeInBytes','LogMode','SecurityDescriptor','AccessChangeRequested')
|
||||
Assert-WelaChannelRecoveryText $desired @('LogMode','SecurityDescriptor')
|
||||
foreach($name in @('SourceExampleBytes','RoundedMinimumBytes','MaximumSizeInBytes')){if($desired.$name -isnot [int] -and $desired.$name -isnot [long]){throw 'Desired byte counts must be integers.'}}
|
||||
if($desired.IsEnabled -isnot [bool] -or $desired.AccessChangeRequested -isnot [bool]){throw 'Desired switches must be Booleans.'}
|
||||
$minimum=ConvertTo-WelaEventLogBytes $control.sourceExampleBytes
|
||||
$acl=$row.Before.SecurityDescriptor;$revoke=$false;$accessRequested=[bool]($result.GrantEventLogReadersRequested -and $control.readerSid)
|
||||
if($accessRequested){
|
||||
$access=Get-WelaChannelAccessPlan $acl
|
||||
if($access.State -cnotin @('GrantPresent','GrantRequired')){throw 'Original descriptor has no reviewed read-grant transformation.'}
|
||||
if($access.State -ceq 'GrantRequired'){$acl=$access.ProposedDescriptor;$revoke=$true}
|
||||
}
|
||||
$expected=[pscustomobject][ordered]@{IsEnabled=$(if($null -eq $control.enabled){$row.Before.IsEnabled}else{$control.enabled});MaximumSizeInBytes=[math]::Max([long]$row.Before.MaximumSizeInBytes,[long]$minimum);LogMode=$row.Before.LogMode;SecurityDescriptor=$acl}
|
||||
if($desired.SourceExampleBytes -ne $control.sourceExampleBytes -or $desired.RoundedMinimumBytes -ne $minimum -or $desired.AccessChangeRequested -ne $accessRequested -or
|
||||
(Get-WelaChannelRecoveryTupleKey $desired) -cne (Get-WelaChannelRecoveryTupleKey $expected) -or (Get-WelaChannelRecoveryTupleKey $row.After) -cne (Get-WelaChannelRecoveryTupleKey $expected) -or
|
||||
(Get-WelaChannelRecoveryKey $row.Before.ProviderNames) -cne (Get-WelaChannelRecoveryKey $row.After.ProviderNames)){throw 'Completed operation includes an unexplained change beyond canonical enable/size/read-grant settings.'}
|
||||
$recover=Get-WelaChannelRecoveryTuple $row.Before;$fields=@()
|
||||
if($recover.MaximumSizeInBytes -ne $expected.MaximumSizeInBytes){$fields+='MaximumSizeInBytes'}
|
||||
if((Get-WelaChannelRecoveryDescriptorKey $recover.SecurityDescriptor) -cne (Get-WelaChannelRecoveryDescriptorKey $expected.SecurityDescriptor)){$fields+='SecurityDescriptor'}
|
||||
if($recover.IsEnabled -ne $expected.IsEnabled){$fields+='IsEnabled'}
|
||||
if(-not $fields.Count){throw 'No completed channel setting change exists to recover.'}
|
||||
[pscustomobject][ordered]@{Channel=$Channel;Profile=$profile.id;Journal=[ordered]@{Path=$journal.Path;Hash=$journal.Hash};OriginalResults=[ordered]@{Path=$file.Path;Hash=$file.Hash};Expected=$expected;RecoverTo=$recover;Fields=$fields
|
||||
RequiresShrinkConsent=($recover.MaximumSizeInBytes -lt $expected.MaximumSizeInBytes);RequiresDisableConsent=($expected.IsEnabled -and -not $recover.IsEnabled);RequiresRevokeConsent=$revoke
|
||||
HistoricalIdentity='Version1 journals bind historical ComputerName only. Current identity/source guards do not authenticate historical ownership. Recovery requires unchanged recorded post-state; no unrelated ACE is removed.'}
|
||||
}
|
||||
function Assert-WelaChannelRecoveryCurrent {
|
||||
param($Definition,$Observed,$Expected,$Guard)
|
||||
if($Observed.Channel -cne $Definition.Channel -or (Get-WelaChannelRecoveryTupleKey $Observed.Settings) -cne (Get-WelaChannelRecoveryTupleKey $Expected) -or
|
||||
($null -ne $Guard -and (Get-WelaChannelRecoveryKey $Observed.Guard) -cne (Get-WelaChannelRecoveryKey $Guard))){throw 'Current channel settings, descriptor or preserved metadata differ from the reviewed state.'}
|
||||
}
|
||||
function Set-WelaChannelRecoveryField {
|
||||
param($Definition,[string]$Field)
|
||||
$argument=switch -CaseSensitive ($Field){
|
||||
'MaximumSizeInBytes' {'/ms:'+$Definition.RecoverTo.MaximumSizeInBytes}
|
||||
'SecurityDescriptor' {'/ca:'+$Definition.RecoverTo.SecurityDescriptor}
|
||||
'IsEnabled' {'/e:'+$Definition.RecoverTo.IsEnabled.ToString().ToLowerInvariant()}
|
||||
default {throw 'Unsupported channel recovery field.'}
|
||||
}
|
||||
$null=Invoke-WelaNative -FilePath (Join-Path ([Environment]::SystemDirectory) 'wevtutil.exe') -Arguments @('sl',$Definition.Channel,$argument)
|
||||
}
|
||||
function Invoke-WelaChannelRecovery {
|
||||
param([ValidateSet('Plan','Restore')][string]$Action='Plan',[string]$JournalPath,[string]$OriginalResultsPath,[string]$Channel,[string]$PlanPath,[string]$PlanHash,[Parameter(Mandatory)][string]$OutputPath,[switch]$AllowShrink,[switch]$AllowDisable,[switch]$AllowRevoke)
|
||||
$ErrorActionPreference='Stop'
|
||||
if($Action -eq 'Plan'){
|
||||
if(-not $JournalPath -or -not $OriginalResultsPath -or -not $Channel -or $PlanPath -or $PlanHash -or $AllowShrink -or $AllowDisable -or $AllowRevoke){throw 'Plan requires original journal/results, exact channel and new output only.'}
|
||||
$source=Read-WelaWecUpdateFile $JournalPath
|
||||
}else{
|
||||
if(-not $PlanPath -or $PlanHash -cnotmatch '^[a-f0-9]{64}$' -or $JournalPath -or $OriginalResultsPath -or $Channel){throw 'Restore requires reviewed plan/hash, new output and applicable explicit consent only.'}
|
||||
$source=Read-WelaWecUpdateFile $PlanPath
|
||||
}
|
||||
$output=New-WelaArrivalOutput $OutputPath $source.Path
|
||||
$report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaChannelRecovery';Action=$Action;Status='Refused';ExitCode=1;OutputPath=$output;PlanHash=$null;NativeWriteAttempted=$false;ConfirmedFields=@();After=$null;Artifacts=@();Diagnostic='';ReadyRuleCredit=0;Scope='One completed channel-settings operation. Shrink can discard events; disable stops generation; read-grant removal can interrupt readers. No automatic rollback, event/retention/forwarding proof or Sigma credit. Sysmon excluded.'}
|
||||
try {
|
||||
$context=Get-WelaChannelRecoveryContext;$contextKey=Get-WelaChannelRecoveryKey $context;$sources=Get-WelaChannelRecoverySources
|
||||
if($Action -eq 'Plan'){
|
||||
$definition=Get-WelaChannelRecoveryDefinition $JournalPath $OriginalResultsPath $Channel
|
||||
$observed=Read-WelaChannelRecoveryState $Channel;Assert-WelaChannelRecoveryCurrent $definition $observed $definition.Expected $null
|
||||
$plan=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaChannelRecoveryPlan';Definition=$definition;ContextKey=$contextKey;Sources=$sources;Guard=$observed.Guard}
|
||||
}else{
|
||||
if($source.Hash -cne $PlanHash){throw 'Reviewed plan hash differs.'}
|
||||
$plan=ConvertFrom-WelaArrivalJson $source.Text;Assert-WelaArrivalObject $plan @('SchemaVersion','Kind','Definition','ContextKey','Sources','Guard');Assert-WelaChannelRecoveryText $plan @('Kind','ContextKey','Sources')
|
||||
if(($plan.SchemaVersion -isnot [int] -and $plan.SchemaVersion -isnot [long]) -or $plan.SchemaVersion -ne 1 -or $plan.Kind -cne 'WelaChannelRecoveryPlan' -or $plan.ContextKey -cne $contextKey -or $plan.Sources -cne $sources){throw 'Reviewed plan schema, context or sources differ.'}
|
||||
$definition=Get-WelaChannelRecoveryDefinition $plan.Definition.Journal.Path $plan.Definition.OriginalResults.Path $plan.Definition.Channel
|
||||
if((Get-WelaChannelRecoveryKey $definition) -cne (Get-WelaChannelRecoveryKey $plan.Definition)){throw 'Plan differs from independently rebuilt original evidence.'}
|
||||
if($definition.RequiresShrinkConsent -and -not $AllowShrink){throw 'Explicit AllowShrink is required; shrinking can discard events.'}
|
||||
if($definition.RequiresDisableConsent -and -not $AllowDisable){throw 'Explicit AllowDisable is required; disabling stops channel generation.'}
|
||||
if($definition.RequiresRevokeConsent -and -not $AllowRevoke){throw 'Explicit AllowRevoke is required; removing the added read ACE can interrupt readers.'}
|
||||
}
|
||||
if((Get-WelaChannelRecoveryKey (Get-WelaChannelRecoveryDefinition $definition.Journal.Path $definition.OriginalResults.Path $definition.Channel)) -cne (Get-WelaChannelRecoveryKey $definition) -or (Get-WelaChannelRecoveryKey (Get-WelaChannelRecoveryContext)) -cne $contextKey -or (Get-WelaChannelRecoverySources) -cne $sources){throw 'Original evidence, actual host/operator or source changed.'}
|
||||
Assert-WelaChannelRecoveryCurrent $definition (Read-WelaChannelRecoveryState $definition.Channel) $definition.Expected $plan.Guard
|
||||
if($Action -eq 'Plan'){
|
||||
$artifact=Write-WelaWecUpdateArtifact $output 'plan.json' ($plan|ConvertTo-Json -Depth 24);$report.Artifacts+=$artifact;$report.PlanHash=$artifact.Sha256;$report.Status='ReviewRequired';$report.ExitCode=0
|
||||
}else{
|
||||
$report.PlanHash=$PlanHash;$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'reviewed-plan.json' $source.Text
|
||||
$expected=Get-WelaChannelRecoveryTuple $definition.Expected;$token=Get-WelaChannelRecoveryKey (Get-WelaChannelReader);$index=0
|
||||
foreach($field in $definition.Fields){
|
||||
$index++;$pendingName=('pending-{0}-{1}.json' -f $index,$field)
|
||||
$report.Artifacts+=Write-WelaWecUpdateArtifact $output $pendingName ([ordered]@{Status='Pending';Field=$field;Expected=$expected;RecoverTo=$definition.RecoverTo;Guard=$plan.Guard;PlanHash=$PlanHash;RecordedUtc=[DateTime]::UtcNow.ToString('o')}|ConvertTo-Json -Depth 16)
|
||||
if((Read-WelaWecUpdateFile $PlanPath).Hash -cne $PlanHash -or (Get-WelaChannelRecoverySources) -cne $sources -or (Read-WelaWecUpdateFile $definition.Journal.Path).Hash -cne $definition.Journal.Hash -or (Read-WelaWecUpdateFile $definition.OriginalResults.Path).Hash -cne $definition.OriginalResults.Hash){throw 'Reviewed plan, sources or original evidence changed before write.'}
|
||||
foreach($artifact in $report.Artifacts){if((Read-WelaWecUpdateFile (Join-Path $output $artifact.Name)).Hash -cne $artifact.Sha256){throw 'Saved recovery evidence changed before write.'}}
|
||||
Assert-WelaChannelRecoveryCurrent $definition (Read-WelaChannelRecoveryState $definition.Channel) $expected $plan.Guard
|
||||
if((Get-WelaChannelRecoveryKey (Get-WelaChannelReader)) -cne $token){throw 'Actual current token changed before native write.'}
|
||||
$report.NativeWriteAttempted=$true;Set-WelaChannelRecoveryField $definition $field
|
||||
$expected.$field=$definition.RecoverTo.$field
|
||||
$report.After=Read-WelaChannelRecoveryState $definition.Channel
|
||||
$report.Artifacts+=Write-WelaWecUpdateArtifact $output ('observed-'+$index+'.json') ($report.After|ConvertTo-Json -Depth 16)
|
||||
Assert-WelaChannelRecoveryCurrent $definition $report.After $expected $plan.Guard
|
||||
if((Get-WelaChannelRecoveryKey (Get-WelaChannelReader)) -cne $token -or (Get-WelaChannelRecoverySources) -cne $sources){throw 'Actual token or source changed during native write/readback.'}
|
||||
$report.Artifacts+=Write-WelaWecUpdateArtifact $output ('confirmed-'+$index+'.json') ([ordered]@{Status='Confirmed';Field=$field;After=$report.After;RecordedUtc=[DateTime]::UtcNow.ToString('o')}|ConvertTo-Json -Depth 16)
|
||||
$report.ConfirmedFields+=$field
|
||||
}
|
||||
Assert-WelaChannelRecoveryCurrent $definition (Read-WelaChannelRecoveryState $definition.Channel) $definition.RecoverTo $plan.Guard
|
||||
if((Get-WelaChannelRecoveryKey (Get-WelaChannelReader)) -cne $token){throw 'Actual token changed before final confirmation.'}
|
||||
if((Get-WelaChannelRecoveryKey (Get-WelaChannelRecoveryContext)) -cne $contextKey -or (Get-WelaChannelRecoverySources) -cne $sources -or (Read-WelaWecUpdateFile $PlanPath).Hash -cne $PlanHash){throw 'Final host/operator, source or reviewed plan changed.'}
|
||||
if((Read-WelaWecUpdateFile $definition.Journal.Path).Hash -cne $definition.Journal.Hash -or (Read-WelaWecUpdateFile $definition.OriginalResults.Path).Hash -cne $definition.OriginalResults.Hash){throw 'Original historical evidence changed during restoration.'}
|
||||
foreach($artifact in $report.Artifacts){if((Read-WelaWecUpdateFile (Join-Path $output $artifact.Name)).Hash -cne $artifact.Sha256){throw 'Saved recovery evidence changed during restoration.'}}
|
||||
$report.Status='RestoredAndVerified';$report.ExitCode=0
|
||||
}
|
||||
}catch{
|
||||
$report.Status=if($report.NativeWriteAttempted){'RestoreAttemptedUnverified'}else{'Refused'};$report.Diagnostic=$_.Exception.Message
|
||||
if($report.NativeWriteAttempted){try{$report.After=Read-WelaChannelRecoveryState $definition.Channel;$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'failure-state.json' ($report.After|ConvertTo-Json -Depth 16)}catch{}}
|
||||
}
|
||||
$null=Write-WelaWecUpdateArtifact $output 'manifest.json' ($report|ConvertTo-Json -Depth 24);$report
|
||||
}
|
||||
@@ -108,7 +108,7 @@ function Invoke-WelaConfigurationControl {
|
||||
|
||||
function Complete-WelaConfiguration {
|
||||
param($Context, [string]$ResultsPath, $Plan,
|
||||
[ValidateSet("native-windows-configuration", "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", "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']) {
|
||||
@@ -284,10 +284,17 @@ function Get-WelaAuditPolicyMask {
|
||||
|
||||
function Set-WelaAuditPolicyControl {
|
||||
param($Context, $Policy, [ValidateRange(0, 3)][int]$Mask = 3,
|
||||
[ValidateSet('exact', 'minimum')][string]$Mode = 'exact', [switch]$RequirePrecedence)
|
||||
[ValidateSet('exact', 'minimum')][string]$Mode = 'exact', [switch]$RequirePrecedence,
|
||||
$IpsecObservations, [scriptblock]$ReadIpsec = { Get-WelaIpsecPrerequisite })
|
||||
$guid = $Policy.GUID
|
||||
$state = @{ Guid = $guid; Mask = $Mask; Mode = $Mode; RequirePrecedence = [bool]$RequirePrecedence }
|
||||
$read = { param($state) Get-WelaAuditPolicyMask -Guid $state.Guid }
|
||||
$state = @{ Guid = $guid; Mask = $Mask; Mode = $Mode; RequirePrecedence = [bool]$RequirePrecedence; IpsecObservations=$IpsecObservations; ReadIpsec=$ReadIpsec }
|
||||
$read = { param($state)
|
||||
if ($null -ne $state.IpsecObservations) {
|
||||
$evidence = & $state.ReadIpsec; $state.IpsecObservations.Add($evidence)
|
||||
Assert-WelaIpsecPrerequisite $evidence
|
||||
}
|
||||
Get-WelaAuditPolicyMask -Guid $state.Guid
|
||||
}
|
||||
$test = {
|
||||
param($value, $state)
|
||||
if ($state.Mode -eq 'minimum') { return ($value -band $state.Mask) -eq $state.Mask }
|
||||
@@ -311,6 +318,11 @@ function Set-WelaAuditPolicyControl {
|
||||
$failure = if ($state.Mask -band 2) { 'enable' } else { 'disable' }
|
||||
$arguments += "/success:$success", "/failure:$failure"
|
||||
}
|
||||
if ($null -ne $state.IpsecObservations) {
|
||||
# This check runs after the operator prompt and durable recovery journal.
|
||||
$evidence = & $state.ReadIpsec; $state.IpsecObservations.Add($evidence)
|
||||
Assert-WelaIpsecPrerequisite $evidence
|
||||
}
|
||||
Invoke-WelaNative -FilePath 'auditpol.exe' -Arguments $arguments
|
||||
}
|
||||
Invoke-WelaConfigurationControl -Context $Context -Id "AuditPolicy/$($Policy.Name)" -Kind AuditPolicy `
|
||||
@@ -318,7 +330,7 @@ function Set-WelaAuditPolicyControl {
|
||||
}
|
||||
|
||||
function Set-WelaProfileAuditControls {
|
||||
param($Context, $Plan)
|
||||
param($Context, $Plan, [scriptblock]$ReadIpsec = { Get-WelaIpsecPrerequisite })
|
||||
if ($Plan.PSObject.Properties['CustomProfileSource']) {
|
||||
$Context | Add-Member NoteProperty CustomProfileGuard ([pscustomobject]@{Source=$Plan.CustomProfileSource;Role=$Plan.role;Build=$Plan.build}) -Force
|
||||
Assert-WelaConfigurationProfileGuard $Context
|
||||
@@ -334,9 +346,26 @@ function Set-WelaProfileAuditControls {
|
||||
$Context.Results.Add([pscustomobject]@{ Id = "AuditPolicy/$($policy.id)"; Kind = 'AuditPolicy'; Target = @{ Guid = $policy.guid }; Desired = $policy.requiredMask; Before = $null; After = $null; Status = 'Skipped'; Diagnostic = 'Audit precedence was not verified; dependent policy was not changed.' })
|
||||
continue
|
||||
}
|
||||
$mode = if ($policy.mode -eq 'minimum') { 'minimum' } else { 'exact' }
|
||||
Set-WelaAuditPolicyControl -Context $Context -Policy @{ GUID = $policy.guid; Name = $policy.id } -Mask $policy.requiredMask -Mode $mode -RequirePrecedence
|
||||
$conditional = Test-WelaIpsecConditionalPolicy $Plan $policy
|
||||
$observations = $null; $blocked = $false
|
||||
if ($conditional) {
|
||||
$observations = New-Object 'System.Collections.Generic.List[object]'
|
||||
try {
|
||||
$evidence = & $ReadIpsec; $observations.Add($evidence)
|
||||
$blocked = $evidence.Status -ne 'Applicable'
|
||||
$status = if ($evidence.Status -eq 'NotObservedWithinScope') { 'Skipped' } else { 'Failed' }
|
||||
$diagnostic = "IPsec prerequisite $($evidence.Status); policy preserved. $($evidence.Diagnostic)"
|
||||
} catch { $blocked = $true; $status = 'Failed'; $diagnostic = $_.ToString() }
|
||||
if ($blocked) {
|
||||
$Context.Results.Add([pscustomobject]@{Id="AuditPolicy/$($policy.id)";Kind='AuditPolicy';Target=@{Guid=$policy.guid};Desired=@{Mask=$policy.requiredMask;Mode='exact'};Before=$null;After=$null;Status=$status;Diagnostic=$diagnostic})
|
||||
}
|
||||
}
|
||||
if (-not $blocked) {
|
||||
$mode = if ($policy.mode -eq 'minimum') { 'minimum' } else { 'exact' }
|
||||
Set-WelaAuditPolicyControl -Context $Context -Policy @{ GUID = $policy.guid; Name = $policy.id } -Mask $policy.requiredMask -Mode $mode -RequirePrecedence -IpsecObservations $observations -ReadIpsec $ReadIpsec
|
||||
}
|
||||
$row = $Context.Results[$Context.Results.Count - 1]
|
||||
if ($conditional) { $row | Add-Member NoteProperty PrerequisiteObservations $observations }
|
||||
$row | Add-Member NoteProperty Profile $Plan.profile
|
||||
$row | Add-Member NoteProperty Version $Plan.version
|
||||
$row | Add-Member NoteProperty SchemaSha256 $Plan.schemaSha256
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
# Restore one completed profile size/mode write; never replay arbitrary wevtutil arguments.
|
||||
function Get-WelaEventRecoverySources {
|
||||
$sources=[ordered]@{}
|
||||
foreach($name in @('WELA.ps1','scripts/EventLogRecovery.ps1','scripts/EventLogConfiguration.ps1','modules/EventLogSettings.psm1','config/eventlog_profiles.json','scripts/Configuration.ps1','scripts/ControlApplicability.ps1','scripts/AuditRecovery.ps1','scripts/ChannelRead.ps1','scripts/ChannelReadNative.cs','scripts/WefArrival.ps1','scripts/WecUpdate.ps1','modules/AuditProfiles.psm1','scripts/CustomAuditProfiles.ps1')){
|
||||
$sources[$name]=(Get-FileHash -LiteralPath (Join-Path $script:ScriptRoot $name) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()
|
||||
}
|
||||
$sources|ConvertTo-Json -Compress
|
||||
}
|
||||
function Get-WelaEventRecoveryContext {
|
||||
foreach($name in @('Winmgmt','EventLog')){if((Get-Service -Name $name -ErrorAction Stop).Status -ne 'Running'){throw 'Native observation services must already be running.'}}
|
||||
$reader=Get-WelaChannelReader
|
||||
if(-not $reader.ElevatedAdministrator){throw 'An elevated native Windows operator is required.'}
|
||||
[pscustomobject][ordered]@{Host=(Get-WelaRecoveryHost);ReviewedHost=(Get-WelaChannelReadHost);Reader=[ordered]@{Sid=$reader.UserSid;Logon=$reader.AuthenticationId;Groups=$reader.GroupSids}}
|
||||
}
|
||||
function Read-WelaEventRecoveryChannel {
|
||||
param([string]$Log)
|
||||
$channel=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new($Log)
|
||||
try {
|
||||
if($channel.LogName -cne $Log -or [string]$channel.LogType -notin @('Administrative','Operational')){throw 'An exact administrative or operational channel is required.'}
|
||||
[pscustomobject][ordered]@{
|
||||
Log=$channel.LogName;MaximumSizeInBytes=[long]$channel.MaximumSizeInBytes;LogMode=[string]$channel.LogMode
|
||||
Guard=[ordered]@{IsEnabled=[bool]$channel.IsEnabled;LogType=[string]$channel.LogType;Isolation=[string]$channel.LogIsolation;Path=[string]$channel.LogFilePath;SecurityDescriptor=[string]$channel.SecurityDescriptor;Provider=[string]$channel.OwningProviderName;Classic=[bool]$channel.IsClassicLog}
|
||||
}
|
||||
}finally{$channel.Dispose()}
|
||||
}
|
||||
function Assert-WelaEventRecoveryText {
|
||||
param($Value,[string[]]$Names)
|
||||
foreach($name in $Names){if($Value.$name -isnot [string]){throw ('Missing or mistyped recovery text field: '+$name)}}
|
||||
}
|
||||
function Assert-WelaEventRecoveryState {
|
||||
param($State,[string]$Log)
|
||||
Assert-WelaEventRecoveryText $State @('Log','ReadStatus','Diagnostic','LogMode')
|
||||
if($State.Log -cne $Log -or $State.ReadStatus -cne 'Available' -or $State.Diagnostic -cne '' -or $State.IsEnabled -isnot [bool] -or
|
||||
($State.MaximumSizeInBytes -isnot [int] -and $State.MaximumSizeInBytes -isnot [long]) -or $State.MaximumSizeInBytes -lt 1048576 -or $State.MaximumSizeInBytes -gt 2199023255552 -or $State.MaximumSizeInBytes % 65536 -ne 0 -or $State.LogMode -cnotin @('Circular','Retain','AutoBackup')){throw 'Original channel state is unavailable, mistyped or unsupported.'}
|
||||
}
|
||||
function Get-WelaEventRecoveryPair {param($Value) [pscustomobject][ordered]@{MaximumSizeInBytes=[long]$Value.MaximumSizeInBytes;LogMode=[string]$Value.LogMode}}
|
||||
function Get-WelaEventRecoveryDefinition {
|
||||
param([string]$JournalPath,[string]$ResultsPath,[string]$Log)
|
||||
$catalog=Import-WelaEventLogProfiles
|
||||
if($Log -cnotin @($catalog.profiles.controls.log)){throw 'Select an exact channel in the bundled event-log profiles.'}
|
||||
$context=Get-WelaEventRecoveryContext
|
||||
$journal=Read-WelaWecUpdateFile $JournalPath;$resultFile=Read-WelaWecUpdateFile $ResultsPath
|
||||
$entries=@($journal.Text -split '\r?\n'|Where-Object {$_ -match '\S'}|ForEach-Object {ConvertFrom-WelaArrivalJson $_})
|
||||
if($entries.Count -lt 1 -or $entries.Count -gt 1024){throw 'Expected 1-1024 bounded journal entries.'}
|
||||
$result=ConvertFrom-WelaArrivalJson $resultFile.Text
|
||||
Assert-WelaEventRecoveryText $result @('Scope')
|
||||
if($result.DryRun -isnot [bool] -or $result.DryRun -or $result.Results -isnot [array] -or $result.Results.Count -gt 2048 -or $result.Scope -cnotin @('native-windows-configuration','event-log-size-and-mode-only')){throw 'Expected original non-dry-run event-log configuration results.'}
|
||||
$id='EventLog/'+$Log+'/ProfileSettings'
|
||||
$rows=@($result.Results|Where-Object Id -eq $id);$matching=@($entries|Where-Object Id -eq $id)
|
||||
if($rows.Count -ne 1 -or $matching.Count -ne 2){throw 'Exactly one result and its original/immediate-prewrite journal pair are required.'}
|
||||
$row=$rows[0];$initial=$matching[0];$fresh=$matching[1]
|
||||
Assert-WelaEventRecoveryText $row @('Status','Kind','Id')
|
||||
Assert-WelaEventRecoveryText $fresh @('Phase')
|
||||
if($initial.PSObject.Properties['Phase'] -or $fresh.Phase -cne 'ImmediatePreWrite' -or $row.Status -cne 'Applied' -or $row.Kind -cne 'EventLog' -or $row.Id -cne $id){throw 'Only completed Applied profile writes with ordered immediate-prewrite evidence are supported.'}
|
||||
foreach($entry in $matching){
|
||||
Assert-WelaEventRecoveryText $entry @('ComputerName','Kind','Id')
|
||||
if(($entry.Version -isnot [int] -and $entry.Version -isnot [long]) -or $entry.Version -ne 1 -or $entry.ComputerName -ine $context.Host.Computer -or $entry.Kind -cne 'EventLog' -or $entry.Id -cne $id){throw 'Unknown or wrong-host event-log journal.'}
|
||||
$time=ConvertTo-WelaArrivalUtc $entry.RecordedUtc;if($time -gt [DateTimeOffset]::UtcNow.AddMinutes(1)){throw 'Future journal timestamp.'}
|
||||
}
|
||||
if((ConvertTo-WelaArrivalUtc $fresh.RecordedUtc) -lt (ConvertTo-WelaArrivalUtc $initial.RecordedUtc)){throw 'Journal times are reversed.'}
|
||||
foreach($field in @('Before','Target','Desired')){if((Get-WelaRecoveryKey $initial.$field) -cne (Get-WelaRecoveryKey $row.$field)){throw "Original/result $field differs."}}
|
||||
Assert-WelaArrivalObject $initial.Target @('Log','Profile');Assert-WelaArrivalObject $fresh.Target @('Log')
|
||||
Assert-WelaEventRecoveryText $initial.Target @('Log','Profile');Assert-WelaEventRecoveryText $fresh.Target @('Log')
|
||||
if($initial.Target.Log -cne $Log -or $fresh.Target.Log -cne $Log -or $initial.Target.Profile -isnot [string]){throw 'Contradictory channel identity.'}
|
||||
$profile=Get-WelaEventLogProfile $initial.Target.Profile;$control=@($profile.controls|Where-Object log -ceq $Log)
|
||||
if($control.Count -ne 1){throw 'Channel is not selected by the original bundled profile.'}
|
||||
Assert-WelaArrivalObject $initial.Desired @('MaximumSizeInBytes','SizeMode','LogMode')
|
||||
Assert-WelaEventRecoveryText $initial.Desired @('SizeMode');Assert-WelaEventRecoveryText $fresh.Desired @('SizeMode')
|
||||
if($null -ne $initial.Desired.LogMode){Assert-WelaEventRecoveryText $initial.Desired @('LogMode')}
|
||||
if((Get-WelaRecoveryKey $initial.Desired) -cne (Get-WelaRecoveryKey $fresh.Desired) -or $initial.Desired.SizeMode -cnotin @('Exact','Minimum') -or ($null -ne $initial.Desired.LogMode -and $initial.Desired.LogMode -cne $control[0].mode) -or
|
||||
($initial.Desired.MaximumSizeInBytes -isnot [int] -and $initial.Desired.MaximumSizeInBytes -isnot [long]) -or $initial.Desired.MaximumSizeInBytes -ne (ConvertTo-WelaEventLogBytes $control[0].minimumBytes)){throw 'Desired configuration differs from the canonical profile operation.'}
|
||||
foreach($state in @($initial.Before,$fresh.Before,$row.After)){Assert-WelaEventRecoveryState $state $Log}
|
||||
if((Get-WelaRecoveryKey $fresh.Before) -cne (Get-WelaRecoveryKey $row.BeforeWrite)){throw 'Immediate prewrite and final BeforeWrite evidence differ.'}
|
||||
if($row.After.IsEnabled -ne $fresh.Before.IsEnabled){throw 'Channel enable state changed during original operation.'}
|
||||
$bytes=if($initial.Desired.SizeMode -ceq 'Exact'){$initial.Desired.MaximumSizeInBytes}else{[math]::Max([long]$fresh.Before.MaximumSizeInBytes,[long]$initial.Desired.MaximumSizeInBytes)}
|
||||
$mode=if($null -ne $initial.Desired.LogMode){$initial.Desired.LogMode}else{$fresh.Before.LogMode}
|
||||
if($row.After.MaximumSizeInBytes -ne $bytes -or $row.After.LogMode -cne $mode){throw 'Final state includes unexplained drift beyond the original size/mode write.'}
|
||||
$expected=Get-WelaEventRecoveryPair $row.After;$recover=Get-WelaEventRecoveryPair $fresh.Before
|
||||
if((Get-WelaRecoveryKey $expected) -ceq (Get-WelaRecoveryKey $recover)){throw 'No completed size/mode change exists to recover.'}
|
||||
[pscustomobject][ordered]@{
|
||||
Log=$Log;Profile=$profile.id;Journal=[ordered]@{Path=$journal.Path;Hash=$journal.Hash};OriginalResults=[ordered]@{Path=$resultFile.Path;Hash=$resultFile.Hash}
|
||||
Expected=$expected;RecoverTo=$recover;ExpectedEnabled=$row.After.IsEnabled
|
||||
RequiresShrinkConsent=($recover.MaximumSizeInBytes -lt $expected.MaximumSizeInBytes);RequiresModeConsent=($recover.LogMode -cne $expected.LogMode)
|
||||
HistoricalIdentity='Version1 records bind historical ComputerName only. Current host/logon and source hashes do not authenticate historical ownership or configuration.'
|
||||
}
|
||||
}
|
||||
function Assert-WelaEventRecoveryCurrent {
|
||||
param($Definition,$Observed,$Guard)
|
||||
if($Observed.Log -cne $Definition.Log -or $Observed.Guard.IsEnabled -ne $Definition.ExpectedEnabled -or
|
||||
(Get-WelaRecoveryKey (Get-WelaEventRecoveryPair $Observed)) -cne (Get-WelaRecoveryKey $Definition.Expected) -or
|
||||
($null -ne $Guard -and (Get-WelaRecoveryKey $Observed.Guard) -cne (Get-WelaRecoveryKey $Guard))){throw 'Current channel size, mode, identity or preserved properties differ from reviewed post-configuration state.'}
|
||||
}
|
||||
function Set-WelaEventRecoveryChannel {
|
||||
param($Definition)
|
||||
$arguments=@('sl',$Definition.Log)
|
||||
if($Definition.RecoverTo.MaximumSizeInBytes -ne $Definition.Expected.MaximumSizeInBytes){$arguments+='/ms:'+ $Definition.RecoverTo.MaximumSizeInBytes}
|
||||
if($Definition.RecoverTo.LogMode -cne $Definition.Expected.LogMode){
|
||||
switch($Definition.RecoverTo.LogMode){'Circular'{$arguments+=@('/rt:false','/ab:false')};'Retain'{$arguments+=@('/rt:true','/ab:false')};'AutoBackup'{$arguments+=@('/rt:true','/ab:true')};default{throw 'Unsupported recovery mode.'}}
|
||||
}
|
||||
if($arguments.Count -le 2){throw 'No fixed recovery argument was selected.'}
|
||||
$null=Invoke-WelaNative -FilePath (Join-Path ([Environment]::GetFolderPath('System')) 'wevtutil.exe') -Arguments $arguments
|
||||
}
|
||||
function Invoke-WelaEventLogRecovery {
|
||||
param([ValidateSet('Plan','Restore')][string]$Action='Plan',[string]$JournalPath,[string]$OriginalResultsPath,[string]$Log,[string]$PlanPath,[string]$PlanHash,[Parameter(Mandatory)][string]$OutputPath,[switch]$AllowShrink,[switch]$AllowRetentionChange)
|
||||
$ErrorActionPreference='Stop'
|
||||
if($Action -eq 'Plan'){
|
||||
if(-not $JournalPath -or -not $OriginalResultsPath -or -not $Log -or $PlanPath -or $PlanHash -or $AllowShrink -or $AllowRetentionChange){throw 'Plan requires original journal/results, exact channel and new output; restore-only options are not accepted.'}
|
||||
$source=Read-WelaWecUpdateFile $JournalPath
|
||||
}else{
|
||||
if(-not $PlanPath -or $PlanHash -cnotmatch '^[a-f0-9]{64}$' -or $JournalPath -or $OriginalResultsPath -or $Log){throw 'Restore requires only reviewed plan path/hash, new output and applicable explicit loss/retention consent.'}
|
||||
$source=Read-WelaWecUpdateFile $PlanPath
|
||||
}
|
||||
$output=New-WelaArrivalOutput $OutputPath $source.Path
|
||||
$report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaEventLogRecovery';Action=$Action;Status='Refused';ExitCode=1;OutputPath=$output;PlanHash=$null;NativeWriteAttempted=$false;After=$null;Artifacts=@();Diagnostic='';ReadyRuleCredit=0;Scope='One completed profile size/mode operation. Shrinking or changing retention may discard events or stop archival; existing records and sustained retention are not proven. Sysmon excluded.'}
|
||||
try{
|
||||
$context=Get-WelaEventRecoveryContext;$contextKey=Get-WelaRecoveryKey $context;$sources=Get-WelaEventRecoverySources
|
||||
if($Action -eq 'Plan'){
|
||||
$definition=Get-WelaEventRecoveryDefinition $JournalPath $OriginalResultsPath $Log
|
||||
$observed=Read-WelaEventRecoveryChannel $Log;Assert-WelaEventRecoveryCurrent $definition $observed $null
|
||||
$plan=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaEventLogRecoveryPlan';Definition=$definition;ContextKey=$contextKey;Sources=$sources;Guard=$observed.Guard}
|
||||
if((Get-WelaRecoveryKey (Get-WelaEventRecoveryDefinition $JournalPath $OriginalResultsPath $Log)) -cne (Get-WelaRecoveryKey $definition) -or (Get-WelaRecoveryKey (Get-WelaEventRecoveryContext)) -cne $contextKey -or (Get-WelaEventRecoverySources) -cne $sources){throw 'Input, host or code changed while planning.'}
|
||||
Assert-WelaEventRecoveryCurrent $definition (Read-WelaEventRecoveryChannel $Log) $plan.Guard
|
||||
$artifact=Write-WelaWecUpdateArtifact $output 'plan.json' ($plan|ConvertTo-Json -Depth 20);$report.Artifacts+=$artifact;$report.PlanHash=$artifact.Sha256;$report.Status='ReviewRequired';$report.ExitCode=0
|
||||
}else{
|
||||
if($source.Hash -cne $PlanHash){throw 'Reviewed plan hash differs.'}
|
||||
$plan=ConvertFrom-WelaArrivalJson $source.Text;Assert-WelaArrivalObject $plan @('SchemaVersion','Kind','Definition','ContextKey','Sources','Guard')
|
||||
Assert-WelaEventRecoveryText $plan @('Kind','ContextKey','Sources')
|
||||
if(($plan.SchemaVersion -isnot [int] -and $plan.SchemaVersion -isnot [long]) -or $plan.SchemaVersion -ne 1 -or $plan.Kind -cne 'WelaEventLogRecoveryPlan' -or $plan.ContextKey -cne $contextKey -or $plan.Sources -cne $sources){throw 'Reviewed plan schema, context or code differs.'}
|
||||
$definition=Get-WelaEventRecoveryDefinition $plan.Definition.Journal.Path $plan.Definition.OriginalResults.Path $plan.Definition.Log
|
||||
if((Get-WelaRecoveryKey $definition) -cne (Get-WelaRecoveryKey $plan.Definition)){throw 'Recovery plan differs from independently rebuilt original evidence.'}
|
||||
if($definition.RequiresShrinkConsent -and -not $AllowShrink){throw 'Restoring the original smaller buffer requires explicit AllowShrink; existing events may be discarded.'}
|
||||
if($definition.RequiresModeConsent -and -not $AllowRetentionChange){throw 'Restoring a different retention mode requires explicit AllowRetentionChange.'}
|
||||
Assert-WelaEventRecoveryCurrent $definition (Read-WelaEventRecoveryChannel $definition.Log) $plan.Guard
|
||||
$report.PlanHash=$PlanHash;$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'reviewed-plan.json' $source.Text
|
||||
$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'before-restore.json' ([ordered]@{Status='Pending';Definition=$definition;Guard=$plan.Guard;Context=$context;AllowShrink=[bool]$AllowShrink;AllowRetentionChange=[bool]$AllowRetentionChange;RecordedUtc=[DateTime]::UtcNow.ToString('o')}|ConvertTo-Json -Depth 20)
|
||||
if((Read-WelaWecUpdateFile $PlanPath).Hash -cne $PlanHash -or (Get-WelaEventRecoverySources) -cne $sources -or (Get-WelaRecoveryKey (Get-WelaEventRecoveryContext)) -cne $contextKey -or (Get-WelaRecoveryKey (Get-WelaEventRecoveryDefinition $definition.Journal.Path $definition.OriginalResults.Path $definition.Log)) -cne (Get-WelaRecoveryKey $definition)){throw 'Plan, source, context or original evidence changed immediately before restore.'}
|
||||
foreach($artifact in $report.Artifacts){if((Read-WelaWecUpdateFile (Join-Path $output $artifact.Name)).Hash -cne $artifact.Sha256){throw 'Saved recovery evidence changed before write.'}}
|
||||
Assert-WelaEventRecoveryCurrent $definition (Read-WelaEventRecoveryChannel $definition.Log) $plan.Guard
|
||||
$report.NativeWriteAttempted=$true;Set-WelaEventRecoveryChannel $definition
|
||||
$report.After=Read-WelaEventRecoveryChannel $definition.Log
|
||||
$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'after.json' ($report.After|ConvertTo-Json -Depth 12)
|
||||
if((Get-WelaRecoveryKey (Get-WelaEventRecoveryPair $report.After)) -cne (Get-WelaRecoveryKey $definition.RecoverTo) -or (Get-WelaRecoveryKey $report.After.Guard) -cne (Get-WelaRecoveryKey $plan.Guard) -or (Get-WelaRecoveryKey (Get-WelaEventRecoveryContext)) -cne $contextKey -or (Get-WelaEventRecoverySources) -cne $sources -or (Read-WelaWecUpdateFile $PlanPath).Hash -cne $PlanHash){throw 'Restored size/mode or preserved properties, context or sources differ.'}
|
||||
$report.Status='RestoredAndVerified';$report.ExitCode=0
|
||||
}
|
||||
}catch{$report.Status=if($report.NativeWriteAttempted){'RestoreAttemptedUnverified'}else{'Refused'};$report.Diagnostic=$_.Exception.Message}
|
||||
$null=Write-WelaWecUpdateArtifact $output 'manifest.json' ($report|ConvertTo-Json -Depth 24)
|
||||
$report
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
# One local nonexistent-account attempt under already configured failure auditing.
|
||||
function Initialize-WelaFailedLogonNative {
|
||||
if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'Native 64-bit Windows is required.'}
|
||||
$path=Join-Path $PSScriptRoot 'FailedLogonProbeNative.cs';$hash=(Get-FileHash -LiteralPath $path -Algorithm SHA256 -ErrorAction Stop).Hash
|
||||
if(-not ('Wela.FailedLogonProbe.Native' -as [type])){Add-Type -Path $path -ErrorAction Stop;$script:WelaFailedLogonHash=$hash}
|
||||
if($script:WelaFailedLogonHash -cne $hash){throw 'Loaded failed-logon helper differs from source; start a fresh process.'}
|
||||
}
|
||||
function Get-WelaFailedLogonSources {
|
||||
$sources=[ordered]@{}
|
||||
foreach($name in @('WELA.ps1','scripts/FailedLogonProbe.ps1','scripts/FailedLogonProbeWorker.ps1','scripts/FailedLogonProbeNative.cs','scripts/ChannelRead.ps1','scripts/ChannelReadNative.cs','scripts/Configuration.ps1','scripts/WefArrival.ps1','scripts/WecUpdate.ps1','modules/AuditProfiles.psm1','scripts/CustomAuditProfiles.ps1')){$sources[$name]=(Get-FileHash -LiteralPath (Join-Path $script:ScriptRoot $name) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()}
|
||||
$sources|ConvertTo-Json -Compress
|
||||
}
|
||||
function Get-WelaFailedLogonTokenKey {
|
||||
param($Token,[switch]$AuthorizationOnly)
|
||||
foreach($name in @('UserSid','AuthenticationId','TokenId','ModifiedId','TokenType','Impersonation')){if($Token.$name -isnot [string]){throw 'Incomplete elevated primary-token observation.'}}
|
||||
if($Token.ElevatedAdministrator -isnot [bool]){throw 'Incomplete elevated primary-token observation.'}
|
||||
if($Token.UserSid -cnotmatch '^S-1-\d+(-\d+)+$' -or $Token.AuthenticationId -cnotmatch '^[a-f0-9]{16}$' -or -not $Token.ElevatedAdministrator -or $Token.TokenType -cne 'Primary' -or $Token.Impersonation -cne 'Absent' -or $Token.GroupSids -isnot [array]){throw 'Incomplete elevated primary-token observation.'}
|
||||
foreach($name in @('TokenId','ModifiedId')){if($Token.$name -cnotmatch '^[a-f0-9]{16}$'){throw 'Incomplete elevated primary-token observation.'}}
|
||||
foreach($name in @('GroupCount','PrivilegeCount','ProcessId')){if($Token.$name -isnot [int] -and $Token.$name -isnot [long] -and $Token.$name -isnot [uint32]){throw 'Incomplete elevated primary-token observation.'};if($Token.$name -lt 1){throw 'Incomplete elevated primary-token observation.'}}
|
||||
if(@($Token.GroupSids|Where-Object {$_ -isnot [string] -or $_ -cnotmatch '^S-1-\d+(-\d+)+$'}).Count){throw 'Incomplete elevated primary-token observation.'}
|
||||
$key=[ordered]@{Sid=$Token.UserSid;Logon=$Token.AuthenticationId;Groups=$Token.GroupSids;GroupCount=$Token.GroupCount;PrivilegeCount=$Token.PrivilegeCount}
|
||||
if(-not $AuthorizationOnly){$key.TokenId=$Token.TokenId;$key.ModifiedId=$Token.ModifiedId;$key.ProcessId=$Token.ProcessId}
|
||||
$key|ConvertTo-Json -Depth 8 -Compress
|
||||
}
|
||||
function Get-WelaFailedLogonState {
|
||||
Initialize-WelaFailedLogonNative
|
||||
foreach($name in @('EventLog','Winmgmt','SamSs','RpcSs')){if((Get-Service -Name $name -ErrorAction Stop).Status -ne 'Running'){throw 'Required native observation/authentication services must already be running.'}}
|
||||
$reader=Get-WelaChannelReader;$hostState=Get-WelaChannelReadHost
|
||||
if($hostState.ProductType -notin @(1,3) -or $hostState.DomainRole -notin @(0,1,2,3)){throw 'A local SAM Windows client or member/standalone server is required; domain controllers are excluded.'}
|
||||
$policies=Get-WelaEffectiveAuditPolicy;$precedence=Get-WelaRegistryState 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' SCENoApplyLegacyAuditPolicy
|
||||
$channel=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('Security')
|
||||
try{$log=[ordered]@{Name=$channel.LogName;Enabled=$channel.IsEnabled;Size=$channel.MaximumSizeInBytes;Mode=[string]$channel.LogMode;Path=$channel.LogFilePath;SecurityDescriptor=$channel.SecurityDescriptor}}finally{$channel.Dispose()}
|
||||
$engine=(Get-Process -Id $PID).Path
|
||||
$state=[pscustomobject][ordered]@{Host=$hostState;Token=$reader;AuditPolicies=$policies;Precedence=$precedence;Channel=$log;Engine=$engine;EngineHash=(Get-FileHash -LiteralPath $engine -Algorithm SHA256).Hash;Sources=(Get-WelaFailedLogonSources)}
|
||||
if((Get-WelaFailedLogonTokenKey (Get-WelaChannelReader)) -cne (Get-WelaFailedLogonTokenKey $reader)){throw 'Reader changed during prerequisite observation.'}
|
||||
$state
|
||||
}
|
||||
function Get-WelaFailedLogonStateKey {
|
||||
param($State)
|
||||
$null=Get-WelaFailedLogonTokenKey $State.Token
|
||||
$mask=$State.AuditPolicies['0cce9215-69ae-11d9-bed3-505054503030']
|
||||
if(($mask -isnot [int] -and $mask -isnot [long]) -or $mask -notin @(2,3) -or $State.Precedence.Type -cne 'DWord' -or -not $State.Precedence.ValueExists -or $State.Precedence.Value -ne 1 -or -not $State.Channel.Enabled){throw 'Logon failure auditing, DWORD1 audit precedence and enabled/readable Security channel must already be configured.'}
|
||||
$State|ConvertTo-Json -Depth 12 -Compress
|
||||
}
|
||||
function Get-WelaFailedLogonWatermark {
|
||||
$record=Get-WinEvent -LogName Security -MaxEvents 1 -ErrorAction Stop
|
||||
try{if($null -eq $record.RecordId -or $record.RecordId -lt 1){throw 'Unknown Security record boundary.'};[long]$record.RecordId}finally{$record.Dispose()}
|
||||
}
|
||||
function Assert-WelaFailedLogonOperation {
|
||||
param($Operation,$State,[string]$Nonce,[int]$ProcessId,[DateTimeOffset]$Launch,[DateTimeOffset]$Observed)
|
||||
$a=$Operation.Attempt
|
||||
foreach($name in @('Nonce','Executable')){if($Operation.$name -isnot [string]){throw 'Unexpected fixed local authentication receipt type.'}}
|
||||
foreach($name in @('UserName','Domain','Clock')){if($a.$name -isnot [string]){throw 'Unexpected fixed local authentication receipt type.'}}
|
||||
foreach($name in @('MissingAccountStatus','LogonType','LogonProvider','NativeError')){if($a.$name -isnot [int] -and $a.$name -isnot [long]){throw 'Unexpected fixed local authentication receipt type.'}}
|
||||
if($Operation.ProcessId -isnot [int] -and $Operation.ProcessId -isnot [long]){throw 'Unexpected fixed local authentication receipt type.'}
|
||||
if($Operation.Nonce -cne $Nonce -or $Operation.ProcessId -ne $ProcessId -or $Operation.Executable -ine $State.Engine -or $a.UserName -cne ('WL'+$Nonce.Substring(0,18)) -or $a.Domain -cne '.' -or $a.MissingAccountStatus -ne 2221 -or $a.LogonType -ne 3 -or $a.LogonProvider -ne 2 -or $a.Succeeded -isnot [bool] -or $a.Succeeded -or $a.NativeError -ne 1326 -or $a.Clock -cne 'GetSystemTimePreciseAsFileTime'){throw 'Unexpected fixed local authentication result; no failed-logon proof is granted.'}
|
||||
$start=ConvertTo-WelaArrivalUtc $a.StartedUtc;$end=ConvertTo-WelaArrivalUtc $a.CompletedUtc
|
||||
if($Launch -gt $Observed -or $start -lt $Launch -or $start -gt $end -or $end -gt $Observed -or ($end-$start).TotalSeconds -gt 20){throw 'Invalid exact native operation interval.'}
|
||||
if((Get-WelaFailedLogonTokenKey $Operation.BeforeToken) -cne (Get-WelaFailedLogonTokenKey $Operation.AfterToken) -or (Get-WelaFailedLogonTokenKey $Operation.BeforeToken -AuthorizationOnly) -cne (Get-WelaFailedLogonTokenKey $State.Token -AuthorizationOnly)){throw 'Worker token changed or differs from the observed caller.'}
|
||||
$a.StartedUtc=$start.UtcDateTime.ToString('o');$a.CompletedUtc=$end.UtcDateTime.ToString('o')
|
||||
}
|
||||
function Start-WelaFailedLogonAttempt {
|
||||
param($State,[string]$OutputPath)
|
||||
if((Get-WelaFailedLogonStateKey (Get-WelaFailedLogonState)) -cne (Get-WelaFailedLogonStateKey $State)){throw 'Prerequisites changed before the fixed attempt.'}
|
||||
$nonce=[guid]::NewGuid().ToString('N');$watermark=Get-WelaFailedLogonWatermark
|
||||
$null=Write-WelaWecUpdateArtifact $OutputPath 'intent.json' ([ordered]@{Nonce=$nonce;LocalAccount=('WL'+$nonce.Substring(0,18));Domain='.';Attempts=1;SecurityRecordIdBefore=$watermark}|ConvertTo-Json)
|
||||
$worker=Join-Path $PSScriptRoot 'FailedLogonProbeWorker.ps1'
|
||||
$info=New-Object Diagnostics.ProcessStartInfo;$info.FileName=$State.Engine;$info.Arguments='-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "'+$worker+'" -Nonce '+$nonce
|
||||
$info.UseShellExecute=$false;$info.CreateNoWindow=$true;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true
|
||||
$info.StandardOutputEncoding=[Text.UTF8Encoding]::new($false,$true);$info.StandardErrorEncoding=[Text.UTF8Encoding]::new($false,$true)
|
||||
$process=$null
|
||||
try{
|
||||
$launch=[DateTimeOffset][Wela.FailedLogonProbe.Native]::UtcNow()
|
||||
$process=[Diagnostics.Process]::Start($info);$output=$process.StandardOutput.ReadToEndAsync();$errors=$process.StandardError.ReadToEndAsync()
|
||||
if(-not $process.WaitForExit(20000)){$process.Kill();$null=$process.WaitForExit(1000);throw 'Fixed local authentication worker exceeded twenty seconds.'}
|
||||
if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($output,$errors),1000)){throw 'Worker output did not complete.'}
|
||||
if($output.Result.Length -gt 65536 -or $errors.Result.Length -gt 65536){throw 'Worker output exceeded its bound.'}
|
||||
if($process.ExitCode -ne 0 -or $errors.Result){throw ('Fixed local authentication worker failed: '+$errors.Result)}
|
||||
$operation=ConvertFrom-WelaArrivalJson $output.Result
|
||||
Assert-WelaFailedLogonOperation $operation $State $nonce $process.Id $launch ([DateTimeOffset][Wela.FailedLogonProbe.Native]::UtcNow())
|
||||
$operation|Add-Member NoteProperty SecurityRecordIdBefore $watermark
|
||||
$operation
|
||||
}finally{if($process){try{if(-not $process.HasExited){$process.Kill();$null=$process.WaitForExit(1000)}}finally{$process.Dispose()}}}
|
||||
}
|
||||
function Read-WelaFailedLogonEvents {
|
||||
param($Operation)
|
||||
$a=$Operation.Attempt
|
||||
$query="*[System[Provider[@Name='Microsoft-Windows-Security-Auditing'] and EventID=4625 and EventRecordID>$($Operation.SecurityRecordIdBefore) and TimeCreated[@SystemTime>='$($a.StartedUtc)' and @SystemTime<='$($a.CompletedUtc)']]]"
|
||||
$records=@();$xml=@()
|
||||
try{
|
||||
try{$records=@(Get-WinEvent -LogName Security -FilterXPath $query -MaxEvents 256 -ErrorAction Stop)}catch{if($_.FullyQualifiedErrorId -notlike 'NoMatchingEventsFound*'){throw}}
|
||||
foreach($record in $records){$text=[string]$record.ToXml();if($text.Length -gt 131072){throw 'Native event exceeded its bound.'};$xml+=$text}
|
||||
[pscustomobject]@{Xml=$xml;Capped=($records.Count -ge 256);Query=$query}
|
||||
}finally{foreach($record in $records){$record.Dispose()}}
|
||||
}
|
||||
function Test-WelaFailedLogonEvent {
|
||||
param([string]$Xml,$Operation,$State)
|
||||
$reader=$null
|
||||
try{
|
||||
if($Xml.Length -gt 131072){return $false}
|
||||
$settings=New-Object Xml.XmlReaderSettings;$settings.DtdProcessing=[Xml.DtdProcessing]::Prohibit;$settings.XmlResolver=$null;$settings.MaxCharactersInDocument=131072
|
||||
$reader=[Xml.XmlReader]::Create([IO.StringReader]::new($Xml),$settings);$doc=New-Object Xml.XmlDocument;$doc.XmlResolver=$null;$doc.Load($reader)
|
||||
$ns=New-Object Xml.XmlNamespaceManager($doc.NameTable);$ns.AddNamespace('e','http://schemas.microsoft.com/win/2004/08/events/event')
|
||||
if($doc.DocumentElement.LocalName -cne 'Event' -or $doc.DocumentElement.NamespaceURI -cne $ns.LookupNamespace('e') -or $doc.SelectNodes('/e:Event/e:System',$ns).Count -ne 1 -or $doc.SelectNodes('/e:Event/e:EventData',$ns).Count -ne 1 -or $doc.SelectNodes('/e:Event/e:UserData',$ns).Count){return $false}
|
||||
$system=@{};foreach($name in @('Provider','EventID','Version','Keywords','EventRecordID','Channel','Computer','TimeCreated')){$nodes=$doc.SelectNodes("/e:Event/e:System/e:$name",$ns);if($nodes.Count -ne 1){return $false};$system[$name]=$nodes[0]}
|
||||
if($system.Provider.GetAttribute('Name') -cne 'Microsoft-Windows-Security-Auditing' -or $system.Provider.GetAttribute('Guid').Trim('{}') -ine '54849625-5478-4994-a5ba-3e3b0328c30d' -or $system.EventID.InnerText -cne '4625' -or $system.Version.InnerText -cne '0' -or $system.Channel.InnerText -cne 'Security' -or $system.Keywords.InnerText -ine '0x8010000000000000' -or $system.EventRecordID.InnerText -cnotmatch '^[1-9][0-9]*$' -or [long]$system.EventRecordID.InnerText -le $Operation.SecurityRecordIdBefore){return $false}
|
||||
$computers=@($State.Host.Computer);if($State.Host.DomainJoined){$computers+=$State.Host.Computer+'.'+$State.Host.Domain};if($system.Computer.InnerText -notin $computers){return $false}
|
||||
$time=ConvertTo-WelaArrivalUtc $system.TimeCreated.GetAttribute('SystemTime')
|
||||
if($time -lt (ConvertTo-WelaArrivalUtc $Operation.Attempt.StartedUtc) -or $time -gt (ConvertTo-WelaArrivalUtc $Operation.Attempt.CompletedUtc)){return $false}
|
||||
$map=@{}
|
||||
foreach($node in $doc.SelectSingleNode('/e:Event/e:EventData',$ns).ChildNodes){
|
||||
if($node.NodeType -eq 'Whitespace'){continue}
|
||||
if($node.NodeType -ne 'Element' -or $node.LocalName -cne 'Data' -or $node.NamespaceURI -cne $ns.LookupNamespace('e') -or @($node.ChildNodes|Where-Object NodeType -eq Element).Count){return $false}
|
||||
$name=$node.GetAttribute('Name');if(-not $name -or $map.ContainsKey($name)){return $false};$map[$name]=$node.InnerText
|
||||
}
|
||||
if($map.TargetUserName -cne $Operation.Attempt.UserName -or $map.TargetDomainName -notin @('.',$State.Host.Computer) -or $map.TargetUserSid -cne 'S-1-0-0' -or $map.LogonType -cne '3' -or $map.AuthenticationPackageName -cne 'MICROSOFT_AUTHENTICATION_PACKAGE_V1_0' -or $map.Status -ine '0xc000006d' -or $map.SubStatus -ine '0xc0000064' -or $map.ProcessName -ine $Operation.Executable -or $map.SubjectUserSid -cne $Operation.BeforeToken.UserSid){return $false}
|
||||
if($map.ProcessId -cnotmatch '^0x[0-9a-fA-F]+$' -or $map.SubjectLogonId -cnotmatch '^0x[0-9a-fA-F]+$' -or [Convert]::ToInt64($map.ProcessId.Substring(2),16) -ne $Operation.ProcessId -or [Convert]::ToUInt64($map.SubjectLogonId.Substring(2),16) -ne [Convert]::ToUInt64($Operation.BeforeToken.AuthenticationId,16)){return $false}
|
||||
return $true
|
||||
}catch{return $false}finally{if($reader){$reader.Dispose()}}
|
||||
}
|
||||
function Invoke-WelaFailedLogonProbe {
|
||||
param([ValidateSet('Plan','Run')][string]$Action='Plan',[string]$OutputPath,[ValidateRange(1,30)][int]$TimeoutSeconds=15)
|
||||
$ErrorActionPreference='Stop'
|
||||
if(($Action -eq 'Run') -ne (-not [string]::IsNullOrWhiteSpace($OutputPath))){throw 'Run requires a new FailedLogonOutputPath; Plan creates no files.'}
|
||||
$report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaLocalFailedLogonProbe';Action=$Action;Status='Unverified';ExitCode=1;Before=$null;After=$null;Operation=$null;Candidates=0;Matches=0;Artifacts=@();Diagnostic='';OutputPath=$null;PolicyChanges=0;AccountChanges=0;ReadyRuleCredit=0;Scope='One fixed local SAM nonexistent-account network-logon-type attempt only. No remote/domain authentication, real credentials, account creation, impersonation, forwarding or Sigma proof. Sysmon excluded.'}
|
||||
if($Action -eq 'Run'){$report.OutputPath=New-WelaArrivalOutput $OutputPath $PSScriptRoot}
|
||||
try{
|
||||
$before=Get-WelaFailedLogonState;$report.Before=$before;$key=Get-WelaFailedLogonStateKey $before
|
||||
if($Action -eq 'Plan'){$null=Get-WelaFailedLogonWatermark;$report.After=$before;$report.Status='PrerequisitesObserved';$report.ExitCode=0;return $report}
|
||||
$report.Artifacts+=Write-WelaWecUpdateArtifact $report.OutputPath 'before.json' ($before|ConvertTo-Json -Depth 16)
|
||||
$operation=Start-WelaFailedLogonAttempt $before $report.OutputPath;$report.Operation=$operation
|
||||
$report.Artifacts+=Write-WelaWecUpdateArtifact $report.OutputPath 'operation.json' ($operation|ConvertTo-Json -Depth 12)
|
||||
$timer=[Diagnostics.Stopwatch]::StartNew();$matches=@()
|
||||
do{
|
||||
$batch=Read-WelaFailedLogonEvents $operation;$report.Candidates=@($batch.Xml).Count
|
||||
if($batch.Capped -isnot [bool] -or $batch.Capped){throw 'Candidate completeness is unknown or the 256-event cap was reached.'}
|
||||
$matches=@($batch.Xml|Where-Object {Test-WelaFailedLogonEvent $_ $operation $before})
|
||||
if($matches.Count){break};Start-Sleep -Milliseconds 250
|
||||
}while($timer.Elapsed.TotalSeconds -lt $TimeoutSeconds)
|
||||
$report.Matches=$matches.Count
|
||||
if($matches.Count -ne 1){$i=0;foreach($xml in @($batch.Xml|Select-Object -First 4)){$i++;$report.Artifacts+=Write-WelaWecUpdateArtifact $report.OutputPath ('candidate-'+$i+'.xml') $xml};throw 'Exactly one matching local nonexistent-account Security4625 was not observed.'}
|
||||
$report.Artifacts+=Write-WelaWecUpdateArtifact $report.OutputPath 'event.xml' $matches[0]
|
||||
if((Get-WelaFailedLogonWatermark) -lt $operation.SecurityRecordIdBefore){throw 'Security record boundary moved backwards; continuity is unknown.'}
|
||||
$after=Get-WelaFailedLogonState;$report.After=$after
|
||||
if((Get-WelaFailedLogonStateKey $after) -cne $key){throw 'Host, token, policies, channel, engine or sources changed during collection.'}
|
||||
$report.Status='LocalFailedLogonObserved';$report.ExitCode=0
|
||||
}catch{$report.Diagnostic=$_.Exception.Message}
|
||||
finally{if($report.Before -and -not $report.After){try{$report.After=Get-WelaFailedLogonState}catch{$report.Diagnostic+=' Final observation failed: '+$_.Exception.Message}}}
|
||||
if($report.OutputPath){if($report.After){$report.Artifacts+=Write-WelaWecUpdateArtifact $report.OutputPath 'after.json' ($report.After|ConvertTo-Json -Depth 16)};$null=Write-WelaWecUpdateArtifact $report.OutputPath 'manifest.json' ($report|ConvertTo-Json -Depth 24)}
|
||||
$report
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.RegularExpressions;
|
||||
namespace Wela.FailedLogonProbe {
|
||||
public sealed class Attempt {
|
||||
public string UserName, Domain, StartedUtc, CompletedUtc, Clock;
|
||||
public int MissingAccountStatus, LogonType, LogonProvider, NativeError;
|
||||
public bool Succeeded;
|
||||
}
|
||||
public static class Native {
|
||||
[DllImport("kernel32.dll", ExactSpelling=true)] private static extern void GetSystemTimePreciseAsFileTime(out long value);
|
||||
[DllImport("Netapi32.dll", CharSet=CharSet.Unicode, ExactSpelling=true)] private static extern int NetUserGetInfo(string server,string user,int level,out IntPtr buffer);
|
||||
[DllImport("Netapi32.dll", ExactSpelling=true)] private static extern int NetApiBufferFree(IntPtr buffer);
|
||||
[DllImport("advapi32.dll", CharSet=CharSet.Unicode, ExactSpelling=true, SetLastError=true)]
|
||||
[return:MarshalAs(UnmanagedType.Bool)] private static extern bool LogonUserW(string user,string domain,string password,int type,int provider,out IntPtr token);
|
||||
[DllImport("kernel32.dll", ExactSpelling=true, SetLastError=true)]
|
||||
[return:MarshalAs(UnmanagedType.Bool)] private static extern bool CloseHandle(IntPtr handle);
|
||||
public static DateTime UtcNow(){long value;GetSystemTimePreciseAsFileTime(out value);return DateTime.FromFileTimeUtc(value);}
|
||||
public static Attempt Run(string nonce){
|
||||
if(!Regex.IsMatch(nonce??"","\\A[a-f0-9]{32}\\z"))throw new ArgumentException("A generated lowercase GUID nonce is required.");
|
||||
string user="WL"+nonce.Substring(0,18);IntPtr buffer=IntPtr.Zero;
|
||||
int missing;
|
||||
try{missing=NetUserGetInfo(null,user,0,out buffer);}finally{if(buffer!=IntPtr.Zero)NetApiBufferFree(buffer);}
|
||||
// Never attempt a known or unreadable real account, and never query a domain server.
|
||||
if(missing!=2221)throw new InvalidOperationException("Exact local account absence is not established; NetUserGetInfo="+missing);
|
||||
Attempt result=new Attempt();result.UserName=user;result.Domain=".";result.MissingAccountStatus=missing;result.LogonType=3;result.LogonProvider=2;result.Clock="GetSystemTimePreciseAsFileTime";
|
||||
IntPtr token=IntPtr.Zero;
|
||||
result.StartedUtc=UtcNow().ToString("o");
|
||||
try{
|
||||
// This fixed public dummy is not a credential. There is exactly one attempt.
|
||||
result.Succeeded=LogonUserW(user,".","WELA-public-noncredential",3,2,out token);
|
||||
result.NativeError=result.Succeeded?0:Marshal.GetLastWin32Error();
|
||||
result.CompletedUtc=UtcNow().ToString("o");
|
||||
}finally{if(token!=IntPtr.Zero && !CloseHandle(token))throw new Win32Exception(Marshal.GetLastWin32Error());}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
param([Parameter(Mandatory)][ValidatePattern('^[a-f0-9]{32}$')][string]$Nonce)
|
||||
$ErrorActionPreference='Stop';[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)
|
||||
$script:ScriptRoot=Split-Path $PSScriptRoot -Parent
|
||||
. "$PSScriptRoot/WefArrival.ps1"
|
||||
. "$PSScriptRoot/ChannelRead.ps1"
|
||||
. "$PSScriptRoot/FailedLogonProbe.ps1"
|
||||
Initialize-WelaFailedLogonNative
|
||||
foreach($name in @('EventLog','Winmgmt','SamSs','RpcSs')){if((Get-Service -Name $name -ErrorAction Stop).Status -ne 'Running'){throw 'Required native services must already be running.'}}
|
||||
$hostState=Get-WelaChannelReadHost
|
||||
if($hostState.ProductType -notin @(1,3) -or $hostState.DomainRole -notin @(0,1,2,3)){throw 'A local SAM client or member/standalone server is required.'}
|
||||
$before=Get-WelaChannelReader
|
||||
$result=[Wela.FailedLogonProbe.Native]::Run($Nonce)
|
||||
$after=Get-WelaChannelReader
|
||||
[pscustomobject][ordered]@{Nonce=$Nonce;ProcessId=$PID;Executable=(Get-Process -Id $PID).Path;BeforeToken=$before;AfterToken=$after;Attempt=$result}|ConvertTo-Json -Depth 10 -Compress
|
||||
@@ -0,0 +1,265 @@
|
||||
# Explicit one-byte existing-file read and exact local Security4663 evidence.
|
||||
# Resolve target scope before reading any external native-helper source or hashing dependencies.
|
||||
# This small literal helper opens metadata only and never reads target bytes.
|
||||
function Initialize-WelaFileProbeScopeNative {
|
||||
if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'The file probe requires native 64-bit Windows.'}
|
||||
$definition=@'
|
||||
using System;using System.ComponentModel;using System.Runtime.InteropServices;using System.Text;
|
||||
namespace Wela.FileAccessScope {
|
||||
public sealed class Observation {public string Path;public uint Links,Attributes;}
|
||||
public static class Native {
|
||||
public const string SourceSha256="__WELA_FILE_SCOPE_SOURCE_SHA256__";
|
||||
[StructLayout(LayoutKind.Sequential,Pack=4)] struct Info {public uint Attributes;public long Created,Accessed,Written;public uint Volume,SizeHigh,SizeLow,Links,IndexHigh,IndexLow;}
|
||||
[DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true,ExactSpelling=true)] static extern IntPtr CreateFileW(string path,uint access,uint share,IntPtr security,uint disposition,uint flags,IntPtr template);
|
||||
[DllImport("kernel32.dll",SetLastError=true)] static extern bool CloseHandle(IntPtr handle);
|
||||
[DllImport("kernel32.dll",SetLastError=true)] static extern bool GetFileInformationByHandle(IntPtr handle,out Info info);
|
||||
[DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true,ExactSpelling=true)] static extern uint GetFinalPathNameByHandleW(IntPtr handle,StringBuilder path,uint length,uint flags);
|
||||
public static Observation Observe(string path) {
|
||||
IntPtr handle=CreateFileW(path,0x80,7,IntPtr.Zero,3,0x00200000,IntPtr.Zero);
|
||||
if(handle==new IntPtr(-1))throw new Win32Exception(Marshal.GetLastWin32Error(),"Metadata-only target-scope observation failed.");
|
||||
try{Info info;if(!GetFileInformationByHandle(handle,out info))throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
StringBuilder final=new StringBuilder(32768);uint length=GetFinalPathNameByHandleW(handle,final,(uint)final.Capacity,0);
|
||||
if(length==0||length>=final.Capacity||!final.ToString().StartsWith(@"\\?\",StringComparison.Ordinal))throw new InvalidOperationException("Canonical local target scope is unknown.");
|
||||
return new Observation{Path=final.ToString().Substring(4),Links=info.Links,Attributes=info.Attributes};
|
||||
}finally{CloseHandle(handle);}
|
||||
}
|
||||
}
|
||||
}
|
||||
'@
|
||||
$hash=Get-WelaArrivalHash ([Text.UTF8Encoding]::new($false).GetBytes($definition))
|
||||
if(-not ('Wela.FileAccessScope.Native' -as [type])){Add-Type -TypeDefinition $definition.Replace('__WELA_FILE_SCOPE_SOURCE_SHA256__',$hash) -ErrorAction Stop}
|
||||
if([Wela.FileAccessScope.Native]::SourceSha256 -cne $hash){throw 'Loaded file scope helper differs; start a fresh session.'}
|
||||
}
|
||||
function Assert-WelaFileProbeScopeObservation {
|
||||
param([string]$SelectedPath,$Selected,$SourceFile,$Engine)
|
||||
foreach($item in @($Selected,$SourceFile,$Engine)){if($item.Path -isnot [string] -or -not $item.Path){throw 'Incomplete canonical target scope.'};Assert-WelaFileProbePath $item.Path}
|
||||
$sourceRoot=$SourceFile.Path.Substring(0,$SourceFile.Path.LastIndexOf('\')+1)
|
||||
if($Selected.Path.StartsWith($sourceRoot,[StringComparison]::OrdinalIgnoreCase)){throw 'Select a file outside the WELA source tree; implementation targets are unsupported.'}
|
||||
if($Selected.Path.Equals($Engine.Path,[StringComparison]::OrdinalIgnoreCase)){throw 'The active PowerShell engine cannot be the selected file target.'}
|
||||
if($Selected.Path -ine $SelectedPath -or -not(Test-WelaFileProbeInteger $Selected.Links) -or $Selected.Links -ne 1 -or -not(Test-WelaFileProbeInteger $Selected.Attributes) -or ($Selected.Attributes -band 1040)){throw 'Only ordinary canonical single-link file targets are supported; aliases and reparse targets are refused.'}
|
||||
}
|
||||
function Assert-WelaFileProbeTargetScope {
|
||||
param([string]$Path)
|
||||
Initialize-WelaFileProbeScopeNative
|
||||
$selected=[Wela.FileAccessScope.Native]::Observe($Path)
|
||||
$source=[Wela.FileAccessScope.Native]::Observe((Join-Path $script:ScriptRoot 'WELA.ps1'))
|
||||
$engine=[Wela.FileAccessScope.Native]::Observe((Get-Process -Id $PID -ErrorAction Stop).Path)
|
||||
Assert-WelaFileProbeScopeObservation $Path $selected $source $engine
|
||||
}
|
||||
function Initialize-WelaFileProbeNative {
|
||||
Initialize-WelaWmiProbeNative
|
||||
$source=Join-Path $PSScriptRoot 'FileAccessProbeNative.cs';$bytes=[IO.File]::ReadAllBytes($source);$hash=Get-WelaArrivalHash $bytes
|
||||
if(-not ('Wela.FileAccessProbe.FileHandle' -as [type])){
|
||||
$definition=[Text.UTF8Encoding]::new($false,$true).GetString($bytes).Replace('__WELA_FILE_PROBE_SOURCE_SHA256__',$hash)
|
||||
Add-Type -TypeDefinition $definition -ErrorAction Stop
|
||||
}
|
||||
if([Wela.FileAccessProbe.FileHandle]::SourceSha256 -cne $hash){throw 'Loaded file probe helper differs from its source; start a fresh session.'}
|
||||
}
|
||||
function Test-WelaFileProbeInteger {param($Value) ($Value -is [int] -or $Value -is [long] -or $Value -is [uint32] -or $Value -is [uint64])}
|
||||
function Assert-WelaFileProbePath {
|
||||
param([string]$Path)
|
||||
if(-not $Path -or $Path.Length -gt 240 -or $Path -cnotmatch '^[A-Za-z]:\\' -or $Path.Substring(2).Contains(':') -or $Path -match '["*?<>|/\x00-\x1f]|(^|\\)\.\.?($|\\)|[ .](\\|$)|\\$|\\\\'){throw 'Select one exact ordinary absolute local leaf file, at most 240 characters; links, streams, wildcards and remote paths are unsupported.'}
|
||||
}
|
||||
function Get-WelaFileProbeSources {
|
||||
$sources=[ordered]@{}
|
||||
foreach($name in @('WELA.ps1','scripts/FileAccessProbe.ps1','scripts/FileAccessProbeWorker.ps1','scripts/FileAccessProbeNative.cs','scripts/WmiProbe.ps1','scripts/WmiProbeNative.cs','scripts/ChannelRead.ps1','scripts/ChannelReadNative.cs','scripts/WefArrival.ps1','scripts/Configuration.ps1','scripts/CustomAuditProfiles.ps1','scripts/IpsecPrerequisites.ps1','modules/AuditProfiles.psm1','config/audit_profiles.json')) {
|
||||
$sources[$name]=(Get-FileHash -LiteralPath (Join-Path $PSScriptRoot ('../'+$name)) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()
|
||||
}
|
||||
[pscustomobject]$sources
|
||||
}
|
||||
function Get-WelaFileProbeKey {param($Value) ConvertTo-Json -InputObject $Value -Depth 24 -Compress}
|
||||
function Get-WelaFileProbeTokenKey {
|
||||
param($Token)
|
||||
foreach($name in @('Sid','Name','AuthenticationId','AuthenticationType','ImpersonationLevel','TokenSource')){if($Token.$name -isnot [string]){throw 'Incomplete typed file-reader token.'}}
|
||||
if($Token.Sid -cnotmatch '^S-1-\d+(-\d+)+$' -or $Token.AuthenticationId -cnotmatch '^0x[0-9a-f]+$' -or $Token.TokenSource -cne 'Process' -or $Token.Groups -isnot [array] -or -not $Token.Groups.Count -or $Token.Privileges -isnot [array]){throw 'An ordinary native primary-token file reader is required.'}
|
||||
foreach($group in $Token.Groups){if($group.Sid -isnot [string] -or $group.Sid -cnotmatch '^S-1-\d+(-\d+)+$' -or -not(Test-WelaFileProbeInteger $group.Attributes)){throw 'Incomplete typed file-reader group.'}}
|
||||
foreach($privilege in $Token.Privileges){if($privilege.Luid -isnot [string] -or $privilege.Luid -cnotmatch '^0x[0-9a-f]+$' -or -not(Test-WelaFileProbeInteger $privilege.Attributes)){throw 'Incomplete typed file-reader privilege.'}}
|
||||
Get-WelaFileProbeKey $Token
|
||||
}
|
||||
function Get-WelaFileProbeReaderKey {
|
||||
param($Reader,[switch]$AuthorizationOnly)
|
||||
foreach($name in @('UserSid','UserName','AuthenticationId','TokenId','ModifiedId','TokenType','Impersonation')){if($Reader.$name -isnot [string]){throw 'Incomplete typed reader observation.'}}
|
||||
if($Reader.TokenType -cne 'Primary' -or $Reader.Impersonation -cne 'Absent' -or $Reader.ElevatedAdministrator -isnot [bool] -or -not $Reader.ElevatedAdministrator){throw 'An elevated primary-token reader with no impersonation is required.'}
|
||||
if($AuthorizationOnly){Get-WelaFileProbeKey ($Reader|Select-Object UserSid,UserName,AuthenticationId,GroupSids,GroupCount,PrivilegeCount,ElevatedAdministrator,TokenType,Impersonation)}
|
||||
else{Get-WelaFileProbeKey $Reader}
|
||||
}
|
||||
function Assert-WelaFileProbeSnapshot {
|
||||
param($Snapshot)
|
||||
foreach($name in @('Path','NativePath','Identity','DescriptorBase64','StateKey')){if($Snapshot.$name -isnot [string] -or -not $Snapshot.$name){throw 'Incomplete typed file observation.'}}
|
||||
Assert-WelaFileProbePath $Snapshot.Path
|
||||
if($Snapshot.NativePath -cnotmatch '^\\Device\\[^\\]+\\'){throw 'Incomplete native NT file path observation.'}
|
||||
if($Snapshot.StateKey -cnotmatch '^[a-f0-9]{64}$' -or -not(Test-WelaFileProbeInteger $Snapshot.Size) -or $Snapshot.Size -le 0 -or -not(Test-WelaFileProbeInteger $Snapshot.SecurityInformation) -or $Snapshot.SecurityInformation -ne 511 -or -not(Test-WelaFileProbeInteger $Snapshot.Links) -or $Snapshot.Links -ne 1 -or -not(Test-WelaFileProbeInteger $Snapshot.Attributes) -or ($Snapshot.Attributes -band (16+1024+4096+16384+262144+4194304))){throw 'Only a complete nonempty ordinary single-link leaf-file observation is supported.'}
|
||||
$Snapshot.LastWriteUtc=(ConvertTo-WelaArrivalUtc $Snapshot.LastWriteUtc).UtcDateTime.ToString('o')
|
||||
if($Snapshot.Aces -isnot [array] -or $Snapshot.Aces.Count -gt 128){throw 'Missing or oversized file audit ACE inventory.'}
|
||||
foreach($ace in $Snapshot.Aces){
|
||||
if($ace.Ordinary -isnot [bool] -or -not(Test-WelaFileProbeInteger $ace.Type) -or -not(Test-WelaFileProbeInteger $ace.Flags) -or -not(Test-WelaFileProbeInteger $ace.Mask) -or $ace.Binary -isnot [string]){throw 'Incomplete typed file audit ACE.'}
|
||||
if($ace.Ordinary -and ($ace.Type -ne 2 -or $ace.Sid -isnot [string] -or $ace.Sid -cnotmatch '^S-1-\d+(-\d+)+$')){throw 'Incomplete ordinary file audit ACE.'}
|
||||
}
|
||||
}
|
||||
function Get-WelaFileProbeSnapshot {
|
||||
param([string]$Path)
|
||||
Assert-WelaFileProbePath $Path;Initialize-WelaFileProbeNative
|
||||
$handle=[Wela.FileAccessProbe.FileHandle]::new($Path,$false)
|
||||
try{$handle.Observe()}finally{$handle.Dispose()}
|
||||
}
|
||||
function Get-WelaFileProbeState {
|
||||
param([string]$Path)
|
||||
Assert-WelaFileProbePath $Path;Assert-WelaFileProbeTargetScope $Path;Initialize-WelaFileProbeNative
|
||||
$services=@(Get-Service -Name EventLog,Winmgmt,RpcSs -ErrorAction Stop|Sort-Object Name|ForEach-Object {[pscustomobject]@{Name=$_.Name;Status=[string]$_.Status}})
|
||||
if($services.Count -ne 3 -or @($services|Where-Object Status -ne 'Running').Count){throw 'EventLog, Winmgmt and RpcSs must already be running.'}
|
||||
$null=Get-WelaFileProbeReaderKey (Get-WelaChannelReader)
|
||||
$tokenBefore=[Wela.WmiProbe.Native]::Snapshot();$snapshot=Get-WelaFileProbeSnapshot $Path
|
||||
$hostState=Get-WelaChannelReadHost
|
||||
$channel=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('Security')
|
||||
try{$log=[pscustomobject]@{Name=$channel.LogName;Enabled=$channel.IsEnabled;SecurityDescriptor=$channel.SecurityDescriptor;MaximumSize=$channel.MaximumSizeInBytes;Mode=[string]$channel.LogMode;Type=[string]$channel.LogType;Provider=$channel.OwningProviderName}}finally{$channel.Dispose()}
|
||||
$policy=Get-WelaEffectiveAuditPolicy;$masks=[ordered]@{};foreach($guid in @($policy.Keys|Sort-Object)){$masks[$guid]=$policy[$guid]}
|
||||
$engine=(Get-Process -Id $PID -ErrorAction Stop).Path
|
||||
$reader=Get-WelaChannelReader;$token=[Wela.WmiProbe.Native]::Snapshot()
|
||||
if((Get-WelaFileProbeTokenKey $tokenBefore) -cne (Get-WelaFileProbeTokenKey $token)){throw 'File prerequisite observation changed token groups or privileges.'}
|
||||
[pscustomobject][ordered]@{Computer=[Environment]::MachineName;Host=$hostState;MachineGuid=(Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Cryptography' -Name MachineGuid -ErrorAction Stop).MachineGuid;Services=$services;Reader=($reader|Select-Object UserSid,UserName,AuthenticationId,GroupSids,GroupCount,PrivilegeCount,ElevatedAdministrator,TokenType,Impersonation);Token=$token;File=$snapshot;AuditPolicies=[pscustomobject]$masks;Precedence=(Get-WelaRegistryState 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' SCENoApplyLegacyAuditPolicy);Channel=$log;Engine=$engine;EngineHash=(Get-FileHash -LiteralPath $engine -Algorithm SHA256).Hash.ToLowerInvariant();Sources=(Get-WelaFileProbeSources)}
|
||||
}
|
||||
function Get-WelaFileProbeStateKey {
|
||||
param($State)
|
||||
Assert-WelaFileProbeSnapshot $State.File;$null=Get-WelaFileProbeTokenKey $State.Token
|
||||
if($State.Computer -isnot [string] -or -not $State.Computer -or $State.MachineGuid -isnot [string] -or $State.MachineGuid -cnotmatch '^[a-fA-F0-9]{8}(-[a-fA-F0-9]{4}){3}-[a-fA-F0-9]{12}$' -or -not(Test-WelaFileProbeInteger $State.Host.ProductType) -or $State.Host.ProductType -notin @(1,2,3) -or -not(Test-WelaFileProbeInteger $State.Host.Build) -or $State.Host.Build -notin @(22000,22621,22631,20348,26100,26200) -or $State.Host.DomainJoined -isnot [bool]){throw 'Complete actual supported Windows host identity is required.'}
|
||||
if($State.Services -isnot [array] -or $State.Services.Count -ne 3 -or (@($State.Services.Name)-join ',') -cne 'EventLog,RpcSs,Winmgmt'){throw 'Complete native service observations are required.'}
|
||||
foreach($service in $State.Services){if($service.Status -isnot [string] -or $service.Status -cne 'Running'){throw 'Required services must already be running.'}}
|
||||
$mask=$State.AuditPolicies.'0CCE921D-69AE-11D9-BED3-505054503030'
|
||||
if(-not(Test-WelaFileProbeInteger $mask) -or $mask -notin @(1,3) -or $State.Precedence.ValueExists -isnot [bool] -or -not $State.Precedence.ValueExists -or $State.Precedence.Type -isnot [string] -or $State.Precedence.Type -cne 'DWord' -or -not(Test-WelaFileProbeInteger $State.Precedence.Value) -or $State.Precedence.Value -ne 1){throw 'File System success auditing and typed audit precedence DWORD1 must already be configured.'}
|
||||
if($State.Channel.Name -isnot [string] -or $State.Channel.Name -cne 'Security' -or $State.Channel.Enabled -isnot [bool] -or -not $State.Channel.Enabled -or $State.Channel.SecurityDescriptor -isnot [string] -or -not $State.Channel.SecurityDescriptor){throw 'The Security channel must already be enabled with readable configuration.'}
|
||||
if($State.Reader.TokenType -isnot [string] -or $State.Reader.TokenType -cne 'Primary' -or $State.Reader.Impersonation -isnot [string] -or $State.Reader.Impersonation -cne 'Absent' -or $State.Reader.ElevatedAdministrator -isnot [bool] -or -not $State.Reader.ElevatedAdministrator -or $State.Reader.UserSid -isnot [string] -or $State.Reader.UserSid -cne $State.Token.Sid){throw 'Complete elevated primary-token reader identity is required.'}
|
||||
$sids=@($State.Token.Sid)+@($State.Token.Groups|Where-Object {($_.Attributes -band 4) -and -not($_.Attributes -band 16)}|ForEach-Object Sid)
|
||||
$matches=@($State.File.Aces|Where-Object {$_.Ordinary -and $_.Type -eq 2 -and ($_.Flags -band 64) -and -not($_.Flags -band 8) -and ($_.Mask -band 1) -and $_.Sid -in $sids})
|
||||
if(-not $matches.Count){throw 'No existing ordinary success ReadData audit ACE matches this token on the selected file; no SACL is added.'}
|
||||
foreach($name in @('Engine','EngineHash')){if($State.$name -isnot [string] -or -not $State.$name){throw 'Missing native engine identity.'}}
|
||||
if($State.EngineHash -cnotmatch '^[a-f0-9]{64}$' -or -not @($State.Sources.PSObject.Properties).Count){throw 'Missing implementation fingerprints.'}
|
||||
foreach($source in $State.Sources.PSObject.Properties){if($source.Value -isnot [string] -or $source.Value -cnotmatch '^[a-f0-9]{64}$'){throw 'Malformed implementation fingerprint.'}}
|
||||
# Windows paths may change spelling/case while referring to this same native identity.
|
||||
$State|ConvertTo-Json -Depth 24 -Compress
|
||||
}
|
||||
function Get-WelaFileProbeWatermark {
|
||||
$result=Read-WelaChannelLatest Security
|
||||
if($result.Status -isnot [string] -or $result.Status -cne 'EventObserved' -or -not(Test-WelaFileProbeInteger $result.Event.RecordId) -or $result.Event.RecordId -lt 1){throw 'A successful native Security query and positive record boundary are required.'}
|
||||
[long]$result.Event.RecordId
|
||||
}
|
||||
function Get-WelaFileProbeOutputKey {
|
||||
param([string]$Path)
|
||||
$full=Resolve-WelaArrivalPath $Path;$item=Get-Item -LiteralPath $full -Force -ErrorAction Stop
|
||||
if(-not $item.PSIsContainer){throw 'Probe output is not a directory.'}
|
||||
$acl=Get-Acl -LiteralPath $full -ErrorAction Stop
|
||||
Get-WelaFileProbeKey ([pscustomobject]@{Path=$item.FullName;CreatedUtc=$item.CreationTimeUtc.ToString('o');Attributes=[int]$item.Attributes;Security=$acl.GetSecurityDescriptorSddlForm([Security.AccessControl.AccessControlSections]::Access -bor [Security.AccessControl.AccessControlSections]::Owner -bor [Security.AccessControl.AccessControlSections]::Group)})
|
||||
}
|
||||
function Write-WelaFileProbeArtifact {
|
||||
param([string]$Root,[string]$OutputKey,[string]$Name,[string]$Text)
|
||||
if((Get-WelaFileProbeOutputKey $Root) -cne $OutputKey){throw 'Private probe output directory changed.'}
|
||||
$bytes=[Text.UTF8Encoding]::new($false).GetBytes($Text);$path=Join-Path $Root $Name
|
||||
$stream=[IO.File]::Open($path,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None)
|
||||
try{$stream.Write($bytes,0,$bytes.Length);$stream.Flush($true)}finally{$stream.Dispose()}
|
||||
$hash=Get-WelaArrivalHash $bytes
|
||||
if((Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() -cne $hash){throw 'Saved probe evidence hash differs.'}
|
||||
[pscustomobject]@{Name=$Name;Sha256=$hash;Bytes=$bytes.Length}
|
||||
}
|
||||
function Assert-WelaFileProbeOperation {
|
||||
param($Operation,$State,[string]$Nonce,[int]$ProcessId,[DateTimeOffset]$LaunchedUtc,[DateTimeOffset]$ObservedUtc)
|
||||
foreach($name in @('Kind','Nonce','Executable','FilePath')){if($Operation.$name -isnot [string]){throw 'Untyped fixed file worker authority.'}}
|
||||
if($Operation.Kind -cne 'WelaOneByteFileRead' -or $Operation.Nonce -cne $Nonce -or -not(Test-WelaFileProbeInteger $Operation.ProcessId) -or $Operation.ProcessId -ne $ProcessId -or $Operation.Executable -ine $State.Engine -or $Operation.FilePath -ine $State.File.Path){throw 'Unexpected fixed file worker identity.'}
|
||||
$read=$Operation.Read
|
||||
foreach($name in @('Clock','Phase','HandleId','BeforeKey','AfterKey')){if($read.$name -isnot [string]){throw 'Untyped native read receipt.'}}
|
||||
if($read.Phase -cne 'OneByteReadAndHeldIdentityReadback' -or $read.Clock -cne 'GetSystemTimePreciseAsFileTime' -or $read.Succeeded -isnot [bool] -or -not $read.Succeeded -or -not(Test-WelaFileProbeInteger $read.ReadCalls) -or $read.ReadCalls -ne 1 -or -not(Test-WelaFileProbeInteger $read.BytesRead) -or $read.BytesRead -ne 1 -or $read.HandleId -cnotmatch '^0x[0-9a-f]+$' -or [Convert]::ToUInt64($read.HandleId.Substring(2),16) -eq 0 -or $read.BeforeKey -cne $State.File.StateKey -or $read.AfterKey -cne $State.File.StateKey){throw 'Expected exactly one successful byte read from the unchanged held file.'}
|
||||
$start=ConvertTo-WelaArrivalUtc $read.StartedUtc;$returned=ConvertTo-WelaArrivalUtc $read.ReadReturnedUtc;$end=ConvertTo-WelaArrivalUtc $read.CompletedUtc
|
||||
if($LaunchedUtc -gt $ObservedUtc -or $start -lt $LaunchedUtc -or $returned -lt $start -or $end -lt $returned -or $end -gt $ObservedUtc -or ($end-$start).TotalSeconds -gt 20){throw 'Invalid precise one-byte/readback operation interval.'}
|
||||
if((Get-WelaFileProbeTokenKey $Operation.BeforeToken) -cne (Get-WelaFileProbeTokenKey $Operation.AfterToken) -or (Get-WelaFileProbeTokenKey $Operation.BeforeToken) -cne (Get-WelaFileProbeTokenKey $State.Token) -or
|
||||
(Get-WelaFileProbeReaderKey $Operation.BeforeReader) -cne (Get-WelaFileProbeReaderKey $Operation.AfterReader) -or (Get-WelaFileProbeReaderKey $Operation.BeforeReader -AuthorizationOnly) -cne (Get-WelaFileProbeKey $State.Reader)){throw 'Worker primary token differs from the caller or changed during the native read.'}
|
||||
$read.StartedUtc=$start.UtcDateTime.ToString('o');$read.ReadReturnedUtc=$returned.UtcDateTime.ToString('o');$read.CompletedUtc=$end.UtcDateTime.ToString('o')
|
||||
}
|
||||
function Start-WelaFileProbeRead {
|
||||
param($State,[string]$RequestPath,[string]$Nonce)
|
||||
$fresh=Get-WelaFileProbeState $State.File.Path
|
||||
if((Get-WelaFileProbeStateKey $fresh) -cne (Get-WelaFileProbeStateKey $State)){throw 'File probe prerequisites drifted before worker launch.'}
|
||||
$watermark=Get-WelaFileProbeWatermark;$worker=Join-Path $PSScriptRoot 'FileAccessProbeWorker.ps1'
|
||||
$info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$State.Engine;$info.Arguments='-NoLogo -NoProfile -NonInteractive -File "'+$worker+'" -RequestPath "'+$RequestPath+'" -Nonce '+$Nonce
|
||||
$info.UseShellExecute=$false;$info.CreateNoWindow=$true;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true
|
||||
$info.StandardOutputEncoding=[Text.UTF8Encoding]::new($false,$true);$info.StandardErrorEncoding=$info.StandardOutputEncoding;$process=$null
|
||||
try {
|
||||
$launch=[DateTimeOffset][Wela.FileAccessProbe.FileHandle]::UtcNow();$process=[Diagnostics.Process]::Start($info)
|
||||
$stdout=$process.StandardOutput.ReadToEndAsync();$stderr=$process.StandardError.ReadToEndAsync()
|
||||
if(-not $process.WaitForExit(20000)){$process.Kill();$null=$process.WaitForExit(1000);throw 'File worker exceeded twenty seconds; the read may have been attempted.'}
|
||||
if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),1000)){throw 'File worker output did not complete.'}
|
||||
if($stdout.Result.Length -gt 1048576 -or $stderr.Result.Length -gt 65536){throw 'File worker output exceeded its evidence bound.'}
|
||||
if($process.ExitCode -ne 0 -or $stderr.Result){throw ('Fixed file worker failed: '+$stderr.Result)}
|
||||
$operation=ConvertFrom-WelaArrivalJson $stdout.Result
|
||||
Assert-WelaFileProbeOperation $operation $State $Nonce $process.Id $launch ([DateTimeOffset][Wela.FileAccessProbe.FileHandle]::UtcNow())
|
||||
$operation|Add-Member NoteProperty RecordIdBefore $watermark
|
||||
$operation
|
||||
}finally{if($process){try{if(-not $process.HasExited){$process.Kill();$null=$process.WaitForExit(1000)}}finally{$process.Dispose()}}}
|
||||
}
|
||||
function Read-WelaFileProbeEvents {
|
||||
param($Operation)
|
||||
# Keep out-of-interval candidates for diagnosis; the matcher never credits them.
|
||||
$query="*[System[Provider[@Name='Microsoft-Windows-Security-Auditing'] and EventID=4663 and EventRecordID>$($Operation.RecordIdBefore)]]"
|
||||
$reader=$null;$records=New-Object 'System.Collections.Generic.List[string]'
|
||||
try {
|
||||
$q=[Diagnostics.Eventing.Reader.EventLogQuery]::new('Security',[Diagnostics.Eventing.Reader.PathType]::LogName,$query);$q.TolerateQueryErrors=$false
|
||||
$reader=[Diagnostics.Eventing.Reader.EventLogReader]::new($q);$reader.BatchSize=16
|
||||
while($records.Count -lt 256){$event=$reader.ReadEvent([TimeSpan]::FromSeconds(1));if($null -eq $event){break};try{$xml=$event.ToXml();if($xml.Length -gt 131072){throw 'Security event exceeds the XML bound.'};$records.Add($xml)}finally{$event.Dispose()}}
|
||||
$status=@($reader.LogStatus|ForEach-Object {[pscustomobject]@{LogName=$_.LogName;StatusCode=$_.StatusCode}});Assert-WelaChannelQueryStatus Security $status
|
||||
[pscustomobject]@{Xml=@($records.ToArray());Capped=($records.Count -ge 256);Query=$query;MaximumEvents=256;LogStatus=$status}
|
||||
}finally{if($reader){$reader.Dispose()}}
|
||||
}
|
||||
function Test-WelaFileProbeEvent {
|
||||
param([string]$Xml,$Operation,$State)
|
||||
$reader=$null
|
||||
try {
|
||||
if($Xml.Length -gt 131072){return $false}
|
||||
$settings=[Xml.XmlReaderSettings]::new();$settings.DtdProcessing=[Xml.DtdProcessing]::Prohibit;$settings.XmlResolver=$null;$settings.MaxCharactersInDocument=131072
|
||||
$reader=[Xml.XmlReader]::Create([IO.StringReader]::new($Xml),$settings);$doc=[Xml.XmlDocument]::new();$doc.XmlResolver=$null;$doc.Load($reader)
|
||||
$ns=[Xml.XmlNamespaceManager]::new($doc.NameTable);$ns.AddNamespace('e','http://schemas.microsoft.com/win/2004/08/events/event')
|
||||
if($doc.DocumentElement.LocalName -cne 'Event' -or $doc.DocumentElement.NamespaceURI -cne $ns.LookupNamespace('e') -or $doc.SelectNodes('/e:Event/e:System',$ns).Count -ne 1 -or $doc.SelectNodes('/e:Event/e:EventData',$ns).Count -ne 1 -or $doc.SelectNodes('/e:Event/e:UserData',$ns).Count){return $false}
|
||||
$system=@{};foreach($name in @('Provider','EventID','Version','Keywords','EventRecordID','Channel','Computer','TimeCreated','Level','Task','Opcode')){$nodes=$doc.SelectNodes("/e:Event/e:System/e:$name",$ns);if($nodes.Count -ne 1){return $false};$system[$name]=$nodes[0]}
|
||||
if($system.Provider.GetAttribute('Name') -cne 'Microsoft-Windows-Security-Auditing' -or $system.Provider.GetAttribute('Guid').Trim('{}') -ine '54849625-5478-4994-a5ba-3e3b0328c30d' -or $system.EventID.InnerText -cne '4663' -or $system.Version.InnerText -cne '1' -or $system.Keywords.InnerText -ine '0x8020000000000000' -or $system.Channel.InnerText -cne 'Security' -or $system.Level.InnerText -cne '0' -or $system.Task.InnerText -cne '12800' -or $system.Opcode.InnerText -cne '0' -or $system.EventRecordID.InnerText -cnotmatch '^[1-9][0-9]*$' -or [long]$system.EventRecordID.InnerText -le $Operation.RecordIdBefore){return $false}
|
||||
$computers=@($State.Computer);if($State.Host.DomainJoined){$computers+=$State.Computer+'.'+$State.Host.Domain};if($system.Computer.InnerText -notin $computers){return $false}
|
||||
$time=ConvertTo-WelaArrivalUtc $system.TimeCreated.GetAttribute('SystemTime');if($time -lt (ConvertTo-WelaArrivalUtc $Operation.Read.StartedUtc) -or $time -gt (ConvertTo-WelaArrivalUtc $Operation.Read.CompletedUtc)){return $false}
|
||||
$data=@{};foreach($node in $doc.SelectSingleNode('/e:Event/e:EventData',$ns).ChildNodes){if($node.NodeType -eq 'Whitespace'){continue};if($node.NodeType -ne 'Element' -or $node.LocalName -cne 'Data' -or $node.NamespaceURI -cne $ns.LookupNamespace('e')){return $false};$name=$node.GetAttribute('Name');if(-not $name -or $data.ContainsKey($name) -or @($node.ChildNodes|Where-Object NodeType -eq Element).Count){return $false};$data[$name]=$node.InnerText}
|
||||
foreach($name in @('SubjectUserSid','SubjectUserName','SubjectDomainName','SubjectLogonId','ObjectServer','ObjectType','ObjectName','HandleId','AccessList','AccessMask','ProcessId','ProcessName','ResourceAttributes')){if(-not $data.ContainsKey($name)){return $false}}
|
||||
if($data.Count -ne 13 -or $data.ObjectServer -cne 'Security' -or $data.ObjectType -cne 'File' -or ($data.ObjectName -ine $State.File.Path -and $data.ObjectName -ine $State.File.NativePath) -or $data.ProcessName -ine $State.Engine -or $data.SubjectUserSid -cne $Operation.BeforeToken.Sid -or $data.AccessList.Trim() -cne '%%4416'){return $false}
|
||||
foreach($name in @('SubjectLogonId','AccessMask','ProcessId','HandleId')){if($data[$name] -cnotmatch '^0x[0-9a-fA-F]+$'){return $false}}
|
||||
if([Convert]::ToUInt64($data.SubjectLogonId.Substring(2),16) -ne [Convert]::ToUInt64($Operation.BeforeToken.AuthenticationId.Substring(2),16) -or [Convert]::ToUInt64($data.AccessMask.Substring(2),16) -ne 1 -or [Convert]::ToUInt64($data.ProcessId.Substring(2),16) -ne $Operation.ProcessId -or [Convert]::ToUInt64($data.HandleId.Substring(2),16) -ne [Convert]::ToUInt64($Operation.Read.HandleId.Substring(2),16)){return $false}
|
||||
$true
|
||||
}catch{$false}finally{if($reader){$reader.Dispose()}}
|
||||
}
|
||||
function Invoke-WelaFileAccessProbe {
|
||||
param([ValidateSet('Plan','Run')][string]$Action='Plan',[string]$FilePath,[string]$OutputPath,[ValidateRange(1,30)][int]$TimeoutSeconds=15)
|
||||
Assert-WelaFileProbePath $FilePath
|
||||
if(($Action -eq 'Run') -ne (-not [string]::IsNullOrWhiteSpace($OutputPath))){throw 'Run requires a new FileProbeOutputPath; Plan creates no output.'}
|
||||
$report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaFileAccessProbe';Action=$Action;Status='Unverified';ExitCode=1;RecordedUtc=[datetime]::UtcNow.ToString('o');Before=$null;After=$null;Operation=$null;Candidates=0;Matches=0;Query=$null;Artifacts=@();Diagnostic='';OutputPath=$null;ConfigurationChanges=0;FileDataWrites=0;RetainedContentBytes=0;SigmaEvtxCredit=0;Scope='One current-token local file ReadData success only; failure access, other files/rights/users, inheritance, forwarding and Sigma are unverified. Reads may update native access metadata.'}
|
||||
$outputKey=$null;$beforeKey=$null
|
||||
try {
|
||||
if($Action -eq 'Run'){$report.OutputPath=New-WelaArrivalOutput $OutputPath $script:ScriptRoot;$outputKey=Get-WelaFileProbeOutputKey $report.OutputPath}
|
||||
$before=Get-WelaFileProbeState $FilePath;$beforeKey=Get-WelaFileProbeStateKey $before;$report.Before=$before
|
||||
if($Action -eq 'Plan'){$report.After=$before;$report.Status='PrerequisitesObserved';$report.ExitCode=0;return $report}
|
||||
$report.Artifacts+=Write-WelaFileProbeArtifact $report.OutputPath $outputKey 'before.json' ($before|ConvertTo-Json -Depth 24)
|
||||
$nonce=[guid]::NewGuid().ToString('N');$intent=[pscustomobject]@{Kind='WelaOneByteFileReadIntent';Nonce=$nonce;Path=$before.File.Path;StateKey=$before.File.StateKey;ExpectedBytes=1;Outcome='Pending; interruption may leave an attempted read without a completion receipt.'}
|
||||
$report.Artifacts+=Write-WelaFileProbeArtifact $report.OutputPath $outputKey 'intent.json' ($intent|ConvertTo-Json -Depth 8)
|
||||
$operation=Start-WelaFileProbeRead $before (Join-Path $report.OutputPath 'before.json') $nonce;$report.Operation=$operation
|
||||
if((Get-FileHash -LiteralPath (Join-Path $report.OutputPath 'before.json')).Hash.ToLowerInvariant() -cne $report.Artifacts[0].Sha256){throw 'Worker request evidence changed.'}
|
||||
$report.Artifacts+=Write-WelaFileProbeArtifact $report.OutputPath $outputKey 'operation.json' ($operation|ConvertTo-Json -Depth 16)
|
||||
$timer=[Diagnostics.Stopwatch]::StartNew();$matches=@()
|
||||
do{$batch=Read-WelaFileProbeEvents $operation;$report.Candidates=@($batch.Xml).Count;$report.Query=$batch.Query
|
||||
if($batch.Capped -isnot [bool] -or $batch.Capped){throw 'Security query reached its 256-event cap or completeness is unknown.'}
|
||||
$matches=@($batch.Xml|Where-Object {Test-WelaFileProbeEvent $_ $operation $before});if($matches.Count){break};Start-Sleep -Milliseconds 250
|
||||
}while($timer.Elapsed.TotalSeconds -lt $TimeoutSeconds)
|
||||
$report.Matches=$matches.Count
|
||||
if($matches.Count -ne 1){$i=0;foreach($xml in @($batch.Xml|Select-Object -First 4)){$i++;$report.Artifacts+=Write-WelaFileProbeArtifact $report.OutputPath $outputKey ('candidate-'+$i+'.xml') $xml};throw 'Exactly one attributable native4663 was not observed in the measured one-byte/readback phase.'}
|
||||
$report.Artifacts+=Write-WelaFileProbeArtifact $report.OutputPath $outputKey 'event.xml' $matches[0]
|
||||
if((Get-WelaFileProbeWatermark) -lt $operation.RecordIdBefore){throw 'Security record boundary moved backwards.'}
|
||||
$after=Get-WelaFileProbeState $before.File.Path;$report.After=$after
|
||||
if((Get-WelaFileProbeStateKey $after) -cne $beforeKey){throw 'File identity/security, policy, channel, host, token or implementation changed during the probe.'}
|
||||
$report.Status='FileReadObserved';$report.ExitCode=0
|
||||
}catch{$report.Diagnostic=$_.Exception.Message}
|
||||
finally{if($report.Before -and -not $report.After){try{$report.After=Get-WelaFileProbeState $report.Before.File.Path}catch{$report.Diagnostic+=' Final observation failed: '+$_.Exception.Message}}}
|
||||
if($report.OutputPath -and $outputKey){
|
||||
if($report.After){$report.Artifacts+=Write-WelaFileProbeArtifact $report.OutputPath $outputKey 'after.json' ($report.After|ConvertTo-Json -Depth 24)}
|
||||
$null=Write-WelaFileProbeArtifact $report.OutputPath $outputKey 'manifest.json' ($report|ConvertTo-Json -Depth 28)
|
||||
}
|
||||
$report
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// A held existing local file handle: observe security and read exactly one byte.
|
||||
// No file creation, data/security writes, backup semantics, or retained contents.
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.AccessControl;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
namespace Wela.FileAccessProbe {
|
||||
public sealed class Ace { public int Type,Flags,Mask; public string Sid,Binary; public bool Ordinary; }
|
||||
public sealed class Observation {
|
||||
public string Path,NativePath,Identity,LastWriteUtc,DescriptorBase64,StateKey;
|
||||
public long Size; public uint Attributes,Links; public int SecurityInformation; public Ace[] Aces;
|
||||
}
|
||||
public sealed class ReadReceipt {
|
||||
public string StartedUtc,ReadReturnedUtc,CompletedUtc,Clock,Phase,HandleId,BeforeKey,AfterKey;
|
||||
public int ReadCalls,BytesRead; public bool Succeeded;
|
||||
}
|
||||
sealed class SecurityPrivilege : IDisposable {
|
||||
[StructLayout(LayoutKind.Sequential)] struct Luid {public uint Low;public int High;}
|
||||
[StructLayout(LayoutKind.Sequential)] struct Privileges {public uint Count;public Luid Id;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 handle);
|
||||
[DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenProcessToken(IntPtr process,uint access,out IntPtr token);
|
||||
[DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenThreadToken(IntPtr thread,uint access,bool self,out IntPtr token);
|
||||
[DllImport("advapi32.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern bool LookupPrivilegeValue(string system,string name,out Luid luid);
|
||||
[DllImport("advapi32.dll",SetLastError=true)] static extern bool AdjustTokenPrivileges(IntPtr token,bool all,ref Privileges requested,uint size,out Privileges previous,out uint required);
|
||||
IntPtr token;Privileges previous;
|
||||
public SecurityPrivilege() {
|
||||
IntPtr thread;if(OpenThreadToken(GetCurrentThread(),8,true,out thread)){CloseHandle(thread);throw new InvalidOperationException("Impersonated file readers are unsupported.");}
|
||||
int error=Marshal.GetLastWin32Error();if(error!=1008)throw new Win32Exception(error);
|
||||
if(!OpenProcessToken(GetCurrentProcess(),0x28,out token))throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
try {Luid id;if(!LookupPrivilegeValue(null,"SeSecurityPrivilege",out id))throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
Privileges requested=new Privileges{Count=1,Id=id,Attributes=2};uint needed;
|
||||
bool ok=AdjustTokenPrivileges(token,false,ref requested,(uint)Marshal.SizeOf(typeof(Privileges)),out previous,out needed);
|
||||
error=Marshal.GetLastWin32Error();if(!ok||error!=0)throw new Win32Exception(error,"Existing SeSecurityPrivilege is required to inspect the SACL.");
|
||||
}catch{CloseHandle(token);token=IntPtr.Zero;throw;}
|
||||
}
|
||||
public void Dispose(){if(token==IntPtr.Zero)return;try{Privileges ignored;uint needed;bool ok=AdjustTokenPrivileges(token,false,ref previous,(uint)Marshal.SizeOf(typeof(Privileges)),out ignored,out needed);int error=Marshal.GetLastWin32Error();if(!ok||error!=0)throw new Win32Exception(error,"SACL observation privilege restoration failed.");}finally{CloseHandle(token);token=IntPtr.Zero;}}
|
||||
}
|
||||
public sealed class FileHandle : IDisposable {
|
||||
[StructLayout(LayoutKind.Sequential,Pack=4)] struct FileInfo {public uint Attributes;public long Created,Accessed,Written;public uint Volume,SizeHigh,SizeLow,Links,IndexHigh,IndexLow;}
|
||||
[DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true,ExactSpelling=true)] static extern IntPtr CreateFileW(string path,uint access,uint share,IntPtr security,uint disposition,uint flags,IntPtr template);
|
||||
[DllImport("kernel32.dll",SetLastError=true)] static extern bool CloseHandle(IntPtr handle);
|
||||
[DllImport("kernel32.dll",SetLastError=true)] static extern uint GetFileType(IntPtr handle);
|
||||
[DllImport("kernel32.dll",SetLastError=true)] static extern bool GetFileInformationByHandle(IntPtr handle,out FileInfo info);
|
||||
[DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true,ExactSpelling=true)] static extern uint GetFinalPathNameByHandleW(IntPtr handle,StringBuilder path,uint length,uint flags);
|
||||
[DllImport("kernel32.dll",SetLastError=true)] static extern bool ReadFile(IntPtr handle,[Out]byte[] buffer,uint count,out uint read,IntPtr overlapped);
|
||||
[DllImport("kernel32.dll",ExactSpelling=true)] static extern void GetSystemTimePreciseAsFileTime(out long value);
|
||||
[DllImport("kernel32.dll")] static extern IntPtr LocalFree(IntPtr memory);
|
||||
[DllImport("advapi32.dll")] static extern uint GetSecurityInfo(IntPtr handle,uint kind,uint flags,out IntPtr owner,out IntPtr group,out IntPtr dacl,out IntPtr sacl,out IntPtr descriptor);
|
||||
[DllImport("advapi32.dll")] static extern uint GetSecurityDescriptorLength(IntPtr descriptor);
|
||||
public const string SourceSha256="__WELA_FILE_PROBE_SOURCE_SHA256__";
|
||||
IntPtr handle;readonly bool canRead;bool readAttempted;readonly string selected;
|
||||
public static DateTime UtcNow(){long value;GetSystemTimePreciseAsFileTime(out value);return DateTime.FromFileTimeUtc(value);}
|
||||
public FileHandle(string path,bool readData) {
|
||||
if(String.IsNullOrEmpty(path)||path.Length>240||!System.Text.RegularExpressions.Regex.IsMatch(path,@"^[A-Za-z]:\\"))throw new InvalidOperationException("Select an ordinary absolute local file path, at most 240 characters.");
|
||||
if(path.Substring(2).IndexOf(':')>=0||path.IndexOfAny(new char[]{'"','*','?','<','>','|','/','\r','\n','\0'})>=0||!String.Equals(Path.GetFullPath(path),path,StringComparison.OrdinalIgnoreCase))throw new InvalidOperationException("Ambiguous file path refused.");
|
||||
string root=Path.GetPathRoot(path);if(new DriveInfo(root).DriveType!=DriveType.Fixed)throw new InvalidOperationException("Only fixed local drives are supported.");
|
||||
string part=root;foreach(string name in path.Substring(root.Length).Split('\\')) {
|
||||
if(name.Length==0||name=="."||name==".."||name.EndsWith(".")||name.EndsWith(" "))throw new InvalidOperationException("Ambiguous file component refused.");
|
||||
part=Path.Combine(part,name);if((File.GetAttributes(part)&FileAttributes.ReparsePoint)!=0)throw new InvalidOperationException("Reparse components are unsupported.");
|
||||
}
|
||||
selected=path;canRead=readData;
|
||||
try {
|
||||
// READ_CONTROL + ACCESS_SYSTEM_SECURITY + READ_ATTRIBUTES, optionally READ_DATA.
|
||||
// Share read only: reject concurrent write/delete handles while this handle is held.
|
||||
using(new SecurityPrivilege()){handle=CreateFileW(path,0x01020080U|(readData?1U:0U),1,IntPtr.Zero,3,0x00200000,IntPtr.Zero);if(handle==new IntPtr(-1)){handle=IntPtr.Zero;throw new Win32Exception(Marshal.GetLastWin32Error());}}
|
||||
if(GetFileType(handle)!=1)throw new InvalidOperationException("The selected handle is not a disk file.");
|
||||
Observe();
|
||||
}catch{Dispose();throw;}
|
||||
}
|
||||
public Observation Observe() {
|
||||
if(handle==IntPtr.Zero)throw new ObjectDisposedException("FileHandle");
|
||||
FileInfo info;if(!GetFileInformationByHandle(handle,out info))throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
// No directories, links, EFS, offline/cloud recall, or empty data streams.
|
||||
if((info.Attributes&(16U|1024U|4096U|16384U|0x40000U|0x400000U))!=0||info.Links!=1)throw new InvalidOperationException("Only ordinary local leaf files with one link are supported.");
|
||||
long size=((long)info.SizeHigh<<32)|info.SizeLow;if(size<=0)throw new InvalidOperationException("The selected file must be nonempty.");
|
||||
StringBuilder final=new StringBuilder(32768);uint length=GetFinalPathNameByHandleW(handle,final,(uint)final.Capacity,0);
|
||||
if(length==0||length>=final.Capacity||!String.Equals(final.ToString(),@"\\?\"+selected,StringComparison.OrdinalIgnoreCase))throw new InvalidOperationException("Native final file path differs from the selected local path.");
|
||||
string actual=final.ToString().Substring(4);
|
||||
// Bind the NT volume name from this same held handle: Security4663 may use it.
|
||||
StringBuilder native=new StringBuilder(32768);uint nativeLength=GetFinalPathNameByHandleW(handle,native,(uint)native.Capacity,2);
|
||||
if(nativeLength==0||nativeLength>=native.Capacity||!System.Text.RegularExpressions.Regex.IsMatch(native.ToString(),@"^\\Device\\[^\\]+\\"))throw new InvalidOperationException("Native NT file path observation failed.");
|
||||
string nativePath=native.ToString();IntPtr owner,group,dacl,sacl,descriptor;
|
||||
uint error=GetSecurityInfo(handle,1,0x1ff,out owner,out group,out dacl,out sacl,out descriptor);if(error!=0)throw new Win32Exception((int)error,"Full current SDK descriptor observation (0x1ff) failed.");
|
||||
byte[] bytes;try{uint count=GetSecurityDescriptorLength(descriptor);if(count<20||count>131072)throw new InvalidOperationException("File descriptor exceeds its observation bound.");bytes=new byte[count];Marshal.Copy(descriptor,bytes,0,(int)count);}finally{LocalFree(descriptor);}
|
||||
RawSecurityDescriptor sd=new RawSecurityDescriptor(bytes,0);List<Ace> entries=new List<Ace>();
|
||||
if(sd.SystemAcl!=null)foreach(GenericAce ace in sd.SystemAcl){if(entries.Count>=128)throw new InvalidOperationException("File SACL exceeds 128 entries.");CommonAce common=ace as CommonAce;bool ordinary=common!=null&&!common.IsCallback&&common.AceType==AceType.SystemAudit;byte[] binary=new byte[ace.BinaryLength];ace.GetBinaryForm(binary,0);entries.Add(new Ace{Type=(int)ace.AceType,Flags=(int)ace.AceFlags,Mask=ordinary?common.AccessMask:0,Sid=ordinary?common.SecurityIdentifier.Value:null,Binary=Convert.ToBase64String(binary),Ordinary=ordinary});}
|
||||
string identity=info.Volume+":"+info.IndexHigh+":"+info.IndexLow+":"+info.Created,encoded=Convert.ToBase64String(bytes),written=DateTime.FromFileTimeUtc(info.Written).ToString("o");
|
||||
string value=actual.ToUpperInvariant()+"|"+nativePath.ToUpperInvariant()+"|"+identity+"|"+size+"|"+written+"|"+info.Attributes+"|"+info.Links+"|"+encoded,key;
|
||||
using(SHA256 sha=SHA256.Create()){key=BitConverter.ToString(sha.ComputeHash(Encoding.UTF8.GetBytes(value))).Replace("-","").ToLowerInvariant();}
|
||||
return new Observation{Path=actual,NativePath=nativePath,Identity=identity,Size=size,LastWriteUtc=written,Attributes=info.Attributes,Links=info.Links,DescriptorBase64=encoded,SecurityInformation=511,Aces=entries.ToArray(),StateKey=key};
|
||||
}
|
||||
public ReadReceipt ReadOne(string expectedKey) {
|
||||
if(!canRead||readAttempted)throw new InvalidOperationException("Exactly one explicitly requested data read is permitted.");
|
||||
Observation before=Observe();if(!String.Equals(before.StateKey,expectedKey,StringComparison.Ordinal))throw new InvalidOperationException("Selected file changed before its one-byte read.");
|
||||
byte[] buffer=new byte[1];readAttempted=true;uint count=0;DateTime started=UtcNow(),returned;bool success;int error;
|
||||
try{success=ReadFile(handle,buffer,1,out count,IntPtr.Zero);error=Marshal.GetLastWin32Error();returned=UtcNow();}finally{Array.Clear(buffer,0,buffer.Length);}
|
||||
if(!success)throw new Win32Exception(error,"The one-byte read failed.");if(count!=1)throw new InvalidOperationException("The fixed read did not return exactly one byte.");
|
||||
Observation after=Observe();if(after.StateKey!=before.StateKey)throw new InvalidOperationException("Held file identity, data metadata or descriptor changed during the read.");
|
||||
// Measure the real completed phase, including the existing held-handle identity/security readback.
|
||||
// Retain the immediate ReadFile return separately; add no delay or timestamp padding.
|
||||
DateTime completed=UtcNow();
|
||||
return new ReadReceipt{StartedUtc=started.ToString("o"),ReadReturnedUtc=returned.ToString("o"),CompletedUtc=completed.ToString("o"),Phase="OneByteReadAndHeldIdentityReadback",Clock="GetSystemTimePreciseAsFileTime",ReadCalls=1,BytesRead=1,Succeeded=true,HandleId="0x"+unchecked((ulong)handle.ToInt64()).ToString("x"),BeforeKey=before.StateKey,AfterKey=after.StateKey};
|
||||
}
|
||||
public void Dispose(){if(handle!=IntPtr.Zero){CloseHandle(handle);handle=IntPtr.Zero;}}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
param([Parameter(Mandatory)][string]$RequestPath,[Parameter(Mandatory)][ValidatePattern('^[a-f0-9]{32}$')][string]$Nonce)
|
||||
$ErrorActionPreference='Stop';[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)
|
||||
if($args.Count){throw 'Unexpected file worker arguments.'}
|
||||
$script:ScriptRoot=Split-Path $PSScriptRoot -Parent
|
||||
Import-Module (Join-Path $script:ScriptRoot 'modules/AuditProfiles.psm1') -ErrorAction Stop
|
||||
foreach($name in @('Configuration','WefArrival','ChannelRead','WmiProbe','FileAccessProbe')){. (Join-Path $PSScriptRoot ($name+'.ps1'))}
|
||||
Initialize-WelaFileProbeNative
|
||||
$requestFile=Get-Item -LiteralPath (Resolve-WelaArrivalPath $RequestPath) -ErrorAction Stop
|
||||
if($requestFile.Length -gt 1048576){throw 'File worker request exceeds one MiB.'}
|
||||
$state=ConvertFrom-WelaArrivalJson ([IO.File]::ReadAllText($requestFile.FullName,[Text.UTF8Encoding]::new($false,$true)))
|
||||
$null=Get-WelaFileProbeStateKey $state
|
||||
$fresh=Get-WelaFileProbeState $state.File.Path
|
||||
if((Get-WelaFileProbeStateKey $fresh) -cne (Get-WelaFileProbeStateKey $state)){throw 'Host, caller, source, file or audit prerequisites changed before worker access.'}
|
||||
$handle=[Wela.FileAccessProbe.FileHandle]::new($state.File.Path,$true)
|
||||
try {
|
||||
if($handle.Observe().StateKey -cne $state.File.StateKey){throw 'Selected file changed before worker read.'}
|
||||
$beforeReader=Get-WelaChannelReader;$beforeToken=[Wela.WmiProbe.Native]::Snapshot()
|
||||
if((Get-WelaFileProbeTokenKey $beforeToken) -cne (Get-WelaFileProbeTokenKey $state.Token) -or (Get-WelaFileProbeReaderKey $beforeReader -AuthorizationOnly) -cne (Get-WelaFileProbeKey $state.Reader)){throw 'Worker does not preserve the expected caller token.'}
|
||||
$read=$handle.ReadOne($state.File.StateKey)
|
||||
$afterToken=[Wela.WmiProbe.Native]::Snapshot();$afterReader=Get-WelaChannelReader
|
||||
[pscustomobject]@{Kind='WelaOneByteFileRead';Nonce=$Nonce;ProcessId=$PID;Executable=(Get-Process -Id $PID).Path;FilePath=$state.File.Path;BeforeReader=$beforeReader;AfterReader=$afterReader;BeforeToken=$beforeToken;AfterToken=$afterToken;Read=$read}|ConvertTo-Json -Depth 16 -Compress
|
||||
}finally{$handle.Dispose()}
|
||||
@@ -0,0 +1,255 @@
|
||||
# One selected completed firewall text-log operation; never replay enforcement or rules.
|
||||
function Get-WelaFirewallRecoveryKey {param($Value) ConvertTo-Json -InputObject $Value -Depth 24 -Compress}
|
||||
|
||||
function ConvertTo-WelaFirewallRecoveryTuple {
|
||||
param($Value,[switch]$Snapshot)
|
||||
$fields=@('LogAllowed','LogBlocked','LogMaxSizeKilobytes','LogFileName')
|
||||
if($Snapshot){$fields=@('Name')+$fields+@('Enabled')}
|
||||
Assert-WelaArrivalObject $Value $fields
|
||||
if($Value.LogAllowed -isnot [string] -or $Value.LogAllowed -cnotin @('True','False') -or
|
||||
$Value.LogBlocked -isnot [string] -or $Value.LogBlocked -cnotin @('True','False')){throw 'Only explicit local True/False logging switches are recoverable; GPO NotConfigured requires manual review.'}
|
||||
$size=$Value.LogMaxSizeKilobytes
|
||||
if(($size -isnot [int] -and $size -isnot [long] -and $size -isnot [uint64] -and $size -isnot [uint32]) -or $size -lt 1 -or $size -gt 32767){throw 'Firewall logging size must be an integer from 1 through 32767 KiB.'}
|
||||
$null=Resolve-WelaFirewallRecoveryLogPath $Value.LogFileName
|
||||
if($Snapshot -and ($Value.Name -isnot [string] -or $Value.Name -cnotin @('Domain','Private','Public') -or $Value.Enabled -isnot [string] -or $Value.Enabled -cnotin @('True','False','NotConfigured'))){throw 'Invalid profile snapshot identity or enabled observation.'}
|
||||
[pscustomobject][ordered]@{LogAllowed=$Value.LogAllowed;LogBlocked=$Value.LogBlocked;LogMaxSizeKilobytes=[long]$size;LogFileName=$Value.LogFileName}
|
||||
}
|
||||
|
||||
function Resolve-WelaFirewallRecoveryLogPath {
|
||||
param($Path)
|
||||
if($Path -isnot [string] -or -not $Path -or $Path.Length -gt 260 -or $Path -match '[\x00-\x1f*?\[\]]' -or $Path -match '(^|[\\/])\.\.?([\\/]|$)'){throw 'A bounded ordinary local firewall log path is required.'}
|
||||
# Only native Windows directory variables have reviewed meaning in old paths.
|
||||
$expanded=[regex]::Replace($Path,'(?i)%(systemroot|windir)%',[Text.RegularExpressions.MatchEvaluator]{param($m) [Environment]::GetFolderPath([Environment+SpecialFolder]::Windows)})
|
||||
if($expanded -notmatch '^[A-Za-z]:\\' -or $expanded -match '%' -or $expanded.Substring(2).Contains(':') -or $expanded.EndsWith('\') -or $expanded.Contains('/')){throw 'UNC/device/relative paths, unknown variables and alternate streams are unsupported.'}
|
||||
foreach($segment in $expanded.Substring(3).Split([char]'\')){
|
||||
if(-not $segment -or $segment -match '[ .]$' -or $segment -match '^(?i:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)'){throw 'Ambiguous path segments and Windows device aliases are unsupported.'}
|
||||
}
|
||||
if([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT){$null=Resolve-WelaArrivalPath $expanded}
|
||||
$expanded
|
||||
}
|
||||
|
||||
function Get-WelaFirewallRecoverySources {
|
||||
$sources=[ordered]@{}
|
||||
foreach($name in @('WELA.ps1','scripts/FirewallLoggingRecovery.ps1','scripts/FirewallLogging.ps1','scripts/Configuration.ps1','scripts/AuditRecovery.ps1','scripts/WefArrival.ps1','scripts/WecUpdate.ps1','scripts/ChannelRead.ps1','scripts/ChannelReadNative.cs')) {
|
||||
$sources[$name]=(Get-FileHash -LiteralPath (Join-Path $script:ScriptRoot $name) -Algorithm SHA256 -ErrorAction Stop).Hash
|
||||
}
|
||||
[pscustomobject]$sources
|
||||
}
|
||||
|
||||
function Get-WelaFirewallRecoveryContext {
|
||||
$reader=Get-WelaChannelReader
|
||||
if(-not $reader.ElevatedAdministrator){throw 'Firewall recovery requires the actual non-impersonated elevated administrator.'}
|
||||
# Observe service state before connecting to native WMI/NetSecurity providers.
|
||||
# A read must not be used to start prerequisites implicitly.
|
||||
$services=@(Get-Service -Name Winmgmt,MpsSvc,BFE -ErrorAction Stop | Sort-Object Name | ForEach-Object {[pscustomobject]@{Name=$_.Name;Status=[string]$_.Status}})
|
||||
if($services.Count -ne 3 -or @($services | Where-Object Status -cne 'Running').Count){throw 'Winmgmt, MpsSvc and BFE must already be running; recovery starts no services.'}
|
||||
$os=Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
|
||||
$computer=Get-CimInstance Win32_ComputerSystem -ErrorAction Stop
|
||||
$build=[int]$os.BuildNumber
|
||||
if(($os.ProductType -eq 1 -and $build -notin @(22000,22621,22631,26100,26200)) -or
|
||||
($os.ProductType -in @(2,3) -and $build -notin @(20348,26100)) -or $os.ProductType -notin @(1,2,3)){throw 'Unreviewed Windows host for firewall recovery.'}
|
||||
$machine=Get-WelaRegistryState 'HKLM:\SOFTWARE\Microsoft\Cryptography' MachineGuid
|
||||
$guid=[guid]::Empty
|
||||
if(-not $machine.ValueExists -or $machine.Type -cne 'String' -or -not [guid]::TryParse([string]$machine.Value,[ref]$guid) -or $guid -eq [guid]::Empty){throw 'Actual machine identity is unavailable.'}
|
||||
$revision=Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -Name UBR -ErrorAction Stop
|
||||
[pscustomobject][ordered]@{Computer=[Environment]::MachineName;MachineGuid=$guid.ToString();Build=$build;UBR=$revision.UBR;ProductType=[int]$os.ProductType;DomainRole=[int]$computer.DomainRole;Domain=[string]$computer.Domain;DomainJoined=[bool]$computer.PartOfDomain;Services=$services
|
||||
Reader=[pscustomobject]@{UserSid=$reader.UserSid;UserName=$reader.UserName;AuthenticationId=$reader.AuthenticationId;GroupSids=$reader.GroupSids;ElevatedAdministrator=$reader.ElevatedAdministrator;Impersonation=$reader.Impersonation}
|
||||
Engine=$PSVersionTable.PSVersion.ToString()}
|
||||
}
|
||||
|
||||
function Get-WelaFirewallRecoveryNativeSources {
|
||||
$base=[IO.Path]::GetFullPath((Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell/v1.0/Modules/NetSecurity'))
|
||||
$commands=@('Get-NetFirewallProfile','Set-NetFirewallProfile','Get-NetFirewallRule')+@('Port','Address','Application','Service','Interface','InterfaceType','Security' | ForEach-Object {"Get-NetFirewall${_}Filter"})
|
||||
foreach($name in $commands){
|
||||
$command=@(Get-Command "NetSecurity\$name" -ErrorAction Stop)
|
||||
if($command.Count -ne 1 -or $command[0].Name -cne $name -or [IO.Path]::GetFullPath($command[0].Module.ModuleBase) -ine $base){throw "Native NetSecurity command source is unverified: $name"}
|
||||
}
|
||||
$files=@(Get-ChildItem -LiteralPath $base -File -Recurse -ErrorAction Stop | Where-Object Extension -in @('.psd1','.psm1','.cdxml','.dll','.ps1xml') | Sort-Object FullName)
|
||||
if($files.Count -lt 1 -or $files.Count -gt 160){throw 'Unexpected native firewall module inventory.'}
|
||||
$hashes=[ordered]@{}
|
||||
foreach($file in $files){if($file.Length -gt 16MB -or ($file.Attributes -band [IO.FileAttributes]::ReparsePoint)){throw 'Unsupported firewall module source.'};$hashes[$file.FullName]=(Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256 -ErrorAction Stop).Hash}
|
||||
[pscustomobject]$hashes
|
||||
}
|
||||
|
||||
function ConvertTo-WelaFirewallRecoveryCim {
|
||||
param($Value,[string[]]$Exclude=@())
|
||||
if(-not $Value.CimClass.CimClassName -or -not $Value.CimInstanceProperties){throw 'Native firewall CIM configuration is missing.'}
|
||||
$properties=@($Value.CimInstanceProperties | Sort-Object Name)
|
||||
if($properties.Count -gt 160){throw 'Native firewall property bound exceeded.'}
|
||||
$result=[ordered]@{Class=[string]$Value.CimClass.CimClassName}
|
||||
foreach($property in $properties){
|
||||
if($property.Name -in $Exclude){continue}
|
||||
if($result.Contains($property.Name)){throw 'Duplicate native firewall property.'}
|
||||
$valueData=$property.Value
|
||||
if(@($valueData).Count -gt 256){throw 'Native firewall property array bound exceeded.'}
|
||||
foreach($item in @($valueData)){
|
||||
if($null -ne $item -and $item -isnot [string] -and $item -isnot [bool] -and $item -isnot [byte] -and
|
||||
$item -isnot [uint16] -and $item -isnot [uint32] -and $item -isnot [uint64] -and $item -isnot [int16] -and $item -isnot [int] -and $item -isnot [long]){throw "Unsupported native property type: $($property.Name)"}
|
||||
if($item -is [string] -and $item.Length -gt 32768){throw 'Native firewall property string bound exceeded.'}
|
||||
}
|
||||
$result[$property.Name]=[pscustomobject]@{Type=$property.CimType.ToString();Value=$valueData}
|
||||
}
|
||||
[pscustomobject]$result
|
||||
}
|
||||
|
||||
function Get-WelaFirewallRecoveryRuleDigest {
|
||||
param([ValidateSet('PersistentStore','ActiveStore')][string]$Store)
|
||||
# Hash configuration fields; volatile operational diagnostics are not policy.
|
||||
$volatile=@('PrimaryStatus','Status','StatusDescriptions','EnforcementStatus','OperationalStatus','CommunicationStatus','HealthState','OperatingStatus','DetailedStatus','TimeOfLastStateChange','InstallDate')
|
||||
foreach($kind in @('Rule','PortFilter','AddressFilter','ApplicationFilter','ServiceFilter','InterfaceFilter','InterfaceTypeFilter','SecurityFilter')){
|
||||
$command="NetSecurity\Get-NetFirewall$kind"
|
||||
$items=@(& $command -PolicyStore $Store -ErrorAction Stop | Select-Object -First 4097)
|
||||
if($items.Count -gt 4096){throw "Firewall $Store $kind inventory exceeded 4096 objects; recovery is unverified."}
|
||||
$keys=@(foreach($item in $items){Get-WelaFirewallRecoveryKey (ConvertTo-WelaFirewallRecoveryCim $item $volatile)}) | Sort-Object
|
||||
$bytes=[Text.UTF8Encoding]::new($false).GetBytes((Get-WelaFirewallRecoveryKey @($keys)))
|
||||
if($bytes.Length -gt 16MB){throw 'Firewall configuration inventory exceeds the byte bound.'}
|
||||
[pscustomobject]@{Store=$Store;Kind=$kind;Count=$items.Count;Sha256=Get-WelaArrivalHash $bytes}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-WelaFirewallRecoveryState {
|
||||
$context=Get-WelaFirewallRecoveryContext
|
||||
$moduleSources=Get-WelaFirewallRecoveryNativeSources
|
||||
$stores=[ordered]@{};$digests=@()
|
||||
foreach($store in @('PersistentStore','ActiveStore')){
|
||||
$profiles=@(NetSecurity\Get-NetFirewallProfile -PolicyStore $store -ErrorAction Stop | Sort-Object Name)
|
||||
if($profiles.Count -ne 3 -or @($profiles.Name | Sort-Object -Unique).Count -ne 3){throw 'Expected exactly three native firewall profiles.'}
|
||||
$byName=[ordered]@{}
|
||||
foreach($profile in $profiles){
|
||||
$snapshot=ConvertTo-WelaFirewallLoggingSnapshot $profile
|
||||
$logging=ConvertTo-WelaFirewallRecoveryTuple $snapshot -Snapshot
|
||||
$byName[$snapshot.Name]=[pscustomobject]@{Logging=$logging;Preserved=ConvertTo-WelaFirewallRecoveryCim $profile @('LogAllowed','LogBlocked','LogMaxSizeKilobytes','LogFileName')}
|
||||
}
|
||||
$stores[$store]=[pscustomobject]$byName
|
||||
$digests+=@(Get-WelaFirewallRecoveryRuleDigest $store)
|
||||
}
|
||||
[pscustomobject][ordered]@{Context=$context;Sources=Get-WelaFirewallRecoverySources;NativeSources=$moduleSources;Profiles=[pscustomobject]$stores;RuleConfiguration=$digests}
|
||||
}
|
||||
|
||||
function Get-WelaFirewallRecoveryInvariant {
|
||||
param($State,[string]$Profile)
|
||||
$copy=Get-WelaFirewallRecoveryKey $State | ConvertFrom-Json
|
||||
$copy.Profiles.PersistentStore.$Profile.Logging=$null
|
||||
$copy.Profiles.ActiveStore.$Profile.Logging=$null
|
||||
Get-WelaFirewallRecoveryKey $copy
|
||||
}
|
||||
|
||||
function Read-WelaFirewallRecoveryEvidence {
|
||||
param([string]$JournalPath,[string]$ResultsPath,[ValidateSet('Domain','Private','Public')][string]$Profile,[string]$Computer)
|
||||
$journal=Read-WelaWecUpdateFile $JournalPath;$resultFile=Read-WelaWecUpdateFile $ResultsPath
|
||||
$entries=@($journal.Text -split '\r?\n' | Where-Object {$_ -match '\S'} | ForEach-Object {ConvertFrom-WelaRecoveryJson $_})
|
||||
$results=ConvertFrom-WelaRecoveryJson $resultFile.Text
|
||||
if($entries.Count -lt 1 -or $entries.Count -gt 3 -or $results.Scope -isnot [string] -or $results.Scope -cne 'firewall-text-logging-only' -or $results.DryRun -isnot [bool] -or $results.DryRun -or $results.Results -isnot [array] -or $results.Results.Count -lt 1 -or $results.Results.Count -gt 3){throw 'Dedicated completed non-dry-run firewall configuration evidence is required.'}
|
||||
$seen=@{};$final=@{}
|
||||
foreach($entry in $entries){
|
||||
if(($entry.Version -isnot [int] -and $entry.Version -isnot [long]) -or $entry.Version -ne 1 -or $entry.Kind -isnot [string] -or $entry.Kind -cne 'FirewallTextLog' -or
|
||||
$entry.Id -cnotin @('FirewallTextLog/Domain','FirewallTextLog/Private','FirewallTextLog/Public') -or $seen.ContainsKey($entry.Id) -or $entry.ComputerName -isnot [string] -or $entry.ComputerName -ine $Computer){throw 'Unknown, duplicate or wrong-host firewall journal entry.'}
|
||||
if((ConvertTo-WelaArrivalUtc $entry.RecordedUtc) -gt [DateTimeOffset]::UtcNow.AddMinutes(1)){throw 'Journal timestamp is in the future.'}
|
||||
$seen[$entry.Id]=$entry
|
||||
}
|
||||
foreach($row in $results.Results){
|
||||
if($row.Kind -isnot [string] -or $row.Kind -cne 'FirewallTextLog' -or $row.Id -cnotin @('FirewallTextLog/Domain','FirewallTextLog/Private','FirewallTextLog/Public') -or $final.ContainsKey($row.Id)){throw 'Unknown or duplicate firewall result.'}
|
||||
$final[$row.Id]=$row
|
||||
}
|
||||
$id="FirewallTextLog/$Profile"
|
||||
if(-not $seen.ContainsKey($id) -or -not $final.ContainsKey($id) -or $final[$id].Status -isnot [string] -or $final[$id].Status -cne 'Applied'){throw 'One selected completed Applied firewall operation is required; partial/failed writes need manual review.'}
|
||||
$entry=$seen[$id];$row=$final[$id]
|
||||
foreach($field in @('Before','Desired','Target')){if((Get-WelaFirewallRecoveryKey $entry.$field) -cne (Get-WelaFirewallRecoveryKey $row.$field)){throw "Journal/result $field mismatch."}}
|
||||
Assert-WelaArrivalObject $entry.Target @('Name','PolicyStore')
|
||||
if($entry.Target.Name -isnot [string] -or $entry.Target.PolicyStore -isnot [string] -or $entry.Target.Name -cne $Profile -or $entry.Target.PolicyStore -cne 'PersistentStore'){throw 'Only the exact selected local PersistentStore profile is recoverable.'}
|
||||
Assert-WelaArrivalObject $entry.Desired @('LogAllowed','LogBlocked','MinimumSizeKiB','LogFileName','PathMode')
|
||||
$desired=$entry.Desired
|
||||
if($desired.LogAllowed -isnot [string] -or $desired.LogBlocked -isnot [string] -or $desired.LogAllowed -cne 'True' -or $desired.LogBlocked -cne 'True' -or ($desired.MinimumSizeKiB -isnot [int] -and $desired.MinimumSizeKiB -isnot [long]) -or $desired.MinimumSizeKiB -lt 16384 -or $desired.MinimumSizeKiB -gt 32767 -or $desired.PathMode -cnotin @('Preserve','CisV4')){throw 'Unsupported original firewall desired state.'}
|
||||
foreach($snapshot in @($entry.Before.Local,$entry.Before.Effective,$row.After.Local,$row.After.Effective)){
|
||||
$null=ConvertTo-WelaFirewallRecoveryTuple $snapshot -Snapshot
|
||||
if($snapshot.Name -cne $Profile){throw 'Original snapshot profile differs from selected profile.'}
|
||||
}
|
||||
$before=ConvertTo-WelaFirewallRecoveryTuple $entry.Before.Local -Snapshot
|
||||
$expected=ConvertTo-WelaFirewallRecoveryTuple $row.After.Local -Snapshot
|
||||
$effective=ConvertTo-WelaFirewallRecoveryTuple $row.After.Effective -Snapshot
|
||||
$requiredSize=[Math]::Max([long]$desired.MinimumSizeKiB,[Math]::Max([long]$entry.Before.Local.LogMaxSizeKilobytes,[long]$entry.Before.Effective.LogMaxSizeKilobytes))
|
||||
$path=if($desired.PathMode -ceq 'CisV4'){'%SystemRoot%\System32\LogFiles\Firewall\'+$Profile.ToLowerInvariant()+'fw.log'}else{$before.LogFileName}
|
||||
if($expected.LogAllowed -cne 'True' -or $expected.LogBlocked -cne 'True' -or $expected.LogMaxSizeKilobytes -ne $requiredSize -or $expected.LogFileName -cne $path){throw 'Recorded local After is not the permitted original logging-only change.'}
|
||||
$desiredPath=Resolve-WelaFirewallRecoveryLogPath $desired.LogFileName
|
||||
$plannedPath=if($desired.PathMode -ceq 'CisV4'){Resolve-WelaFirewallRecoveryLogPath $path}else{Resolve-WelaFirewallRecoveryLogPath $entry.Before.Effective.LogFileName}
|
||||
if($desiredPath -ine $plannedPath -or $effective.LogAllowed -cne 'True' -or $effective.LogBlocked -cne 'True' -or $effective.LogMaxSizeKilobytes -lt $desired.MinimumSizeKiB -or
|
||||
(Resolve-WelaFirewallRecoveryLogPath $effective.LogFileName) -ine $desiredPath -or $row.After.Access.State -isnot [string] -or $row.After.Access.State -cne 'VerifiedExplicitGrant'){throw 'Recorded effective After does not confirm the original logging configuration.'}
|
||||
if((Get-WelaFirewallRecoveryKey $before) -ceq (Get-WelaFirewallRecoveryKey $expected)){throw 'Selected evidence records no local logging change.'}
|
||||
[pscustomobject][ordered]@{Id=$id;Profile=$Profile;Journal=[pscustomobject]@{Path=$journal.Path;Sha256=$journal.Hash};OriginalResults=[pscustomobject]@{Path=$resultFile.Path;Sha256=$resultFile.Hash};Expected=$expected;RecoverTo=$before}
|
||||
}
|
||||
|
||||
function Set-WelaFirewallRecoveryLogging {
|
||||
param([ValidateSet('Domain','Private','Public')][string]$Profile,$Tuple)
|
||||
$values=ConvertTo-WelaFirewallRecoveryTuple $Tuple
|
||||
NetSecurity\Set-NetFirewallProfile -Name $Profile -PolicyStore PersistentStore -LogAllowed $values.LogAllowed -LogBlocked $values.LogBlocked -LogMaxSizeKilobytes ([uint64]$values.LogMaxSizeKilobytes) -LogFileName $values.LogFileName -Confirm:$false -ErrorAction Stop
|
||||
}
|
||||
|
||||
function Assert-WelaFirewallRecoveryInputs {
|
||||
param($Plan,[string]$PlanPath,[string]$PlanHash)
|
||||
if((Read-WelaWecUpdateFile $PlanPath).Hash -cne $PlanHash){throw 'Reviewed recovery plan bytes changed.'}
|
||||
$rebuilt=Read-WelaFirewallRecoveryEvidence $Plan.Control.Journal.Path $Plan.Control.OriginalResults.Path $Plan.Profile $Plan.State.Context.Computer
|
||||
if((Get-WelaFirewallRecoveryKey $rebuilt) -cne (Get-WelaFirewallRecoveryKey $Plan.Control)){throw 'Original recovery evidence changed or no longer matches the plan.'}
|
||||
}
|
||||
|
||||
function Invoke-WelaFirewallLoggingRecovery {
|
||||
param([ValidateSet('Plan','Restore')][string]$Action='Plan',[string]$Profile,[string]$JournalPath,[string]$ResultsPath,[string]$PlanPath,[string]$PlanHash,[string]$OutputPath,[switch]$Auto,[switch]$DryRun)
|
||||
$ErrorActionPreference='Stop'
|
||||
if($Action -eq 'Plan'){
|
||||
if($Profile -cnotin @('Domain','Private','Public') -or -not $JournalPath -or -not $ResultsPath -or -not $OutputPath -or $PlanPath -or $PlanHash -or $Auto -or $DryRun){throw 'Plan requires one profile, original journal/results and new output only.'}
|
||||
}elseif($Profile -or $JournalPath -or $ResultsPath -or -not $PlanPath -or $PlanHash -cnotmatch '^[a-f0-9]{64}$' -or ($DryRun -and $OutputPath) -or (-not $DryRun -and -not $OutputPath)){throw 'Restore requires a reviewed plan/hash and new output, or DryRun without output.'}
|
||||
$report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaFirewallLoggingRecovery';Action=$Action;Status='Refused';ExitCode=1;WriteAttempted=$false;Before=$null;After=$null;EffectiveMatchesLocal=$null;OutputPath=$null;Artifacts=@();PlanSha256=$null;Diagnostic='';ReadyRuleCredit=0;Scope='Restore four PersistentStore logging fields on one profile only; effective policy and event generation are separate.'}
|
||||
try {
|
||||
if($Action -eq 'Plan'){
|
||||
$state=Get-WelaFirewallRecoveryState
|
||||
$control=Read-WelaFirewallRecoveryEvidence $JournalPath $ResultsPath $Profile $state.Context.Computer
|
||||
$local=$state.Profiles.PersistentStore.$Profile.Logging
|
||||
if((Get-WelaFirewallRecoveryKey $local) -cne (Get-WelaFirewallRecoveryKey $control.Expected)){throw 'Current local logging tuple differs from the completed original After state.'}
|
||||
$plan=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaFirewallLoggingRecoveryPlan';Profile=$Profile;Control=$control;State=$state;HistoricalIdentity='Version-1 configuration journals record only ComputerName; current MachineGuid and operator/logon bind this recovery plan, not historical authorship.'}
|
||||
if((Get-WelaFirewallRecoveryKey (Get-WelaFirewallRecoveryState)) -cne (Get-WelaFirewallRecoveryKey $state)){throw 'Current firewall context changed during planning.'}
|
||||
$report.OutputPath=New-WelaArrivalOutput $OutputPath $script:ScriptRoot
|
||||
$planText=Get-WelaFirewallRecoveryKey $plan
|
||||
if([Text.UTF8Encoding]::new($false).GetByteCount($planText) -gt 4MB){throw 'Recovery plan exceeds its input byte bound.'}
|
||||
$artifact=Write-WelaWecUpdateArtifact $report.OutputPath 'plan.json' $planText;$report.Artifacts+=$artifact;$report.PlanSha256=$artifact.Sha256
|
||||
$report.Before=$state;$report.Status='Planned';$report.ExitCode=0
|
||||
}else{
|
||||
$source=Read-WelaWecUpdateFile $PlanPath
|
||||
if($source.Hash -cne $PlanHash){throw 'Reviewed plan SHA256 differs from the selected file.'}
|
||||
$plan=ConvertFrom-WelaRecoveryJson $source.Text
|
||||
Assert-WelaArrivalObject $plan @('SchemaVersion','Kind','Profile','Control','State','HistoricalIdentity')
|
||||
if(($plan.SchemaVersion -isnot [int] -and $plan.SchemaVersion -isnot [long]) -or $plan.SchemaVersion -ne 1 -or $plan.Kind -isnot [string] -or $plan.Kind -cne 'WelaFirewallLoggingRecoveryPlan' -or $plan.Profile -cnotin @('Domain','Private','Public')){throw 'Unsupported firewall recovery plan.'}
|
||||
$report.PlanSha256=$source.Hash
|
||||
Assert-WelaFirewallRecoveryInputs $plan $source.Path $source.Hash
|
||||
$current=Get-WelaFirewallRecoveryState;$report.Before=$current
|
||||
$invariant=Get-WelaFirewallRecoveryInvariant $plan.State $plan.Profile
|
||||
if((Get-WelaFirewallRecoveryInvariant $current $plan.Profile) -cne $invariant){throw 'Host, operator, source, enforcement, other profile or rule configuration changed since planning.'}
|
||||
$local=$current.Profiles.PersistentStore.($plan.Profile).Logging
|
||||
$already=(Get-WelaFirewallRecoveryKey $local) -ceq (Get-WelaFirewallRecoveryKey $plan.Control.RecoverTo)
|
||||
if(-not $already -and (Get-WelaFirewallRecoveryKey $local) -cne (Get-WelaFirewallRecoveryKey $plan.Control.Expected)){throw 'Selected local logging tuple drifted from the confirmed original After state.'}
|
||||
if($DryRun){$report.Status=if($already){'AlreadyRestored'}else{'WouldRestore'};$report.ExitCode=0;return $report}
|
||||
$report.OutputPath=New-WelaArrivalOutput $OutputPath $script:ScriptRoot
|
||||
$report.Artifacts+=Write-WelaWecUpdateArtifact $report.OutputPath 'reviewed-plan.json' $source.Text
|
||||
if(-not $already){
|
||||
if(-not $Auto -and (Read-Host "Restore only $($plan.Profile) firewall logging fields to the reviewed original values? (y/N)") -cnotin @('y','Y')){throw 'Recovery declined; no setter was called.'}
|
||||
$report.Artifacts+=Write-WelaWecUpdateArtifact $report.OutputPath 'pending.json' (Get-WelaFirewallRecoveryKey ([pscustomobject]@{Status='Pending';RecordedUtc=[DateTime]::UtcNow.ToString('o');PlanSha256=$source.Hash;Before=$current;RecoverTo=$plan.Control.RecoverTo}))
|
||||
Assert-WelaFirewallRecoveryInputs $plan $source.Path $source.Hash
|
||||
$fresh=Get-WelaFirewallRecoveryState
|
||||
if((Get-WelaFirewallRecoveryKey $fresh) -cne (Get-WelaFirewallRecoveryKey $current)){throw 'Context changed after confirmation/intent receipt; no recovery setter was called.'}
|
||||
foreach($artifact in $report.Artifacts){if((Get-FileHash -LiteralPath (Join-Path $report.OutputPath $artifact.Name) -Algorithm SHA256).Hash.ToLowerInvariant() -cne $artifact.Sha256){throw 'Durable recovery evidence changed before the setter.'}}
|
||||
$report.WriteAttempted=$true
|
||||
Set-WelaFirewallRecoveryLogging $plan.Profile $plan.Control.RecoverTo
|
||||
}
|
||||
$after=Get-WelaFirewallRecoveryState;$report.After=$after
|
||||
if((Get-WelaFirewallRecoveryInvariant $after $plan.Profile) -cne $invariant -or
|
||||
(Get-WelaFirewallRecoveryKey $after.Profiles.PersistentStore.($plan.Profile).Logging) -cne (Get-WelaFirewallRecoveryKey $plan.Control.RecoverTo)){throw 'Local logging restoration or preserved firewall context did not verify.'}
|
||||
Assert-WelaFirewallRecoveryInputs $plan $source.Path $source.Hash
|
||||
$report.EffectiveMatchesLocal=(Get-WelaFirewallRecoveryKey $after.Profiles.ActiveStore.($plan.Profile).Logging) -ceq (Get-WelaFirewallRecoveryKey $after.Profiles.PersistentStore.($plan.Profile).Logging)
|
||||
$report.Artifacts+=Write-WelaWecUpdateArtifact $report.OutputPath 'confirmed.json' (Get-WelaFirewallRecoveryKey ([pscustomobject]@{Status='LocalReadbackVerified';PlanSha256=$source.Hash;After=$after;WriteAttempted=$report.WriteAttempted;EffectiveMatchesLocal=$report.EffectiveMatchesLocal}))
|
||||
$final=Get-WelaFirewallRecoveryState;$report.After=$final
|
||||
if((Get-WelaFirewallRecoveryKey $final) -cne (Get-WelaFirewallRecoveryKey $after)){throw 'Final firewall context drifted after readback.'}
|
||||
Assert-WelaFirewallRecoveryInputs $plan $source.Path $source.Hash
|
||||
$report.Status=if($already){'AlreadyRestored'}else{'LocalLoggingRestored'};$report.ExitCode=0
|
||||
}
|
||||
}catch{$report.Status=if($report.WriteAttempted){'WriteAttemptedUnverified'}else{'Refused'};$report.Diagnostic=$_.Exception.Message}
|
||||
if($report.OutputPath){$null=Write-WelaWecUpdateArtifact $report.OutputPath 'result.json' (Get-WelaFirewallRecoveryKey $report)}
|
||||
$report
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
# Read-only local NetSecurity evidence. No policy, service or traffic changes.
|
||||
function Test-WelaIpsecConditionalPolicy {
|
||||
param($Plan, $Policy)
|
||||
return (-not $Plan.PSObject.Properties['CustomProfileSource'] -and
|
||||
$Plan.profile -ceq 'microsoft-stronger-reviewed-2026-09' -and
|
||||
$Policy.guid -ieq '0CCE9218-69AE-11D9-BED3-505054503030' -and $Policy.mode -eq 'optional')
|
||||
}
|
||||
|
||||
function Get-WelaIpsecPrerequisite {
|
||||
[CmdletBinding()]
|
||||
param([switch]$Offline,
|
||||
[scriptblock]$ReadRules = { NetSecurity\Get-NetIPsecRule -PolicyStore ActiveStore -ErrorAction Stop },
|
||||
[scriptblock]$ReadAssociations = { NetSecurity\Get-NetIPsecMainModeSA -ErrorAction Stop })
|
||||
$started = [DateTime]::UtcNow.ToString('o')
|
||||
$rules = @(); $associations = @(); $reads = @(); $diagnostics = @()
|
||||
if ($Offline) { $diagnostics += 'Offline scenario; this host was not queried.' }
|
||||
else {
|
||||
foreach ($source in @('ActiveStoreRules', 'MainModeAssociations')) {
|
||||
$status = 'Complete'; $errorText = ''; $items = @()
|
||||
try {
|
||||
$reader = if ($source -eq 'ActiveStoreRules') { $ReadRules } else { $ReadAssociations }
|
||||
# Keep at most 4096 observations per native source. A cap is not an empty/successful inventory.
|
||||
$items = @(& $reader | Select-Object -First 4097)
|
||||
if ($items.Count -gt 4096) { throw 'Observation cap exceeded (4096 records).' }
|
||||
$seen = @{}
|
||||
foreach ($item in $items) {
|
||||
if ($source -eq 'ActiveStoreRules') {
|
||||
foreach ($property in @('Name', 'Enabled', 'InboundSecurity', 'OutboundSecurity', 'PrimaryStatus')) {
|
||||
if ($null -eq $item -or -not $item.PSObject.Properties[$property] -or $null -eq $item.$property) { throw "Missing native rule property: $property." }
|
||||
}
|
||||
$name = [string]$item.Name
|
||||
$enabled = [string]$item.Enabled; $inbound = [string]$item.InboundSecurity; $outbound = [string]$item.OutboundSecurity; $health = [string]$item.PrimaryStatus
|
||||
if (-not $name -or $name.Length -gt 1024 -or $seen.ContainsKey($name) -or $enabled -cnotin @('True','False') -or
|
||||
$inbound -cnotin @('None','Request','Require') -or $outbound -cnotin @('None','Request','Require') -or
|
||||
$health -cnotin @('OK','Inactive','Error','Unknown')) { throw "Unrecognized or duplicate native IPsec rule observation: Name='$name', Enabled='$enabled', InboundSecurity='$inbound', OutboundSecurity='$outbound', PrimaryStatus='$health'." }
|
||||
$seen[$name] = $true
|
||||
$qualifies = $enabled -ceq 'True' -and ($inbound -cne 'None' -or $outbound -cne 'None') -and $health -ceq 'OK'
|
||||
$rules += [pscustomobject]@{ Name=$name; Enabled=$enabled; InboundSecurity=$inbound; OutboundSecurity=$outbound; PrimaryStatus=$health; Qualifies=$qualifies }
|
||||
if ($enabled -ceq 'True' -and ($inbound -cne 'None' -or $outbound -cne 'None') -and $health -cne 'OK') { throw 'Enabled non-exemption rule has uncertain effective health.' }
|
||||
} else {
|
||||
foreach ($property in @('Name','LocalEndpoint','RemoteEndpoint')) {
|
||||
if ($null -eq $item -or -not $item.PSObject.Properties[$property] -or -not [string]$item.$property) { throw "Missing native association property: $property." }
|
||||
}
|
||||
$name = [string]$item.Name; $local = [string]$item.LocalEndpoint; $remote = [string]$item.RemoteEndpoint
|
||||
$address = $null
|
||||
if ($name.Length -gt 1024 -or $seen.ContainsKey($name) -or -not [Net.IPAddress]::TryParse($local,[ref]$address) -or -not [Net.IPAddress]::TryParse($remote,[ref]$address)) { throw 'Unrecognized or duplicate main-mode association.' }
|
||||
$seen[$name] = $true
|
||||
$associations += [pscustomobject]@{ Name=$name; LocalEndpoint=$local; RemoteEndpoint=$remote }
|
||||
}
|
||||
}
|
||||
} catch { $status = 'Unknown'; $errorText = $_.Exception.Message; $diagnostics += "$source`: $errorText" }
|
||||
$reads += [pscustomobject]@{ Source=$source; Status=$status; ObservedCount=$items.Count; Diagnostic=$errorText }
|
||||
}
|
||||
}
|
||||
$status = if ($Offline -or @($reads | Where-Object Status -ne Complete).Count) { 'Unknown' }
|
||||
elseif (@($rules | Where-Object Qualifies).Count -or $associations.Count) { 'Applicable' }
|
||||
else { 'NotObservedWithinScope' }
|
||||
[pscustomobject][ordered]@{
|
||||
SchemaVersion=1; Status=$status; Scope='Local NetSecurity ActiveStore rules and current main-mode SAs'
|
||||
StartedUtc=$started; CompletedUtc=[DateTime]::UtcNow.ToString('o'); ComputerName=$env:COMPUTERNAME
|
||||
Basis=$(if ($status -eq 'Applicable') { 'Enabled healthy non-exemption effective rule or current main-mode SA observed.' } else { 'No complete positive prerequisite evidence.' })
|
||||
Reads=$reads; Rules=$rules; MainModeAssociations=$associations; Diagnostic=($diagnostics -join ' ')
|
||||
Limitations='Point-in-time local scope. Configured rules do not prove matching traffic, successful negotiation or audit events. Absence does not exclude legacy IPsec, VPN or other providers. No event-volume, failure-outcome or Sigma credit.'
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-WelaIpsecPrerequisite {
|
||||
param($Evidence)
|
||||
if ($null -eq $Evidence -or $Evidence.Status -cne 'Applicable') {
|
||||
$status = if ($Evidence) { $Evidence.Status } else { 'Unknown' }
|
||||
throw "IPsec Main Mode prerequisite is $status; this conditional audit setting was not changed. $($Evidence.Diagnostic)"
|
||||
}
|
||||
}
|
||||
@@ -53,12 +53,15 @@ function Get-WelaProviderPackSchema {
|
||||
if ([guid]$provider.Id -eq [guid]::Empty) { throw 'Provider GUID is unknown.' }
|
||||
if (@($logs[0].ProviderNames) -notcontains $Pack.provider -or @($provider.LogLinks.LogName) -notcontains $Pack.channel) { throw 'Provider/channel links disagree.' }
|
||||
$events = @()
|
||||
# EventMetadata.Id is Int64; WinRM includes unrelated IDs above Int32.MaxValue.
|
||||
# Compare before parsing selected templates, without narrowing the native ID.
|
||||
$expectedIds = @($Pack.events | ForEach-Object { [long]$_.id })
|
||||
foreach ($event in $provider.Events) {
|
||||
if (@($Pack.events.id) -contains [int]$event.Id -and $event.LogLink.LogName -eq $Pack.channel) {
|
||||
if ($expectedIds -contains [long]$event.Id -and $event.LogLink.LogName -eq $Pack.channel) {
|
||||
$fields = @(Get-WelaProviderTemplateFields -Template $event.Template)
|
||||
$sha = [Security.Cryptography.SHA256]::Create()
|
||||
try { $templateHash = ([BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes([string]$event.Template)))).Replace('-','').ToLowerInvariant() } finally { $sha.Dispose() }
|
||||
$events += [pscustomobject]@{ Id=[int]$event.Id; Version=[int]$event.Version; Channel=[string]$event.LogLink.LogName; Fields=$fields; TemplateSha256=$templateHash }
|
||||
$events += [pscustomobject]@{ Id=[long]$event.Id; Version=[int]$event.Version; Channel=[string]$event.LogLink.LogName; Fields=$fields; TemplateSha256=$templateHash }
|
||||
}
|
||||
}
|
||||
[pscustomobject]@{ State='Observed'; Provider=[string]$provider.Name; ProviderGuid=[string]$provider.Id; ChannelType=[string]$logs[0].LogType; Events=$events; Diagnostic=$null }
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# Explicit outgoing audit policy; does not invoke the broad configure workflow.
|
||||
function Get-WelaOutgoingAuditSnapshot {
|
||||
if ($env:OS -ne 'Windows_NT' -or -not [Environment]::Is64BitProcess) { throw 'Use 64-bit PowerShell on Windows.' }
|
||||
$os=Get-CimInstance Win32_OperatingSystem -Property BuildNumber,ProductType -ErrorAction Stop
|
||||
$computer=Get-CimInstance Win32_ComputerSystem -Property DomainRole,PartOfDomain -ErrorAction Stop
|
||||
$build=[int]$os.BuildNumber;$product=[int]$os.ProductType
|
||||
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 for the scoped command.' }
|
||||
if ($computer.DomainRole -notin @(0,1,2,3,4,5) -or $computer.PartOfDomain -isnot [bool]) {throw 'Computer role/join context is unavailable.'}
|
||||
$policy=Get-WelaRegistryState 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0' RestrictSendingNTLMTraffic
|
||||
if (-not $policy.KeyExists) {throw 'The existing MSV1_0 policy key is required; no parent key will be created.'}
|
||||
[pscustomobject][ordered]@{Host=[pscustomobject][ordered]@{Build=$build;ProductType=$product;DomainRole=[int]$computer.DomainRole;PartOfDomain=$computer.PartOfDomain};Policy=$policy}
|
||||
}
|
||||
|
||||
function Get-WelaOutgoingAuditDisposition {
|
||||
param($Snapshot,[ValidateSet('PreserveOrAudit','Audit')][string]$Mode)
|
||||
$p=$Snapshot.Policy
|
||||
if ($p.ValueExists -and ($p.Type -cne 'DWord' -or $p.Value -notin @(0,1,2))) {return 'Unknown'}
|
||||
if ($p.ValueExists -and $p.Value -eq 1) {return 'AlreadyCompliant'}
|
||||
if ($p.ValueExists -and $p.Value -eq 2 -and $Mode -eq 'PreserveOrAudit') {return 'PreservedEnforcement'}
|
||||
return 'ChangeRequired'
|
||||
}
|
||||
|
||||
function Get-WelaOutgoingAuditPlan {
|
||||
param([ValidateSet('PreserveOrAudit','Audit')][string]$Mode='PreserveOrAudit')
|
||||
try {
|
||||
$snapshot=Get-WelaOutgoingAuditSnapshot
|
||||
$status=Get-WelaOutgoingAuditDisposition $snapshot $Mode
|
||||
$diagnostic=switch($status){
|
||||
Unknown {'Unknown registry type/value is preserved; investigate it before configuration.'}
|
||||
PreservedEnforcement {'Deny all (2) is authentication enforcement, preserved by default. Explicit -OutgoingNtlmMode Audit authorizes replacing it with Audit all (1).'}
|
||||
AlreadyCompliant {'Audit all (1) is configured; authentication, events and policy persistence are unverified.'}
|
||||
default {'Set only outgoing NTLM Audit all (DWORD 1).'}
|
||||
}
|
||||
[pscustomobject]@{Status=$status;Mode=$Mode;Desired=1;Before=$snapshot;Diagnostic=$diagnostic;PolicySource=Get-WelaOutgoingNtlmPolicySource}
|
||||
}catch{[pscustomobject]@{Status='Unknown';Mode=$Mode;Desired=1;Before=$null;Diagnostic=$_.ToString();PolicySource='Unknown'}}
|
||||
}
|
||||
|
||||
function Invoke-WelaOutgoingAuditCommand {
|
||||
param([ValidateSet('Audit','Plan','Configure')][string]$Action='Audit',[ValidateSet('PreserveOrAudit','Audit')][string]$Mode='PreserveOrAudit',[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.'}
|
||||
$plan=Get-WelaOutgoingAuditPlan $Mode
|
||||
if ($Action -eq 'Configure') {
|
||||
$context=New-WelaConfigurationContext -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath
|
||||
$path='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0';$name='RestrictSendingNTLMTraffic'
|
||||
if ($plan.Status -eq 'Unknown') {
|
||||
$context.Results.Add([pscustomobject]@{Id="Registry/$path/$name";Kind='Registry';Target=@{Path=$path;Name=$name};Desired=@{Value=1;Type='DWord'};Before=$plan.Before;After=$null;Status='Failed';Diagnostic=$plan.Diagnostic})
|
||||
}else{
|
||||
$state=@{Observed=$null;PlannedHost=($plan.Before.Host|ConvertTo-Json -Compress);Mode=$Mode;Path=$path;Name=$name}
|
||||
$read={param($s)
|
||||
$snapshot=Get-WelaOutgoingAuditSnapshot
|
||||
if (($snapshot.Host|ConvertTo-Json -Compress) -cne $s.PlannedHost) {throw 'Observed host context changed; review a new plan.'}
|
||||
if ((Get-WelaOutgoingAuditDisposition $snapshot $s.Mode) -eq 'Unknown') {throw 'Unknown registry type/value is preserved.'}
|
||||
$s.Observed=$snapshot
|
||||
return $snapshot
|
||||
}
|
||||
$test={param($snapshot) $snapshot.Policy.ValueExists -and $snapshot.Policy.Type -ceq 'DWord' -and $snapshot.Policy.Value -eq 1}
|
||||
$preserve=if($Mode -eq 'PreserveOrAudit'){{param($snapshot) if($snapshot.Policy.ValueExists -and $snapshot.Policy.Type -ceq 'DWord' -and $snapshot.Policy.Value -eq 2){'Preserved Deny all enforcement; explicit Audit mode is required to replace it.'}}}else{$null}
|
||||
$apply={param($s)
|
||||
$fresh=Get-WelaOutgoingAuditSnapshot
|
||||
if (($fresh|ConvertTo-Json -Depth 8 -Compress) -cne ($s.Observed|ConvertTo-Json -Depth 8 -Compress)) {throw 'Outgoing NTLM state changed after the original journal snapshot; no write was attempted.'}
|
||||
if ((Get-WelaOutgoingAuditDisposition $fresh $s.Mode) -ne 'ChangeRequired') {throw 'The current state no longer authorizes this write.'}
|
||||
Set-ItemProperty -LiteralPath $s.Path -Name $s.Name -Value 1 -Type DWord -ErrorAction Stop
|
||||
'Only outgoing NTLM Audit all (1) was requested; no authentication or event-generation test was performed.'
|
||||
}
|
||||
Invoke-WelaConfigurationControl -Context $context -Id "Registry/$path/$name" -Kind Registry -Target @{Path=$path;Name=$name} -Desired @{Value=1;Type='DWord'} -Read $read -Compliant $test -PreserveWhen $preserve -Apply $apply -CallbackState $state -Description $plan.Diagnostic
|
||||
}
|
||||
$report=Complete-WelaConfiguration -Context $context -Scope 'outgoing-ntlm-audit-policy-only' -SuccessMessage 'Outgoing NTLM configuration results recorded; inspect preserved/skipped controls separately.'
|
||||
$report|Add-Member NoteProperty Plan $plan
|
||||
}else{$report=[pscustomobject]@{ExitCode=$(if($plan.Status -eq 'Unknown'){1}else{0});Scope='outgoing-ntlm-audit-policy-only';Action=$Action;Plan=$plan}}
|
||||
$report|Add-Member NoteProperty EventGeneration 'Not verified; registry compliance does not establish authentication, NTLM events, forwarding, GPO persistence or Sigma readiness.'
|
||||
$report|Add-Member NoteProperty ReadyRuleCredit 0
|
||||
if ($ResultsPath) {
|
||||
try {$report|ConvertTo-Json -Depth 16|Set-Content -LiteralPath $ResultsPath -Encoding UTF8 -ErrorAction Stop}
|
||||
catch {$report.ExitCode=1;Write-Host "[Failed] Writing outgoing NTLM results: $_" -ForegroundColor Red}
|
||||
}
|
||||
return $report
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
# One proven selected-root audit ACE, only with empty historical/current descendants.
|
||||
function Get-WelaRegistryRecoveryKey {param($Value) ConvertTo-Json -InputObject $Value -Depth 32 -Compress}
|
||||
function Assert-WelaRegistryRecoveryText {param($Value,[string[]]$Names) foreach($name in $Names){if($Value.$name -isnot [string]){throw ('Missing or mistyped registry recovery text: '+$name)}}}
|
||||
function Assert-WelaRegistryRecoveryNumber {param($Value) if($Value -isnot [int] -and $Value -isnot [long]){throw 'Registry recovery requires an integer.'}}
|
||||
function Initialize-WelaRegistryRecoveryNative {
|
||||
$bytes=[IO.File]::ReadAllBytes((Join-Path $PSScriptRoot 'RegistrySaclRecoveryNative.cs'));$hash=Get-WelaArrivalHash $bytes
|
||||
if(-not ('Wela.RegistrySaclRecovery.Descriptor' -as [type])){
|
||||
$source=[Text.UTF8Encoding]::new($false,$true).GetString($bytes).TrimStart([char]0xfeff);$marker='__WELA_REGISTRY_SACL_RECOVERY_SOURCE_SHA256__'
|
||||
if(($source.Split(@($marker),[StringSplitOptions]::None)).Count -ne 2){throw 'Unexpected native registry recovery source binding.'}
|
||||
Add-Type -TypeDefinition $source.Replace($marker,$hash) -ErrorAction Stop
|
||||
}
|
||||
if([Wela.RegistrySaclRecovery.Descriptor]::SourceSha256 -cne $hash){throw 'Loaded registry recovery code differs; start a fresh PowerShell process.'}
|
||||
}
|
||||
function Get-WelaRegistryRecoverySources {
|
||||
$sources=[ordered]@{}
|
||||
foreach($name in @('WELA.ps1','scripts/RegistrySaclRecovery.ps1','scripts/RegistrySaclRecoveryNative.cs','scripts/SelectedSaclConfiguration.ps1','scripts/SelectedSaclNative.cs','scripts/SelectedSaclDescendants.ps1','scripts/TargetedSaclPlanning.ps1','scripts/Configuration.ps1','scripts/ControlApplicability.ps1','scripts/CustomAuditProfiles.ps1','scripts/AuditRecovery.ps1','scripts/EvtxRecovery.ps1','scripts/WefArrival.ps1','scripts/WecUpdate.ps1','modules/AuditProfiles.psm1','modules/AuditCatalog.psm1','modules/NativeProviders.psm1','config/audit_profiles.json','config/audit_sacl_targets.json','config/control_applicability.json','config/baselines.json')){
|
||||
$sources[$name]=(Get-FileHash -LiteralPath (Join-Path $script:ScriptRoot $name) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()
|
||||
}
|
||||
[pscustomobject]$sources
|
||||
}
|
||||
function Assert-WelaRegistryRecoverySources {
|
||||
param($Sources)
|
||||
if($Sources -isnot [array]){throw 'Original source inventory must be an array.'}
|
||||
foreach($entry in $Sources){Assert-WelaEvtxObject $entry @('Path','Sha256');Assert-WelaRegistryRecoveryText $entry @('Path','Sha256');if($entry.Sha256 -cnotmatch '^[a-f0-9]{64}$'){throw 'Original source fingerprint is malformed.'}}
|
||||
Assert-WelaSelectedSaclSources $Sources
|
||||
}
|
||||
function Get-WelaRegistryRecoveryContext {
|
||||
if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'Registry SACL recovery requires native 64-bit Windows.'}
|
||||
foreach($name in @('Winmgmt','EventLog')){if((Get-Service -Name $name -ErrorAction Stop).Status -ne 'Running'){throw 'Native observation services must already be running.'}}
|
||||
Initialize-WelaRegistryRecoveryNative
|
||||
$token=[Wela.RegistrySaclRecovery.TokenReader]::Snapshot();$identity=[Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
try{if(-not ([Security.Principal.WindowsPrincipal]::new($identity)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){throw 'Registry SACL recovery requires an elevated operator.'}}finally{$identity.Dispose()}
|
||||
$actualMasks=Get-WelaEffectiveAuditPolicy;$masks=[ordered]@{};foreach($id in @($actualMasks.Keys|Sort-Object)){$masks[$id]=$actualMasks[$id]}
|
||||
[pscustomobject][ordered]@{Host=(Get-WelaRecoveryHost);Selected=(Get-WelaSelectedSaclContext);Token=$token;AuditMasks=$masks;Precedence=(Get-WelaRegistryState 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' SCENoApplyLegacyAuditPolicy)}
|
||||
}
|
||||
function Read-WelaRegistryRecoveryInput {
|
||||
param([string]$Path)
|
||||
$file=Read-WelaWecUpdateFile $Path 4194304
|
||||
if(-not $file.Text){throw 'Original recovery evidence is empty.'}
|
||||
[pscustomobject]@{Path=$file.Path;Sha256=$file.Hash;Data=(ConvertFrom-WelaEvtxJson $file.Text)}
|
||||
}
|
||||
function Assert-WelaRegistryRecoverySnapshot {
|
||||
param($Snapshot,$Definition)
|
||||
Assert-WelaEvtxObject $Snapshot @('Path','Kind','Identity','IsDirectory','DescriptorBase64','Owner','Group','DaclBase64','ControlFlags','SecurityInformation','DescriptorScope','Aces')
|
||||
Assert-WelaRegistryRecoveryText $Snapshot @('Path','Kind','Identity','DescriptorBase64','DescriptorScope')
|
||||
$path=Resolve-WelaSelectedSaclNativePath $Definition
|
||||
if($Snapshot.Kind -cne 'Registry' -or $Snapshot.Path -cne $path -or $Snapshot.IsDirectory -isnot [bool] -or $Snapshot.IsDirectory -or $Snapshot.Identity -cnotmatch ('^'+[regex]::Escape($path)+':[0-9]+$') -or $Snapshot.Aces -isnot [array]){throw 'Only exact historical registry snapshots are supported.'}
|
||||
foreach($name in @('ControlFlags','SecurityInformation')){Assert-WelaRegistryRecoveryNumber $Snapshot.$name}
|
||||
foreach($ace in $Snapshot.Aces){
|
||||
Assert-WelaEvtxObject $ace @('Binary','Type','Flags','Mask','Sid','Ordinary');Assert-WelaRegistryRecoveryText $ace @('Binary')
|
||||
if($ace.Ordinary -isnot [bool] -or ($null -ne $ace.Sid -and $ace.Sid -isnot [string])){throw 'Mistyped historical ACE metadata.'}
|
||||
foreach($name in @('Type','Flags','Mask')){Assert-WelaRegistryRecoveryNumber $ace.$name}
|
||||
}
|
||||
$parsed=Get-WelaRegistryRecoveryDescriptorObservation $Snapshot
|
||||
if((Get-WelaSelectedSaclSnapshotKey $parsed) -cne (Get-WelaSelectedSaclSnapshotKey $Snapshot)){throw 'Historical metadata differs from its native descriptor bytes.'}
|
||||
}
|
||||
function Get-WelaRegistryRecoveryDescriptorObservation {param($Snapshot) Initialize-WelaRegistryRecoveryNative;[Wela.RegistrySaclRecovery.Descriptor]::Observe($Snapshot.Path,$Snapshot.Identity,[Convert]::FromBase64String($Snapshot.DescriptorBase64))}
|
||||
function Assert-WelaRegistryRecoveryEmpty {
|
||||
param($Inventory,$Root)
|
||||
Assert-WelaEvtxObject $Inventory @('Status','Maximum','MaximumDepth','StartedUtc','CompletedUtc','Root','Entries','Diagnostics')
|
||||
Assert-WelaRegistryRecoveryText $Inventory @('Status');Assert-WelaRegistryRecoveryNumber $Inventory.Maximum;Assert-WelaRegistryRecoveryNumber $Inventory.MaximumDepth
|
||||
if($Inventory.Status -cne 'Complete' -or $Inventory.Maximum -ne 128 -or $Inventory.MaximumDepth -ne 16 -or $Inventory.Entries -isnot [array] -or $Inventory.Entries.Count -ne 0 -or $Inventory.Diagnostics -isnot [array] -or $Inventory.Diagnostics.Count -ne 0 -or (Get-WelaSelectedSaclSnapshotKey $Inventory.Root) -cne (Get-WelaSelectedSaclSnapshotKey $Root)){throw 'Recovery requires complete exact empty historical/current descendants.'}
|
||||
$start=ConvertTo-WelaArrivalUtc $Inventory.StartedUtc;$end=ConvertTo-WelaArrivalUtc $Inventory.CompletedUtc
|
||||
if($start -gt $end -or $end -gt [DateTimeOffset]::UtcNow.AddMinutes(1)){throw 'Descendant timestamps are invalid.'}
|
||||
}
|
||||
function Assert-WelaRegistryRecoveryDescendantOutcome {
|
||||
param($Value)
|
||||
Assert-WelaEvtxObject $Value @('Status','Scope','Ownership','Outcomes','Diagnostics');Assert-WelaRegistryRecoveryText $Value @('Status','Scope','Ownership')
|
||||
if($Value.Status -cne 'Observed' -or $Value.Outcomes -isnot [array] -or $Value.Outcomes.Count -ne 0 -or $Value.Diagnostics -isnot [array] -or $Value.Diagnostics.Count -ne 0){throw 'Historical descendant verification is incomplete or nonempty.'}
|
||||
}
|
||||
function Get-WelaRegistryRecoverySnapshot {
|
||||
param($Definition)
|
||||
if($Definition.Kind -isnot [string] -or $Definition.Kind -cne 'Registry'){throw 'Only registry targets are supported.'}
|
||||
Initialize-WelaRegistryRecoveryNative;$target=[Wela.RegistrySaclRecovery.Target]::new((Resolve-WelaSelectedSaclNativePath $Definition))
|
||||
try{$target.Read()}finally{$target.Dispose()}
|
||||
}
|
||||
function Get-WelaRegistryRecoveryAddition {param($Before,$After,$Ace) Initialize-WelaRegistryRecoveryNative;[Wela.RegistrySaclRecovery.Descriptor]::AddedAce($Before.DescriptorBase64,$After.DescriptorBase64,$Ace.Sid,$Ace.Mask,$Ace.Flags)}
|
||||
function Assert-WelaRegistryRecoveryEmptyCatalog {
|
||||
param($Value)
|
||||
# The original selected command's empty subexpression serializes as {} in
|
||||
# Windows PowerShell 5.1 and null in PowerShell 7. Neither contains targets.
|
||||
if($null -eq $Value -or ($Value -is [array] -and $Value.Count -eq 0) -or ($Value -is [pscustomobject] -and @($Value.PSObject.Properties).Count -eq 0)){return}
|
||||
throw 'Original selected plan catalog must be empty.'
|
||||
}
|
||||
function New-WelaRegistryRecoveryPlan {
|
||||
param([string]$OriginalPlanPath,[string]$PendingPath,[string]$ConfirmedPath,[string]$ResultsPath)
|
||||
$context=Get-WelaRegistryRecoveryContext;$sources=Get-WelaRegistryRecoverySources
|
||||
$files=[ordered]@{};foreach($entry in @(@('OriginalPlan',$OriginalPlanPath),@('Pending',$PendingPath),@('Confirmed',$ConfirmedPath),@('Results',$ResultsPath))){$files[$entry[0]]=Read-WelaRegistryRecoveryInput $entry[1]}
|
||||
if(@($files.Values.Path|Sort-Object -Unique).Count -ne 4){throw 'Four distinct original evidence files are required.'}
|
||||
$plan=$files.OriginalPlan.Data;$pending=$files.Pending.Data;$confirmed=$files.Confirmed.Data;$result=$files.Results.Data
|
||||
$planFields=@('SchemaVersion','Kind','CapturedUtc','Profile','IncludeOptional','IncludeChildren','Context','Sources','Rows','GenerationReadiness','UsableRuleCredit','Catalog','UserInventory')
|
||||
$rowFields=@('Id','DefinitionKey','Definition','Before','Ace','Status','Diagnostic','After','DescendantsBefore','DescendantsAfter','DescendantVerification')
|
||||
Assert-WelaEvtxObject $plan $planFields
|
||||
foreach($value in @($plan,$pending,$confirmed,$result)){Assert-WelaRegistryRecoveryNumber $value.SchemaVersion;if($value.SchemaVersion -ne 1){throw 'Unsupported original schema.'};Assert-WelaRegistryRecoveryText $value @('Kind')}
|
||||
Assert-WelaRegistryRecoveryText $plan @('Profile','GenerationReadiness')
|
||||
if($plan.Kind -cne 'WelaSelectedSaclPlan' -or $plan.IncludeChildren -isnot [bool] -or -not $plan.IncludeChildren -or $plan.IncludeOptional -isnot [bool] -or $plan.Rows -isnot [array] -or $plan.Rows.Count -ne 1){throw 'Require one original selected registry target with explicit child consent.'}
|
||||
Assert-WelaRegistryRecoveryEmptyCatalog $plan.Catalog
|
||||
$row=$plan.Rows[0];Assert-WelaEvtxObject $row $rowFields;Assert-WelaRegistryRecoveryText $row @('Id','DefinitionKey','Status','Diagnostic')
|
||||
Assert-WelaRegistryRecoveryText $row.Definition @('Kind','Path','Inheritance','Propagation')
|
||||
if($row.Status -cne 'ChangeRequired' -or $row.Diagnostic -cne '' -or $null -ne $row.After -or $null -ne $row.DescendantsAfter -or $null -ne $row.DescendantVerification -or $row.Id -cnotmatch '^sacl-[a-f0-9]{24}$' -or $row.Definition.Kind -cne 'Registry' -or $row.Definition.Inheritance -cnotin @('None','ContainerInherit') -or $row.Definition.Propagation -cne 'None'){throw 'Original plan is not one supported registry root audit addition.'}
|
||||
Assert-WelaRegistryRecoverySources $plan.Sources
|
||||
Assert-WelaRegistryRecoveryText $plan.Context @('Key','Computer')
|
||||
if((Get-WelaRegistryRecoveryKey $plan.Context) -cne (Get-WelaRegistryRecoveryKey $context.Selected) -or $plan.Context.Computer -cne $context.Host.Computer){throw 'Original host context differs from the actual recovery host.'}
|
||||
$catalog=Get-WelaSelectedSaclCatalog -Profile $plan.Profile -IncludeOptional:$plan.IncludeOptional -Context $context.Selected
|
||||
$selection=@($catalog.Rows|Where-Object Id -CEQ $row.Id)
|
||||
if($selection.Count -ne 1 -or $selection[0].DefinitionKey -cne $row.DefinitionKey -or (Get-WelaSelectedSaclDefinitionKey $row.Definition) -cne $row.DefinitionKey -or (Get-WelaRegistryRecoveryKey $selection[0].Definition) -cne (Get-WelaRegistryRecoveryKey $row.Definition)){throw 'Original target is not the exact currently source-bound catalog selection.'}
|
||||
Assert-WelaRegistryRecoverySnapshot $row.Before $row.Definition;Assert-WelaRegistryRecoveryEmpty $row.DescendantsBefore $row.Before
|
||||
$ace=Get-WelaSelectedSaclAce $row.Definition $row.Before -IncludeChildren
|
||||
Assert-WelaEvtxObject $row.Ace @('Sid','Mask','Flags','RequiredPolicyMask');Assert-WelaRegistryRecoveryText $row.Ace @('Sid');foreach($name in @('Mask','Flags','RequiredPolicyMask')){Assert-WelaRegistryRecoveryNumber $row.Ace.$name}
|
||||
if((Get-WelaRegistryRecoveryKey $ace) -cne (Get-WelaRegistryRecoveryKey $row.Ace) -or $ace.Flags -notin @(64,128,192,66,130,194) -or (Test-WelaSelectedSaclAce $row.Before $ace)){throw 'Original ACE is mistyped, inherited or already covered.'}
|
||||
$receiptFields=@('SchemaVersion','Kind','State','RecordedUtc','Computer','ContextKey','Id','Sources','Definition','Before','Ace','After','DescendantsBefore','DescendantsAfter','DescendantVerification','Ownership')
|
||||
foreach($receipt in @($pending,$confirmed)){
|
||||
Assert-WelaEvtxObject $receipt $receiptFields;Assert-WelaRegistryRecoveryText $receipt @('Kind','State','Computer','ContextKey','Id','Ownership')
|
||||
if($receipt.Kind -cne 'WelaSelectedSaclReceipt' -or $receipt.Computer -cne $context.Host.Computer -or $receipt.ContextKey -cne $context.Selected.Key -or $receipt.Id -cne $row.Id -or $receipt.Ownership -cne 'Only the verified explicit selected-root addition; never descendant ACE ownership or bulk rollback authority.'){throw 'Original receipt scope or ownership differs.'}
|
||||
Assert-WelaRegistryRecoverySources $receipt.Sources
|
||||
foreach($name in @('Definition','Ace')){if((Get-WelaRegistryRecoveryKey $receipt.$name) -cne (Get-WelaRegistryRecoveryKey $row.$name)){throw 'Original receipt differs from selected plan.'}}
|
||||
Assert-WelaRegistryRecoverySnapshot $receipt.Before $row.Definition
|
||||
if((Get-WelaSelectedSaclSnapshotKey $receipt.Before) -cne (Get-WelaSelectedSaclSnapshotKey $row.Before)){throw 'Original before-state differs across records.'}
|
||||
Assert-WelaRegistryRecoveryEmpty $receipt.DescendantsBefore $receipt.Before
|
||||
}
|
||||
if($pending.State -cne 'Pending' -or $null -ne $pending.After -or $null -ne $pending.DescendantsAfter -or $null -ne $pending.DescendantVerification -or $confirmed.State -cne 'Confirmed' -or $null -eq $confirmed.After -or (ConvertTo-WelaArrivalUtc $pending.RecordedUtc) -ne (ConvertTo-WelaArrivalUtc $confirmed.RecordedUtc)){throw 'A matching original Pending and Confirmed pair is required.'}
|
||||
Assert-WelaRegistryRecoverySnapshot $confirmed.After $row.Definition;Assert-WelaRegistryRecoveryEmpty $confirmed.DescendantsAfter $confirmed.After;Assert-WelaRegistryRecoveryDescendantOutcome $confirmed.DescendantVerification
|
||||
# Last-write metadata can change during the original SACL write; current state must match its exact observed After.
|
||||
$added=Get-WelaRegistryRecoveryAddition $row.Before $confirmed.After $ace
|
||||
Assert-WelaEvtxObject $result @('SchemaVersion','Kind','ExitCode','DryRun','BackupPath','Plan','Results','GenerationReadiness','UsableRuleCredit');Assert-WelaRegistryRecoveryNumber $result.ExitCode;Assert-WelaRegistryRecoveryText $result @('BackupPath','GenerationReadiness')
|
||||
if($result.Kind -cne 'WelaSelectedSaclResult' -or $result.ExitCode -ne 0 -or $result.DryRun -isnot [bool] -or $result.DryRun -or $result.Results -isnot [array] -or $result.Results.Count -ne 1){throw 'Require one completed successful, non-dry-run operation.'}
|
||||
$applied=$result.Results[0];Assert-WelaEvtxObject $applied $rowFields;Assert-WelaRegistryRecoveryText $applied @('Id','DefinitionKey','Status','Diagnostic')
|
||||
Assert-WelaEvtxObject $result.Plan $planFields;Assert-WelaRegistryRecoveryText $result.Plan @('Kind');Assert-WelaRegistryRecoveryNumber $result.Plan.SchemaVersion;if($result.Plan.SchemaVersion -ne 1){throw 'Unsupported completed plan schema.'}
|
||||
foreach($value in @($plan,$result.Plan,$result)){Assert-WelaRegistryRecoveryText $value @('GenerationReadiness');Assert-WelaRegistryRecoveryNumber $value.UsableRuleCredit;if($value.GenerationReadiness -cne 'Conditional' -or $value.UsableRuleCredit -ne 0){throw 'Original evidence carries unsupported generation credit.'}}
|
||||
if($applied.Status -cne 'Applied' -or $applied.Diagnostic -cne '' -or $result.Plan.Kind -cne 'WelaSelectedSaclPlan' -or $result.Plan.Rows -isnot [array] -or $result.Plan.Rows.Count -ne 1 -or (Get-WelaRegistryRecoveryKey $applied) -cne (Get-WelaRegistryRecoveryKey $result.Plan.Rows[0]) -or $applied.Id -cne $row.Id -or $applied.DefinitionKey -cne $row.DefinitionKey){throw 'Completed result status, rows or scope disagree.'}
|
||||
foreach($name in @('Definition','Ace')){if((Get-WelaRegistryRecoveryKey $applied.$name) -cne (Get-WelaRegistryRecoveryKey $row.$name)){throw 'Completed selection differs from original plan.'}}
|
||||
foreach($name in @('Before','After')){Assert-WelaRegistryRecoverySnapshot $applied.$name $row.Definition;if((Get-WelaSelectedSaclSnapshotKey $applied.$name) -cne (Get-WelaSelectedSaclSnapshotKey $confirmed.$name)){throw 'Completed descriptor evidence disagrees.'}}
|
||||
Assert-WelaRegistryRecoveryEmpty $applied.DescendantsBefore $applied.Before;Assert-WelaRegistryRecoveryEmpty $applied.DescendantsAfter $applied.After;Assert-WelaRegistryRecoveryDescendantOutcome $applied.DescendantVerification
|
||||
foreach($name in @('Profile','IncludeOptional','IncludeChildren','Context','Sources')){if((Get-WelaRegistryRecoveryKey $result.Plan.$name) -cne (Get-WelaRegistryRecoveryKey $plan.$name)){throw 'Completed plan context differs from original selection.'}}
|
||||
$backup=Resolve-WelaArrivalPath $result.BackupPath
|
||||
if($files.Pending.Path -ine (Join-Path $backup ($row.Id+'.pending.json')) -or $files.Confirmed.Path -ine (Join-Path $backup ($row.Id+'.confirmed.json'))){throw 'Receipt paths do not match recorded backup directory.'}
|
||||
$originalTime=ConvertTo-WelaArrivalUtc $plan.CapturedUtc;$configuredTime=ConvertTo-WelaArrivalUtc $result.Plan.CapturedUtc;$receiptTime=ConvertTo-WelaArrivalUtc $pending.RecordedUtc
|
||||
if($originalTime -gt $configuredTime -or $configuredTime -gt $receiptTime -or $receiptTime -gt [DateTimeOffset]::UtcNow.AddMinutes(1)){throw 'Original timestamps are reversed or in the future.'}
|
||||
$current=Get-WelaRegistryRecoverySnapshot $row.Definition
|
||||
if((Get-WelaSelectedSaclSnapshotKey $current) -cne (Get-WelaSelectedSaclSnapshotKey $confirmed.After)){throw 'Current registry last-write identity or full descriptor differs from completed After; value changes also require manual assessment.'}
|
||||
$inputFiles=[ordered]@{};foreach($name in $files.Keys){$inputFiles[$name]=[pscustomobject]@{Path=$files[$name].Path;Sha256=$files[$name].Sha256}}
|
||||
$recovery=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaRegistrySaclRecoveryPlan';Id=$row.Id;Context=$context;Sources=$sources;OriginalFiles=[pscustomobject]$inputFiles;Definition=$row.Definition;BeforeAddition=$row.Before;Expected=$current;AddedAce=$added;RequiresAuditReductionConsent=$true;RequiresInheritanceConsent=$true;HistoricalBinding='Original version1 records contain host context/source hashes, not authenticated historical operator identity. Registry path/last-write metadata cannot prove durable key identity.';Outcome='Remove one proven explicit registry-root audit ACE; preserve all other descriptor components and ACE order. Empty or null present SACL can remain. Empty-child observations are not an atomic tree guarantee.';ReadyRuleCredit=0}
|
||||
Assert-WelaRegistryRecoveryFresh $recovery
|
||||
$recovery
|
||||
}
|
||||
function Assert-WelaRegistryRecoveryBindings {
|
||||
param($Plan)
|
||||
if((Get-WelaRegistryRecoveryKey (Get-WelaRegistryRecoverySources)) -cne (Get-WelaRegistryRecoveryKey $Plan.Sources) -or (Get-WelaRegistryRecoveryKey (Get-WelaRegistryRecoveryContext)) -cne (Get-WelaRegistryRecoveryKey $Plan.Context)){throw 'Recovery source, host, logon, token or audit policy changed.'}
|
||||
foreach($entry in $Plan.OriginalFiles.PSObject.Properties){if((Read-WelaRegistryRecoveryInput $entry.Value.Path).Sha256 -cne $entry.Value.Sha256){throw 'Original recovery evidence changed.'}}
|
||||
}
|
||||
function Assert-WelaRegistryRecoveryFresh {param($Plan) Assert-WelaRegistryRecoveryBindings $Plan;if((Get-WelaSelectedSaclSnapshotKey (Get-WelaRegistryRecoverySnapshot $Plan.Definition)) -cne (Get-WelaSelectedSaclSnapshotKey $Plan.Expected)){throw 'Reviewed registry identity/descriptor changed before removal.'}}
|
||||
function Open-WelaRegistryRecoveryTarget {param($Definition) Initialize-WelaRegistryRecoveryNative;[Wela.RegistrySaclRecovery.Target]::new((Resolve-WelaSelectedSaclNativePath $Definition))}
|
||||
function Invoke-WelaRegistrySaclRecovery {
|
||||
param([ValidateSet('Plan','Restore')][string]$Action='Plan',[string]$OriginalPlanPath,[string]$PendingPath,[string]$ConfirmedPath,[string]$OriginalResultsPath,[string]$PlanPath,[string]$PlanHash,[string]$OutputPath,[switch]$AllowAuditReduction,[switch]$AllowInheritance)
|
||||
$ErrorActionPreference='Stop'
|
||||
if($Action -eq 'Plan'){
|
||||
if(-not $OriginalPlanPath -or -not $PendingPath -or -not $ConfirmedPath -or -not $OriginalResultsPath -or -not $OutputPath -or $PlanPath -or $PlanHash -or $AllowAuditReduction -or $AllowInheritance){throw 'Plan requires four original evidence paths and a new output directory only.'}
|
||||
}elseif(-not $PlanPath -or $PlanHash -cnotmatch '^[a-f0-9]{64}$' -or -not $OutputPath -or $OriginalPlanPath -or $PendingPath -or $ConfirmedPath -or $OriginalResultsPath){throw 'Restore requires only reviewed plan path/hash, new output and both explicit consents.'}
|
||||
$output=New-WelaArrivalOutput $OutputPath $script:ScriptRoot
|
||||
$report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaRegistrySaclRecovery';Action=$Action;Status='Refused';ExitCode=1;PlanHash=$null;WriteAttempted=$false;Before=$null;After=$null;OriginalDescriptorBytesMatch=$false;Artifacts=@();OutputPath=$output;Diagnostic='';ReadyRuleCredit=0;PolicyChanges=0;Scope='One proven registry-root audit ACE; complete empty historical/current descendants only. No full descriptor rollback, historical key identity, atomic-tree, event or Sigma claim.'}
|
||||
$target=$null
|
||||
try {
|
||||
if($Action -eq 'Plan'){
|
||||
$plan=New-WelaRegistryRecoveryPlan $OriginalPlanPath $PendingPath $ConfirmedPath $OriginalResultsPath
|
||||
$artifact=Write-WelaWecUpdateArtifact $output 'plan.json' (Get-WelaRegistryRecoveryKey $plan);$report.Artifacts+=$artifact;$report.PlanHash=$artifact.Sha256
|
||||
Assert-WelaRegistryRecoveryFresh $plan
|
||||
$report.Status='ReviewRequired';$report.ExitCode=0
|
||||
}else{
|
||||
$inputFile=Read-WelaRegistryRecoveryInput $PlanPath
|
||||
if($inputFile.Sha256 -cne $PlanHash){throw 'Reviewed recovery plan hash differs.'}
|
||||
$plan=$inputFile.Data;Assert-WelaRegistryRecoveryText $plan @('Kind')
|
||||
if($plan.Kind -cne 'WelaRegistrySaclRecoveryPlan'){throw 'Unsupported recovery plan kind.'}
|
||||
$inputs=$plan.OriginalFiles;$rebuilt=New-WelaRegistryRecoveryPlan $inputs.OriginalPlan.Path $inputs.Pending.Path $inputs.Confirmed.Path $inputs.Results.Path
|
||||
if((Get-WelaRegistryRecoveryKey $plan) -cne (Get-WelaRegistryRecoveryKey $rebuilt)){throw 'Reviewed recovery plan is stale or modified.'}
|
||||
if(-not $AllowAuditReduction -or -not $AllowInheritance){throw 'Restore requires explicit AllowAuditReduction and AllowInheritance: selected auditing is reduced and concurrent children can be affected.'}
|
||||
$report.PlanHash=$PlanHash;$report.Before=$plan.Expected
|
||||
$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'reviewed-plan.json' (Get-WelaRegistryRecoveryKey $plan)
|
||||
$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'pending.json' (Get-WelaRegistryRecoveryKey ([pscustomobject]@{Kind='WelaRegistrySaclRecoveryIntent';State='Pending';PlanHash=$PlanHash;Before=$plan.Expected;RemoveAce=$plan.AddedAce;AllowAuditReduction=[bool]$AllowAuditReduction;AllowInheritance=[bool]$AllowInheritance;RecordedUtc=[DateTime]::UtcNow.ToString('o')}))
|
||||
Assert-WelaRegistryRecoveryFresh $plan
|
||||
if((Read-WelaRegistryRecoveryInput $PlanPath).Sha256 -cne $PlanHash){throw 'Reviewed recovery plan changed immediately before write.'}
|
||||
foreach($artifact in $report.Artifacts){if((Get-FileHash -LiteralPath (Join-Path $output $artifact.Name) -Algorithm SHA256).Hash.ToLowerInvariant() -cne $artifact.Sha256){throw 'Saved recovery receipt changed before write.'}}
|
||||
$target=Open-WelaRegistryRecoveryTarget $plan.Definition
|
||||
try{$report.After=$target.Remove($plan.Expected.Identity,$plan.Expected.DescriptorBase64,$plan.AddedAce)}finally{$report.WriteAttempted=$target.WriteAttempted;if($target.AfterObservation){$report.After=$target.AfterObservation}}
|
||||
$target.Dispose();$target=$null
|
||||
if($null -ne $report.After){$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'after.json' (Get-WelaRegistryRecoveryKey $report.After)}
|
||||
Assert-WelaRegistryRecoveryBindings $plan
|
||||
if((Get-WelaSelectedSaclSnapshotKey (Get-WelaRegistryRecoverySnapshot $plan.Definition)) -cne (Get-WelaSelectedSaclSnapshotKey $report.After)){throw 'Final registry identity/descriptor or empty-child state differs after removal.'}
|
||||
if((Read-WelaRegistryRecoveryInput $PlanPath).Sha256 -cne $PlanHash){throw 'Reviewed plan changed after write.'}
|
||||
foreach($artifact in $report.Artifacts){if((Get-FileHash -LiteralPath (Join-Path $output $artifact.Name) -Algorithm SHA256).Hash.ToLowerInvariant() -cne $artifact.Sha256){throw 'Saved recovery receipt changed after write.'}}
|
||||
$report.OriginalDescriptorBytesMatch=$report.After.DescriptorBase64 -ceq $plan.BeforeAddition.DescriptorBase64
|
||||
$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'confirmed.json' (Get-WelaRegistryRecoveryKey ([pscustomobject]@{Kind='WelaRegistrySaclRecoveryConfirmation';State='Confirmed';PlanHash=$PlanHash;Before=$plan.Expected;After=$report.After;RemoveAce=$plan.AddedAce;RecordedUtc=[DateTime]::UtcNow.ToString('o')}))
|
||||
$report.Status='AddedAceRemoved';$report.ExitCode=0
|
||||
}
|
||||
}catch{$report.Status=if($report.WriteAttempted){'WriteAttemptedUnverified'}else{'Refused'};$report.Diagnostic=$_.Exception.Message}
|
||||
finally{if($target){try{$target.Dispose()}catch{$report.Status='WriteAttemptedUnverified';$report.ExitCode=1;$report.Diagnostic+=' Native cleanup failed: '+$_.Exception.Message}}}
|
||||
$null=Write-WelaWecUpdateArtifact $output 'manifest.json' (Get-WelaRegistryRecoveryKey $report)
|
||||
$report
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
// Empty-key registry recovery: remove one proven explicit selected-root audit ACE.
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.AccessControl;
|
||||
using System.Security.Principal;
|
||||
using System.Text;
|
||||
namespace Wela.RegistrySaclRecovery {
|
||||
public sealed class Ace { public string Binary; public int Type,Flags,Mask; public string Sid; public bool Ordinary; }
|
||||
public sealed class Snapshot {
|
||||
public string Path,Kind,Identity; public bool IsDirectory;
|
||||
public string DescriptorBase64,Owner,Group,DaclBase64; public int ControlFlags,SecurityInformation;
|
||||
public string DescriptorScope; public Ace[] Aces;
|
||||
}
|
||||
public static class Descriptor {
|
||||
public const string SourceSha256="__WELA_REGISTRY_SACL_RECOVERY_SOURCE_SHA256__";
|
||||
public static string Bytes(GenericAcl value) { if(value==null)return null;byte[] b=new byte[value.BinaryLength];value.GetBinaryForm(b,0);return Convert.ToBase64String(b); }
|
||||
public static string Bytes(GenericAce value) { byte[] b=new byte[value.BinaryLength];value.GetBinaryForm(b,0);return Convert.ToBase64String(b); }
|
||||
static string Sid(SecurityIdentifier value) {return value==null?null:value.Value;}
|
||||
public static RawSecurityDescriptor Parse(string value) {
|
||||
byte[] b=Convert.FromBase64String(value);
|
||||
if(b.Length<20||b.Length>1048576||Convert.ToBase64String(b)!=value)throw new InvalidOperationException("Invalid or noncanonical descriptor bytes.");
|
||||
RawSecurityDescriptor sd=new RawSecurityDescriptor(b,0);
|
||||
return sd;
|
||||
}
|
||||
static Dictionary<string,int> Counts(RawAcl acl) {
|
||||
Dictionary<string,int> counts=new Dictionary<string,int>(StringComparer.Ordinal);
|
||||
if(acl!=null)foreach(GenericAce ace in acl){string b=Bytes(ace);if(!counts.ContainsKey(b))counts[b]=0;counts[b]++;}
|
||||
return counts;
|
||||
}
|
||||
static void Outside(RawSecurityDescriptor before,RawSecurityDescriptor after,bool allowPresence) {
|
||||
int mask=allowPresence?~16:~0;
|
||||
if(Sid(before.Owner)!=Sid(after.Owner)||Sid(before.Group)!=Sid(after.Group)||Bytes(before.DiscretionaryAcl)!=Bytes(after.DiscretionaryAcl)||before.ResourceManagerControl!=after.ResourceManagerControl||(((int)before.ControlFlags)&mask)!=(((int)after.ControlFlags)&mask))throw new InvalidOperationException("Owner, group, DACL or preserved control/header fields differ.");
|
||||
}
|
||||
public static string AddedAce(string beforeBytes,string afterBytes,string sid,int mask,int flags) {
|
||||
if((sid!="S-1-1-0"&&sid!="S-1-5-11")||mask<=0||(flags!=64&&flags!=128&&flags!=192&&flags!=66&&flags!=130&&flags!=194))throw new InvalidOperationException("Only an explicit ordinary selected-root audit ACE is supported.");
|
||||
RawSecurityDescriptor before=Parse(beforeBytes),after=Parse(afterBytes);Outside(before,after,true);
|
||||
if(after.SystemAcl==null||after.SystemAcl.Revision!=(before.SystemAcl==null?2:before.SystemAcl.Revision))throw new InvalidOperationException("SACL revision changed during the claimed addition.");
|
||||
if(before.SystemAcl!=null)foreach(GenericAce entry in before.SystemAcl){CommonAce common=entry as CommonAce;if(common!=null&&!common.IsCallback&&common.AceType==AceType.SystemAudit&&common.SecurityIdentifier.Value==sid&&(int)common.AceFlags==flags&&(common.AccessMask&mask)==mask)throw new InvalidOperationException("Original descriptor already covered the requested audit ACE.");}
|
||||
string added=Bytes(new CommonAce((AceFlags)flags,AceQualifier.SystemAudit,mask,new SecurityIdentifier(sid),false,null));
|
||||
Dictionary<string,int> remaining=Counts(after.SystemAcl);
|
||||
if(!remaining.ContainsKey(added)||remaining[added]!=1)throw new InvalidOperationException("Expected exactly one new matching audit ACE.");
|
||||
remaining[added]--;
|
||||
if(before.SystemAcl!=null)foreach(GenericAce entry in before.SystemAcl){string b=Bytes(entry);if(!remaining.ContainsKey(b)||remaining[b]<1)throw new InvalidOperationException("An original ACE was changed or removed.");remaining[b]--;}
|
||||
foreach(int count in remaining.Values)if(count!=0)throw new InvalidOperationException("The completed operation changed more than one audit ACE.");
|
||||
int oldCount=before.SystemAcl==null?0:before.SystemAcl.Count;for(int i=0;i<oldCount;i++)if(Bytes(before.SystemAcl[i])!=Bytes(after.SystemAcl[i]))throw new InvalidOperationException("Original ACE ordering changed.");if(Bytes(after.SystemAcl[oldCount])!=added)throw new InvalidOperationException("The selected audit ACE was not the single append.");
|
||||
return added;
|
||||
}
|
||||
public static void Removed(string beforeBytes,string afterBytes,string added) {
|
||||
RawSecurityDescriptor before=Parse(beforeBytes),after=Parse(afterBytes);Outside(before,after,false);
|
||||
if(before.SystemAcl==null)throw new InvalidOperationException("Original SACL is absent.");
|
||||
if(after.SystemAcl==null){if(before.SystemAcl.Count!=1)throw new InvalidOperationException("A null SACL would lose unrelated audit ACEs.");}
|
||||
else if(before.SystemAcl.Revision!=after.SystemAcl.Revision)throw new InvalidOperationException("SACL revision changed during removal: "+before.SystemAcl.Revision+" to "+after.SystemAcl.Revision+" (after count "+after.SystemAcl.Count+").");
|
||||
Dictionary<string,int> expected=Counts(before.SystemAcl),actual=Counts(after.SystemAcl);
|
||||
if(!expected.ContainsKey(added)||expected[added]!=1)throw new InvalidOperationException("The selected audit ACE is no longer unique.");
|
||||
expected[added]--;
|
||||
foreach(KeyValuePair<string,int> entry in expected){int count=actual.ContainsKey(entry.Key)?actual[entry.Key]:0;if(count!=entry.Value)throw new InvalidOperationException("Unrelated audit ACEs changed during removal.");actual.Remove(entry.Key);}
|
||||
if(actual.Count!=0)throw new InvalidOperationException("Unexpected ACE appeared during removal.");
|
||||
List<string> ordered=new List<string>();foreach(GenericAce entry in before.SystemAcl)if(Bytes(entry)!=added)ordered.Add(Bytes(entry));if(after.SystemAcl!=null){for(int i=0;i<ordered.Count;i++)if(Bytes(after.SystemAcl[i])!=ordered[i])throw new InvalidOperationException("Unrelated audit ACE order changed during removal.");}
|
||||
}
|
||||
public static string SaclRepresentation(string value) {
|
||||
RawSecurityDescriptor sd=Parse(value);bool present=(sd.ControlFlags&ControlFlags.SystemAclPresent)!=0;
|
||||
if(!present)return "Absent";
|
||||
if(sd.SystemAcl==null)return "PresentNull";
|
||||
return (sd.SystemAcl.Count==0?"PresentEmpty":"PresentWithAces")+";Revision="+sd.SystemAcl.Revision;
|
||||
}
|
||||
public static Snapshot Observe(string path,string identity,byte[] bytes) {
|
||||
string encoded=Convert.ToBase64String(bytes);RawSecurityDescriptor sd=Parse(encoded);List<Ace> entries=new List<Ace>();
|
||||
if(sd.SystemAcl!=null)foreach(GenericAce ace in sd.SystemAcl){CommonAce common=ace as CommonAce;bool ordinary=common!=null&&!common.IsCallback&&common.AceType==AceType.SystemAudit;entries.Add(new Ace {Binary=Bytes(ace),Type=(int)ace.AceType,Flags=(int)ace.AceFlags,Mask=ordinary?common.AccessMask:0,Sid=ordinary?common.SecurityIdentifier.Value:null,Ordinary=ordinary});}
|
||||
return new Snapshot {Path=path,Kind="Registry",Identity=identity,IsDirectory=false,DescriptorBase64=encoded,Owner=Sid(sd.Owner),Group=Sid(sd.Group),DaclBase64=Bytes(sd.DiscretionaryAcl),ControlFlags=(int)sd.ControlFlags,SecurityInformation=511,DescriptorScope="WinSDK-defined sections 0x1ff; future sections unobserved",Aces=entries.ToArray()};
|
||||
}
|
||||
}
|
||||
sealed class Privilege : IDisposable {
|
||||
[StructLayout(LayoutKind.Sequential)] struct Luid {public uint Low;public int High;}
|
||||
[StructLayout(LayoutKind.Sequential)] struct TokenPrivileges {public uint Count;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 value);
|
||||
[DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenProcessToken(IntPtr process,uint access,out IntPtr token);
|
||||
[DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenThreadToken(IntPtr thread,uint access,bool self,out IntPtr token);
|
||||
[DllImport("advapi32.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern bool LookupPrivilegeValue(string system,string name,out Luid luid);
|
||||
[DllImport("advapi32.dll",SetLastError=true)] static extern bool AdjustTokenPrivileges(IntPtr token,bool disable,ref TokenPrivileges value,uint size,out TokenPrivileges previous,out uint required);
|
||||
IntPtr token;TokenPrivileges previous;
|
||||
public Privilege(){IntPtr thread;
|
||||
if(OpenThreadToken(GetCurrentThread(),8,true,out thread)){CloseHandle(thread);throw new InvalidOperationException("Impersonated recovery is unsupported.");}
|
||||
int error=Marshal.GetLastWin32Error();if(error!=1008)throw new Win32Exception(error);
|
||||
if(!OpenProcessToken(GetCurrentProcess(),0x28,out token))throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
try{Luid luid;if(!LookupPrivilegeValue(null,"SeSecurityPrivilege",out luid))throw new Win32Exception(Marshal.GetLastWin32Error());TokenPrivileges request=new TokenPrivileges {Count=1,Luid=luid,Attributes=2};uint required;bool ok=AdjustTokenPrivileges(token,false,ref request,(uint)Marshal.SizeOf(typeof(TokenPrivileges)),out previous,out required);error=Marshal.GetLastWin32Error();if(!ok||error!=0)throw new Win32Exception(error,"SeSecurityPrivilege is unavailable.");}
|
||||
catch{CloseHandle(token);token=IntPtr.Zero;throw;}
|
||||
}
|
||||
public void Dispose(){if(token==IntPtr.Zero)return;try{TokenPrivileges ignored;uint required;bool ok=AdjustTokenPrivileges(token,false,ref previous,(uint)Marshal.SizeOf(typeof(TokenPrivileges)),out ignored,out required);int error=Marshal.GetLastWin32Error();if(!ok||error!=0)throw new Win32Exception(error,"SeSecurityPrivilege restoration failed.");}finally{CloseHandle(token);token=IntPtr.Zero;}}
|
||||
}
|
||||
public sealed class Target : IDisposable {
|
||||
[DllImport("kernel32.dll")] static extern IntPtr LocalFree(IntPtr value);
|
||||
[DllImport("advapi32.dll",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegOpenKeyExW(IntPtr root,string name,uint options,uint access,out IntPtr key);
|
||||
[DllImport("advapi32.dll",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegQueryValueExW(IntPtr key,string name,IntPtr reserved,out uint type,IntPtr data,ref uint size);
|
||||
[DllImport("advapi32.dll",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegQueryInfoKeyW(IntPtr key,IntPtr cls,IntPtr clsSize,IntPtr reserved,IntPtr subKeys,IntPtr maxSubKey,IntPtr maxClass,IntPtr values,IntPtr maxValueName,IntPtr maxValue,IntPtr securitySize,out long written);
|
||||
[DllImport("advapi32.dll",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegEnumKeyExW(IntPtr key,uint index,StringBuilder name,ref uint length,IntPtr reserved,IntPtr cls,IntPtr clsLength,IntPtr written);
|
||||
[DllImport("advapi32.dll")] static extern int RegCloseKey(IntPtr key);
|
||||
[DllImport("ntdll.dll")] static extern int NtQueryKey(IntPtr key,int cls,IntPtr information,uint length,out uint resultLength);
|
||||
[DllImport("advapi32.dll")] static extern uint GetSecurityInfo(IntPtr handle,uint kind,uint flags,out IntPtr owner,out IntPtr group,out IntPtr dacl,out IntPtr sacl,out IntPtr descriptor);
|
||||
[DllImport("advapi32.dll")] static extern uint GetSecurityDescriptorLength(IntPtr descriptor);
|
||||
[DllImport("advapi32.dll")] static extern uint SetSecurityInfo(IntPtr handle,uint kind,uint flags,IntPtr owner,IntPtr group,IntPtr dacl,IntPtr sacl);
|
||||
readonly string path,nativePath;readonly List<IntPtr> keys=new List<IntPtr>();IntPtr handle;Privilege privilege;
|
||||
public bool WriteAttempted {get;private set;}public Snapshot AfterObservation {get;private set;}
|
||||
public Target(string path){
|
||||
this.path=path;
|
||||
if(String.IsNullOrEmpty(path)||path.IndexOfAny(new char[]{'/','*','?','%','\0'})>=0)throw new InvalidOperationException("Exact local registry path required.");
|
||||
string[] parts=path.Split('\\');IntPtr root;
|
||||
if(parts[0]=="HKEY_LOCAL_MACHINE"){root=new IntPtr(unchecked((int)0x80000002));nativePath="\\REGISTRY\\MACHINE";}
|
||||
else if(parts[0]=="HKEY_USERS"){root=new IntPtr(unchecked((int)0x80000003));nativePath="\\REGISTRY\\USER";}
|
||||
else throw new InvalidOperationException("Only selected HKLM/HKU keys are supported.");
|
||||
if(parts.Length<2)throw new InvalidOperationException("Hive roots cannot be recovered.");
|
||||
for(int i=1;i<parts.Length;i++){if(parts[i].Length==0||parts[i]=="."||parts[i]=="..")throw new InvalidOperationException("Ambiguous registry path.");nativePath+="\\"+parts[i];}
|
||||
try{
|
||||
privilege=new Privilege();IntPtr current=root;
|
||||
for(int i=1;i<parts.Length;i++){
|
||||
IntPtr opened;int error=RegOpenKeyExW(current,parts[i],8,0x01020101U|(i==parts.Length-1?8U:0U),out opened);
|
||||
if(error!=0)throw new Win32Exception(error,"Selected registry component open failed.");keys.Add(opened);current=opened;
|
||||
uint type,size=0;error=RegQueryValueExW(current,"SymbolicLinkValue",IntPtr.Zero,out type,IntPtr.Zero,ref size);
|
||||
if(error==0&&type==6)throw new InvalidOperationException("Registry symbolic-link component refused.");if(error!=0&&error!=2)throw new Win32Exception(error,"Registry link state is unreadable.");
|
||||
}
|
||||
handle=current;Check();AssertEmpty();
|
||||
}catch{Dispose();throw;}
|
||||
}
|
||||
string Check(){
|
||||
if(handle==IntPtr.Zero)throw new ObjectDisposedException("Target");uint required;int status=NtQueryKey(handle,3,IntPtr.Zero,0,out required);
|
||||
if((status!=unchecked((int)0xC0000023)&&status!=unchecked((int)0x80000005))||required<6||required>65536)throw new InvalidOperationException("Native registry name query is unavailable.");
|
||||
IntPtr buffer=Marshal.AllocHGlobal((int)required);
|
||||
try{uint actual;status=NtQueryKey(handle,3,buffer,required,out actual);if(status!=0||actual>required)throw new InvalidOperationException("Native registry name query failed.");int size=Marshal.ReadInt32(buffer);if(size<2||size%2!=0||size>required-4)throw new InvalidOperationException("Native registry name is malformed.");string name=Marshal.PtrToStringUni(IntPtr.Add(buffer,4),size/2);if(!String.Equals(name,nativePath,StringComparison.OrdinalIgnoreCase))throw new InvalidOperationException("Held registry name differs from the selected path.");}finally{Marshal.FreeHGlobal(buffer);}
|
||||
long written;int error=RegQueryInfoKeyW(handle,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,out written);if(error!=0)throw new Win32Exception(error,"Registry last-write observation failed.");return path+":"+written;
|
||||
}
|
||||
public void AssertEmpty(){if(handle==IntPtr.Zero)throw new ObjectDisposedException("Target");StringBuilder name=new StringBuilder(256);uint size=256;int error=RegEnumKeyExW(handle,0,name,ref size,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero);if(error==0||error==234)throw new InvalidOperationException("Recovery requires an observed empty registry descendant inventory.");if(error!=259)throw new Win32Exception(error,"Registry child enumeration is unknown.");}
|
||||
public Snapshot Read(){
|
||||
string identity=Check();AssertEmpty();IntPtr owner,group,dacl,sacl,descriptor;uint error=GetSecurityInfo(handle,4,511,out owner,out group,out dacl,out sacl,out descriptor);if(error!=0)throw new Win32Exception((int)error,"Full SDK-defined registry descriptor read failed.");byte[] bytes;
|
||||
try{uint size=GetSecurityDescriptorLength(descriptor);if(size<20||size>1048576)throw new InvalidOperationException("Invalid native descriptor size.");bytes=new byte[size];Marshal.Copy(descriptor,bytes,0,(int)size);}finally{LocalFree(descriptor);}
|
||||
AssertEmpty();if(Check()!=identity)throw new InvalidOperationException("Registry last-write identity changed during observation.");return Descriptor.Observe(path,identity,bytes);
|
||||
}
|
||||
public Snapshot Remove(string expectedIdentity,string expectedDescriptor,string added){
|
||||
Snapshot before=Read();if(before.Identity!=expectedIdentity||before.DescriptorBase64!=expectedDescriptor)throw new InvalidOperationException("Reviewed registry identity or descriptor changed before removal.");
|
||||
RawSecurityDescriptor sd=Descriptor.Parse(before.DescriptorBase64);int index=-1;
|
||||
if(sd.SystemAcl!=null)for(int i=0;i<sd.SystemAcl.Count;i++)if(Descriptor.Bytes(sd.SystemAcl[i])==added){if(index!=-1)throw new InvalidOperationException("Audit ACE is not unique.");index=i;}
|
||||
if(index<0)throw new InvalidOperationException("Audit ACE is absent.");CommonAce ace=sd.SystemAcl[index] as CommonAce;
|
||||
if(ace==null||ace.IsCallback||ace.AceType!=AceType.SystemAudit||((int)ace.AceFlags!=64&&(int)ace.AceFlags!=128&&(int)ace.AceFlags!=192&&(int)ace.AceFlags!=66&&(int)ace.AceFlags!=130&&(int)ace.AceFlags!=194))throw new InvalidOperationException("Only the proven explicit selected-root audit ACE can be removed.");
|
||||
sd.SystemAcl.RemoveAce(index);byte[] bytes=new byte[sd.SystemAcl.BinaryLength];sd.SystemAcl.GetBinaryForm(bytes,0);IntPtr buffer=Marshal.AllocHGlobal(bytes.Length);
|
||||
try{
|
||||
Marshal.Copy(bytes,0,buffer,bytes.Length);Snapshot last=Read();if(last.Identity!=expectedIdentity||last.DescriptorBase64!=expectedDescriptor)throw new InvalidOperationException("Registry changed immediately before removal.");
|
||||
WriteAttempted=true;uint error=SetSecurityInfo(handle,4,8,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,buffer);if(error!=0)throw new Win32Exception((int)error,"SACL-only registry removal failed.");
|
||||
}finally{Marshal.FreeHGlobal(buffer);}
|
||||
Snapshot after=Read();AfterObservation=after;Descriptor.Removed(before.DescriptorBase64,after.DescriptorBase64,added);return after;
|
||||
}
|
||||
public void Dispose(){try{for(int i=keys.Count-1;i>=0;i--)RegCloseKey(keys[i]);keys.Clear();handle=IntPtr.Zero;}finally{if(privilege!=null){privilege.Dispose();privilege=null;}}}
|
||||
}
|
||||
public sealed class Group { public string Sid; public uint Attributes; }
|
||||
public sealed class TokenPrivilege { public string Luid; public uint Attributes; }
|
||||
public sealed class Token {
|
||||
public string Sid, Name, AuthenticationId, AuthenticationType, ImpersonationLevel, TokenSource;
|
||||
public Group[] Groups; public TokenPrivilege[] Privileges;
|
||||
}
|
||||
public static class TokenReader {
|
||||
[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(length<Marshal.SizeOf(typeof(Statistics)))throw new InvalidOperationException("Truncated token statistics.");result.AuthenticationId=Hex(((Statistics)Marshal.PtrToStructure(p,typeof(Statistics))).AuthenticationId);}finally{Marshal.FreeHGlobal(p);}
|
||||
p=Read(token,2,out length);
|
||||
try {int count=Marshal.ReadInt32(p),offset=(int)Marshal.OffsetOf(typeof(TokenGroups),"First"),size=Marshal.SizeOf(typeof(SidAndAttributes));if(count<0||count>4096||offset+(long)count*size>length)throw new InvalidOperationException("Invalid token groups.");List<Group> groups=new List<Group>();for(int i=0;i<count;i++){SidAndAttributes g=(SidAndAttributes)Marshal.PtrToStructure(IntPtr.Add(p,offset+i*size),typeof(SidAndAttributes));groups.Add(new Group{Sid=new SecurityIdentifier(g.Sid).Value,Attributes=g.Attributes});}groups.Sort((a,b)=>String.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<TokenPrivilege> privileges=new List<TokenPrivilege>();for(int i=0;i<count;i++){LuidAndAttributes v=(LuidAndAttributes)Marshal.PtrToStructure(IntPtr.Add(p,4+i*size),typeof(LuidAndAttributes));privileges.Add(new TokenPrivilege{Luid=Hex(v.Luid),Attributes=v.Attributes});}privileges.Sort((a,b)=>String.CompareOrdinal(a.Luid,b.Luid));result.Privileges=privileges.ToArray();}finally{Marshal.FreeHGlobal(p);}
|
||||
return result;
|
||||
}
|
||||
[DllImport("advapi32.dll")] static extern bool IsTokenRestricted(IntPtr token);
|
||||
public static Token Snapshot() {
|
||||
IntPtr thread=IntPtr.Zero,process=IntPtr.Zero;
|
||||
if(!OpenThreadToken(GetCurrentThread(),8,true,out thread)){int error=Marshal.GetLastWin32Error();if(error!=1008)throw new Win32Exception(error);}
|
||||
try {
|
||||
if(!OpenProcessToken(GetCurrentProcess(),8,out process))throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
if(IsTokenRestricted(process)||(thread!=IntPtr.Zero&&IsTokenRestricted(thread)))throw new InvalidOperationException("Restricted tokens are unsupported.");
|
||||
Token primary=ReadToken(process,"Process");
|
||||
if(thread!=IntPtr.Zero)throw new InvalidOperationException("Impersonated recovery is unsupported.");
|
||||
return primary;
|
||||
} finally {if(process!=IntPtr.Zero)CloseHandle(process);if(thread!=IntPtr.Zero)CloseHandle(thread);}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
# Explicit native audit-switch activation. No registry policy, security, share or service writes.
|
||||
function Get-WelaSmbRuntimeKey { param($Value) ConvertTo-Json -InputObject $Value -Depth 24 -Compress }
|
||||
|
||||
function Get-WelaSmbRuntimeSources {
|
||||
$result=[ordered]@{}
|
||||
foreach($name in @('WELA.ps1','scripts/SmbRuntimeActivation.ps1','scripts/SmbAuditing.ps1','scripts/Configuration.ps1','scripts/WefArrival.ps1')) {
|
||||
$result[$name]=(Get-FileHash -LiteralPath (Join-Path $script:ScriptRoot $name) -Algorithm SHA256 -ErrorAction Stop).Hash
|
||||
}
|
||||
[pscustomobject]$result
|
||||
}
|
||||
|
||||
function Assert-WelaSmbRuntimeCommand {
|
||||
param($Command,[ValidateSet('Server','Client')][string]$Side,[ValidateSet('Get','Set')][string]$Verb,[string]$ModuleBase)
|
||||
# SmbShare exports functions from these native nested CDXML modules.
|
||||
if($Command.Name -cne "$Verb-Smb${Side}Configuration" -or $Command.ModuleName -cne "Smb${Side}Configuration" -or
|
||||
[string]$Command.CommandType -cne 'Function' -or [IO.Path]::GetFullPath($Command.Module.ModuleBase) -ine $ModuleBase){
|
||||
$observed=[pscustomobject]@{Name=$Command.Name;ModuleName=$Command.ModuleName;ModuleBase=$Command.Module.ModuleBase;Type=[string]$Command.CommandType}
|
||||
throw "SMB commands must resolve to the reviewed native SmbShare CDXML module. Expected $ModuleBase; observed $(Get-WelaSmbRuntimeKey $observed)"
|
||||
}
|
||||
if($Verb -eq 'Set') {
|
||||
$component=if($Side -eq 'Server'){'LanmanServer'}else{'LanmanWorkstation'}
|
||||
foreach($definition in @(Get-WelaSmbAuditDefinitions | Where-Object Component -eq $component)) {
|
||||
if(-not $Command.Parameters.ContainsKey($definition.Name) -or $Command.Parameters[$definition.Name].ParameterType -ne [bool]) {
|
||||
throw "Native setter lacks the exact Boolean parameter $($definition.Name)."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-WelaSmbRuntimeCommands {
|
||||
$base=[IO.Path]::GetFullPath((Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell/v1.0/Modules/SmbShare'))
|
||||
$commands=[ordered]@{}
|
||||
foreach($side in @('Server','Client')) {
|
||||
foreach($verb in @('Get','Set')) {
|
||||
$name="SmbShare\$verb-Smb${side}Configuration"
|
||||
$found=@(Get-Command -Name $name -ErrorAction Stop)
|
||||
if($found.Count -ne 1){throw 'Expected exactly one native module-qualified SMB command.'}
|
||||
Assert-WelaSmbRuntimeCommand -Command $found[0] -Side $side -Verb $verb -ModuleBase $base
|
||||
$commands[$name]=[pscustomobject]@{ModuleName=$found[0].ModuleName;ModuleBase=$base;ModuleVersion=$found[0].Module.Version.ToString();CommandType=$found[0].CommandType.ToString()}
|
||||
}
|
||||
}
|
||||
$files=@(Get-ChildItem -LiteralPath $base -File -Recurse -ErrorAction Stop | Where-Object Extension -in @('.psd1','.psm1','.cdxml','.dll','.ps1xml') | Sort-Object FullName)
|
||||
if($files.Count -lt 1 -or $files.Count -gt 100){throw 'Unexpected native SMB module inventory.'}
|
||||
$hashes=[ordered]@{}
|
||||
foreach($file in $files){
|
||||
if($file.Length -gt 16MB -or ($file.Attributes -band [IO.FileAttributes]::ReparsePoint)){throw 'Unsupported SMB module source.'}
|
||||
$hashes[$file.FullName]=(Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256 -ErrorAction Stop).Hash
|
||||
}
|
||||
[pscustomobject]@{Commands=[pscustomobject]$commands;Files=[pscustomobject]$hashes}
|
||||
}
|
||||
|
||||
function ConvertTo-WelaSmbRuntimeConfiguration {
|
||||
param($Configuration,[ValidateSet('Server','Client')][string]$Side)
|
||||
if($Configuration.CimClass.CimClassName -cne "MSFT_Smb${Side}Configuration"){throw 'Expected one actual native SMB configuration CIM instance.'}
|
||||
$properties=@($Configuration.CimInstanceProperties | Sort-Object Name)
|
||||
if($properties.Count -lt 3 -or $properties.Count -gt 160){throw 'Unexpected SMB configuration property count.'}
|
||||
$result=[ordered]@{}
|
||||
foreach($property in $properties) {
|
||||
if($result.Contains($property.Name)){throw 'Duplicate SMB configuration property.'}
|
||||
$value=$property.Value
|
||||
foreach($item in @($value)) {
|
||||
if($null -ne $item -and $item -isnot [bool] -and $item -isnot [string] -and
|
||||
$item -isnot [byte] -and $item -isnot [uint16] -and $item -isnot [uint32] -and $item -isnot [uint64] -and
|
||||
$item -isnot [int16] -and $item -isnot [int32] -and $item -isnot [int64]){throw "Unsupported native configuration value: $($property.Name)"}
|
||||
if($item -is [string] -and $item.Length -gt 8192){throw 'Native configuration string exceeds bound.'}
|
||||
}
|
||||
if(@($value).Count -gt 128){throw 'Native configuration array exceeds bound.'}
|
||||
$result[$property.Name]=[pscustomobject]@{CimType=$property.CimType.ToString();Value=$value}
|
||||
}
|
||||
$component=if($Side -eq 'Server'){'LanmanServer'}else{'LanmanWorkstation'}
|
||||
foreach($definition in @(Get-WelaSmbAuditDefinitions | Where-Object Component -eq $component)) {
|
||||
if(-not $result.Contains($definition.Name) -or $result[$definition.Name].Value -isnot [bool] -or $result[$definition.Name].CimType -cne 'Boolean') {
|
||||
throw "Native getter lacks the exact Boolean property $($definition.Name)."
|
||||
}
|
||||
}
|
||||
[pscustomobject]$result
|
||||
}
|
||||
|
||||
function Get-WelaSmbRuntimeState {
|
||||
$hostState=Get-WelaSmbAuditHost
|
||||
if($hostState.Status -ne 'Candidate'){throw "SMB runtime activation is $($hostState.Status): $($hostState.Diagnostic)"}
|
||||
$commands=Get-WelaSmbRuntimeCommands
|
||||
$policies=[ordered]@{}
|
||||
foreach($definition in Get-WelaSmbAuditDefinitions) {
|
||||
$capability=Get-WelaSmbAuditCapability -Definition $definition -HostState $hostState
|
||||
if($capability.Status -ne 'Supported'){throw "Unverified $($definition.Component)/$($definition.Name): $($capability.Diagnostic)"}
|
||||
$policies["$($definition.Component)/$($definition.Name)"]=[pscustomobject]@{
|
||||
Path=$definition.Path;Name=$definition.Name;AdmxSha256=$capability.AdmxSha256
|
||||
Policy=Get-WelaRegistryState -Path $definition.Path -Name $definition.Name
|
||||
}
|
||||
}
|
||||
$configurations=[ordered]@{}
|
||||
foreach($side in @('Server','Client')) {
|
||||
$command="SmbShare\Get-Smb${side}Configuration"
|
||||
$native=@(& $command -ErrorAction Stop)
|
||||
if($native.Count -ne 1){throw 'Expected exactly one native SMB configuration.'}
|
||||
$configurations[$side]=ConvertTo-WelaSmbRuntimeConfiguration -Configuration $native[0] -Side $side
|
||||
}
|
||||
[pscustomobject][ordered]@{Computer=[Environment]::MachineName;Host=$hostState;Commands=$commands;Sources=Get-WelaSmbRuntimeSources;Policies=[pscustomobject]$policies;Configurations=[pscustomobject]$configurations}
|
||||
}
|
||||
|
||||
function Get-WelaSmbRuntimePlan {
|
||||
param($State)
|
||||
foreach($definition in Get-WelaSmbAuditDefinitions) {
|
||||
$id="$($definition.Component)/$($definition.Name)"
|
||||
$policy=$State.Policies.$id.Policy
|
||||
$side=if($definition.Component -eq 'LanmanServer'){'Server'}else{'Client'}
|
||||
$value=$State.Configurations.$side.($definition.Name).Value
|
||||
$compatible=($policy.ValueExists -is [bool] -and -not $policy.ValueExists) -or
|
||||
($policy.ValueExists -eq $true -and $policy.Type -ceq 'DWord' -and
|
||||
($policy.Value -is [int] -or $policy.Value -is [long] -or $policy.Value -is [uint32]) -and $policy.Value -eq 1)
|
||||
[pscustomobject][ordered]@{Id=$id;Side=$side;Name=$definition.Name;Before=$value;Desired=$true;Policy=$policy
|
||||
Status=$(if(-not $compatible){'BlockedPolicy'}elseif($value){'AlreadyActive'}else{'ActivationRequired'})
|
||||
Diagnostic=$(if(-not $compatible){'Existing policy is not absent or DWORD 1; review its authority. It will not be overwritten.'}elseif($policy.ValueExists){'Policy DWORD 1 and runtime Boolean are separate observations.'}else{'Policy value is absent; explicit activation changes native local configuration only.'})}
|
||||
}
|
||||
}
|
||||
|
||||
function Set-WelaSmbRuntimeFlag {
|
||||
param([string]$Id)
|
||||
$matches=@(Get-WelaSmbAuditDefinitions | Where-Object {"$($_.Component)/$($_.Name)" -ceq $Id})
|
||||
if($matches.Count -ne 1){throw 'Unknown SMB audit switch.'}
|
||||
$definition=$matches[0]
|
||||
$side=if($definition.Component -eq 'LanmanServer'){'Server'}else{'Client'}
|
||||
$command="SmbShare\Set-Smb${side}Configuration"
|
||||
$parameters=@{Confirm=$false;Force=$true;ErrorAction='Stop'}
|
||||
$parameters[$definition.Name]=$true
|
||||
$null=& $command @parameters
|
||||
}
|
||||
|
||||
function Write-WelaSmbRuntimeReceipt {
|
||||
param([string]$Root,[string]$Name,$Value)
|
||||
if($Name -notmatch '^(plan|result|[1-6]-(pending|confirmed))\.json$'){throw 'Unexpected receipt filename.'}
|
||||
$null=Resolve-WelaArrivalPath $Root
|
||||
$path=Join-Path $Root $Name
|
||||
$bytes=[Text.UTF8Encoding]::new($false).GetBytes((Get-WelaSmbRuntimeKey $Value))
|
||||
if($bytes.Length -gt 4MB){throw 'SMB activation receipt exceeds bound.'}
|
||||
$stream=[IO.File]::Open($path,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None)
|
||||
try {$stream.Write($bytes,0,$bytes.Length);$stream.Flush($true)}finally{$stream.Dispose()}
|
||||
$expected=Get-WelaArrivalHash $bytes
|
||||
if((Get-FileHash -LiteralPath $path -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() -cne $expected){throw 'SMB receipt readback differs.'}
|
||||
[pscustomobject]@{Name=$Name;Bytes=$bytes.Length;Sha256=$expected}
|
||||
}
|
||||
|
||||
function Invoke-WelaSmbRuntimeActivation {
|
||||
param([ValidateSet('Plan','Activate')][string]$Action='Plan',[string]$OutputPath,[switch]$Auto,[switch]$DryRun)
|
||||
if($DryRun -and $Action -ne 'Activate'){throw 'DryRun requires SmbRuntimeAction Activate.'}
|
||||
if($Action -eq 'Plan' -and ($Auto -or $OutputPath)){throw 'Plan reads only; Auto and OutputPath apply to Activate.'}
|
||||
$report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaSmbRuntimeActivation';Action=$Action;DryRun=[bool]$DryRun;RecordedUtc=[DateTime]::UtcNow.ToString('o')
|
||||
Status='Unverified';ExitCode=1;Before=$null;After=$null;Controls=@();Results=@();Artifacts=@();OutputPath=$null;Diagnostic=''
|
||||
VerificationScope='Native local audit switches at the recorded observations; policy authority and persistence are unknown';ReadyRuleCredit=0;EventGeneration='Not tested';Forwarding='Not tested'}
|
||||
try {
|
||||
$state=Get-WelaSmbRuntimeState;$report.Before=$state
|
||||
$report.Controls=@(Get-WelaSmbRuntimePlan $state)
|
||||
if(@($report.Controls | Where-Object Status -eq BlockedPolicy).Count){throw 'One or more policy values conflict or are malformed. No audit flags were changed.'}
|
||||
if($Action -eq 'Plan' -or $DryRun){$report.Status=if($DryRun){'DryRun'}else{'Planned'};$report.ExitCode=0;return $report}
|
||||
if(-not $OutputPath){throw 'Activate requires a new SmbRuntimeOutputPath on a local fixed drive.'}
|
||||
$output=New-WelaArrivalOutput -Path $OutputPath -SourcePath $script:ScriptRoot;$report.OutputPath=$output
|
||||
$report.Artifacts+=Write-WelaSmbRuntimeReceipt $output 'plan.json' ([pscustomobject]@{State=$state;Controls=$report.Controls})
|
||||
$expectedKey=Get-WelaSmbRuntimeKey $state
|
||||
$index=0;$stopped=$false
|
||||
foreach($control in $report.Controls) {
|
||||
$index++
|
||||
$row=[pscustomobject][ordered]@{Id=$control.Id;Before=$control.Before;After=$null;Status='Skipped';Diagnostic='';PendingReceipt=$null;ConfirmedReceipt=$null}
|
||||
$report.Results+= $row
|
||||
if($stopped){$row.Diagnostic='A prior activation failed; no further changes were attempted.';continue}
|
||||
try {
|
||||
$fresh=Get-WelaSmbRuntimeState
|
||||
if((Get-WelaSmbRuntimeKey $fresh) -cne $expectedKey){throw 'Host, source, policy or native configuration drifted after the snapshot.'}
|
||||
if($control.Before){$row.After=$true;$row.Status='AlreadyActive';continue}
|
||||
if(-not $Auto -and (Read-Host "Activate only SMB audit flag $($control.Id)? (y/N)") -cnotin @('y','Y')){$row.Diagnostic='Declined by operator.';continue}
|
||||
$row.PendingReceipt=Write-WelaSmbRuntimeReceipt $output "$index-pending.json" ([pscustomobject]@{Kind='Pending';Id=$control.Id;Before=$fresh;Desired=$true;RecordedUtc=[DateTime]::UtcNow.ToString('o')})
|
||||
# Re-read after interaction and durable intent, immediately before the setter.
|
||||
if((Get-WelaSmbRuntimeKey (Get-WelaSmbRuntimeState)) -cne $expectedKey){throw 'Context drifted before the native setter; activation refused.'}
|
||||
Set-WelaSmbRuntimeFlag -Id $control.Id
|
||||
$after=Get-WelaSmbRuntimeState;$row.After=$after.Configurations.($control.Side).($control.Name).Value
|
||||
# The only permitted delta is this one Boolean. All policies and every
|
||||
# other native configuration property (including security) must match.
|
||||
$next=Get-WelaSmbRuntimeKey $fresh | ConvertFrom-Json
|
||||
$next.Configurations.($control.Side).($control.Name).Value=$true
|
||||
if((Get-WelaSmbRuntimeKey $after) -cne (Get-WelaSmbRuntimeKey $next)){throw 'Native readback did not show exactly the requested audit-only delta.'}
|
||||
$row.ConfirmedReceipt=Write-WelaSmbRuntimeReceipt $output "$index-confirmed.json" ([pscustomobject]@{Kind='Confirmed';Id=$control.Id;Pending=$row.PendingReceipt;After=$after;RecordedUtc=[DateTime]::UtcNow.ToString('o')})
|
||||
$state=$after;$expectedKey=Get-WelaSmbRuntimeKey $state
|
||||
$row.Status='Activated';$row.Diagnostic='Native Boolean True observed; policy tuple and all other configuration properties preserved.'
|
||||
}catch{$row.Status='Failed';$row.Diagnostic=$_.Exception.Message;$stopped=$true}
|
||||
}
|
||||
$report.After=Get-WelaSmbRuntimeState
|
||||
if((Get-WelaSmbRuntimeKey $report.After) -cne $expectedKey){throw 'Final context differs from the last verified configuration. Review partial receipts; no automatic rollback is attempted.'}
|
||||
if(@($report.Results | Where-Object Status -notin @('Activated','AlreadyActive')).Count){throw 'Some flags were not activated. Inspect per-control results and receipts.'}
|
||||
$report.Status='RuntimeAuditingActive';$report.ExitCode=0
|
||||
}catch{$report.Diagnostic=$_.Exception.Message}
|
||||
if($report.OutputPath){$null=Write-WelaSmbRuntimeReceipt $report.OutputPath 'result.json' $report}
|
||||
$report
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
# Explicit recovery of one completed Windows PowerShell transcription policy write.
|
||||
function Copy-WelaTranscriptRecoveryValue {
|
||||
param($Value)
|
||||
# Windows PowerShell 5.1 annotates a root array emitted by ConvertFrom-Json;
|
||||
# serializing that annotated array can introduce synthetic value/count keys.
|
||||
# Keep arrays nested during the JSON roundtrip and emit their actual items.
|
||||
$holder=ConvertFrom-WelaRecoveryJson (Get-WelaRecoveryKey ([pscustomobject]@{Data=$Value}))
|
||||
$holder.Data
|
||||
}
|
||||
function Get-WelaTranscriptRecoverySources {
|
||||
$sources=[ordered]@{}
|
||||
foreach($name in @('WELA.ps1','scripts/TranscriptionRecovery.ps1','scripts/PowerShellTranscription.ps1','scripts/Configuration.ps1','scripts/AuditRecovery.ps1','scripts/ControlApplicability.ps1','scripts/ChannelRead.ps1','scripts/ChannelReadNative.cs','scripts/WefArrival.ps1')) {
|
||||
$sources[$name]=(Get-FileHash -LiteralPath (Join-Path $script:ScriptRoot $name) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()
|
||||
}
|
||||
[pscustomobject]$sources
|
||||
}
|
||||
function Get-WelaTranscriptRecoveryContext {
|
||||
$hostState=Get-WelaRecoveryHost
|
||||
$reader=Get-WelaChannelReader
|
||||
if(-not $reader.ElevatedAdministrator){throw 'Transcription recovery requires an elevated administrator primary token.'}
|
||||
# A reviewed plan can be consumed by a new process in the same logon session.
|
||||
[pscustomobject][ordered]@{Host=$hostState;Reader=($reader|Select-Object UserSid,UserName,AuthenticationId,GroupSids,ElevatedAdministrator,TokenType,Impersonation)}
|
||||
}
|
||||
function Assert-WelaTranscriptRecoveryLocalPath {
|
||||
param([string]$Path)
|
||||
Test-WelaTranscriptDirectoryPath $Path
|
||||
if($Path -notmatch '^[A-Za-z]:\\' -or (Get-WelaRecoveryOutputDriveType ([IO.Path]::GetPathRoot($Path))) -ne [IO.DriveType]::Fixed){throw 'Transcription recovery supports ordinary local fixed-drive paths only; UNC and mapped drives require manual recovery.'}
|
||||
}
|
||||
function Read-WelaTranscriptRecoveryFile {
|
||||
param([string]$Path)
|
||||
Assert-WelaTranscriptRecoveryLocalPath $Path
|
||||
Get-WelaRecoveryFile $Path
|
||||
}
|
||||
function Get-WelaTranscriptRecoveryProtectedPolicy {
|
||||
# Inventory the complete PowerShell policy tree, excluding only the two owned
|
||||
# machine values. No policy, header, module/script-block or user writes occur.
|
||||
$rows=New-Object 'System.Collections.Generic.List[object]'
|
||||
foreach($hive in @('LocalMachine','CurrentUser')) {
|
||||
$base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::$hive,[Microsoft.Win32.RegistryView]::Registry64)
|
||||
try {
|
||||
$queue=New-Object 'System.Collections.Generic.Queue[string]';$queue.Enqueue('')
|
||||
while($queue.Count) {
|
||||
$relative=$queue.Dequeue();$path='SOFTWARE\Policies\Microsoft\Windows\PowerShell'+$relative
|
||||
$key=$base.OpenSubKey($path,$false)
|
||||
try {
|
||||
$values=@();$children=@()
|
||||
if($null -ne $key) {
|
||||
$children=@($key.GetSubKeyNames()|Sort-Object)
|
||||
foreach($name in ($key.GetValueNames()|Sort-Object)) {
|
||||
if($hive -eq 'LocalMachine' -and $relative -eq '\Transcription' -and $name -in @('EnableTranscripting','OutputDirectory')){continue}
|
||||
$values += [pscustomobject][ordered]@{Name=$name;Type=$key.GetValueKind($name).ToString();Value=$key.GetValue($name,$null,[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)}
|
||||
}
|
||||
}
|
||||
$rows.Add([pscustomobject][ordered]@{Hive=$hive;Path=$relative;Exists=($null -ne $key);Values=$values;Children=$children})
|
||||
if($rows.Count -gt 128 -or $queue.Count+$children.Count -gt 128 -or $relative.Length -gt 1024 -or $values.Count -gt 256){throw 'PowerShell policy inventory exceeded bounded recovery scope.'}
|
||||
foreach($child in $children){$queue.Enqueue($relative+'\'+$child)}
|
||||
} finally {if($key){$key.Dispose()}}
|
||||
}
|
||||
} finally {$base.Dispose()}
|
||||
}
|
||||
$result=@($rows.ToArray())
|
||||
if((Get-WelaRecoveryKey $result).Length -gt 1048576){throw 'PowerShell policy inventory exceeded 1 MiB.'}
|
||||
return ,$result
|
||||
}
|
||||
function Assert-WelaTranscriptRecoveryValue {
|
||||
param($Value,[string]$Name)
|
||||
if($null -eq $Value -or $Value.KeyExists -isnot [bool] -or $Value.ValueExists -isnot [bool]){throw 'Missing typed transcription value state.'}
|
||||
if(-not $Value.ValueExists) {
|
||||
if($null -ne $Value.Type -or $null -ne $Value.Value){throw 'Absent transcription value has inconsistent state.'}
|
||||
} elseif(-not $Value.KeyExists){throw 'A present transcription value requires an existing key.'}
|
||||
elseif($Name -eq 'EnableTranscripting') {
|
||||
if($Value.Type -isnot [string] -or $Value.Type -cne 'DWord' -or ($Value.Value -isnot [int] -and $Value.Value -isnot [long]) -or $Value.Value -notin @(0,1)){throw 'Only DWORD 0/1 or absent enablement can be restored; other types require manual recovery.'}
|
||||
} elseif($Value.Type -isnot [string] -or $Value.Type -cne 'String' -or $Value.Value -isnot [string] -or -not $Value.Value){throw 'Only a nonempty REG_SZ or absent output directory can be restored.'}
|
||||
}
|
||||
function Get-WelaTranscriptRecoveryTypedKey {
|
||||
param($Value)
|
||||
Get-WelaRecoveryKey ($Value|Select-Object ValueExists,Type,Value)
|
||||
}
|
||||
function Get-WelaTranscriptRecoveryDestinations {
|
||||
param([string[]]$Paths)
|
||||
foreach($path in ($Paths|Sort-Object -Unique)) {
|
||||
Assert-WelaTranscriptRecoveryLocalPath $path
|
||||
$directory=Get-WelaTranscriptDestination $path
|
||||
if(-not $directory.ConfigureAllowed -or $directory.Status -cne 'Observed'){throw "Recovery destination cannot be verified: $($directory.Diagnostic)"}
|
||||
$directory
|
||||
}
|
||||
}
|
||||
function New-WelaTranscriptRecoveryPlan {
|
||||
param([string]$JournalPath,[string]$OriginalResultsPath)
|
||||
$context=Get-WelaTranscriptRecoveryContext;$sources=Get-WelaTranscriptRecoverySources
|
||||
$journal=Read-WelaTranscriptRecoveryFile $JournalPath;$resultFile=Read-WelaTranscriptRecoveryFile $OriginalResultsPath
|
||||
$entries=@($journal.Text -split '\r?\n'|Where-Object {$_ -match '\S'}|ForEach-Object {ConvertFrom-WelaRecoveryJson $_})
|
||||
$results=ConvertFrom-WelaRecoveryJson $resultFile.Text
|
||||
foreach($field in @('ExitCode','Failed','Skipped')) {
|
||||
if(($results.$field -isnot [int] -and $results.$field -isnot [long]) -or $results.$field -ne 0){throw 'Completed transcription history requires integer zero exit/failure/skipped counters.'}
|
||||
}
|
||||
if($entries.Count -ne 1 -or $results.Results -isnot [array] -or $results.Results.Count -ne 1 -or $results.DryRun -isnot [bool] -or $results.DryRun -or
|
||||
$results.Action -isnot [string] -or $results.Action -cne 'Configure' -or $results.Scope -isnot [string] -or $results.Scope -cne 'windows-powershell-transcription-policy-only'){throw 'Recovery requires one completed Applied transcription Configure journal/result, without other controls or partial outcomes.'}
|
||||
$entry=$entries[0];$last=$results.Results[0]
|
||||
if(($entry.Version -isnot [int] -and $entry.Version -isnot [long]) -or $entry.Version -ne 1 -or $entry.ComputerName -isnot [string] -or $entry.ComputerName -ine $context.Host.Computer -or
|
||||
$entry.Id -isnot [string] -or $entry.Id -cne 'PowerShellTranscription/CisV4L2' -or $entry.Kind -isnot [string] -or $entry.Kind -cne 'PowerShellTranscription' -or
|
||||
$last.Status -isnot [string] -or $last.Status -cne 'Applied' -or $last.Id -isnot [string] -or $last.Id -cne $entry.Id -or $last.Kind -isnot [string] -or $last.Kind -cne $entry.Kind){throw 'Wrong host, control, schema or incomplete transcription history.'}
|
||||
$time=ConvertTo-WelaArrivalUtc $entry.RecordedUtc
|
||||
if($time -gt [datetimeoffset]::UtcNow.AddMinutes(1)){throw 'Original journal requires a valid UTC timestamp.'}
|
||||
foreach($field in @('Before','Target','Desired')){if((Get-WelaRecoveryKey $entry.$field) -cne (Get-WelaRecoveryKey $last.$field)){throw "Original journal/result $field differs."}}
|
||||
if($entry.Target.Hive -isnot [string] -or $entry.Target.Hive -cne 'LocalMachine' -or $entry.Target.SubKey -isnot [string] -or $entry.Target.SubKey -cne 'SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription' -or $entry.Target.OutputDirectory -isnot [string] -or
|
||||
$entry.Desired.EnableTranscripting.Type -isnot [string] -or $entry.Desired.EnableTranscripting.Type -cne 'DWord' -or ($entry.Desired.EnableTranscripting.Value -isnot [int] -and $entry.Desired.EnableTranscripting.Value -isnot [long]) -or $entry.Desired.EnableTranscripting.Value -ne 1 -or
|
||||
$entry.Desired.OutputDirectory.Type -isnot [string] -or $entry.Desired.OutputDirectory.Type -cne 'String' -or $entry.Desired.OutputDirectory.Value -isnot [string] -or $entry.Desired.OutputDirectory.Value -cne $entry.Target.OutputDirectory -or
|
||||
$entry.Desired.EnableInvocationHeader -isnot [string] -or $entry.Desired.EnableInvocationHeader -cne 'Preserve'){throw 'Unsupported transcription target or desired state.'}
|
||||
$before=$entry.Before;$after=$last.After
|
||||
foreach($snapshot in @($before,$after)) {
|
||||
if($snapshot.Capability.Status -isnot [string] -or $snapshot.Capability.Status -cne 'Supported' -or $snapshot.Policy -isnot [array] -or $snapshot.Policy.Count -ne 2 -or
|
||||
$snapshot.Policy[0].View -isnot [string] -or $snapshot.Policy[0].View -cne 'Registry64' -or $snapshot.Policy[1].View -isnot [string] -or $snapshot.Policy[1].View -cne 'Registry32'){
|
||||
$policyType=if($null -eq $snapshot.Policy){'<null>'}else{$snapshot.Policy.GetType().FullName}
|
||||
throw "Both canonical shared registry views are required. Capability=$($snapshot.Capability.Status); PolicyType=$policyType; Count=$(@($snapshot.Policy).Count); Views=$(@($snapshot.Policy.View) -join ','); Observation=$(Get-WelaRecoveryKey $snapshot)"
|
||||
}
|
||||
Test-WelaTranscriptSharedPolicy $snapshot.Policy
|
||||
foreach($name in @('EnableTranscripting','OutputDirectory')){Assert-WelaTranscriptRecoveryValue $snapshot.Policy[0].Machine.$name $name}
|
||||
}
|
||||
if(-not (Test-WelaTranscriptConfigured $after $entry.Target.OutputDirectory)){throw 'Final transcription policy was not the requested enabled state.'}
|
||||
if((Get-WelaRecoveryKey $before.Policy[0].CurrentUser) -cne (Get-WelaRecoveryKey $after.Policy[0].CurrentUser) -or
|
||||
(Get-WelaTranscriptRecoveryTypedKey $before.Policy[0].Machine.EnableInvocationHeader) -cne (Get-WelaTranscriptRecoveryTypedKey $after.Policy[0].Machine.EnableInvocationHeader)){throw 'Original configuration did not preserve user/header policy.'}
|
||||
$current=Get-WelaTranscriptState $entry.Target.OutputDirectory
|
||||
if((Get-WelaRecoveryKey $current.Policy) -cne (Get-WelaRecoveryKey $after.Policy) -or (Get-WelaRecoveryKey $current.Destination) -cne (Get-WelaRecoveryKey $after.Destination)){throw 'Current policy/destination differs from the original final After state.'}
|
||||
$target=Copy-WelaTranscriptRecoveryValue $after.Policy
|
||||
foreach($view in $target){foreach($name in @('EnableTranscripting','OutputDirectory')) {
|
||||
$view.Machine.$name=Copy-WelaTranscriptRecoveryValue $before.Policy[0].Machine.$name
|
||||
# Keep the existing key; absence recovery removes only the selected value.
|
||||
$view.Machine.$name.KeyExists=$true
|
||||
}}
|
||||
$prior=$before.Policy[0].Machine;$paths=@([string]$entry.Target.OutputDirectory)
|
||||
if($prior.OutputDirectory.ValueExists){$paths += [string]$prior.OutputDirectory.Value}
|
||||
elseif(-not ($prior.EnableTranscripting.ValueExists -and $prior.EnableTranscripting.Value -eq 0)) {
|
||||
throw 'Restoring an absent output directory requires explicit prior DWORD 0; user/default destinations require manual recovery.'
|
||||
}
|
||||
$directories=@(Get-WelaTranscriptRecoveryDestinations $paths)
|
||||
$outputChanges=(Get-WelaTranscriptRecoveryTypedKey $prior.OutputDirectory) -cne (Get-WelaTranscriptRecoveryTypedKey $after.Policy[0].Machine.OutputDirectory)
|
||||
$suspend=$outputChanges -and -not ($prior.EnableTranscripting.ValueExists -and $prior.EnableTranscripting.Value -eq 0)
|
||||
$steps=New-Object 'System.Collections.Generic.List[object]'
|
||||
if($outputChanges) {
|
||||
$off=if($suspend){[pscustomobject]@{KeyExists=$true;ValueExists=$true;Value=0;Type='DWord'}}else{$target[0].Machine.EnableTranscripting}
|
||||
$steps.Add([pscustomobject]@{Name='EnableTranscripting';Value=$off;Purpose=$(if($suspend){'Explicit temporary suspension'}else{'Restore disabled state before destination'})})
|
||||
$steps.Add([pscustomobject]@{Name='OutputDirectory';Value=$target[0].Machine.OutputDirectory;Purpose='Restore original destination value or absence'})
|
||||
if($suspend){$steps.Add([pscustomobject]@{Name='EnableTranscripting';Value=$target[0].Machine.EnableTranscripting;Purpose='Restore original enablement value or absence'})}
|
||||
} elseif((Get-WelaTranscriptRecoveryTypedKey $prior.EnableTranscripting) -cne (Get-WelaTranscriptRecoveryTypedKey $after.Policy[0].Machine.EnableTranscripting)) {
|
||||
$steps.Add([pscustomobject]@{Name='EnableTranscripting';Value=$target[0].Machine.EnableTranscripting;Purpose='Restore original enablement value or absence'})
|
||||
}
|
||||
if(-not $steps.Count){throw 'Original Applied evidence contains no recoverable typed changes.'}
|
||||
$protected=Get-WelaTranscriptRecoveryProtectedPolicy
|
||||
[pscustomobject][ordered]@{Kind='WelaTranscriptionRecoveryPlan';SchemaVersion=1;Context=$context;Sources=$sources;
|
||||
Journal=[pscustomobject]@{Path=$journal.Path;Sha256=$journal.Sha256};OriginalResults=[pscustomobject]@{Path=$resultFile.Path;Sha256=$resultFile.Sha256};
|
||||
ExpectedPolicy=$after.Policy;RecoverTo=$target;Directories=$directories;ProtectedPolicy=$protected;RequiresTemporarySuspension=[bool]$suspend;Steps=@($steps.ToArray());
|
||||
HistoricalIdentity='Version-1 configuration journals record ComputerName only. Current host/reader/code bindings do not authenticate historical identity or evidence.';SigmaEvtxCredit=0}
|
||||
}
|
||||
function Assert-WelaTranscriptRecoveryBindings {
|
||||
param($Plan,$Policy,[string]$PlanPath,[string]$PlanHash)
|
||||
foreach($source in @($Plan.Journal,$Plan.OriginalResults)){if((Read-WelaTranscriptRecoveryFile $source.Path).Sha256 -cne $source.Sha256){throw 'Original transcription recovery evidence changed.'}}
|
||||
if($PlanPath -and (Read-WelaTranscriptRecoveryFile $PlanPath).Sha256 -cne $PlanHash){throw 'Reviewed transcription recovery plan changed.'}
|
||||
if((Get-WelaRecoveryKey (Get-WelaTranscriptRecoveryContext)) -cne (Get-WelaRecoveryKey $Plan.Context) -or (Get-WelaRecoveryKey (Get-WelaTranscriptRecoverySources)) -cne (Get-WelaRecoveryKey $Plan.Sources)){throw 'Actual host, reader or recovery implementation changed.'}
|
||||
if((Get-WelaRecoveryKey (Get-WelaTranscriptRecoveryProtectedPolicy)) -cne (Get-WelaRecoveryKey $Plan.ProtectedPolicy)){throw 'Preserved PowerShell policy changed; recovery stopped.'}
|
||||
if((Get-WelaRecoveryKey @(Get-WelaTranscriptRecoveryDestinations @($Plan.Directories.RequestedPath))) -cne (Get-WelaRecoveryKey $Plan.Directories)){throw 'A reviewed transcript directory changed.'}
|
||||
$capability=Get-WelaTranscriptCapability
|
||||
if($capability.Status -cne 'Supported'){throw 'Windows PowerShell capability changed.'}
|
||||
$current=@(Get-WelaTranscriptPolicy $capability.Views);Test-WelaTranscriptSharedPolicy $current
|
||||
if((Get-WelaRecoveryKey $current) -cne (Get-WelaRecoveryKey $Policy)){throw 'Current typed transcription policy drifted from the expected recovery step.'}
|
||||
}
|
||||
function Set-WelaTranscriptRecoveryValue {
|
||||
param([ValidateSet('EnableTranscripting','OutputDirectory')][string]$Name,$Value)
|
||||
Assert-WelaTranscriptRecoveryValue $Value $Name
|
||||
$base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64)
|
||||
$key=$null
|
||||
try {
|
||||
$key=$base.OpenSubKey('SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription',$true)
|
||||
if($null -eq $key){throw 'Existing transcription key disappeared; it will not be recreated.'}
|
||||
if($Value.ValueExists){$key.SetValue($Name,$Value.Value,[Microsoft.Win32.RegistryValueKind]([string]$Value.Type))}
|
||||
else{$key.DeleteValue($Name,$false)}
|
||||
$key.Flush()
|
||||
} finally {if($key){$key.Dispose()};$base.Dispose()}
|
||||
}
|
||||
function Write-WelaTranscriptRecoveryArtifact {
|
||||
param($Directory,[string]$Name,$Value)
|
||||
$fresh=Get-WelaTranscriptDestination $Directory.RequestedPath
|
||||
if(-not $fresh.ConfigureAllowed -or (Get-WelaRecoveryKey $fresh) -cne (Get-WelaRecoveryKey $Directory)){throw 'Private recovery output directory changed.'}
|
||||
Write-WelaRecoveryArtifact (Join-Path $Directory.Path $Name) $Value
|
||||
}
|
||||
function Invoke-WelaTranscriptRecovery {
|
||||
param([ValidateSet('Plan','Restore')][string]$Action='Plan',[string]$JournalPath,[string]$OriginalResultsPath,[string]$PlanPath,[string]$PlanHash,[string]$OutputPath,[switch]$AllowTemporarySuspension,[switch]$Auto,[switch]$DryRun)
|
||||
$ErrorActionPreference='Stop'
|
||||
if($Action -eq 'Plan') {
|
||||
if($PlanPath -or $PlanHash -or $Auto -or $DryRun -or $AllowTemporarySuspension){throw 'Plan takes original journal/results and new output only; consent flags are Restore-only.'}
|
||||
$plan=New-WelaTranscriptRecoveryPlan $JournalPath $OriginalResultsPath
|
||||
Assert-WelaTranscriptRecoveryBindings $plan $plan.ExpectedPolicy
|
||||
Assert-WelaTranscriptRecoveryLocalPath $OutputPath
|
||||
$output=New-WelaRecoveryOutput $OutputPath
|
||||
$outputObservation=Get-WelaTranscriptDestination $output
|
||||
Write-WelaTranscriptRecoveryArtifact $outputObservation 'plan.json' $plan
|
||||
$hash=(Read-WelaTranscriptRecoveryFile (Join-Path $output 'plan.json')).Sha256
|
||||
return [pscustomobject]@{Status='Planned';ExitCode=0;OutputPath=$output;PlanSha256=$hash;RequiresTemporarySuspension=$plan.RequiresTemporarySuspension;SigmaEvtxCredit=0}
|
||||
}
|
||||
if($JournalPath -or $OriginalResultsPath -or -not $PlanPath -or $PlanHash -cnotmatch '^[0-9a-f]{64}$'){throw 'Restore consumes a reviewed plan path, its exact SHA-256 and a new output directory.'}
|
||||
$source=Read-WelaTranscriptRecoveryFile $PlanPath
|
||||
if($source.Sha256 -cne $PlanHash){throw 'Supplied reviewed plan hash differs.'}
|
||||
$plan=ConvertFrom-WelaRecoveryJson $source.Text
|
||||
if($plan.Kind -isnot [string] -or $plan.Kind -cne 'WelaTranscriptionRecoveryPlan' -or ($plan.SchemaVersion -isnot [int] -and $plan.SchemaVersion -isnot [long]) -or $plan.SchemaVersion -ne 1){throw 'Unsupported transcription recovery plan.'}
|
||||
$rebuilt=New-WelaTranscriptRecoveryPlan $plan.Journal.Path $plan.OriginalResults.Path
|
||||
if((Get-WelaRecoveryKey $rebuilt) -cne (Get-WelaRecoveryKey $plan)){throw 'Reviewed plan differs from independently rebuilt original evidence and current observations.'}
|
||||
Assert-WelaTranscriptRecoveryBindings $plan $plan.ExpectedPolicy $source.Path $source.Sha256
|
||||
if($plan.RequiresTemporarySuspension -and -not $AllowTemporarySuspension){throw 'Restoring this destination requires explicit -TranscriptRecoveryAllowTemporarySuspension consent, including for preview.'}
|
||||
if($DryRun) {
|
||||
if($OutputPath){throw 'DryRun writes no directory; omit OutputPath.'}
|
||||
return [pscustomobject]@{Status='WouldRestore';ExitCode=0;DryRun=$true;Steps=$plan.Steps;SigmaEvtxCredit=0}
|
||||
}
|
||||
Assert-WelaTranscriptRecoveryLocalPath $OutputPath
|
||||
$output=New-WelaRecoveryOutput $OutputPath
|
||||
$outputObservation=Get-WelaTranscriptDestination $output
|
||||
$report=[pscustomobject][ordered]@{Status='Failed';ExitCode=1;OutputPath=$output;PlanSha256=$source.Sha256;Steps=@();Before=$plan.ExpectedPolicy;After=$null;Diagnostic='';SigmaEvtxCredit=0;Scope='Two typed Windows PowerShell machine transcription values only; no transcript, session adoption, central collection or policy persistence proof.'}
|
||||
$expected=Copy-WelaTranscriptRecoveryValue $plan.ExpectedPolicy
|
||||
try {
|
||||
if(-not $Auto -and (Read-Host 'Restore the reviewed transcription values, including any explicitly consented temporary suspension? (y/N)') -cnotin @('y','Y')){$report.Status='Declined';$report.ExitCode=0}
|
||||
else {
|
||||
Write-WelaTranscriptRecoveryArtifact $outputObservation 'plan.json' $plan
|
||||
$sequence=0
|
||||
foreach($step in $plan.Steps) {
|
||||
$sequence++
|
||||
Assert-WelaTranscriptRecoveryBindings $plan $expected $source.Path $source.Sha256
|
||||
$receipt=[pscustomobject]@{Sequence=$sequence;Status='Pending';RecordedUtc=[datetime]::UtcNow.ToString('o');PlanSha256=$source.Sha256;Step=$step;Before=(Copy-WelaTranscriptRecoveryValue $expected);After=$null}
|
||||
Write-WelaTranscriptRecoveryArtifact $outputObservation ('{0:d3}-pending.json' -f $sequence) $receipt
|
||||
Assert-WelaTranscriptRecoveryBindings $plan $expected $source.Path $source.Sha256
|
||||
Set-WelaTranscriptRecoveryValue $step.Name $step.Value
|
||||
foreach($view in $expected){$view.Machine.($step.Name)=Copy-WelaTranscriptRecoveryValue $step.Value}
|
||||
Assert-WelaTranscriptRecoveryBindings $plan $expected $source.Path $source.Sha256
|
||||
$receipt.Status='Confirmed';$receipt.After=Copy-WelaTranscriptRecoveryValue $expected
|
||||
Write-WelaTranscriptRecoveryArtifact $outputObservation ('{0:d3}-confirmed.json' -f $sequence) $receipt
|
||||
$report.Steps += [pscustomobject]@{Sequence=$sequence;Name=$step.Name;Status='Confirmed';Value=$step.Value}
|
||||
}
|
||||
Assert-WelaTranscriptRecoveryBindings $plan $plan.RecoverTo $source.Path $source.Sha256
|
||||
$report.Status='Restored';$report.ExitCode=0
|
||||
}
|
||||
} catch {$report.Diagnostic=$_.Exception.Message}
|
||||
try {
|
||||
$report.After=@(Get-WelaTranscriptPolicy (Get-WelaTranscriptCapability).Views)
|
||||
if($report.Status -eq 'Restored') {
|
||||
if((Get-WelaRecoveryKey $report.After) -cne (Get-WelaRecoveryKey $plan.RecoverTo)){throw 'Final returned policy differs from the recovery target.'}
|
||||
Assert-WelaTranscriptRecoveryBindings $plan $plan.RecoverTo $source.Path $source.Sha256
|
||||
}
|
||||
}catch{$report.Diagnostic+=' Final policy verification failed: '+$_.Exception.Message;$report.Status='Failed';$report.ExitCode=1}
|
||||
# A failed result write fails outward; pending/confirmed receipts remain intact.
|
||||
Write-WelaTranscriptRecoveryArtifact $outputObservation 'result.json' $report
|
||||
return $report
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
# Reviewed authorization only; an enabled subscription is never edited or paused.
|
||||
function Get-WelaWecAuthorizationKey {param($Value) ConvertTo-Json -InputObject $Value -Depth 24 -Compress}
|
||||
function Get-WelaWecAuthorizationSids {
|
||||
param([object[]]$SourceSids)
|
||||
if($SourceSids.Count -lt 1 -or $SourceSids.Count -gt 32){throw 'Select 1 to 32 explicit domain-format source SIDs.'}
|
||||
$seen=@{};$result=@()
|
||||
foreach($sid in $SourceSids){
|
||||
if($sid -isnot [string] -or $sid -cnotmatch '^S-1-5-21-(0|[1-9][0-9]{0,9})-(0|[1-9][0-9]{0,9})-(0|[1-9][0-9]{0,9})-(0|[1-9][0-9]{0,9})$'){throw 'Source SIDs must be canonical explicit domain-format strings.'}
|
||||
foreach($part in @($sid.Split('-')|Select-Object -Skip 4)){$value=[uint32]0;if(-not [uint32]::TryParse($part,[ref]$value)){throw 'Source SID subauthority exceeds the native range.'}}
|
||||
if($seen.ContainsKey($sid)){throw 'Duplicate source SID.'};$seen[$sid]=$true;$result+=$sid
|
||||
}
|
||||
[string[]]$ordered=$result;[Array]::Sort($ordered,[StringComparer]::Ordinal);$ordered
|
||||
}
|
||||
function Get-WelaWecAuthorizationDefinition {
|
||||
param([string]$Xml)
|
||||
if(-not $Xml -or $Xml.Length -gt 1048576){throw 'Native subscription XML is absent or oversized.'}
|
||||
$doc=Read-WelaWefXml $Xml;$root=$doc.DocumentElement;$ns=[Xml.XmlNamespaceManager]::new($doc.NameTable);$ns.AddNamespace('s','http://schemas.microsoft.com/2006/03/windows/events/subscription')
|
||||
$nodes=@($root.SelectNodes('s:AllowedSourceDomainComputers',$ns));if($nodes.Count -ne 1){throw 'One explicit source authorization is required.'}
|
||||
$authorization=[string]$nodes[0].InnerText
|
||||
if($authorization.Length -gt 4096 -or $authorization -cnotmatch '^O:NSG:NSD:(?:\(A;;GA;;;S-1-5-21-[0-9]+-[0-9]+-[0-9]+-[0-9]+\)){1,32}$'){throw 'Only the explicit standard domain-source allow list is supported; no arbitrary/default SDDL.'}
|
||||
$sids=@(Get-WelaWecAuthorizationSids @([regex]::Matches($authorization,'S-1-5-21-[0-9]+-[0-9]+-[0-9]+-[0-9]+')|ForEach-Object Value))
|
||||
if((Get-WelaWefAuthorization $sids) -cne $authorization){throw 'Observed authorization is not the canonical explicit SID list.'}
|
||||
$model=ConvertFrom-WelaWefSubscription -Xml $Xml -SourceSids $sids -Observed
|
||||
if($model.Definition.Enabled -ne $false){throw 'Only an already disabled source-initiated subscription can change authorization.'}
|
||||
$whole=Get-WelaWefXmlKey $root;$null=$root.RemoveChild($nodes[0])
|
||||
[pscustomobject][ordered]@{Id=$model.Id;Xml=$Xml;SourceSids=$sids;Authorization=$authorization;WholeKey=$whole;PreservedKey=(Get-WelaWefXmlKey $root);QueryKey=$model.Query.Key;Description=$model.Definition.Description}
|
||||
}
|
||||
function Read-WelaWecAuthorizationDefinition {
|
||||
param([string]$Id)
|
||||
if($Id -cnotmatch '^[A-Za-z0-9][A-Za-z0-9 ._-]{0,127}$'){throw 'Select one exact subscription ID.'}
|
||||
$definition=Get-WelaWecAuthorizationDefinition (Read-WelaWecSubscriptionXml $Id)
|
||||
if($definition.Id -cne $Id){throw 'Native subscription identity differs.'};$definition
|
||||
}
|
||||
function Get-WelaWecAuthorizationContext {
|
||||
$reader=Get-WelaChannelReader
|
||||
if(-not $reader.ElevatedAdministrator){throw 'Actual non-impersonated elevated administrator required.'}
|
||||
$required=@(Get-Service -Name Wecsvc,Winmgmt,EventLog -ErrorAction Stop)
|
||||
if($required.Count -ne 3 -or @($required|Where-Object Status -ne Running).Count){throw 'Wecsvc, Winmgmt and EventLog must already be running; no services are started.'}
|
||||
$hostState=Get-WelaChannelReadHost
|
||||
if($hostState.ProductType -ne 3 -or $hostState.DomainRole -notin @(2,3) -or $hostState.Build -notin @(20348,26100) -or $null -eq $hostState.UBR -or $hostState.UBR -lt 1){throw 'Observed patched Server 2022/2025 member or standalone collector required.'}
|
||||
$services=@(Get-CimInstance Win32_Service -Filter "Name='Wecsvc' OR Name='Winmgmt' OR Name='EventLog'" -ErrorAction Stop|Sort-Object Name|Select-Object Name,State,StartMode)
|
||||
if($services.Count -ne 3 -or @($services|Where-Object {$_.State -ne 'Running' -or $_.StartMode -notin @('Auto','Manual')}).Count){throw 'Stable running service observations required.'}
|
||||
$log=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('ForwardedEvents')
|
||||
try{$channel=[pscustomobject][ordered]@{Name=$log.LogName;Enabled=$log.IsEnabled;Mode=[string]$log.LogMode;MaximumBytes=$log.MaximumSizeInBytes;Path=$log.LogFilePath;SecurityDescriptor=$log.SecurityDescriptor}}finally{$log.Dispose()}
|
||||
if((Get-WelaWecAuthorizationKey (Get-WelaChannelReader)) -cne (Get-WelaWecAuthorizationKey $reader)){throw 'Actual token changed during observations.'}
|
||||
[pscustomobject][ordered]@{Host=$hostState;Reader=$reader;Services=$services;Destination=$channel}
|
||||
}
|
||||
function Get-WelaWecAuthorizationReviewKey {
|
||||
param($Context)
|
||||
$copy=Get-WelaWecAuthorizationKey $Context|ConvertFrom-Json
|
||||
$copy.Reader.ProcessId=$null;$copy.Reader.TokenId=$null;$copy.Reader.ModifiedId=$null
|
||||
Get-WelaWecAuthorizationKey $copy
|
||||
}
|
||||
function Get-WelaWecAuthorizationSources {
|
||||
$sources=[ordered]@{}
|
||||
foreach($path in @('WELA.ps1','scripts/WecAuthorization.ps1','scripts/WecAuthorizationNative.cs','scripts/ChannelRead.ps1','scripts/ChannelReadNative.cs','scripts/WefArrival.ps1','scripts/WecUpdate.ps1','modules/WefSubscriptions.psm1','modules/WecSubscriptionXml.cs','modules/AuditProfiles.psm1','scripts/CustomAuditProfiles.ps1')){$sources[$path]=(Get-FileHash -LiteralPath (Join-Path $script:ScriptRoot $path) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()}
|
||||
Get-WelaWecAuthorizationKey $sources
|
||||
}
|
||||
function Initialize-WelaWecAuthorizationNative {
|
||||
$bytes=[IO.File]::ReadAllBytes((Join-Path $PSScriptRoot 'WecAuthorizationNative.cs'));if($bytes.Length -gt 65536){throw 'Native authorization source exceeds bound.'}
|
||||
$hash=Get-WelaArrivalHash $bytes
|
||||
if(-not ('Wela.WecAuthorization.Edit' -as [type])){
|
||||
$source=[Text.UTF8Encoding]::new($false,$true).GetString($bytes).TrimStart([char]0xfeff)
|
||||
if([regex]::Matches($source,'__WELA_SOURCE_SHA256__').Count -ne 1){throw 'Native source binding marker is missing or ambiguous.'}
|
||||
Add-Type -TypeDefinition $source.Replace('__WELA_SOURCE_SHA256__',$hash) -ErrorAction Stop
|
||||
}
|
||||
if([Wela.WecAuthorization.Edit]::SourceSha256 -cne $hash){throw 'Loaded authorization setter differs from source; start a fresh process.'}
|
||||
}
|
||||
function New-WelaWecAuthorizationEdit {
|
||||
param($Before)
|
||||
Initialize-WelaWecAuthorizationNative;$edit=[Wela.WecAuthorization.Edit]::new($Before.Id)
|
||||
try{if($edit.OriginalAuthorization -cne $Before.Authorization -or $edit.OriginalDescription -cne $Before.Description -or (ConvertFrom-WelaWefQuery $edit.OriginalQuery).Key -cne $Before.QueryKey){throw 'Native handle differs from reviewed subscription.'};$edit}catch{$edit.Dispose();throw}
|
||||
}
|
||||
function Assert-WelaWecAuthorizationPlan {
|
||||
param($Plan)
|
||||
Assert-WelaArrivalObject $Plan @('SchemaVersion','Kind','Id','DesiredSourceSids','ContextKey','Sources','BeforeXml','RecordedUtc')
|
||||
if(($Plan.SchemaVersion -isnot [int] -and $Plan.SchemaVersion -isnot [long]) -or $Plan.SchemaVersion -ne 1 -or $Plan.Kind -isnot [string] -or $Plan.Kind -cne 'WelaWecAuthorizationPlan' -or $Plan.Id -isnot [string] -or $Plan.Id -cnotmatch '^[A-Za-z0-9][A-Za-z0-9 ._-]{0,127}$' -or $Plan.DesiredSourceSids -isnot [array] -or $Plan.ContextKey -isnot [string] -or -not $Plan.ContextKey -or $Plan.Sources -isnot [string] -or -not $Plan.Sources -or $Plan.BeforeXml -isnot [string]){throw 'Unknown or mistyped authorization plan.'}
|
||||
$desired=@(Get-WelaWecAuthorizationSids $Plan.DesiredSourceSids)
|
||||
if((Get-WelaWecAuthorizationKey $desired) -cne (Get-WelaWecAuthorizationKey $Plan.DesiredSourceSids)){throw 'Desired SID list is not canonical.'}
|
||||
$null=ConvertTo-WelaArrivalUtc $Plan.RecordedUtc
|
||||
$before=Get-WelaWecAuthorizationDefinition $Plan.BeforeXml;if($before.Id -cne $Plan.Id){throw 'Plan identity contradicts original subscription.'}
|
||||
}
|
||||
function Assert-WelaWecAuthorizationArtifacts {
|
||||
param([string]$Output,$Artifacts)
|
||||
foreach($a in $Artifacts){if((Get-FileHash -LiteralPath (Join-Path $Output $a.Name) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() -cne $a.Sha256){throw 'Retained authorization evidence changed.'}}
|
||||
}
|
||||
function Invoke-WelaWecAuthorization {
|
||||
param([ValidateSet('Plan','Apply')][string]$Action='Plan',[string]$Id,[object[]]$SourceSids,[string]$PlanPath,[string]$PlanHash,[string]$OutputPath)
|
||||
$ErrorActionPreference='Stop'
|
||||
if($args.Count){throw 'Unknown authorization arguments are not supported.'}
|
||||
if($Action -eq 'Plan'){
|
||||
if(-not $Id -or -not $SourceSids -or -not $OutputPath -or $PSBoundParameters.ContainsKey('PlanPath') -or $PSBoundParameters.ContainsKey('PlanHash')){throw 'Plan requires exact ID, desired source SIDs and new output only.'}
|
||||
$desired=@(Get-WelaWecAuthorizationSids $SourceSids);$reviewed=$null;$source=Join-Path $script:ScriptRoot 'scripts'
|
||||
}else{
|
||||
if(-not $PlanPath -or $PlanHash -cnotmatch '^[a-fA-F0-9]{64}$' -or -not $OutputPath -or $PSBoundParameters.ContainsKey('Id') -or $PSBoundParameters.ContainsKey('SourceSids')){throw 'Apply accepts only a reviewed plan, SHA256 and new output.'}
|
||||
$PlanHash=$PlanHash.ToLowerInvariant();$reviewed=Read-WelaWecUpdateFile $PlanPath;$source=$reviewed.Path
|
||||
}
|
||||
$output=New-WelaArrivalOutput $OutputPath $source
|
||||
$report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaWecAuthorization';Action=$Action;Status='Refused';ExitCode=1;OutputPath=$output;PlanHash=$null;NativeSaveAttempted=$false;NativeErrorCode=$null;BeforeSourceSids=@();DesiredSourceSids=@();After=$null;Artifacts=@();Diagnostic='';ReadyRuleCredit=0;Scope='Only the explicit source-domain SID authorization of one existing disabled native subscription. No SID resolution, AD membership, authentication, forwarding, bookmark or Sigma proof; Sysmon excluded.'}
|
||||
$edit=$null;$plan=$null
|
||||
try {
|
||||
$context=Get-WelaWecAuthorizationContext;$contextKey=Get-WelaWecAuthorizationKey $context;$sources=Get-WelaWecAuthorizationSources
|
||||
if($Action -eq 'Plan'){
|
||||
$before=Read-WelaWecAuthorizationDefinition $Id
|
||||
$plan=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaWecAuthorizationPlan';Id=$Id;DesiredSourceSids=$desired;ContextKey=(Get-WelaWecAuthorizationReviewKey $context);Sources=$sources;BeforeXml=$before.Xml;RecordedUtc=[DateTime]::UtcNow.ToString('o')}
|
||||
Assert-WelaWecAuthorizationPlan $plan
|
||||
if((Read-WelaWecAuthorizationDefinition $Id).WholeKey -cne $before.WholeKey -or (Get-WelaWecAuthorizationKey (Get-WelaWecAuthorizationContext)) -cne $contextKey -or (Get-WelaWecAuthorizationSources) -cne $sources){throw 'Context, subscription or sources changed during planning.'}
|
||||
$text=$plan|ConvertTo-Json -Depth 24;if([Text.Encoding]::UTF8.GetByteCount($text) -gt 4194304){throw 'Reviewed plan exceeds four MiB.'}
|
||||
$artifact=Write-WelaWecUpdateArtifact $output 'plan.json' $text;$report.Artifacts+=$artifact;$report.PlanHash=$artifact.Sha256;$report.Status='ReviewRequired'
|
||||
}else{
|
||||
if($reviewed.Hash -cne $PlanHash){throw 'Reviewed plan hash differs.'};$plan=ConvertFrom-WelaArrivalJson $reviewed.Text;Assert-WelaWecAuthorizationPlan $plan;$report.PlanHash=$PlanHash
|
||||
if($plan.ContextKey -cne (Get-WelaWecAuthorizationReviewKey $context) -or $plan.Sources -cne $sources){throw 'Reviewed actual host/operator/service/channel or sources differ.'}
|
||||
$before=Get-WelaWecAuthorizationDefinition $plan.BeforeXml;$desired=@($plan.DesiredSourceSids);$desiredAuthorization=Get-WelaWefAuthorization $desired
|
||||
if((Read-WelaWecAuthorizationDefinition $plan.Id).WholeKey -cne $before.WholeKey){throw 'Current subscription differs from reviewed complete definition.'}
|
||||
$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'reviewed-plan.json' $reviewed.Text
|
||||
if($before.Authorization -ceq $desiredAuthorization){$report.Status='AlreadyMatches'}else{
|
||||
$edit=New-WelaWecAuthorizationEdit $before
|
||||
$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'before-save.json' ([ordered]@{Status='Pending';PlanHash=$PlanHash;Context=$context;BeforeXml=$before.Xml;DesiredSourceSids=$desired;DesiredAuthorization=$desiredAuthorization;RecordedUtc=[DateTime]::UtcNow.ToString('o')}|ConvertTo-Json -Depth 24)
|
||||
Assert-WelaWecAuthorizationArtifacts $output $report.Artifacts
|
||||
if((Read-WelaWecUpdateFile $PlanPath).Hash -cne $PlanHash -or (Get-WelaWecAuthorizationSources) -cne $sources -or (Get-WelaWecAuthorizationKey (Get-WelaWecAuthorizationContext)) -cne $contextKey -or (Read-WelaWecAuthorizationDefinition $plan.Id).WholeKey -cne $before.WholeKey){throw 'Plan, code, context or complete subscription changed immediately before save.'}
|
||||
try{$edit.Save($desiredAuthorization)}finally{$report.NativeSaveAttempted=[bool]$edit.SaveAttempted}
|
||||
}
|
||||
$after=Read-WelaWecAuthorizationDefinition $plan.Id;$report.After=$after;$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'after.xml' $after.Xml
|
||||
if($after.Authorization -cne $desiredAuthorization -or $after.PreservedKey -cne $before.PreservedKey -or (Get-WelaWecAuthorizationKey (Get-WelaWecAuthorizationContext)) -cne $contextKey -or (Get-WelaWecAuthorizationSources) -cne $sources -or (Read-WelaWecUpdateFile $PlanPath).Hash -cne $PlanHash){throw 'Authorization readback, preserved definition, context, source or plan differs after operation.'}
|
||||
if($report.NativeSaveAttempted){$report.Status='AuthorizationChangedAndVerified'}
|
||||
}
|
||||
$report.BeforeSourceSids=@($before.SourceSids);$report.DesiredSourceSids=@($plan.DesiredSourceSids)
|
||||
Assert-WelaWecAuthorizationArtifacts $output $report.Artifacts;$report.ExitCode=0
|
||||
}catch{
|
||||
$report.Status=if($report.NativeSaveAttempted){'SaveAttemptedUnverified'}else{'Refused'};$report.ExitCode=1;$report.Diagnostic=$_.Exception.Message
|
||||
$exception=$_.Exception;while($exception){if($exception -is [ComponentModel.Win32Exception]){$report.NativeErrorCode=$exception.NativeErrorCode;break};$exception=$exception.InnerException}
|
||||
if($report.NativeSaveAttempted -and $plan){try{$xml=Read-WelaWecSubscriptionXml $plan.Id;$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'failed-after.xml' $xml}catch{$report.Diagnostic+=' Final native definition unavailable: '+$_.Exception.Message}}
|
||||
}finally{if($edit){try{$edit.Dispose()}catch{$report.ExitCode=1;$report.Status=if($report.NativeSaveAttempted){'SaveAttemptedUnverified'}else{'Refused'};$report.Diagnostic+=' Native handle cleanup failed: '+$_.Exception.Message}}}
|
||||
$null=Write-WelaWecUpdateArtifact $output 'manifest.json' ($report|ConvertTo-Json -Depth 24);$report
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Existing-only native WEC authorization setter. No creation, deletion, activation or other setters.
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
namespace Wela.WecAuthorization {
|
||||
public sealed class Edit : IDisposable {
|
||||
public const string SourceSha256="__WELA_SOURCE_SHA256__";
|
||||
[StructLayout(LayoutKind.Explicit, Size=16)] struct Variant {
|
||||
[FieldOffset(0)] public IntPtr Text; [FieldOffset(8)] public uint Count; [FieldOffset(12)] public uint Type;
|
||||
}
|
||||
[DllImport("wecapi.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern IntPtr EcOpenSubscription(string name,uint access,uint flags);
|
||||
[DllImport("wecapi.dll",SetLastError=true)] [return:MarshalAs(UnmanagedType.Bool)] static extern bool EcGetSubscriptionProperty(IntPtr handle,int property,uint flags,uint size,IntPtr value,out uint used);
|
||||
[DllImport("wecapi.dll",SetLastError=true)] [return:MarshalAs(UnmanagedType.Bool)] static extern bool EcSetSubscriptionProperty(IntPtr handle,int property,uint flags,ref Variant value);
|
||||
[DllImport("wecapi.dll",SetLastError=true)] [return:MarshalAs(UnmanagedType.Bool)] static extern bool EcSaveSubscription(IntPtr handle,uint flags);
|
||||
[DllImport("wecapi.dll",SetLastError=true)] [return:MarshalAs(UnmanagedType.Bool)] static extern bool EcClose(IntPtr handle);
|
||||
IntPtr handle; readonly string name,oldQuery,oldDescription,oldAuthorization;
|
||||
public string OriginalQuery {get{return oldQuery;}}
|
||||
public string OriginalDescription {get{return oldDescription;}}
|
||||
public string OriginalAuthorization {get{return oldAuthorization;}}
|
||||
public bool SaveAttempted {get;private set;}
|
||||
public static void ValidateAuthorization(string value) {
|
||||
if(String.IsNullOrEmpty(value)||value.Length>4096||!value.StartsWith("O:NSG:NSD:",StringComparison.Ordinal))throw new ArgumentException("Explicit canonical domain-source authorization required.");
|
||||
var matches=System.Text.RegularExpressions.Regex.Matches(value,@"\(A;;GA;;;(S-1-5-21-[0-9]+-[0-9]+-[0-9]+-[0-9]+)\)");
|
||||
if(matches.Count<1||matches.Count>32)throw new ArgumentException("Select 1 to 32 source SIDs.");
|
||||
var expected=new StringBuilder("O:NSG:NSD:");string prior=null;
|
||||
foreach(System.Text.RegularExpressions.Match match in matches) {
|
||||
string sid=match.Groups[1].Value;string[] parts=sid.Split('-');
|
||||
for(int i=4;i<parts.Length;i++){uint number;if(!UInt32.TryParse(parts[i],System.Globalization.NumberStyles.None,System.Globalization.CultureInfo.InvariantCulture,out number)||number.ToString(System.Globalization.CultureInfo.InvariantCulture)!=parts[i])throw new ArgumentException("Noncanonical SID subauthority.");}
|
||||
if(prior!=null&&StringComparer.Ordinal.Compare(prior,sid)>=0)throw new ArgumentException("SIDs must be unique and sorted.");prior=sid;expected.Append(match.Value);
|
||||
}
|
||||
if(!String.Equals(expected.ToString(),value,StringComparison.Ordinal))throw new ArgumentException("Unsupported authorization descriptor.");
|
||||
}
|
||||
// Public only for allocated-buffer ABI/type regression tests; performs no native calls.
|
||||
public static object Decode(IntPtr buffer,uint size,int property) {
|
||||
if(buffer==IntPtr.Zero||size<16||size>1048576||property!=0&&property!=6&&property!=7&&property!=10&&property!=11&&property!=19&&property!=27&&property!=31)throw new InvalidOperationException("Invalid native property buffer or selection.");
|
||||
int type=Marshal.ReadInt32(buffer,12);
|
||||
if(property==27){if(type!=2)throw new InvalidOperationException("Subscription type is not UInt32.");return unchecked((uint)Marshal.ReadInt32(buffer));}
|
||||
if(property==0){if(type!=1)throw new InvalidOperationException("Enabled is not a scalar Boolean.");int value=Marshal.ReadInt32(buffer);if(value!=0&&value!=1)throw new InvalidOperationException("Invalid native Boolean.");return value==1;}
|
||||
if(type==0&&property==6)return "";
|
||||
if(type!=4)throw new InvalidOperationException("Expected scalar native string.");
|
||||
IntPtr pointer=Marshal.ReadIntPtr(buffer);long offset=pointer.ToInt64()-buffer.ToInt64();
|
||||
if(pointer==IntPtr.Zero||offset<16||offset>size-2)throw new InvalidOperationException("Native string pointer is outside its buffer.");
|
||||
StringBuilder text=new StringBuilder();
|
||||
for(int i=0;i<524288&&offset+2L*i+2<=size;i++){char c=(char)(ushort)Marshal.ReadInt16(pointer,2*i);if(c==0)return text.ToString();text.Append(c);}
|
||||
throw new InvalidOperationException("Unterminated native string.");
|
||||
}
|
||||
static object Read(IntPtr h,int property) {
|
||||
uint size=16;
|
||||
for(int attempt=0;attempt<3;attempt++) {
|
||||
IntPtr buffer=Marshal.AllocHGlobal((int)size);
|
||||
try {
|
||||
uint used;bool ok=EcGetSubscriptionProperty(h,property,0,size,buffer,out used);int error=Marshal.GetLastWin32Error();
|
||||
if(!ok){if(error!=122)throw new Win32Exception(error);if(used<=size||used>1048576)throw new InvalidOperationException("Invalid native property buffer size.");size=used;continue;}
|
||||
if(used<16||used>size)throw new InvalidOperationException("Invalid native property length.");
|
||||
return Decode(buffer,used,property);
|
||||
}finally{Marshal.FreeHGlobal(buffer);}
|
||||
}
|
||||
throw new InvalidOperationException("Native property changed repeatedly.");
|
||||
}
|
||||
void Check(IntPtr h) {
|
||||
if((bool)Read(h,0)||(uint)Read(h,27)!=0||!String.Equals((string)Read(h,7),"http://schemas.microsoft.com/wbem/wsman/1/windows/EventLog",StringComparison.Ordinal)||!String.Equals((string)Read(h,11),"HTTP",StringComparison.Ordinal)||!String.Equals((string)Read(h,19),"ForwardedEvents",StringComparison.Ordinal)||!String.Equals((string)Read(h,10),oldQuery,StringComparison.Ordinal)||!String.Equals((string)Read(h,6),oldDescription,StringComparison.Ordinal)||!String.Equals((string)Read(h,31),oldAuthorization,StringComparison.Ordinal))throw new InvalidOperationException("Native enabled/query/description/authorization changed since review.");
|
||||
}
|
||||
public Edit(string id) {
|
||||
if(String.IsNullOrWhiteSpace(id)||id.Length>128||id.IndexOf('\0')>=0)throw new ArgumentException("Invalid subscription ID.");
|
||||
name=id;handle=EcOpenSubscription(name,3,2);if(handle==IntPtr.Zero)throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
try{oldQuery=(string)Read(handle,10);oldDescription=(string)Read(handle,6);oldAuthorization=(string)Read(handle,31);ValidateAuthorization(oldAuthorization);Check(handle);}catch{Dispose();throw;}
|
||||
}
|
||||
public void Save(string authorization) {
|
||||
if(handle==IntPtr.Zero)throw new ObjectDisposedException("Edit");
|
||||
if(SaveAttempted)throw new InvalidOperationException("A native edit may be saved only once.");
|
||||
ValidateAuthorization(authorization);
|
||||
if(String.Equals(authorization,oldAuthorization,StringComparison.Ordinal))throw new InvalidOperationException("Idempotent authorization must not save.");
|
||||
IntPtr fresh=EcOpenSubscription(name,1,2);if(fresh==IntPtr.Zero)throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
try{Check(fresh);}finally{EcClose(fresh);}
|
||||
IntPtr text=Marshal.StringToHGlobalUni(authorization);
|
||||
try {
|
||||
Variant value=new Variant{Text=text,Count=0,Type=4};
|
||||
if(!EcSetSubscriptionProperty(handle,31,0,ref value))throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
SaveAttempted=true;
|
||||
if(!EcSaveSubscription(handle,0))throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
}finally{Marshal.FreeHGlobal(text);}
|
||||
}
|
||||
public void Dispose(){if(handle!=IntPtr.Zero){EcClose(handle);handle=IntPtr.Zero;}}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
# Explicit creation of one new, narrowly scoped collector firewall rule.
|
||||
function ConvertTo-WelaIngressAddress {
|
||||
param([string]$Value,[switch]$Remote,[switch]$Observed)
|
||||
if($Value -cnotmatch '^([0-9]{1,3}\.){3}[0-9]{1,3}(/([0-9]{1,2}|([0-9]{1,3}\.){3}[0-9]{1,3}))?$'){throw 'An exact canonical IPv4 address or remote /24-/32 network is required.'}
|
||||
$parts=$Value.Split('/');$octets=$parts[0].Split('.');$number=0L
|
||||
foreach($octet in $octets){if([int]$octet -gt 255 -or ([int]$octet).ToString() -cne $octet){throw 'Noncanonical IPv4 address.'};$number=($number -shl 8)+[int]$octet}
|
||||
if([int]$octets[0] -in @(0,127) -or [int]$octets[0] -ge 224 -or $parts[0] -eq '169.254.0.0'){throw 'Unspecified, loopback or multicast/reserved addresses are unsupported.'}
|
||||
$prefix=32
|
||||
if($parts.Count -eq 2){
|
||||
if(-not $Remote){throw 'Local addresses must be exact assigned IPv4 addresses.'}
|
||||
if($parts[1].Contains('.')){
|
||||
if(-not $Observed){throw 'Use a numeric CIDR prefix.'}
|
||||
$mask=0L;foreach($piece in $parts[1].Split('.')){if([int]$piece -gt 255){throw 'Invalid netmask.'};$mask=($mask -shl 8)+[int]$piece}
|
||||
$prefix=0;while($prefix -lt 32 -and ($mask -band (1L -shl (31-$prefix)))){$prefix++}
|
||||
$expected=if($prefix -eq 0){0L}else{(0xffffffffL -shl (32-$prefix)) -band 0xffffffffL}
|
||||
if($mask -ne $expected){throw 'Noncontiguous netmask.'}
|
||||
}else{$prefix=[int]$parts[1];if($prefix.ToString() -cne $parts[1]){throw 'Noncanonical prefix.'}}
|
||||
if($prefix -lt 24 -or $prefix -gt 32 -or ($number -band ((1L -shl (32-$prefix))-1)) -ne 0){throw 'Remote scopes require aligned /24-/32 networks.'}
|
||||
}
|
||||
if($prefix -eq 32){$parts[0]}else{$parts[0]+'/'+$prefix}
|
||||
}
|
||||
function Get-WelaIngressSelection {
|
||||
param([string]$Name,[object[]]$LocalAddresses,[object[]]$RemoteAddresses)
|
||||
if($Name -cnotmatch '^WELA-WEC-[A-Za-z0-9][A-Za-z0-9-]{0,63}$'){throw 'Rule name must start WELA-WEC- and contain only letters, digits and hyphens.'}
|
||||
if($LocalAddresses.Count -lt 1 -or $LocalAddresses.Count -gt 8 -or $RemoteAddresses.Count -lt 1 -or $RemoteAddresses.Count -gt 16){throw 'Select 1-8 local addresses and 1-16 remote scopes.'}
|
||||
$local=@();$remote=@()
|
||||
foreach($value in $LocalAddresses){if($value -isnot [string]){throw 'Address must be a string.'};$local+=ConvertTo-WelaIngressAddress $value}
|
||||
foreach($value in $RemoteAddresses){if($value -isnot [string]){throw 'Address must be a string.'};$remote+=ConvertTo-WelaIngressAddress $value -Remote}
|
||||
if(@($local|Select-Object -Unique).Count -ne $local.Count -or @($remote|Select-Object -Unique).Count -ne $remote.Count){throw 'Duplicate address scopes are unsupported.'}
|
||||
[pscustomobject][ordered]@{Name=$Name;LocalAddresses=@($local|Sort-Object);RemoteAddresses=@($remote|Sort-Object)}
|
||||
}
|
||||
function Get-WelaIngressSources {
|
||||
$root=Split-Path $PSScriptRoot -Parent;$sources=[ordered]@{}
|
||||
foreach($name in @('WELA.ps1','scripts/WecIngress.ps1','scripts/WecUpdate.ps1','scripts/WefArrival.ps1','scripts/ChannelRead.ps1','scripts/ChannelReadNative.cs','modules/AuditProfiles.psm1','scripts/CustomAuditProfiles.ps1')){
|
||||
$sources[$name]=(Get-FileHash -LiteralPath (Join-Path $root $name) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()
|
||||
}
|
||||
$sources|ConvertTo-Json -Compress
|
||||
}
|
||||
function Get-WelaIngressContext {
|
||||
$reader=Get-WelaChannelReader;$hostState=Get-WelaChannelReadHost
|
||||
if(-not $reader.ElevatedAdministrator -or $hostState.ProductType -ne 3 -or $hostState.DomainRole -notin @(2,3) -or $hostState.Build -notin @(20348,26100)){throw 'An elevated native Server 2022/2025 member or standalone collector is required.'}
|
||||
$services=@();foreach($name in @('BFE','MpsSvc','WinRM','Wecsvc')){
|
||||
$found=@(Get-CimInstance Win32_Service -Filter "Name='$name'" -ErrorAction Stop)
|
||||
if($found.Count -ne 1 -or ($name -in @('BFE','MpsSvc') -and $found[0].State -ne 'Running')){throw 'Firewall services must run and WinRM/Wecsvc must be installed.'}
|
||||
$services+=[ordered]@{Name=$name;State=[string]$found[0].State;StartMode=[string]$found[0].StartMode}
|
||||
}
|
||||
$profiles=@(NetSecurity\Get-NetFirewallProfile -PolicyStore ActiveStore -ErrorAction Stop|Sort-Object Name|Select-Object Name,Enabled,DefaultInboundAction,DefaultOutboundAction,AllowInboundRules,AllowLocalFirewallRules)
|
||||
$domain=@($profiles|Where-Object Name -eq 'Domain')
|
||||
if($profiles.Count -ne 3 -or $domain.Count -ne 1 -or [string]$domain[0].Enabled -ne 'True' -or [string]$domain[0].AllowLocalFirewallRules -eq 'False' -or [string]$domain[0].AllowInboundRules -eq 'False'){throw 'Domain firewall must be enabled and permit local inbound rules.'}
|
||||
$addresses=@(NetTCPIP\Get-NetIPAddress -AddressFamily IPv4 -ErrorAction Stop|Where-Object AddressState -eq 'Preferred'|ForEach-Object IPAddress|Sort-Object -Unique)
|
||||
[pscustomobject][ordered]@{Host=$hostState;MachineGuid=(Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Cryptography' -Name MachineGuid -ErrorAction Stop).MachineGuid;Reader=[ordered]@{Sid=$reader.UserSid;Logon=$reader.AuthenticationId;Groups=$reader.GroupSids};Services=$services;Profiles=$profiles;Addresses=$addresses}
|
||||
}
|
||||
function Read-WelaIngressRules {
|
||||
param([string]$Store)
|
||||
$rules=@(NetSecurity\Get-NetFirewallRule -PolicyStore $Store -ErrorAction Stop|Select-Object -First 4097)
|
||||
if($rules.Count -gt 4096){throw 'Firewall inventory exceeds the 4096-rule bound.'}
|
||||
$rules
|
||||
}
|
||||
function Assert-WelaIngressAbsent {
|
||||
param([string]$Name)
|
||||
foreach($store in @('PersistentStore','ActiveStore')){if(@(Read-WelaIngressRules $store|Where-Object Name -eq $Name).Count){throw 'The selected rule name already exists; existing rules are never replaced.'}}
|
||||
}
|
||||
function New-WelaIngressNativeRule {
|
||||
param($Selection)
|
||||
$null=NetSecurity\New-NetFirewallRule -PolicyStore PersistentStore -Name $Selection.Name -DisplayName $Selection.Name -Description 'WELA reviewed collector ingress; TCP 5985, Domain profile, explicit IPv4 scopes.' -Group 'WELA reviewed collector ingress' -Enabled True -Profile Domain -Direction Inbound -Action Allow -Protocol TCP -LocalPort 5985 -RemotePort Any -LocalAddress $Selection.LocalAddresses -RemoteAddress $Selection.RemoteAddresses -EdgeTraversalPolicy Block -LooseSourceMapping $false -LocalOnlyMapping $false -Authentication NotRequired -Encryption NotRequired -OverrideBlockRules $false -ErrorAction Stop
|
||||
}
|
||||
function Read-WelaIngressRule {
|
||||
param([string]$Store,[string]$Name)
|
||||
$rule=@(Read-WelaIngressRules $Store|Where-Object Name -eq $Name)
|
||||
if($rule.Count -ne 1){throw 'Exactly one selected rule must be observed.'}
|
||||
$r=$rule[0];$filters=[ordered]@{}
|
||||
foreach($kind in @('Port','Address','Application','Service','Interface','InterfaceType','Security')){
|
||||
$command='NetSecurity\Get-NetFirewall'+$kind+'Filter';$items=@(&$command -AssociatedNetFirewallRule $r -ErrorAction Stop)
|
||||
if($items.Count -ne 1){throw "Ambiguous $kind filter."};$filters[$kind]=$items[0]
|
||||
}
|
||||
[pscustomobject]@{Store=$Store;Rule=$r;Filters=$filters}
|
||||
}
|
||||
function ConvertTo-WelaIngressEvidence {
|
||||
param($Observed)
|
||||
# Project the inspected fields, not recursive CIM class/session metadata.
|
||||
$fields=[ordered]@{
|
||||
Rule=@('Name','DisplayName','Description','Group','Enabled','Profile','Direction','Action','EdgeTraversalPolicy','LooseSourceMapping','LocalOnlyMapping','PolicyStoreSourceType','Owner','Platform','PrimaryStatus','EnforcementStatus')
|
||||
Port=@('Protocol','LocalPort','RemotePort','IcmpType','DynamicTarget')
|
||||
Address=@('LocalAddress','RemoteAddress')
|
||||
Application=@('Program','Package')
|
||||
Service=@('Service');Interface=@('InterfaceAlias');InterfaceType=@('InterfaceType')
|
||||
Security=@('Authentication','Encryption','OverrideBlockRules','LocalUser','RemoteUser','RemoteMachine')
|
||||
}
|
||||
$result=[ordered]@{Store=$Observed.Store}
|
||||
foreach($kind in $fields.Keys){
|
||||
$item=if($kind -eq 'Rule'){$Observed.Rule}else{$Observed.Filters[$kind]};$values=[ordered]@{}
|
||||
foreach($name in $fields[$kind]){
|
||||
$property=$item.PSObject.Properties[$name]
|
||||
$values[$name]=[ordered]@{Present=($null -ne $property);Value=$(if($null -eq $property -or $null -eq $property.Value){$null}else{@($property.Value|ForEach-Object {[string]$_})})}
|
||||
}
|
||||
$result[$kind]=$values
|
||||
}
|
||||
[pscustomobject]$result
|
||||
}
|
||||
function Assert-WelaIngressReadback {
|
||||
param($Observed,$Selection)
|
||||
$r=$Observed.Rule;$f=$Observed.Filters
|
||||
foreach($field in @('Name','DisplayName')){if([string]$r.$field -cne $Selection.Name){throw "Rule $field differs."}}
|
||||
$fixed=@{Description='WELA reviewed collector ingress; TCP 5985, Domain profile, explicit IPv4 scopes.';Group='WELA reviewed collector ingress';Enabled='True';Profile='Domain';Direction='Inbound';Action='Allow';EdgeTraversalPolicy='Block';LooseSourceMapping='False';LocalOnlyMapping='False';PolicyStoreSourceType='Local'}
|
||||
foreach($field in $fixed.Keys){if([string]$r.$field -cne $fixed[$field]){throw "Rule $field differs."}}
|
||||
if($r.Owner -or @($r.Platform|Where-Object {$_}).Count){throw 'Unexpected rule owner or platform restriction.'}
|
||||
if([string]$f.Port.Protocol -notin @('TCP','6') -or [string]$f.Port.LocalPort -ne '5985' -or [string]$f.Port.RemotePort -ne 'Any' -or [string]$f.Port.IcmpType -ne 'Any' -or [string]$f.Port.DynamicTarget -ne 'Any'){throw 'Port filter differs.'}
|
||||
$local=@($f.Address.LocalAddress|ForEach-Object {ConvertTo-WelaIngressAddress $_}|Sort-Object)
|
||||
$remote=@($f.Address.RemoteAddress|ForEach-Object {ConvertTo-WelaIngressAddress $_ -Remote -Observed}|Sort-Object)
|
||||
if(($local -join '|') -cne ($Selection.LocalAddresses -join '|') -or ($remote -join '|') -cne ($Selection.RemoteAddresses -join '|')){throw 'Address filters differ.'}
|
||||
foreach($pair in @(@('Application','Program'),@('Service','Service'),@('Interface','InterfaceAlias'),@('InterfaceType','InterfaceType'),@('Security','LocalUser'),@('Security','RemoteUser'),@('Security','RemoteMachine'))){if([string]$f[$pair[0]].($pair[1]) -ne 'Any'){throw "Unexpected $($pair -join '/') filter: '$($f[$pair[0]].($pair[1]))'."}}
|
||||
# Native Package is a nullable SID, unlike the Program 'Any' alias. A
|
||||
# present empty/null Package means no package restriction; missing is unknown.
|
||||
$package=$f.Application.PSObject.Properties['Package']
|
||||
if($null -eq $package -or ($null -ne $package.Value -and ($package.Value -isnot [string] -or $package.Value -cnotin @('','Any')))){throw 'Unexpected or missing Application/Package filter.'}
|
||||
if([string]$f.Security.Authentication -ne 'NotRequired' -or [string]$f.Security.Encryption -ne 'NotRequired' -or [string]$f.Security.OverrideBlockRules -ne 'False'){throw 'Security filter differs.'}
|
||||
}
|
||||
function Assert-WelaIngressPlan {
|
||||
param($Plan)
|
||||
Assert-WelaArrivalObject $Plan @('SchemaVersion','Kind','Selection','ContextKey','Sources','RecordedUtc')
|
||||
if(($Plan.SchemaVersion -isnot [int] -and $Plan.SchemaVersion -isnot [long]) -or $Plan.SchemaVersion -ne 1 -or $Plan.Kind -cne 'WelaCollectorIngressPlan' -or $Plan.ContextKey -isnot [string] -or $Plan.Sources -isnot [string]){throw 'Unknown ingress plan.'}
|
||||
Assert-WelaArrivalObject $Plan.Selection @('Name','LocalAddresses','RemoteAddresses')
|
||||
if($Plan.Selection.Name -isnot [string] -or $Plan.Selection.LocalAddresses -isnot [array] -or $Plan.Selection.RemoteAddresses -isnot [array]){throw 'Mistyped ingress selection.'}
|
||||
$null=Get-WelaIngressSelection $Plan.Selection.Name $Plan.Selection.LocalAddresses $Plan.Selection.RemoteAddresses
|
||||
$null=ConvertTo-WelaArrivalUtc $Plan.RecordedUtc
|
||||
}
|
||||
function Invoke-WelaWecIngress {
|
||||
param([ValidateSet('Plan','Apply')][string]$Action='Plan',[string]$Name,[string[]]$LocalAddress,[string[]]$RemoteAddress,[string]$PlanPath,[string]$PlanHash,[Parameter(Mandatory)][string]$OutputPath)
|
||||
if($Action -eq 'Plan'){
|
||||
if(-not $Name -or -not $LocalAddress -or -not $RemoteAddress -or $PlanPath -or $PlanHash){throw 'Plan requires a new rule name and explicit local/remote IPv4 scopes, without a prior plan.'}
|
||||
$selection=Get-WelaIngressSelection $Name $LocalAddress $RemoteAddress;$inputFile=$null
|
||||
}else{
|
||||
if(-not $PlanPath -or $PlanHash -cnotmatch '^[a-f0-9]{64}$' -or $Name -or $LocalAddress -or $RemoteAddress){throw 'Apply accepts only a reviewed plan path, SHA256 and new output.'}
|
||||
$inputFile=Read-WelaWecUpdateFile $PlanPath
|
||||
}
|
||||
$ErrorActionPreference='Stop'
|
||||
$sourcePath=if($inputFile){$inputFile.Path}else{Join-Path (Split-Path $PSScriptRoot -Parent) 'WELA.ps1'}
|
||||
$output=New-WelaArrivalOutput $OutputPath $sourcePath
|
||||
$report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaCollectorIngress';Action=$Action;Status='Refused';ExitCode=1;OutputPath=$output;PlanHash=$null;NativeCreateAttempted=$false;Artifacts=@();Diagnostic='';ReadyRuleCredit=0;Scope='One local Domain-profile TCP5985 IPv4 allow rule only. Other rules may allow broader access; no listener, service, authentication, subscription, packet delivery or Sigma proof. Sysmon excluded.'}
|
||||
try {
|
||||
$context=Get-WelaIngressContext;$key=$context|ConvertTo-Json -Depth 16 -Compress;$sources=Get-WelaIngressSources
|
||||
if($Action -eq 'Apply'){
|
||||
if($inputFile.Hash -cne $PlanHash){throw 'Reviewed plan hash differs.'}
|
||||
$plan=ConvertFrom-WelaArrivalJson $inputFile.Text;Assert-WelaIngressPlan $plan
|
||||
if($plan.ContextKey -cne $key -or $plan.Sources -cne $sources){throw 'Host, reader, firewall context or source code differs from reviewed plan.'}
|
||||
$selection=Get-WelaIngressSelection $plan.Selection.Name $plan.Selection.LocalAddresses $plan.Selection.RemoteAddresses;$report.PlanHash=$inputFile.Hash
|
||||
}
|
||||
foreach($address in $selection.LocalAddresses){if($address -cnotin $context.Addresses){throw 'Every local address must currently be assigned and Preferred.'}}
|
||||
Assert-WelaIngressAbsent $selection.Name
|
||||
if($Action -eq 'Plan'){
|
||||
$plan=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaCollectorIngressPlan';Selection=$selection;ContextKey=$key;Sources=$sources;RecordedUtc=[DateTime]::UtcNow.ToString('o')};Assert-WelaIngressPlan $plan
|
||||
if((Get-WelaIngressContext|ConvertTo-Json -Depth 16 -Compress) -cne $key -or (Get-WelaIngressSources) -cne $sources){throw 'Context or code drift during planning.'}
|
||||
Assert-WelaIngressAbsent $selection.Name
|
||||
$artifact=Write-WelaWecUpdateArtifact $output 'plan.json' ($plan|ConvertTo-Json -Depth 20);$report.Artifacts+=$artifact;$report.PlanHash=$artifact.Sha256;$report.Status='ReviewRequired';$report.ExitCode=0
|
||||
}else{
|
||||
$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'reviewed-plan.json' $inputFile.Text
|
||||
$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'before-create.json' ([ordered]@{Status='Pending';Selection=$selection;Context=$context;PlanHash=$PlanHash;RecordedUtc=[DateTime]::UtcNow.ToString('o')}|ConvertTo-Json -Depth 20)
|
||||
if((Read-WelaWecUpdateFile $PlanPath).Hash -cne $PlanHash -or (Get-WelaIngressSources) -cne $sources -or (Get-WelaIngressContext|ConvertTo-Json -Depth 16 -Compress) -cne $key){throw 'Plan, context or source drift immediately before creation.'}
|
||||
Assert-WelaIngressAbsent $selection.Name
|
||||
$report.NativeCreateAttempted=$true;New-WelaIngressNativeRule $selection
|
||||
foreach($store in @('PersistentStore','ActiveStore')){
|
||||
$observed=Read-WelaIngressRule $store $selection.Name
|
||||
$report.Artifacts+=Write-WelaWecUpdateArtifact $output ($store+'-after.json') ((ConvertTo-WelaIngressEvidence $observed)|ConvertTo-Json -Depth 8)
|
||||
Assert-WelaIngressReadback $observed $selection
|
||||
}
|
||||
if((Read-WelaWecUpdateFile $PlanPath).Hash -cne $PlanHash -or (Get-WelaIngressSources) -cne $sources -or (Get-WelaIngressContext|ConvertTo-Json -Depth 16 -Compress) -cne $key){throw 'Context, code or plan changed after creation.'}
|
||||
$report.Status='CreatedAndVerified';$report.ExitCode=0
|
||||
}
|
||||
}catch{$report.Status=if($report.NativeCreateAttempted){'CreateAttemptedUnverified'}else{'Refused'};$report.Diagnostic=$_.Exception.Message}
|
||||
$null=Write-WelaWecUpdateArtifact $output 'manifest.json' ($report|ConvertTo-Json -Depth 20)
|
||||
$report
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
# One reviewed exact-IP HTTP listener; native creation always runs in Windows PowerShell 5.1.
|
||||
function Get-WelaListenerKey {
|
||||
param($Value)
|
||||
# Windows PowerShell 5.1 escapes these HTML characters even with default JSON settings.
|
||||
# Normalize the same spelling in both engines before binding nested context strings.
|
||||
$json=ConvertTo-Json -InputObject $Value -Depth 24 -Compress
|
||||
$json.Replace('<','\u003c').Replace('>','\u003e').Replace('&','\u0026').Replace("'",'\u0027')
|
||||
}
|
||||
function Get-WelaListenerSelection {
|
||||
param($ComputerName,$LocalAddress)
|
||||
if($ComputerName -isnot [string] -or $ComputerName -cnotmatch '^[A-Za-z0-9][A-Za-z0-9-]{0,62}$'){throw 'Select the actual local computer name.'}
|
||||
if($LocalAddress -isnot [string] -or $LocalAddress -cnotmatch '^([0-9]{1,3}\.){3}[0-9]{1,3}$'){throw 'Select one canonical assigned IPv4 address.'}
|
||||
$pieces=$LocalAddress.Split('.')
|
||||
foreach($part in $pieces){if([int]$part -gt 255 -or ([int]$part).ToString() -cne $part){throw 'Select one canonical assigned IPv4 address.'}}
|
||||
if([int]$pieces[0] -in @(0,127) -or [int]$pieces[0] -ge 224 -or ($pieces[0] -eq '169' -and $pieces[1] -eq '254')){throw 'Unspecified, loopback, link-local and multicast/reserved addresses are unsupported.'}
|
||||
[pscustomobject][ordered]@{ComputerName=$ComputerName.ToUpperInvariant();LocalAddress=$LocalAddress}
|
||||
}
|
||||
function ConvertFrom-WelaListenerXml {
|
||||
param([string]$Xml)
|
||||
if(-not $Xml -or $Xml.Length -gt 131072){throw 'Listener XML exceeds its bound or is absent.'}
|
||||
$doc=Read-WelaWefXml $Xml;$root=$doc.DocumentElement;$ns='http://schemas.microsoft.com/wbem/wsman/1/config/listener'
|
||||
if($root.LocalName -cne 'Listener' -or $root.NamespaceURI -cne $ns){throw 'Unexpected native listener root.'}
|
||||
$fields=@('Address','Transport','Port','Hostname','Enabled','URLPrefix','CertificateThumbprint');$result=[ordered]@{};$policy=$false
|
||||
foreach($node in @($root)+@($root.ChildNodes|Where-Object NodeType -eq Element)){
|
||||
foreach($attr in @($node.Attributes)){
|
||||
if($attr.NamespaceURI -eq 'http://www.w3.org/2000/xmlns/' -or ($node -eq $root -and $attr.NamespaceURI -eq 'http://www.w3.org/XML/1998/namespace' -and $attr.LocalName -eq 'lang')){continue}
|
||||
if($attr.Name -cne 'Source' -or -not $attr.Value){throw 'Unsupported listener provenance attribute.'};$policy=$true
|
||||
}
|
||||
}
|
||||
foreach($child in $root.ChildNodes){if($child.NodeType -eq 'ProcessingInstruction' -or ($child.NodeType -in @('Text','CDATA') -and -not [string]::IsNullOrWhiteSpace($child.Value))){throw 'Unsupported listener container text.'}}
|
||||
foreach($child in @($root.ChildNodes|Where-Object NodeType -eq Element)){
|
||||
if($child.NamespaceURI -cne $ns -or $child.LocalName -cnotin ($fields+@('ListeningOn')) -or @($child.ChildNodes|Where-Object NodeType -in @('Element','ProcessingInstruction')).Count){throw 'Unsupported native listener field.'}
|
||||
}
|
||||
foreach($name in $fields){$nodes=@($root.ChildNodes|Where-Object {$_.NodeType -eq 'Element' -and $_.LocalName -ceq $name});if($nodes.Count -ne 1){throw "Listener field is absent or duplicated: $name"};$result[$name]=[string]$nodes[0].InnerText}
|
||||
if(-not $result.Address -or $result.Address.Length -gt 256 -or $result.Transport -cnotin @('HTTP','HTTPS') -or $result.Port -cnotmatch '^[1-9][0-9]{0,4}$' -or [int]$result.Port -gt 65535 -or $result.Enabled -cnotin @('true','false') -or $result.Hostname.Length -gt 255 -or $result.URLPrefix -cnotmatch '^[A-Za-z0-9_]+(?:/[A-Za-z0-9_]+)*$' -or $result.CertificateThumbprint -cnotmatch '^(|[0-9A-Fa-f]{40})$'){throw 'Unsupported native listener values.'}
|
||||
$listening=@($root.ChildNodes|Where-Object {$_.NodeType -eq 'Element' -and $_.LocalName -ceq 'ListeningOn'}|ForEach-Object InnerText|Sort-Object)
|
||||
if($listening.Count -gt 64 -or @($listening|Sort-Object -Unique).Count -ne $listening.Count){throw 'Ambiguous or excessive ListeningOn addresses.'}
|
||||
foreach($value in $listening){$ip=$null;if(-not [Net.IPAddress]::TryParse($value,[ref]$ip)){throw 'Invalid native ListeningOn address.'}}
|
||||
$result.ListeningOn=$listening;$result.PolicyOwned=$policy;$result.RawXml=$Xml
|
||||
[pscustomobject]$result
|
||||
}
|
||||
function Read-WelaListenerInventory {
|
||||
$values=@(Microsoft.WSMan.Management\Get-WSManInstance -ResourceURI 'http://schemas.microsoft.com/wbem/wsman/1/config/listener' -Enumerate -ErrorAction Stop|Select-Object -First 33)
|
||||
if($values.Count -gt 32){throw 'Listener inventory exceeds 32 entries.'}
|
||||
$seen=@{};$bytes=0
|
||||
$rows=@(foreach($value in $values){$row=ConvertFrom-WelaListenerXml ([string]$value.OuterXml);$bytes+=$row.RawXml.Length;$id=$row.Address+'|'+$row.Transport;if($seen.ContainsKey($id) -or $bytes -gt 1048576){throw 'Duplicate or oversized listener inventory.'};$seen[$id]=$true;$row})
|
||||
@($rows|Sort-Object Address,Transport)
|
||||
}
|
||||
function Assert-WelaListenerAbsent {
|
||||
param([object[]]$Listeners,$Selection)
|
||||
foreach($row in $Listeners){if($row.Transport -ceq 'HTTP' -and ($row.Address -ceq '*' -or $row.Port -ceq '5985' -or $row.Address -ieq ('IP:'+$Selection.LocalAddress))){throw 'Existing HTTP5985, wildcard or selected listener conflicts; existing listeners are never changed.'}}
|
||||
}
|
||||
function Assert-WelaListenerCreated {
|
||||
param($Listener,$Selection)
|
||||
$expected=@{Address=('IP:'+$Selection.LocalAddress);Transport='HTTP';Port='5985';Hostname='';Enabled='true';URLPrefix='wsman';CertificateThumbprint=''}
|
||||
foreach($name in $expected.Keys){if($Listener.$name -isnot [string] -or $Listener.$name -cne $expected[$name]){throw "Created listener $name differs from the fixed selection."}}
|
||||
if($Listener.PolicyOwned -isnot [bool] -or $Listener.PolicyOwned -or $Listener.ListeningOn.Count -ne 1 -or $Listener.ListeningOn[0] -cne $Selection.LocalAddress){throw 'Created listener must be local and listen on exactly the selected IPv4 address.'}
|
||||
}
|
||||
function Get-WelaListenerSources {
|
||||
$sources=[ordered]@{}
|
||||
foreach($name in @('WELA.ps1','scripts/WecListener.ps1','scripts/WecListenerWorker.ps1','scripts/WecListenerPipeNative.cs','scripts/WefArrival.ps1','scripts/WecUpdate.ps1','scripts/ChannelRead.ps1','scripts/ChannelReadNative.cs','scripts/FirewallLoggingRecovery.ps1','modules/WefSubscriptions.psm1','modules/AuditProfiles.psm1','scripts/CustomAuditProfiles.ps1')){$sources[$name]=(Get-FileHash -LiteralPath (Join-Path $script:ScriptRoot $name) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()}
|
||||
Get-WelaListenerKey $sources
|
||||
}
|
||||
function Get-WelaListenerReaderKey {
|
||||
param($Reader)
|
||||
Get-WelaListenerKey ([ordered]@{Computer=$Reader.Computer;UserSid=$Reader.UserSid;UserName=$Reader.UserName;AuthenticationId=$Reader.AuthenticationId;GroupSids=$Reader.GroupSids;GroupCount=$Reader.GroupCount;PrivilegeCount=$Reader.PrivilegeCount;ElevatedAdministrator=$Reader.ElevatedAdministrator;TokenType=$Reader.TokenType;Impersonation=$Reader.Impersonation})
|
||||
}
|
||||
function Read-WelaListenerPolicy {
|
||||
# Refuse policy-owned WinRM settings; observe both native registry views without writing keys.
|
||||
$observations=@()
|
||||
foreach($view in @([Microsoft.Win32.RegistryView]::Registry64,[Microsoft.Win32.RegistryView]::Registry32)){
|
||||
$base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,$view)
|
||||
try {
|
||||
$queue=@('SOFTWARE\Policies\Microsoft\Windows\WinRM');$visited=0
|
||||
while($queue.Count){$path=$queue[0];$queue=@($queue|Select-Object -Skip 1);$visited++;if($visited -gt 32){throw 'WinRM policy key bound exceeded.'};$key=$base.OpenSubKey($path,$false)
|
||||
try{if($null -eq $key){$observations+=[pscustomobject]@{View=[string]$view;Path=$path;Exists=$false};continue};if($key.ValueCount){throw 'Policy-owned WinRM settings require manual review; no policy is overwritten.'};$children=@($key.GetSubKeyNames()|Sort-Object);$observations+=[pscustomobject]@{View=[string]$view;Path=$path;Exists=$true;Children=$children};foreach($child in $children){$queue+=($path+'\'+$child)}}finally{if($key){$key.Dispose()}}
|
||||
}
|
||||
}finally{$base.Dispose()}
|
||||
}
|
||||
Get-WelaListenerKey $observations
|
||||
}
|
||||
function Read-WelaListenerWinrm {
|
||||
$values=@(Microsoft.WSMan.Management\Get-WSManInstance -ResourceURI 'http://schemas.microsoft.com/wbem/wsman/1/config' -ErrorAction Stop)
|
||||
if($values.Count -ne 1 -or -not $values[0].OuterXml -or $values[0].OuterXml.Length -gt 262144){throw 'WinRM configuration is missing, ambiguous or oversized.'}
|
||||
$doc=Read-WelaWefXml ([string]$values[0].OuterXml)
|
||||
if($doc.DocumentElement.LocalName -cne 'Config' -or $doc.DocumentElement.NamespaceURI -cne 'http://schemas.microsoft.com/wbem/wsman/1/config'){throw 'Unexpected native WinRM configuration.'}
|
||||
[string]$doc.OuterXml
|
||||
}
|
||||
function Get-WelaListenerLocalState {
|
||||
$reader=Get-WelaChannelReader
|
||||
if(-not $reader.ElevatedAdministrator){throw 'The actual non-impersonated elevated administrator is required.'}
|
||||
$services=@(Get-Service -Name WinRM,Winmgmt,BFE,MpsSvc -ErrorAction Stop|Sort-Object Name|ForEach-Object {[pscustomobject]@{Name=$_.Name;Status=[string]$_.Status}})
|
||||
if($services.Count -ne 4 -or @($services|Where-Object Status -cne 'Running').Count){throw 'WinRM, Winmgmt, BFE and MpsSvc must already be running; no service is started.'}
|
||||
$hostState=Get-WelaChannelReadHost
|
||||
if($hostState.ProductType -ne 3 -or $hostState.DomainRole -notin @(2,3) -or $hostState.Build -notin @(20348,26100) -or $null -eq $hostState.UBR -or $hostState.UBR -lt 1){throw 'A reviewed patched native Server 2022/2025 standalone or member collector is required.'}
|
||||
$guid=(Get-ItemProperty -LiteralPath 'HKLM:\SOFTWARE\Microsoft\Cryptography' -Name MachineGuid -ErrorAction Stop).MachineGuid;$parsed=[guid]::Empty
|
||||
if($guid -isnot [string] -or -not [guid]::TryParse($guid,[ref]$parsed) -or $parsed -eq [guid]::Empty){throw 'Actual machine identity is unavailable.'}
|
||||
$nativeServices=@(Get-CimInstance Win32_Service -Filter "Name='WinRM' OR Name='Wecsvc' OR Name='Winmgmt' OR Name='BFE' OR Name='MpsSvc'" -ErrorAction Stop|Sort-Object Name|Select-Object Name,State,StartMode)
|
||||
if($nativeServices.Count -ne 5 -or @($nativeServices|Where-Object {$_.State -notin @('Running','Stopped') -or $_.StartMode -notin @('Auto','Manual','Disabled')}).Count){throw 'Complete stable collector service observations are required.'}
|
||||
$addresses=@(NetTCPIP\Get-NetIPAddress -AddressFamily IPv4 -ErrorAction Stop|Sort-Object InterfaceIndex,IPAddress|Select-Object IPAddress,InterfaceIndex,PrefixLength,PrefixOrigin,SuffixOrigin,AddressState,SkipAsSource)
|
||||
if($addresses.Count -lt 1 -or $addresses.Count -gt 128){throw 'Assigned address inventory is incomplete or excessive.'}
|
||||
$state=[pscustomobject][ordered]@{Host=$hostState;MachineGuid=$parsed.ToString();Reader=$reader;Services=$nativeServices;Addresses=$addresses;Policy=Read-WelaListenerPolicy;WinrmXml=Read-WelaListenerWinrm;Listeners=@(Read-WelaListenerInventory)}
|
||||
if((Get-WelaListenerKey (Get-WelaChannelReader)) -cne (Get-WelaListenerKey $reader)){throw 'Actual token changed during native observations.'}
|
||||
$state
|
||||
}
|
||||
function Get-WelaListenerState {
|
||||
$local=Get-WelaListenerLocalState;$native=Get-WelaFirewallRecoveryNativeSources;$profiles=@();$digests=@()
|
||||
foreach($store in @('PersistentStore','ActiveStore')){
|
||||
$rows=@(NetSecurity\Get-NetFirewallProfile -PolicyStore $store -ErrorAction Stop|Sort-Object Name)
|
||||
if($rows.Count -ne 3){throw 'All three firewall profiles must be observed in both stores.'}
|
||||
foreach($row in $rows){$profiles+=[pscustomobject]@{Store=$store;Profile=ConvertTo-WelaFirewallRecoveryCim $row @('Status','StatusCode','PrimaryStatus','OperationalStatus','InstanceID','InstanceId')}}
|
||||
$digests+=@(Get-WelaFirewallRecoveryRuleDigest $store)
|
||||
}
|
||||
$engine=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell/v1.0/powershell.exe';$worker=Join-Path $PSScriptRoot 'WecListenerWorker.ps1'
|
||||
$cmd=Get-Command 'Microsoft.WSMan.Management\Get-WSManInstance' -CommandType Cmdlet -ErrorAction Stop;$assembly=$cmd.ImplementingType.Assembly.Location
|
||||
if(-not $assembly -or $cmd.ModuleName -cne 'Microsoft.WSMan.Management'){throw 'Native WSMan reader source is unavailable.'}
|
||||
[pscustomobject][ordered]@{Local=$local;Profiles=$profiles;Rules=$digests;NativeFirewall=$native;NativeReader=[ordered]@{Path=$assembly;Sha256=(Get-FileHash $assembly -Algorithm SHA256).Hash};Adapter=[ordered]@{ModulePath=[IO.Path]::Combine([Environment]::SystemDirectory,'WindowsPowerShell\v1.0\Modules');Engine=$engine;EngineSha256=(Get-FileHash $engine -Algorithm SHA256).Hash;Worker=$worker;WorkerSha256=(Get-FileHash $worker -Algorithm SHA256).Hash};Sources=Get-WelaListenerSources}
|
||||
}
|
||||
function Get-WelaListenerReviewKey {
|
||||
param($State,[switch]$ExcludeSelected,$Selection)
|
||||
$copy=Get-WelaListenerKey $State|ConvertFrom-Json
|
||||
$copy.Local.Reader.ProcessId=$null;$copy.Local.Reader.TokenId=$null;$copy.Local.Reader.ModifiedId=$null
|
||||
if($ExcludeSelected){$copy.Local.Listeners=@($copy.Local.Listeners|Where-Object {-not($_.Address -ceq ('IP:'+$Selection.LocalAddress) -and $_.Transport -ceq 'HTTP')})}
|
||||
Get-WelaListenerKey $copy
|
||||
}
|
||||
function Assert-WelaListenerSelectedHost {
|
||||
param($State,$Selection)
|
||||
if($State.Host.Computer.ToUpperInvariant() -cne $Selection.ComputerName -or @($State.Addresses|Where-Object {$_.IPAddress -ceq $Selection.LocalAddress -and [string]$_.AddressState -ceq 'Preferred'}).Count -ne 1){throw 'Selection must identify this actual computer and exactly one currently assigned Preferred IPv4 address.'}
|
||||
}
|
||||
function New-WelaListenerPayload {
|
||||
'<cfg:Listener xmlns:cfg="http://schemas.microsoft.com/wbem/wsman/1/config/listener"><cfg:Port>5985</cfg:Port><cfg:Hostname/><cfg:Enabled>true</cfg:Enabled><cfg:URLPrefix>wsman</cfg:URLPrefix><cfg:CertificateThumbprint/></cfg:Listener>'
|
||||
}
|
||||
function Assert-WelaListenerPlan {
|
||||
param($Plan)
|
||||
Assert-WelaArrivalObject $Plan @('SchemaVersion','Kind','Selection','StateKey','RecordedUtc')
|
||||
if(($Plan.SchemaVersion -isnot [int] -and $Plan.SchemaVersion -isnot [long]) -or $Plan.SchemaVersion -ne 1 -or $Plan.Kind -isnot [string] -or $Plan.Kind -cne 'WelaExactIpListenerPlan' -or $Plan.StateKey -isnot [string] -or -not $Plan.StateKey -or $Plan.StateKey.Length -gt 2097152){throw 'Unknown or mistyped listener plan.'}
|
||||
Assert-WelaArrivalObject $Plan.Selection @('ComputerName','LocalAddress');$selection=Get-WelaListenerSelection $Plan.Selection.ComputerName $Plan.Selection.LocalAddress
|
||||
if((Get-WelaListenerKey $selection) -cne (Get-WelaListenerKey $Plan.Selection)){throw 'Listener plan selection is not canonical.'};$null=ConvertTo-WelaArrivalUtc $Plan.RecordedUtc
|
||||
}
|
||||
function Get-WelaListenerWorkerContextKey {
|
||||
param($Local)
|
||||
$copy=Get-WelaListenerKey $Local|ConvertFrom-Json
|
||||
$copy.Reader=Get-WelaListenerReaderKey $Local.Reader
|
||||
Get-WelaListenerKey $copy
|
||||
}
|
||||
function Invoke-WelaListenerWorkerRequest {
|
||||
param([string]$RequestPath,[string]$RequestHash)
|
||||
$report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaNative51ListenerCreate';Status='Refused';NativeCreateAttempted=$false;ProcessId=$PID;Engine=[Diagnostics.Process]::GetCurrentProcess().MainModule.FileName;EngineVersion=$PSVersionTable.PSVersion.ToString();ModulePath=[string]$env:PSModulePath;Reader=$null;Selection=$null;CreatedXml=$null;After=@();Diagnostic='';NativeHResult=$null}
|
||||
$held=$null
|
||||
try {
|
||||
if($PSVersionTable.PSVersion.Major -ne 5 -or $RequestHash -cnotmatch '^[a-f0-9]{64}$'){throw 'A hashed fixed native Windows PowerShell5.1 request is required.'}
|
||||
$file=Read-WelaWecUpdateFile $RequestPath;if($file.Hash -cne $RequestHash){throw 'Native request hash differs.'}
|
||||
$request=ConvertFrom-WelaArrivalJson $file.Text
|
||||
Assert-WelaArrivalObject $request @('SchemaVersion','Kind','Selection','ContextKey','Sources','EngineSha256','PayloadHash')
|
||||
if(($request.SchemaVersion -isnot [int] -and $request.SchemaVersion -isnot [long]) -or $request.SchemaVersion -ne 1 -or $request.Kind -isnot [string] -or $request.Kind -cne 'WelaNative51ListenerRequest' -or $request.ContextKey -isnot [string] -or $request.Sources -isnot [string] -or $request.EngineSha256 -isnot [string] -or $request.PayloadHash -isnot [string] -or $request.PayloadHash -cnotmatch '^[a-f0-9]{64}$'){throw 'Unknown or mistyped native request.'}
|
||||
Assert-WelaArrivalObject $request.Selection @('ComputerName','LocalAddress');$selection=Get-WelaListenerSelection $request.Selection.ComputerName $request.Selection.LocalAddress;$report.Selection=$selection
|
||||
$expectedEngine=Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell/v1.0/powershell.exe'
|
||||
if($report.Engine -ine $expectedEngine -or (Get-FileHash $expectedEngine -Algorithm SHA256).Hash -cne $request.EngineSha256 -or (Get-WelaListenerSources) -cne $request.Sources){throw 'Native adapter engine or installed sources differ.'}
|
||||
$payloadPath=Join-Path (Split-Path $file.Path -Parent) 'native-payload.xml';$payload=Read-WelaWecUpdateFile $payloadPath 4096
|
||||
if($payload.Hash -cne $request.PayloadHash -or $payload.Text -cne (New-WelaListenerPayload)){throw 'Native listener payload is not the exact fixed XML.'}
|
||||
$held=[IO.File]::Open($payload.Path,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read)
|
||||
$local=Get-WelaListenerLocalState;$report.Reader=$local.Reader
|
||||
Assert-WelaListenerSelectedHost $local $selection;Assert-WelaListenerAbsent $local.Listeners $selection
|
||||
if((Get-WelaListenerWorkerContextKey $local) -cne $request.ContextKey -or (Read-WelaWecUpdateFile $RequestPath).Hash -cne $RequestHash -or (Read-WelaWecUpdateFile $payloadPath 4096).Hash -cne $request.PayloadHash -or (Get-WelaListenerSources) -cne $request.Sources){throw 'Fresh native adapter context, input or code differs.'}
|
||||
$report.NativeCreateAttempted=$true
|
||||
$created=@(Microsoft.WSMan.Management\New-WSManInstance -ResourceURI 'http://schemas.microsoft.com/wbem/wsman/1/config/listener' -SelectorSet @{Address=('IP:'+$selection.LocalAddress);Transport='HTTP'} -FilePath $payload.Path -ErrorAction Stop)
|
||||
if($created.Count -ne 1 -or -not $created[0].OuterXml -or $created[0].OuterXml.Length -gt 32768){throw 'Native create response is incomplete or excessive.'};$report.CreatedXml=[string]$created[0].OuterXml
|
||||
$after=Get-WelaListenerLocalState;$report.After=$after.Listeners
|
||||
$chosen=@($after.Listeners|Where-Object {$_.Address -ceq ('IP:'+$selection.LocalAddress) -and $_.Transport -ceq 'HTTP'})
|
||||
if($chosen.Count -ne 1){throw 'Native creation did not produce exactly one selected listener.'};Assert-WelaListenerCreated $chosen[0] $selection
|
||||
$after.Listeners=@($after.Listeners|Where-Object {-not($_.Address -ceq ('IP:'+$selection.LocalAddress) -and $_.Transport -ceq 'HTTP')})
|
||||
if((Get-WelaListenerWorkerContextKey $after) -cne $request.ContextKey -or (Get-WelaListenerKey $after.Reader) -cne (Get-WelaListenerKey $local.Reader) -or (Get-WelaListenerSources) -cne $request.Sources){throw 'Native adapter context, token, other listeners or source changed during creation.'}
|
||||
$report.Status='Created'
|
||||
}catch{$report.Status=if($report.NativeCreateAttempted){'CreateAttemptedUnverified'}else{'Refused'};$report.Diagnostic=$_.Exception.Message;$report.NativeHResult=$_.Exception.HResult;try{$report.After=@(Read-WelaListenerInventory)}catch{}}
|
||||
finally{if($held){$held.Dispose()}}
|
||||
$report
|
||||
}
|
||||
function Initialize-WelaListenerPipe {
|
||||
$path=Join-Path $PSScriptRoot 'WecListenerPipeNative.cs';$bytes=[IO.File]::ReadAllBytes($path)
|
||||
if($bytes.Length -gt 65536){throw 'Listener pipe source exceeds its bound.'};$hash=Get-WelaArrivalHash $bytes
|
||||
if(-not('Wela.ListenerPipe.Bounded' -as [type])){
|
||||
$source=[Text.UTF8Encoding]::new($false,$true).GetString($bytes).TrimStart([char]0xfeff)
|
||||
if([regex]::Matches($source,'__WELA_SOURCE_SHA256__').Count -ne 1){throw 'Listener pipe source marker is missing or ambiguous.'}
|
||||
Add-Type -TypeDefinition $source.Replace('__WELA_SOURCE_SHA256__',$hash) -ErrorAction Stop
|
||||
}
|
||||
if([Wela.ListenerPipe.Bounded]::SourceSha256 -cne $hash){throw 'Loaded listener pipe helper differs from its source.'}
|
||||
}
|
||||
function Close-WelaListenerAdapterProcess {
|
||||
param($Process,$Result)
|
||||
# Cleanup must never discard Started=true after a possibly mutating child ran.
|
||||
if($Result.Started){
|
||||
$exited=$false
|
||||
try{$exited=$Process.HasExited}catch{$Result.Diagnostic+=' Adapter exit observation failed: '+$_.Exception.Message}
|
||||
if(-not $exited){
|
||||
try{$Process.Kill()}catch{$Result.Diagnostic+=' Adapter termination request failed: '+$_.Exception.Message}
|
||||
try{$exited=$Process.WaitForExit(5000)}catch{$Result.Diagnostic+=' Adapter termination wait failed: '+$_.Exception.Message}
|
||||
}
|
||||
$Result.TerminationConfirmed=[bool]$exited
|
||||
if(-not $exited){$Result.Diagnostic+=' Adapter termination is unconfirmed.'}
|
||||
}
|
||||
try{$Process.Dispose()}catch{$Result.Diagnostic+=' Adapter resource cleanup failed: '+$_.Exception.Message}
|
||||
}
|
||||
function Assert-WelaListenerAdapterReceipt {
|
||||
param($Receipt,$State,[int]$ProcessId,[int]$ExitCode)
|
||||
Assert-WelaArrivalObject $receipt @('SchemaVersion','Kind','Status','NativeCreateAttempted','ProcessId','Engine','EngineVersion','ModulePath','Reader','Selection','CreatedXml','After','Diagnostic','NativeHResult')
|
||||
if(($receipt.SchemaVersion -isnot [int] -and $receipt.SchemaVersion -isnot [long]) -or $receipt.SchemaVersion -ne 1 -or $receipt.Kind -isnot [string] -or $receipt.Kind -cne 'WelaNative51ListenerCreate' -or $receipt.NativeCreateAttempted -isnot [bool] -or ($receipt.ProcessId -isnot [int] -and $receipt.ProcessId -isnot [long]) -or $receipt.ProcessId -ne $ProcessId -or $receipt.Engine -isnot [string] -or $receipt.Engine -ine $State.Adapter.Engine -or $receipt.ModulePath -isnot [string] -or $receipt.ModulePath -cne $State.Adapter.ModulePath -or $receipt.EngineVersion -isnot [string] -or $receipt.EngineVersion -cnotmatch '^5\.1\.[0-9]+\.[0-9]+$' -or $receipt.Status -isnot [string] -or $receipt.Status -cnotin @('Created','Refused','CreateAttemptedUnverified') -or $receipt.Diagnostic -isnot [string]){throw 'Native adapter receipt has inconsistent identity or status.'}
|
||||
if($receipt.Reader -and (Get-WelaListenerReaderKey $receipt.Reader) -cne (Get-WelaListenerReaderKey $State.Local.Reader)){throw 'Native adapter did not run under the reviewed actual account/logon.'}
|
||||
if($receipt.Status -ceq 'Created' -and (-not $receipt.Reader -or -not $receipt.NativeCreateAttempted -or $ExitCode -ne 0 -or $receipt.Diagnostic)){throw 'Native adapter success receipt is incomplete.'}
|
||||
}
|
||||
function Start-WelaListenerAdapter {
|
||||
param($State,[string]$RequestPath,[string]$RequestHash)
|
||||
foreach($path in @($State.Adapter.Engine,$State.Adapter.Worker,$RequestPath)){if($path.Contains('"') -or $path.EndsWith('\') -or $path -match '[\x00-\x1f]'){throw 'Unsupported native adapter path.'}}
|
||||
$info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$State.Adapter.Engine
|
||||
$info.Arguments='-NoLogo -NoProfile -NonInteractive -File "'+$State.Adapter.Worker+'" -RequestPath "'+$RequestPath+'" -RequestHash '+$RequestHash
|
||||
$info.EnvironmentVariables['PSModulePath']=$State.Adapter.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)
|
||||
Initialize-WelaListenerPipe
|
||||
$result=[pscustomobject][ordered]@{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 'Native listener adapter did not start.'};$result.Started=$true;$result.ProcessId=$process.Id
|
||||
$stdout=[Wela.ListenerPipe.Bounded]::Read($process.StandardOutput,524288);$stderr=[Wela.ListenerPipe.Bounded]::Read($process.StandardError,65536)
|
||||
if(-not $process.WaitForExit(45000)){$result.TimedOut=$true;throw 'Native listener adapter timed out; creation may have been attempted.'}
|
||||
$result.ExitCode=$process.ExitCode
|
||||
if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),5000)){throw 'Native listener adapter output drain timed out.'}
|
||||
$text=$stdout.Result;$errorText=$stderr.Result
|
||||
if($errorText -or $text.Length -gt 524288){throw 'Native adapter output is incomplete, excessive or contains errors.'}
|
||||
$receipt=ConvertFrom-WelaArrivalJson $text
|
||||
Assert-WelaListenerAdapterReceipt $receipt $State $result.ProcessId $result.ExitCode
|
||||
$result.Receipt=$receipt
|
||||
}catch{$result.Diagnostic=$_.Exception.Message}
|
||||
finally{Close-WelaListenerAdapterProcess $process $result}
|
||||
$result
|
||||
}
|
||||
function Invoke-WelaWecListener {
|
||||
param([ValidateSet('Plan','Apply')][string]$Action='Plan',[string]$ComputerName,[string]$LocalAddress,[string]$PlanPath,[string]$PlanHash,[string]$OutputPath)
|
||||
if($args.Count){throw 'Unknown listener arguments are not supported.'}
|
||||
if($Action -eq 'Plan'){
|
||||
if(-not $ComputerName -or -not $LocalAddress -or -not $OutputPath -or $PSBoundParameters.ContainsKey('PlanPath') -or $PSBoundParameters.ContainsKey('PlanHash')){throw 'Plan requires the actual computer, assigned IPv4 and new output only.'}
|
||||
$selection=Get-WelaListenerSelection $ComputerName $LocalAddress;$reviewedFile=$null
|
||||
}else{
|
||||
if(-not $PlanPath -or $PlanHash -cnotmatch '^[a-fA-F0-9]{64}$' -or -not $OutputPath -or $PSBoundParameters.ContainsKey('ComputerName') -or $PSBoundParameters.ContainsKey('LocalAddress')){throw 'Apply requires only a reviewed plan, SHA256 and new output.'}
|
||||
$PlanHash=$PlanHash.ToLowerInvariant();$reviewedFile=Read-WelaWecUpdateFile $PlanPath
|
||||
}
|
||||
$source=if($reviewedFile){$reviewedFile.Path}else{Join-Path $script:ScriptRoot 'WELA.ps1'};$output=New-WelaArrivalOutput $OutputPath $source
|
||||
$report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaExactIpListener';Action=$Action;Status='Refused';ExitCode=1;OutputPath=$output;PlanHash=$null;AdapterStarted=$false;NativeCreateAttempted=$null;Adapter=$null;Artifacts=@();Diagnostic='';ReadyRuleCredit=0;ServiceChanges=0;AuthenticationChanges=0;FirewallChanges=0;Scope='One new exact assigned-IPv4 HTTP5985/wsman listener through a fixed native Windows PowerShell5.1 adapter. Existing WinRM endpoints may use it. No remote connection, WEF delivery, packet acceptance, retention or Sigma proof. Sysmon excluded.'}
|
||||
try {
|
||||
$state=Get-WelaListenerState;$key=Get-WelaListenerReviewKey $state;$tokenKey=Get-WelaListenerKey $state.Local.Reader
|
||||
if($Action -eq 'Apply'){
|
||||
if($reviewedFile.Hash -cne $PlanHash){throw 'Reviewed listener plan hash differs.'};$plan=ConvertFrom-WelaArrivalJson $reviewedFile.Text;Assert-WelaListenerPlan $plan
|
||||
if($plan.StateKey -cne $key){throw 'Reviewed host/operator/code/listener/WinRM/firewall state differs.'};$selection=Get-WelaListenerSelection $plan.Selection.ComputerName $plan.Selection.LocalAddress;$report.PlanHash=$PlanHash
|
||||
}
|
||||
Assert-WelaListenerSelectedHost $state.Local $selection;Assert-WelaListenerAbsent $state.Local.Listeners $selection
|
||||
if($Action -eq 'Plan'){
|
||||
$plan=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaExactIpListenerPlan';Selection=$selection;StateKey=$key;RecordedUtc=[DateTime]::UtcNow.ToString('o')};Assert-WelaListenerPlan $plan
|
||||
$fresh=Get-WelaListenerState;if((Get-WelaListenerReviewKey $fresh) -cne $key -or (Get-WelaListenerKey $fresh.Local.Reader) -cne $tokenKey){throw 'Context changed during listener planning.'};Assert-WelaListenerAbsent $fresh.Local.Listeners $selection
|
||||
$artifact=Write-WelaWecUpdateArtifact $output 'plan.json' ($plan|ConvertTo-Json -Depth 24);$report.Artifacts+=$artifact;$report.PlanHash=$artifact.Sha256;$report.Status='ReviewRequired';$report.ExitCode=0
|
||||
}else{
|
||||
$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'reviewed-plan.json' $reviewedFile.Text
|
||||
$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'before-create.json' ([ordered]@{Status='Pending';PlanHash=$PlanHash;Selection=$selection;State=$state;RecordedUtc=[DateTime]::UtcNow.ToString('o')}|ConvertTo-Json -Depth 24)
|
||||
$payload=Write-WelaWecUpdateArtifact $output 'native-payload.xml' (New-WelaListenerPayload);$report.Artifacts+=$payload
|
||||
$request=[ordered]@{SchemaVersion=1;Kind='WelaNative51ListenerRequest';Selection=$selection;ContextKey=(Get-WelaListenerWorkerContextKey $state.Local);Sources=$state.Sources;EngineSha256=$state.Adapter.EngineSha256;PayloadHash=$payload.Sha256}
|
||||
$artifact=Write-WelaWecUpdateArtifact $output 'native-request.json' ($request|ConvertTo-Json -Depth 24);$report.Artifacts+=$artifact
|
||||
$fresh=Get-WelaListenerState;if((Get-WelaListenerReviewKey $fresh) -cne $key -or (Get-WelaListenerKey $fresh.Local.Reader) -cne $tokenKey -or (Read-WelaWecUpdateFile $PlanPath).Hash -cne $PlanHash){throw 'Plan or actual state changed immediately before creation.'};Assert-WelaListenerAbsent $fresh.Local.Listeners $selection
|
||||
$adapter=Start-WelaListenerAdapter $state (Join-Path $output 'native-request.json') $artifact.Sha256;$report.Adapter=$adapter;$report.AdapterStarted=$adapter.Started
|
||||
if($adapter.Receipt){$report.NativeCreateAttempted=$adapter.Receipt.NativeCreateAttempted}
|
||||
$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'adapter-receipt.json' ($adapter|ConvertTo-Json -Depth 24)
|
||||
$after=Get-WelaListenerState;$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'after-state.json' ($after|ConvertTo-Json -Depth 24)
|
||||
if($adapter.Diagnostic -or -not $adapter.Receipt -or $adapter.Receipt.Status -cne 'Created' -or -not $adapter.Receipt.NativeCreateAttempted -or (Get-WelaListenerKey $adapter.Receipt.Selection) -cne (Get-WelaListenerKey $selection)){throw ('Native creation is unverified: '+$adapter.Diagnostic+' '+$adapter.Receipt.Diagnostic)}
|
||||
$selected=@($after.Local.Listeners|Where-Object {$_.Address -ceq ('IP:'+$selection.LocalAddress) -and $_.Transport -ceq 'HTTP'});if($selected.Count -ne 1){throw 'Expected exactly one created listener.'};Assert-WelaListenerCreated $selected[0] $selection
|
||||
if((Get-WelaListenerReviewKey $after -ExcludeSelected -Selection $selection) -cne $key -or (Get-WelaListenerKey $after.Local.Reader) -cne $tokenKey -or (Read-WelaWecUpdateFile $PlanPath).Hash -cne $PlanHash){throw 'Host/token/code/plan, other listeners, WinRM or firewall configuration changed during creation.'}
|
||||
$report.Status='CreatedAndVerified';$report.ExitCode=0
|
||||
}
|
||||
}catch{
|
||||
$report.Status=if($report.AdapterStarted -and ($null -eq $report.NativeCreateAttempted -or $report.NativeCreateAttempted)){'CreateAttemptedUnverified'}else{'Refused'};$report.Diagnostic=$_.Exception.Message
|
||||
if($report.AdapterStarted -and -not @($report.Artifacts|Where-Object Name -eq 'after-state.json').Count){try{$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'after-state.json' ((Get-WelaListenerState)|ConvertTo-Json -Depth 24)}catch{}}
|
||||
}
|
||||
$null=Write-WelaWecUpdateArtifact $output 'manifest.json' ($report|ConvertTo-Json -Depth 24);$report
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
namespace Wela.ListenerPipe {
|
||||
public static class Bounded {
|
||||
public const string SourceSha256 = "__WELA_SOURCE_SHA256__";
|
||||
public static async Task<string> Read(TextReader reader, int maximumCharacters) {
|
||||
if (reader == null || maximumCharacters < 1 || maximumCharacters > 1048576) throw new ArgumentException("Invalid bounded reader.");
|
||||
var text = new StringBuilder(); var buffer = new char[1024];
|
||||
while (true) {
|
||||
int count = await reader.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false);
|
||||
if (count == 0) return text.ToString();
|
||||
if (count > maximumCharacters - text.Length) throw new InvalidDataException("Native listener adapter output exceeded its character bound.");
|
||||
text.Append(buffer, 0, count);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
param([string]$RequestPath,[string]$RequestHash)
|
||||
$env:PSModulePath=[IO.Path]::Combine([Environment]::SystemDirectory,'WindowsPowerShell\v1.0\Modules')
|
||||
$ErrorActionPreference='Stop';[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)
|
||||
if($args.Count -or $PSVersionTable.PSVersion.Major -ne 5 -or -not [Environment]::Is64BitProcess){throw 'Only the fixed native Windows PowerShell 5.1 listener adapter is supported.'}
|
||||
$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
|
||||
. (Join-Path $PSScriptRoot 'WefArrival.ps1')
|
||||
. (Join-Path $PSScriptRoot 'WecUpdate.ps1')
|
||||
. (Join-Path $PSScriptRoot 'ChannelRead.ps1')
|
||||
. (Join-Path $PSScriptRoot 'WecListener.ps1')
|
||||
$report=Invoke-WelaListenerWorkerRequest $RequestPath $RequestHash
|
||||
$report|ConvertTo-Json -Depth 24 -Compress
|
||||
if($report.Status -cne 'Created'){exit 1}
|
||||
@@ -0,0 +1,139 @@
|
||||
# Reviewed, existing-only Enabled changes; runtime observations are separate evidence.
|
||||
function Initialize-WelaWecStateNative {
|
||||
$path=Join-Path $PSScriptRoot 'WecStateNative.cs';$hash=(Get-FileHash -LiteralPath $path -Algorithm SHA256 -ErrorAction Stop).Hash
|
||||
if(-not('Wela.WecState.Edit' -as [type])){Add-Type -Path $path -ErrorAction Stop;$script:WelaWecStateNativeHash=$hash}
|
||||
if($script:WelaWecStateNativeHash -cne $hash){throw 'Loaded native state setter differs from source; start a fresh process.'}
|
||||
}
|
||||
function Get-WelaWecStateContext {
|
||||
$context=Get-WelaWecUpdateContext
|
||||
Initialize-WelaWecStateNative
|
||||
$identity=[Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
try {$context.Reader=[ordered]@{Name=$identity.Name;Sid=$identity.User.Value;AuthenticationType=$identity.AuthenticationType;ImpersonationLevel=[string]$identity.ImpersonationLevel;Groups=@($identity.Groups.Value|Sort-Object);TokenStatistics=[Wela.WecState.Edit]::TokenKey($identity.Token)}}finally{$identity.Dispose()}
|
||||
$channel=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('ForwardedEvents')
|
||||
try {$context|Add-Member NoteProperty DestinationLog ([ordered]@{Name=$channel.LogName;Enabled=$channel.IsEnabled;Mode=[string]$channel.LogMode;MaximumBytes=$channel.MaximumSizeInBytes;Path=$channel.LogFilePath;SecurityDescriptor=$channel.SecurityDescriptor})}finally{$channel.Dispose()}
|
||||
$context
|
||||
}
|
||||
function Get-WelaWecStateReviewKey {
|
||||
param($Context)
|
||||
# Separate CLI invocations can hold different token objects in the same logon.
|
||||
# Bind plan/apply to the actual logon, and compare complete token statistics
|
||||
# within each operation to reject privilege or token changes during writes.
|
||||
$copy=$Context|ConvertTo-Json -Depth 16 -Compress|ConvertFrom-Json
|
||||
$copy.Reader.TokenStatistics=$Context.Reader.TokenStatistics.Substring(16,16)
|
||||
$copy|ConvertTo-Json -Depth 16 -Compress
|
||||
}
|
||||
function Get-WelaWecStateSources {
|
||||
$root=Split-Path $PSScriptRoot -Parent;$sources=[ordered]@{}
|
||||
foreach($name in @('scripts/WecState.ps1','scripts/WecStateNative.cs','scripts/WecUpdate.ps1','modules/WefSubscriptions.psm1','modules/WecSubscriptionXml.cs','scripts/Configuration.ps1','scripts/ControlApplicability.ps1','scripts/WefArrival.ps1','modules/AuditProfiles.psm1','scripts/WecRuntime.ps1','scripts/WecRuntimeNative.cs')){$sources[$name]=(Get-FileHash -LiteralPath (Join-Path $root $name) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()}
|
||||
$sources|ConvertTo-Json -Compress
|
||||
}
|
||||
function Get-WelaWecStateDefinition {
|
||||
param([string]$Xml,[string[]]$SourceSids)
|
||||
$model=ConvertFrom-WelaWefSubscription -Xml $Xml -SourceSids $SourceSids -Observed
|
||||
$doc=Read-WelaWefXml $Xml;$root=$doc.DocumentElement;$whole=Get-WelaWefXmlKey $root
|
||||
$ns=New-Object Xml.XmlNamespaceManager($doc.NameTable);$ns.AddNamespace('s',$root.NamespaceURI)
|
||||
$null=$root.RemoveChild($root.SelectSingleNode('s:Enabled',$ns))
|
||||
[pscustomobject]@{Id=$model.Id;Xml=$Xml;WholeKey=$whole;PreservedKey=(Get-WelaWefXmlKey $root);Enabled=$model.Definition.Enabled;QueryKey=$model.Query.Key;Description=$model.Definition.Description;SourceAuthorization=$model.Definition.SourceAuthorization}
|
||||
}
|
||||
function Read-WelaWecStateDefinition {
|
||||
param([string]$Id,[string[]]$SourceSids)
|
||||
if($Id -cnotmatch '^[A-Za-z0-9][A-Za-z0-9 ._-]{0,127}$'){throw 'Invalid exact subscription ID.'}
|
||||
$definition=Get-WelaWecStateDefinition (Read-WelaWecSubscriptionXml $Id) $SourceSids
|
||||
if($definition.Id -cne $Id){throw 'Native subscription identity differs from the selected ID.'}
|
||||
$definition
|
||||
}
|
||||
function New-WelaWecStateEdit {
|
||||
param($Before)
|
||||
Initialize-WelaWecStateNative
|
||||
$edit=[Wela.WecState.Edit]::new($Before.Id)
|
||||
try {
|
||||
if($edit.OriginalEnabled -ne $Before.Enabled -or (ConvertFrom-WelaWefQuery $edit.OriginalQuery).Key -cne $Before.QueryKey -or $edit.OriginalDescription -cne $Before.Description -or $edit.OriginalAuthorization -cne $Before.SourceAuthorization){throw 'Native handle state differs from the reviewed definition.'}
|
||||
$edit
|
||||
}catch{$edit.Dispose();throw}
|
||||
}
|
||||
function Assert-WelaWecStatePlan {
|
||||
param($Plan)
|
||||
Assert-WelaArrivalObject $Plan @('SchemaVersion','Kind','Id','SourceSids','ContextKey','Sources','BeforeXml','DesiredEnabled','RecordedUtc')
|
||||
if(($Plan.SchemaVersion -isnot [int] -and $Plan.SchemaVersion -isnot [long]) -or $Plan.SchemaVersion -ne 1 -or $Plan.Kind -cne 'WelaWecStatePlan' -or $Plan.Id -isnot [string] -or $Plan.Id -cnotmatch '^[A-Za-z0-9][A-Za-z0-9 ._-]{0,127}$' -or $Plan.SourceSids -isnot [array] -or $Plan.ContextKey -isnot [string] -or $Plan.Sources -isnot [string] -or $Plan.BeforeXml -isnot [string] -or $Plan.DesiredEnabled -isnot [bool]){throw 'Unknown or mistyped state plan.'}
|
||||
$null=ConvertTo-WelaArrivalUtc $Plan.RecordedUtc
|
||||
foreach($sid in $Plan.SourceSids){if($sid -isnot [string]){throw 'Source SID must be a string.'}}
|
||||
$null=Get-WelaWefAuthorization $Plan.SourceSids
|
||||
$before=Get-WelaWecStateDefinition $Plan.BeforeXml $Plan.SourceSids
|
||||
if($before.Id -cne $Plan.Id){throw 'Plan identity contradicts its original subscription.'}
|
||||
}
|
||||
function Read-WelaWecStateRuntime {
|
||||
param([string]$Id)
|
||||
try {Get-WelaWecRuntime -Id $Id -MaximumSources 32}
|
||||
catch {[pscustomobject]@{Status='Unknown';Diagnostic=$_.Exception.Message;ReadyRuleCredit=0}}
|
||||
}
|
||||
function Assert-WelaWecStateArtifacts {
|
||||
param([string]$Root,$Artifacts)
|
||||
foreach($artifact in $Artifacts){if((Get-FileHash -LiteralPath (Join-Path $Root $artifact.Name) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() -cne $artifact.Sha256){throw 'Saved state evidence changed before completion.'}}
|
||||
}
|
||||
function Invoke-WelaWecState {
|
||||
param([ValidateSet('Plan','Apply')][string]$Action='Plan',[string]$Id,[string[]]$SourceSids,[ValidateSet('Enabled','Disabled')][string]$State,[string]$PlanPath,[string]$PlanHash,[Parameter(Mandatory)][string]$OutputPath)
|
||||
$ErrorActionPreference='Stop'
|
||||
if($Action -eq 'Plan'){
|
||||
if(-not $Id -or -not $SourceSids -or -not $State -or $PlanPath -or $PlanHash){throw 'Plan requires exact ID, explicit source SIDs, Enabled or Disabled state and new output; no prior plan.'}
|
||||
$sourceInput=$null;$sourcePath=Join-Path (Split-Path $PSScriptRoot -Parent) 'scripts'
|
||||
}else{
|
||||
if(-not $PlanPath -or $PlanHash -cnotmatch '^[a-f0-9]{64}$' -or $Id -or $SourceSids -or $State){throw 'Apply accepts only a reviewed plan path, its SHA256 and new output.'}
|
||||
$sourceInput=Read-WelaWecUpdateFile $PlanPath;$sourcePath=$sourceInput.Path
|
||||
}
|
||||
$output=New-WelaArrivalOutput $OutputPath $sourcePath
|
||||
$report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaWecState';Action=$Action;Status='Refused';ExitCode=1;RecordedUtc=[DateTime]::UtcNow.ToString('o');OutputPath=$output;PlanHash=$null;BeforeEnabled=$null;DesiredEnabled=$null;NativeSaveAttempted=$false;NativeErrorCode=$null;After=$null;RuntimeBefore=$null;RuntimeAfter=$null;Artifacts=@();Diagnostic='';ReadyRuleCredit=0;Delivery='Not established';BookmarkContinuity='Not established';Scope='Only Enabled on one existing native source-initiated subscription. Disable interrupts collection; enable/save activates it. No listener, firewall, service or authorization changes. Sysmon excluded.'}
|
||||
$edit=$null;$plan=$null;$before=$null
|
||||
try {
|
||||
$context=Get-WelaWecStateContext;$contextKey=$context|ConvertTo-Json -Depth 16 -Compress;$sources=Get-WelaWecStateSources
|
||||
if($Action -eq 'Plan'){
|
||||
$before=Read-WelaWecStateDefinition $Id $SourceSids
|
||||
$plan=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaWecStatePlan';Id=$Id;SourceSids=@($SourceSids);ContextKey=(Get-WelaWecStateReviewKey $context);Sources=$sources;BeforeXml=$before.Xml;DesiredEnabled=($State -eq 'Enabled');RecordedUtc=[DateTime]::UtcNow.ToString('o')}
|
||||
Assert-WelaWecStatePlan $plan
|
||||
if($plan.DesiredEnabled -and -not $context.DestinationLog.Enabled){throw 'ForwardedEvents must already be enabled before planning activation; no channel changes are made.'}
|
||||
$report.RuntimeBefore=Read-WelaWecStateRuntime $Id
|
||||
if((Read-WelaWecStateDefinition $Id $SourceSids).WholeKey -cne $before.WholeKey -or ((Get-WelaWecStateContext|ConvertTo-Json -Depth 16 -Compress) -cne $contextKey) -or (Get-WelaWecStateSources) -cne $sources){throw 'Host, reader, implementation or subscription drift during planning.'}
|
||||
$planText=$plan|ConvertTo-Json -Depth 20
|
||||
if([Text.Encoding]::UTF8.GetByteCount($planText) -gt 4194304){throw 'Reviewed plan exceeds the four-MiB apply limit.'}
|
||||
$artifact=Write-WelaWecUpdateArtifact $output 'plan.json' $planText;$report.Artifacts+=$artifact;$report.PlanHash=$artifact.Sha256;$report.Status='ReviewRequired'
|
||||
}else{
|
||||
if($sourceInput.Hash -cne $PlanHash){throw 'Reviewed plan hash differs from the selected file bytes.'}
|
||||
$plan=ConvertFrom-WelaArrivalJson $sourceInput.Text;Assert-WelaWecStatePlan $plan;$report.PlanHash=$sourceInput.Hash
|
||||
if($plan.DesiredEnabled -and -not $context.DestinationLog.Enabled){throw 'ForwardedEvents must already be enabled before activation; no channel changes are made.'}
|
||||
if($plan.ContextKey -cne (Get-WelaWecStateReviewKey $context) -or $plan.Sources -cne $sources){throw 'Actual host/reader/token/service or implementation sources differ from the reviewed plan.'}
|
||||
$before=Get-WelaWecStateDefinition $plan.BeforeXml $plan.SourceSids
|
||||
$report.BeforeEnabled=$before.Enabled;$report.DesiredEnabled=$plan.DesiredEnabled
|
||||
$report.RuntimeBefore=Read-WelaWecStateRuntime $plan.Id
|
||||
if((Read-WelaWecStateDefinition $plan.Id $plan.SourceSids).WholeKey -cne $before.WholeKey){throw 'Current subscription differs from the reviewed complete definition.'}
|
||||
$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'reviewed-plan.json' $sourceInput.Text
|
||||
if($before.Enabled -eq $plan.DesiredEnabled){$report.Status='AlreadyMatches'}else{
|
||||
$edit=New-WelaWecStateEdit $before
|
||||
$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'before-save.json' ([ordered]@{Status='Pending';RecordedUtc=[DateTime]::UtcNow.ToString('o');Context=$context;BeforeXml=$before.Xml;DesiredEnabled=$plan.DesiredEnabled;PlanHash=$sourceInput.Hash}|ConvertTo-Json -Depth 20)
|
||||
Assert-WelaWecStateArtifacts $output $report.Artifacts
|
||||
if((Read-WelaWecUpdateFile $PlanPath).Hash -cne $PlanHash -or (Get-WelaWecStateSources) -cne $sources -or ((Get-WelaWecStateContext|ConvertTo-Json -Depth 16 -Compress) -cne $contextKey) -or (Read-WelaWecStateDefinition $plan.Id $plan.SourceSids).WholeKey -cne $before.WholeKey){throw 'Plan, code, context or complete subscription changed immediately before save.'}
|
||||
try {$edit.Save($plan.DesiredEnabled)}finally{$report.NativeSaveAttempted=[bool]$edit.SaveAttempted}
|
||||
$report.Status='SavedAwaitingReadback'
|
||||
}
|
||||
$report.RuntimeAfter=Read-WelaWecStateRuntime $plan.Id
|
||||
$after=Read-WelaWecStateDefinition $plan.Id $plan.SourceSids;$report.After=$after
|
||||
$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'after.xml' $after.Xml
|
||||
if($after.Enabled -ne $plan.DesiredEnabled -or $after.PreservedKey -cne $before.PreservedKey -or ((Get-WelaWecStateContext|ConvertTo-Json -Depth 16 -Compress) -cne $contextKey) -or (Get-WelaWecStateSources) -cne $sources -or (Read-WelaWecUpdateFile $PlanPath).Hash -cne $PlanHash){throw 'Readback, preserved configuration, context, plan or implementation differs after operation.'}
|
||||
if($report.NativeSaveAttempted){$report.Status='StateChangedAndVerified'}
|
||||
}
|
||||
$report.BeforeEnabled=$before.Enabled;$report.DesiredEnabled=$plan.DesiredEnabled
|
||||
Assert-WelaWecStateArtifacts $output $report.Artifacts
|
||||
$report.ExitCode=0
|
||||
}catch{
|
||||
$report.Status=if($report.NativeSaveAttempted){'SaveAttemptedUnverified'}else{'Refused'};$report.ExitCode=1;$report.Diagnostic=$_.Exception.Message
|
||||
$errorObject=$_.Exception
|
||||
while($errorObject){if($errorObject -is [ComponentModel.Win32Exception]){$report.NativeErrorCode=$errorObject.NativeErrorCode;break};$errorObject=$errorObject.InnerException}
|
||||
if($report.NativeSaveAttempted -and $plan){
|
||||
# A failed activation can still persist Enabled. Never imply rollback.
|
||||
$report.RuntimeAfter=Read-WelaWecStateRuntime $plan.Id
|
||||
try {$report.After=Read-WelaWecStateDefinition $plan.Id $plan.SourceSids;$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'failed-after.xml' $report.After.Xml}
|
||||
catch {$report.Diagnostic+=' Final definition unavailable: '+$_.Exception.Message}
|
||||
}
|
||||
}
|
||||
finally{if($edit){$edit.Dispose()}}
|
||||
$null=Write-WelaWecUpdateArtifact $output 'manifest.json' ($report|ConvertTo-Json -Depth 32)
|
||||
$report
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Existing-only native WEC Enabled setter. No create/delete or other setters.
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
namespace Wela.WecState {
|
||||
public sealed class Edit : IDisposable {
|
||||
[StructLayout(LayoutKind.Explicit, Size=16)] struct Variant {
|
||||
[FieldOffset(0)] public int Boolean; [FieldOffset(8)] public uint Count; [FieldOffset(12)] public uint Type;
|
||||
}
|
||||
[DllImport("wecapi.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern IntPtr EcOpenSubscription(string name,uint access,uint flags);
|
||||
[DllImport("wecapi.dll",SetLastError=true)] [return:MarshalAs(UnmanagedType.Bool)] static extern bool EcGetSubscriptionProperty(IntPtr handle,int property,uint flags,uint size,IntPtr value,out uint used);
|
||||
[DllImport("wecapi.dll",SetLastError=true)] [return:MarshalAs(UnmanagedType.Bool)] static extern bool EcSetSubscriptionProperty(IntPtr handle,int property,uint flags,ref Variant value);
|
||||
[DllImport("wecapi.dll",SetLastError=true)] [return:MarshalAs(UnmanagedType.Bool)] static extern bool EcSaveSubscription(IntPtr handle,uint flags);
|
||||
[DllImport("wecapi.dll",SetLastError=true)] [return:MarshalAs(UnmanagedType.Bool)] static extern bool EcClose(IntPtr handle);
|
||||
[DllImport("advapi32.dll",SetLastError=true)] [return:MarshalAs(UnmanagedType.Bool)] static extern bool GetTokenInformation(IntPtr token,int information,IntPtr buffer,int size,out int used);
|
||||
IntPtr handle; readonly string name,oldQuery,oldDescription,oldAuthorization; readonly bool oldEnabled;
|
||||
public bool OriginalEnabled {get{return oldEnabled;}}
|
||||
public string OriginalQuery {get{return oldQuery;}}
|
||||
public string OriginalDescription {get{return oldDescription;}}
|
||||
public string OriginalAuthorization {get{return oldAuthorization;}}
|
||||
public bool SaveAttempted {get;private set;}
|
||||
// TOKEN_STATISTICS: TokenId, AuthenticationId and ModifiedId, plus token type.
|
||||
public static string TokenKey(IntPtr token) {
|
||||
IntPtr buffer=Marshal.AllocHGlobal(56);
|
||||
try {int used;if(!GetTokenInformation(token,10,buffer,56,out used))throw new Win32Exception(Marshal.GetLastWin32Error());if(used!=56)throw new InvalidOperationException("Unexpected TOKEN_STATISTICS size.");
|
||||
byte[] bytes=new byte[56];Marshal.Copy(buffer,bytes,0,bytes.Length);return BitConverter.ToString(bytes).Replace("-","");
|
||||
}finally{Marshal.FreeHGlobal(buffer);}
|
||||
}
|
||||
static object Read(IntPtr h,int property) {
|
||||
uint size=16;
|
||||
for(int attempt=0;attempt<3;attempt++) {
|
||||
IntPtr buffer=Marshal.AllocHGlobal((int)size);
|
||||
try {
|
||||
uint used;bool ok=EcGetSubscriptionProperty(h,property,0,size,buffer,out used);int error=Marshal.GetLastWin32Error();
|
||||
if(!ok){if(error!=122)throw new Win32Exception(error);if(used<=size||used>1048576)throw new InvalidOperationException("Invalid native property buffer size.");size=used;continue;}
|
||||
if(used<16||used>size)throw new InvalidOperationException("Invalid native property length.");
|
||||
int type=Marshal.ReadInt32(buffer,12);
|
||||
if(property==0){if(type!=1)throw new InvalidOperationException("Enabled is not a scalar Boolean.");int value=Marshal.ReadInt32(buffer);if(value!=0&&value!=1)throw new InvalidOperationException("Invalid native Boolean.");return value==1;}
|
||||
if(type==0&&property==6)return "";
|
||||
if(type!=4)throw new InvalidOperationException("Expected scalar native string.");
|
||||
IntPtr pointer=Marshal.ReadIntPtr(buffer);long offset=pointer.ToInt64()-buffer.ToInt64();
|
||||
if(pointer==IntPtr.Zero||offset<16||offset>used-2)throw new InvalidOperationException("Native string pointer is outside its buffer.");
|
||||
StringBuilder text=new StringBuilder();
|
||||
for(int i=0;i<524288&&offset+2L*i+2<=used;i++){char c=(char)(ushort)Marshal.ReadInt16(pointer,2*i);if(c==0)return text.ToString();text.Append(c);}
|
||||
throw new InvalidOperationException("Unterminated native string.");
|
||||
}finally{Marshal.FreeHGlobal(buffer);}
|
||||
}
|
||||
throw new InvalidOperationException("Native property changed repeatedly.");
|
||||
}
|
||||
void Check(IntPtr h) {
|
||||
if((bool)Read(h,0)!=oldEnabled||!String.Equals((string)Read(h,10),oldQuery,StringComparison.Ordinal)||!String.Equals((string)Read(h,6),oldDescription,StringComparison.Ordinal)||!String.Equals((string)Read(h,31),oldAuthorization,StringComparison.Ordinal))throw new InvalidOperationException("Native enabled/query/description/authorization changed since review.");
|
||||
}
|
||||
public Edit(string id) {
|
||||
if(String.IsNullOrWhiteSpace(id)||id.Length>128||id.IndexOf('\0')>=0)throw new ArgumentException("Invalid subscription ID.");
|
||||
name=id;handle=EcOpenSubscription(name,3,2);if(handle==IntPtr.Zero)throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
try{oldEnabled=(bool)Read(handle,0);oldQuery=(string)Read(handle,10);oldDescription=(string)Read(handle,6);oldAuthorization=(string)Read(handle,31);}catch{Dispose();throw;}
|
||||
}
|
||||
public void Save(bool enabled) {
|
||||
if(handle==IntPtr.Zero)throw new ObjectDisposedException("Edit");
|
||||
if(SaveAttempted)throw new InvalidOperationException("A native edit may be saved only once.");
|
||||
if(enabled==oldEnabled)throw new InvalidOperationException("Idempotent state must not save or reactivate a subscription.");
|
||||
IntPtr fresh=EcOpenSubscription(name,1,2);if(fresh==IntPtr.Zero)throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
try{Check(fresh);}finally{EcClose(fresh);}
|
||||
Variant value=new Variant{Boolean=enabled?1:0,Count=0,Type=1};
|
||||
if(!EcSetSubscriptionProperty(handle,0,0,ref value))throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
SaveAttempted=true;
|
||||
if(!EcSaveSubscription(handle,0))throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
}
|
||||
public void Dispose(){if(handle!=IntPtr.Zero){EcClose(handle);handle=IntPtr.Zero;}}
|
||||
}
|
||||
}
|
||||
@@ -30,13 +30,14 @@ function Get-WelaWefControlState {
|
||||
'SubscriptionManager' { return Get-WelaRegistryState -Path $Target.Path -Name $Target.Name }
|
||||
'ForwardedEvents' { return Get-WelaNativeChannel -Name 'ForwardedEvents' }
|
||||
'Subscription' {
|
||||
$ids = @((Invoke-WelaNative -FilePath 'wecutil.exe' -Arguments @('es')).Output | ForEach-Object { $_.ToString().Trim() } | Where-Object { $_ })
|
||||
$ids = @(Get-WelaWecSubscriptionIds)
|
||||
if ($ids -notcontains $Target.Id) { return [pscustomobject]@{ Exists=$false; Xml=$null; Key=$null; Definition=$null } }
|
||||
# Keep evidence as a plain string. Windows PowerShell 5.1's JSON
|
||||
# serializer expands ETS properties on strings (for example a test
|
||||
# reader's PSDrive/PSProvider graph), unlike modern PowerShell.
|
||||
$xml = [string]::Concat((Invoke-WelaNative -FilePath 'wecutil.exe' -Arguments @('gs',$Target.Id,'/f:xml')).Diagnostic)
|
||||
$xml = [string]::Concat((Read-WelaWecSubscriptionXml -Id $Target.Id))
|
||||
$model = ConvertFrom-WelaWefSubscription -Xml $xml -SourceSids $Target.SourceSids -Observed
|
||||
if($model.Id -cne $Target.Id){throw 'Native subscription identity differs from the selected ID.'}
|
||||
return [pscustomobject]@{ Exists=$true; Xml=$xml; Key=$model.Key; Definition=$model.Definition }
|
||||
}
|
||||
default { throw "Unsupported WEF control kind: $Kind" }
|
||||
@@ -125,8 +126,8 @@ function Get-WelaWefCollectorPrerequisites {
|
||||
if ($rules.Count -ne 1) { throw 'Expected exactly one existing effective firewall rule.' }
|
||||
$rule=$rules[0]; $ports=@($rule | Get-NetFirewallPortFilter -ErrorAction Stop); $addresses=@($rule | Get-NetFirewallAddressFilter -ErrorAction Stop)
|
||||
$scopeMatches=$addresses.Count -eq 1 -and
|
||||
(@(Compare-Object @($Config.IngressLocalAddresses | Sort-Object -Unique) @($addresses[0].LocalAddress | Sort-Object -Unique)).Count -eq 0) -and
|
||||
(@(Compare-Object @($Config.IngressRemoteAddresses | Sort-Object -Unique) @($addresses[0].RemoteAddress | Sort-Object -Unique)).Count -eq 0)
|
||||
(Test-WelaWefFirewallAddressSet $Config.IngressLocalAddresses @($addresses[0].LocalAddress)) -and
|
||||
(Test-WelaWefFirewallAddressSet $Config.IngressRemoteAddresses @($addresses[0].RemoteAddress))
|
||||
$ok=[string]$rule.Enabled -eq 'True' -and [string]$rule.Direction -eq 'Inbound' -and [string]$rule.Action -eq 'Allow' -and [string]$rule.Profile -eq 'Domain' -and
|
||||
$ports.Count -eq 1 -and [string]$ports[0].Protocol -in @('TCP','6') -and [string]$ports[0].LocalPort -eq '5985' -and $scopeMatches
|
||||
$checks += [pscustomobject]@{ Name='Existing scoped domain ingress rule'; Verified=[bool]$ok; Evidence=@{ Rule=($rule | Select-Object Name,Enabled,Direction,Action,Profile,PolicyStoreSourceType,EnforcementStatus); Ports=$ports | Select-Object Protocol,LocalPort,RemotePort; Addresses=$addresses | Select-Object LocalAddress,RemoteAddress }; Diagnostic='Exact selected rule definition only; other rules, network reachability and effective packet acceptance are not established.' }
|
||||
|
||||
@@ -59,6 +59,12 @@ function Get-WelaWmiProbeWatermark {
|
||||
$record=Get-WinEvent -LogName Security -MaxEvents 1 -ErrorAction Stop
|
||||
try{if($null -eq $record.RecordId -or $record.RecordId -lt 1){throw 'Unknown Security record boundary.'};[long]$record.RecordId}finally{$record.Dispose()}
|
||||
}
|
||||
function Assert-WelaWmiProbeInterval {
|
||||
param($Operation,[DateTimeOffset]$LaunchedUtc,[DateTimeOffset]$ObservedUtc)
|
||||
$start=ConvertTo-WelaArrivalUtc $Operation.StartedUtc;$end=ConvertTo-WelaArrivalUtc $Operation.CompletedUtc
|
||||
if($Operation.Clock -cne 'GetSystemTimePreciseAsFileTime' -or $LaunchedUtc -gt $ObservedUtc -or $start -lt $LaunchedUtc -or $start -gt $end -or ($end-$start).TotalSeconds -gt 20 -or $end -gt $ObservedUtc){throw 'Invalid precise fixed worker time interval.'}
|
||||
[pscustomobject]@{Start=$start;End=$end}
|
||||
}
|
||||
function Start-WelaWmiProbeRead {
|
||||
param($State)
|
||||
$fresh=Get-WelaWmiProbeState $State.Namespace
|
||||
@@ -71,6 +77,7 @@ function Start-WelaWmiProbeRead {
|
||||
$info.StandardOutputEncoding=[Text.UTF8Encoding]::new($false,$true);$info.StandardErrorEncoding=[Text.UTF8Encoding]::new($false,$true)
|
||||
$process=$null
|
||||
try{
|
||||
$launch=[DateTimeOffset][Wela.WmiProbe.Native]::UtcNow()
|
||||
$process=[Diagnostics.Process]::Start($info);$output=$process.StandardOutput.ReadToEndAsync();$errors=$process.StandardError.ReadToEndAsync()
|
||||
if(-not $process.WaitForExit(20000)){$process.Kill();$null=$process.WaitForExit(1000);throw 'The fixed WMI read worker exceeded twenty seconds.'}
|
||||
if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($output,$errors),1000)){throw 'The fixed read output did not complete.'}
|
||||
@@ -79,8 +86,8 @@ function Start-WelaWmiProbeRead {
|
||||
if($process.ExitCode -ne 0 -or $diagnostic){throw ('Fixed local WMI read failed: '+$diagnostic)}
|
||||
$operation=ConvertFrom-WelaArrivalJson $text
|
||||
if($operation.Namespace -cne $State.Namespace -or $operation.ProcessId -ne $process.Id -or $operation.ExpectedAccessMask -ne 1 -or $operation.ReturnedRows -ne 0 -or $operation.Query -cnotmatch "^SELECT Name FROM __Namespace WHERE Name='WelaReadProbe_[a-f0-9]{32}'$"){throw 'Unexpected fixed worker response.'}
|
||||
$start=ConvertTo-WelaArrivalUtc $operation.StartedUtc;$end=ConvertTo-WelaArrivalUtc $operation.CompletedUtc
|
||||
if($start -gt $end -or ($end-$start).TotalSeconds -gt 20 -or $end -gt [DateTimeOffset]::UtcNow){throw 'Invalid fixed worker time interval.'}
|
||||
$interval=Assert-WelaWmiProbeInterval $operation $launch ([DateTimeOffset][Wela.WmiProbe.Native]::UtcNow())
|
||||
$start=$interval.Start;$end=$interval.End
|
||||
# Older PowerShell7 JSON readers can materialize UTC strings as DateTime.
|
||||
$operation.StartedUtc=$start.UtcDateTime.ToString('o');$operation.CompletedUtc=$end.UtcDateTime.ToString('o')
|
||||
if((Get-WelaWmiProbeTokenKey $operation.BeforeToken) -cne (Get-WelaWmiProbeTokenKey $operation.AfterToken) -or (Get-WelaWmiProbeTokenKey $operation.BeforeToken -AuthorizationOnly) -cne (Get-WelaWmiProbeTokenKey $State.Token -AuthorizationOnly)){throw 'Worker token differs from the observed caller or changed during access.'}
|
||||
|
||||
@@ -12,6 +12,8 @@ namespace Wela.WmiProbe {
|
||||
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;}
|
||||
|
||||
@@ -16,7 +16,7 @@ $options.Impersonation=[System.Management.ImpersonationLevel]::Impersonate
|
||||
$options.Timeout=[TimeSpan]::FromSeconds(10)
|
||||
$scope=New-Object System.Management.ManagementScope -ArgumentList ('\\.\'+$Namespace),$options
|
||||
$searcher=$null;$rows=$null
|
||||
$started=[DateTime]::UtcNow
|
||||
$started=[Wela.WmiProbe.Native]::UtcNow()
|
||||
try {
|
||||
$scope.Connect()
|
||||
$enumeration=New-Object System.Management.EnumerationOptions
|
||||
@@ -24,8 +24,8 @@ try {
|
||||
$searcher=New-Object System.Management.ManagementObjectSearcher -ArgumentList $scope,([System.Management.ObjectQuery]::new($query)),$enumeration
|
||||
$rows=$searcher.Get();$count=0
|
||||
foreach($row in $rows){try{$count++;if($count -gt 0){throw 'The random nonexistent namespace filter unexpectedly matched an instance.'}}finally{$row.Dispose()}}
|
||||
$completed=[DateTime]::UtcNow
|
||||
$completed=[Wela.WmiProbe.Native]::UtcNow()
|
||||
$after=[Wela.WmiProbe.Native]::Snapshot()
|
||||
if((Get-WelaWmiProbeTokenKey $before) -cne (Get-WelaWmiProbeTokenKey $after)){throw 'Worker token changed during the fixed query.'}
|
||||
[pscustomobject]@{Namespace=$Namespace;Query=$query;ExpectedAccessMask=1;ProcessId=$PID;StartedUtc=$started.ToString('o');CompletedUtc=$completed.ToString('o');BeforeToken=$before;AfterToken=$after;ReturnedRows=$count;Operation='Fixed local read; provider completion is separate from audited namespace access'}|ConvertTo-Json -Depth 10 -Compress
|
||||
[pscustomobject]@{Namespace=$Namespace;Query=$query;ExpectedAccessMask=1;ProcessId=$PID;StartedUtc=$started.ToString('o');CompletedUtc=$completed.ToString('o');Clock='GetSystemTimePreciseAsFileTime';BeforeToken=$before;AfterToken=$after;ReturnedRows=$count;Operation='Fixed local read; provider completion is separate from audited namespace access'}|ConvertTo-Json -Depth 10 -Compress
|
||||
}finally{if($rows){$rows.Dispose()};if($searcher){$searcher.Dispose()}}
|
||||
Reference in new issue
Block a user