Integrate reviewed logging and recovery base for WMI tree safeguards

This commit is contained in:
Shirofune-Security committed 2026-09-22 15:08:52 +09:00
commit 66162232b6
46 files changed
+2449 -47

No files matched your search

+29 -5
View File
@@ -1,4 +1,5 @@
# Conservative, explicitly selected recovery of completed audit-policy writes.
. (Join-Path $PSScriptRoot 'NamedRegistryRecovery.ps1')
function ConvertFrom-WelaRecoveryJson {
param([string]$Text)
# ConvertFrom-Json accepts some JavaScript extensions (including single-quoted
@@ -103,9 +104,10 @@ function New-WelaRecoveryPlan {
$precedenceId='Registry/HKLM:\SYSTEM\CurrentControlSet\Control\Lsa/SCENoApplyLegacyAuditPolicy'
$rows=New-Object 'System.Collections.Generic.List[object]'
$targets=@{}
$named=@{}; foreach ($item in Get-WelaNamedRecoveryCatalog) {$named[$item.Id]=$item}
foreach ($id in ($ControlId | Sort-Object)) {
if (-not $byId.ContainsKey($id) -or -not $final.ContainsKey($id)) {throw "Missing journal/final evidence for $id"}
$entry=$byId[$id]; $last=$final[$id]
$entry=$byId[$id]; $last=$final[$id];$namedControl=$false
if ($last.Status -cne 'Applied' -or $last.Id -cne $entry.Id -or $last.Kind -cne $entry.Kind) {throw "Only completed Applied writes can be recovered: $id"}
foreach ($field in @('Before','Desired','Target')) {if ((Get-WelaRecoveryKey $entry.$field) -cne (Get-WelaRecoveryKey $last.$field)) {throw "Journal/final $field mismatch: $id"}}
if ($entry.Kind -ceq 'AuditPolicy' -and $catalog.ContainsKey($id)) {
@@ -119,10 +121,23 @@ function New-WelaRecoveryPlan {
# Never disable precedence while leaving another journaled subcategory unrestored.
foreach ($other in $entries) {if ($other.Kind -eq 'AuditPolicy' -and $other.Id -notin $ControlId) {throw 'Precedence recovery requires every journaled audit subcategory to be selected.'}}
$target=$entry.Before
} elseif ($entry.Kind -ceq 'Registry' -and $named.ContainsKey($id)) {
$definition=$named[$id]
if ($id -cne $definition.Id -or $entry.Target.Path -cne $definition.Path -or $entry.Target.Name -cne $definition.Name -or $entry.Desired.Type -cne 'DWord' -or ($entry.Desired.Value -isnot [int] -and $entry.Desired.Value -isnot [long]) -or $entry.Desired.Value -ne 1) {throw 'Unsupported named logging registry recovery target.'}
Assert-WelaNamedRecoveryValue $entry.Before; Assert-WelaNamedRecoveryValue $last.After
if (-not $last.After.ValueExists -or $last.After.Value -ne 1) {throw 'Final logging switch is not enabled.'}
$target=[pscustomobject]@{KeyExists=$true;ValueExists=$entry.Before.ValueExists;Value=$entry.Before.Value;Type=$entry.Before.Type}
$namedControl=$true
} else {throw "Unsupported control requires manual recovery: $id"}
$rows.Add([pscustomobject][ordered]@{Id=$id;Kind=$entry.Kind;Target=$entry.Target;Expected=$last.After;RecoverTo=$target})
$row=[pscustomobject][ordered]@{Id=$id;Kind=$entry.Kind;Target=$entry.Target;Expected=$last.After;RecoverTo=$target}
if ($namedControl) {
$row.Kind='NamedLoggingRegistry'
$row | Add-Member NoteProperty OriginalKeyExisted $entry.Before.KeyExists
$row | Add-Member NoteProperty RegistryGuard (Get-WelaNamedRecoveryGuard (Get-WelaNamedRecoveryObservation $entry.Target))
}
$rows.Add($row)
}
[pscustomobject][ordered]@{
$plan=[pscustomobject][ordered]@{
Kind='WelaAuditRecoveryPlan';SchemaVersion=1
Host=$hostState;Journal=[pscustomobject]@{Path=$journal.Path;Sha256=$journal.Sha256}
OriginalResults=[pscustomobject]@{Path=$resultFile.Path;Sha256=$resultFile.Sha256}
@@ -132,6 +147,8 @@ function New-WelaRecoveryPlan {
UnsupportedJournalControls=@($entries | Where-Object {$_.Id -notin $ControlId} | Select-Object Id,Kind)
ReadyRuleCredit=0
}
if (@($rows | Where-Object Kind -eq 'NamedLoggingRegistry').Count) {$plan | Add-Member NoteProperty NamedSources @(Get-WelaNamedRecoverySources)}
return $plan
}
function Get-WelaRecoveryOutputDriveType {
param([string]$Root)
@@ -172,17 +189,24 @@ function Write-WelaRecoveryArtifact {
function Get-WelaRecoveryCurrent {
param($Control)
if ($Control.Kind -eq 'AuditPolicy') {return Get-WelaAuditPolicyMask $Control.Target.Guid}
if ($Control.Kind -eq 'NamedLoggingRegistry') {
$observation=Get-WelaNamedRecoveryObservation $Control.Target
Assert-WelaNamedRecoveryGuard $Control $observation
return Get-WelaNamedRecoveryState $observation
}
Get-WelaRegistryState 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' SCENoApplyLegacyAuditPolicy
}
function Set-WelaRecoveryCurrent {
param($Control)
if ($Control.Kind -eq 'AuditPolicy') {Set-WelaEffectiveAuditPolicy -Guid $Control.Target.Guid -Mask $Control.RecoverTo -Mode exact;return}
if ($Control.Kind -eq 'NamedLoggingRegistry') {Set-WelaNamedRecoveryValue $Control;return}
$path='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa'
if ($Control.RecoverTo.ValueExists) {Set-ItemProperty -LiteralPath $path -Name SCENoApplyLegacyAuditPolicy -Value $Control.RecoverTo.Value -Type DWord -ErrorAction Stop}
else {Remove-ItemProperty -LiteralPath $path -Name SCENoApplyLegacyAuditPolicy -ErrorAction Stop}
}
function Assert-WelaRecoverySources {
param($Plan)
if ($Plan.PSObject.Properties.Name -contains 'NamedSources' -and (Get-WelaRecoveryKey @(Get-WelaNamedRecoverySources)) -cne (Get-WelaRecoveryKey $Plan.NamedSources)) {throw 'Named registry recovery implementation changed.'}
foreach ($source in @($Plan.Journal,$Plan.OriginalResults)) {if ((Get-WelaRecoveryFile $source.Path).Sha256 -cne $source.Sha256) {throw 'Original recovery evidence changed.'}}
if ((Get-FileHash -LiteralPath (Join-Path $PSScriptRoot '../config/audit_profiles.json')).Hash.ToLowerInvariant() -cne $Plan.CatalogSha256) {throw 'Canonical catalog changed.'}
if ((Get-WelaRecoveryKey (Get-WelaRecoveryHost)) -cne (Get-WelaRecoveryKey $Plan.Host)) {throw 'Actual host changed since recovery planning.'}
@@ -232,7 +256,7 @@ function Invoke-WelaAuditRecovery {
if ($control.Kind -eq 'AuditPolicy') {
$p=Get-WelaRegistryState 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' SCENoApplyLegacyAuditPolicy
if (-not $p.ValueExists -or $p.Type -ne 'DWord' -or $p.Value -ne 1) {throw 'Audit precedence changed before recovery write.'}
} else {
} elseif ($control.Kind -eq 'Registry') {
foreach ($prior in $plan.Controls | Where-Object Kind -eq 'AuditPolicy') {if ((Get-WelaRecoveryKey (Get-WelaRecoveryCurrent $prior)) -cne (Get-WelaRecoveryKey $prior.RecoverTo)) {throw 'An audit mask changed before precedence recovery.'}}
}
Set-WelaRecoveryCurrent $control
@@ -252,7 +276,7 @@ function Invoke-WelaAuditRecovery {
if ((Get-WelaRecoveryKey $row.After) -cne (Get-WelaRecoveryKey $control.RecoverTo)) {throw 'State changed during final recovery verification.'}
} catch {$row.Status='Failed';$row.Diagnostic=$_.Exception.Message;$blocked=$true}
}
$report=[pscustomobject]@{Status=$(if ($blocked) {'Incomplete'} elseif ($DryRun) {'DryRun'} else {'Recovered'});ExitCode=[int]$blocked;DryRun=[bool]$DryRun;OutputPath=$output;Results=@($results.ToArray());ReadyRuleCredit=0;Scope='Selected audit masks and typed audit precedence only; no persistence or event-generation proof.'}
$report=[pscustomobject]@{Status=$(if ($blocked) {'Incomplete'} elseif ($DryRun) {'DryRun'} else {'Recovered'});ExitCode=[int]$blocked;DryRun=[bool]$DryRun;OutputPath=$output;Results=@($results.ToArray());ReadyRuleCredit=0;Scope='Selected audit masks, typed audit precedence and three named logging DWORDs only; value-only registry recovery retains keys. No persistence or event-generation proof.'}
if (-not $DryRun) {Write-WelaRecoveryArtifact (Join-Path $output 'results.json') $report}
return $report
}
+160
View File
@@ -0,0 +1,160 @@
# Fixed native DNS Client event3008 collection; no policy/channel/DNS configuration.
function Initialize-WelaDnsClientProbeNative {
if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'DNS Client probe requires native 64-bit Windows.'}
$bytes=[IO.File]::ReadAllBytes((Join-Path $PSScriptRoot 'DnsClientProbeNative.cs'));$hash=Get-WelaArrivalHash $bytes
if(-not ('Wela.DnsClientProbe.Native' -as [type])){$source=[Text.UTF8Encoding]::new($false,$true).GetString($bytes).TrimStart([char]0xfeff);Add-Type -TypeDefinition $source.Replace('__WELA_DNS_CLIENT_SOURCE_SHA256__',$hash) -ErrorAction Stop}
if([Wela.DnsClientProbe.Native]::SourceSha256 -cne $hash){throw 'Loaded DNS helper differs from source; start a fresh PowerShell process.'}
}
function Assert-WelaDnsClientResolver {
param([string]$Resolver)
$ip=$null
if($Resolver -cnotmatch '^(0|[1-9][0-9]{0,2})(\.(0|[1-9][0-9]{0,2})){3}$' -or -not [Net.IPAddress]::TryParse($Resolver,[ref]$ip) -or $ip.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork -or $ip.ToString() -cne $Resolver -or $ip.GetAddressBytes()[0] -eq 0 -or $ip.GetAddressBytes()[0] -ge 224){throw 'Select one approved canonical unicast IPv4 DNS resolver; no hostname, port, multicast or unspecified address.'}
}
function Get-WelaDnsClientProbeSources {
$sources=[ordered]@{}
foreach($name in @('WELA.ps1','scripts/DnsClientProbe.ps1','scripts/DnsClientProbeWorker.ps1','scripts/DnsClientProbeNative.cs','scripts/WefArrival.ps1','scripts/AuditRecovery.ps1','scripts/ChannelRead.ps1','scripts/ChannelReadNative.cs','scripts/NativeProviderPacks.ps1','scripts/ControlApplicability.ps1','config/native_provider_packs.json','config/security_rules.json','modules/NativeProviders.psm1','modules/AuditProfiles.psm1')){$sources[$name]=(Get-FileHash -LiteralPath (Join-Path $PSScriptRoot ('../'+$name)) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()}
$catalog=Get-WelaProviderPackCatalog
foreach($rule in $catalog.ruleReviews){$sources['config/'+$rule.localPath]=$rule.sha256}
[pscustomobject]$sources
}
function Get-WelaDnsClientProbeState {
$service=Get-Service Dnscache -ErrorAction Stop
if($service.Status -ne 'Running'){throw 'DNS Client must already be running; no service is started.'}
$hostState=Get-WelaDefaultContext
if(-not(Test-WelaDefaultContextComplete $hostState) -or ($hostState.ProductType -eq 1 -and $hostState.Build -notin @(22000,22621,22631,26100,26200)) -or ($hostState.ProductType -in @(2,3) -and $hostState.Build -notin @(20348,26100))){throw 'Complete reviewed Windows 11/Server2022/2025 context required.'}
$catalog=Get-WelaProviderPackCatalog;$pack=@($catalog.packs|Where-Object id -ceq 'dns-client')[0]
$schema=Get-WelaProviderPackSchema $pack
$channel=Get-WelaNativeChannel $pack.channel
$engine=(Get-Process -Id $PID -ErrorAction Stop).Path
[pscustomobject][ordered]@{Computer=[Environment]::MachineName;Host=$hostState;Service=[string]$service.Status;Schema=$schema;Channel=$channel;Engine=$engine;EngineSha256=(Get-FileHash $engine -Algorithm SHA256).Hash.ToLowerInvariant();Sources=(Get-WelaDnsClientProbeSources);RuleReviews=@($catalog.ruleReviews|Where-Object {$pack.ruleIds -contains $_.id}|Select-Object id,title,sha256,ruleChannels);Reader=(Get-WelaChannelReader)}
}
function Get-WelaDnsClientProbeStateKey {
param($State)
$metadataErrors=if($State.Channel.MetadataErrors -is [Collections.IDictionary]){$State.Channel.MetadataErrors.Count}else{@($State.Channel.MetadataErrors.PSObject.Properties|Where-Object MemberType -eq NoteProperty).Count}
if($State.Service -cne 'Running' -or $State.Channel.State -cne 'Enabled' -or $State.Channel.Name -cne 'Microsoft-Windows-DNS-Client/Operational' -or -not $State.Channel.SecurityDescriptor -or $metadataErrors -or $State.Channel.Error -or $State.Channel.IsEnabled -ne $true -or $State.Channel.MaximumSizeInBytes -le 0 -or $State.Channel.LogMode -notin @('Circular','AutoBackup','Retain')){throw 'Enabled, fully observed DNS Client Operational channel is required.'}
if($State.Schema.State -cne 'Observed' -or $State.Schema.Provider -cne 'Microsoft-Windows-DNS-Client' -or $State.Schema.ChannelType -cne 'Operational' -or -not $State.Schema.ProviderGuid){throw 'Exact native DNS Client provider/channel manifest required.'}
$events=@($State.Schema.Events|Where-Object Id -eq 3008)
if($events.Count -ne 1){throw 'Exactly one reviewed native event3008 template is required.'}
foreach($event in $events){
if($event.Version -ne 0 -or $event.Channel -cne $State.Channel.Name -or @($event.Fields).Count -ne 5){throw 'Unreviewed native DNS3008 version/channel.'}
foreach($name in @('QueryName','QueryType','QueryOptions','QueryStatus','QueryResults')){
$field=@($event.Fields|Where-Object Name -ceq $name)
$types=switch($name){QueryName {@('win:UnicodeString')} QueryResults {@('win:UnicodeString')} QueryOptions {@('win:UInt64','win:HexInt64')} default {@('win:UInt32')}}
if($field.Count -ne 1 -or $field[0].InType -cnotin $types){throw "Native DNS3008 field/type is unreviewed: $name"}
}
}
# Metadata inventories may adjust and restore token privileges. Full token stability
# is verified around query/event I/O, outside those inventories.
$key=[ordered]@{};foreach($property in $State.PSObject.Properties){if($property.Name -cne 'Reader'){$key[$property.Name]=$property.Value}}
$key.ReaderContext=Get-WelaDnsClientProbeReaderKey $State.Reader
Get-WelaChannelReadKey ([pscustomobject]$key)
}
function Get-WelaDnsClientProbeReaderKey {
param($Reader)
Get-WelaChannelReadKey ([pscustomobject][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 Get-WelaDnsClientProbeWatermark {
$reader=$null;$record=$null
try{$query=[Diagnostics.Eventing.Reader.EventLogQuery]::new('Microsoft-Windows-DNS-Client/Operational',[Diagnostics.Eventing.Reader.PathType]::LogName,'*');$query.ReverseDirection=$true;$reader=[Diagnostics.Eventing.Reader.EventLogReader]::new($query);$reader.BatchSize=1;$record=$reader.ReadEvent([TimeSpan]::FromSeconds(5));Assert-WelaChannelQueryStatus -Channel 'Microsoft-Windows-DNS-Client/Operational' -LogStatus @($reader.LogStatus);if($record){if($record.RecordId -le 0){throw 'Invalid native record boundary.'};return [long]$record.RecordId};return [long]0}finally{if($record){$record.Dispose()};if($reader){$reader.Dispose()}}
}
function Start-WelaDnsClientProbeQuery {
param($State,[string]$Resolver,[string]$QueryName,$Report)
Initialize-WelaDnsClientProbeNative
$fresh=Get-WelaDnsClientProbeState
if((Get-WelaDnsClientProbeStateKey $fresh) -cne (Get-WelaDnsClientProbeStateKey $State)){throw 'DNS prerequisites changed before query.'}
$boundary=Get-WelaDnsClientProbeWatermark
$callerBefore=Get-WelaChannelReader
$worker=Join-Path $PSScriptRoot 'DnsClientProbeWorker.ps1'
$info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$State.Engine;$info.Arguments='-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "'+$worker+'" -Resolver "'+$Resolver+'" -QueryName "'+$QueryName+'"';$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=[Diagnostics.Process]::new();$process.StartInfo=$info;$started=$false
try{
$launch=[DateTimeOffset][Wela.DnsClientProbe.Native]::UtcNow();$started=$process.Start();if(-not $started){throw 'DNS probe worker did not start.'}
$output=$process.StandardOutput.ReadToEndAsync();$errorText=$process.StandardError.ReadToEndAsync()
if(-not $process.WaitForExit(20000)){throw 'DNS query worker exceeded twenty seconds; operation completion is unverified.'}
if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($output,$errorText),5000)){throw 'DNS worker output did not finish.'}
if($output.Result.Length -gt 262144 -or $errorText.Result.Length -gt 65536){throw 'DNS worker output exceeded evidence bounds.'}
if($process.ExitCode -ne 0 -or $errorText.Result){throw ('DNS worker failed: '+$errorText.Result)}
# Retain bounded owned-worker output even when schema/status validation refuses it.
$Report.Artifacts+=Write-WelaArrivalArtifact $Report.OutputPath 'worker.json' $output.Result
$operation=ConvertFrom-WelaRecoveryJson $output.Result
if($operation.ProcessId -ne $process.Id -or $operation.Query.QueryName -cne $QueryName -or $operation.Query.Resolver -cne $Resolver -or $operation.Query.Options -ne 2103790 -or $operation.Query.Status -ne $operation.Query.ResultStatus -or $operation.Query.Status -notin @(0,9003,9501)){throw ('Unexpected DNS worker response or unsupported native outcome: PID='+$operation.ProcessId+' expectedPID='+$process.Id+' options='+$operation.Query.Options+' APIstatus='+$operation.Query.Status+' resultStatus='+$operation.Query.ResultStatus)}
$begin=ConvertTo-WelaArrivalUtc $operation.StartedUtc;$end=ConvertTo-WelaArrivalUtc $operation.CompletedUtc
$operation.StartedUtc=$begin.UtcDateTime.ToString('o');$operation.CompletedUtc=$end.UtcDateTime.ToString('o')
if($operation.Clock -cne 'GetSystemTimePreciseAsFileTime' -or $begin -lt $launch -or $end -lt $begin -or $end -gt [DateTimeOffset][Wela.DnsClientProbe.Native]::UtcNow() -or ($end-$begin).TotalSeconds -gt 20){throw 'Invalid DNS operation timestamps.'}
if((Get-WelaChannelReadKey $operation.BeforeToken) -cne (Get-WelaChannelReadKey $operation.AfterToken) -or (Get-WelaDnsClientProbeReaderKey $operation.BeforeToken) -cne (Get-WelaDnsClientProbeReaderKey $callerBefore)){throw 'DNS worker token differs from observed caller or changed.'}
$operation|Add-Member NoteProperty RecordIdBefore $boundary
$operation|Add-Member NoteProperty CallerBefore $callerBefore
$operation
}finally{try{if($started -and -not $process.HasExited){$process.Kill();if(-not $process.WaitForExit(5000)){throw 'Owned DNS worker termination could not be verified.'}}}finally{$process.Dispose()}}
}
function Read-WelaDnsClientProbeEvents {
param($Operation)
$channel='Microsoft-Windows-DNS-Client/Operational'
# Read only this nonce in a bounded recent interval. The validator still requires
# exact operation timestamps; outside-interval XML is useful failure evidence only.
$name=$Operation.Query.QueryName
if($name -cnotmatch '^wela-[a-f0-9]{32}\.wela\.test\.$'){throw 'Unexpected DNS event query name.'}
$xpath="*[System[Provider[@Name='Microsoft-Windows-DNS-Client'] and EventID=3008 and EventRecordID>$($Operation.RecordIdBefore) and TimeCreated[timediff(@SystemTime)<=60000]] and EventData[Data[@Name='QueryName']='$name' or Data[@Name='QueryName']='$($name.TrimEnd('.'))']]"
$reader=$null;$record=$null;$xml=@();$timer=[Diagnostics.Stopwatch]::StartNew()
try{
$query=[Diagnostics.Eventing.Reader.EventLogQuery]::new($channel,[Diagnostics.Eventing.Reader.PathType]::LogName,$xpath);$query.TolerateQueryErrors=$false
$reader=[Diagnostics.Eventing.Reader.EventLogReader]::new($query);$reader.BatchSize=16
while($xml.Count -lt 256){
if($timer.Elapsed.TotalSeconds -ge 5){throw 'DNS event read exceeded five-second bound.'}
$record=$reader.ReadEvent([TimeSpan]::FromSeconds(5-$timer.Elapsed.TotalSeconds))
if($null -eq $record){break}
try{$text=$record.ToXml();if($text.Length -gt 131072){throw 'Native DNS XML exceeds bound.'};$xml+=$text}finally{$record.Dispose();$record=$null}
}
$status=@($reader.LogStatus|ForEach-Object{[pscustomobject]@{LogName=$_.LogName;StatusCode=$_.StatusCode}})
Assert-WelaChannelQueryStatus -Channel $channel -LogStatus $status
[pscustomobject]@{Xml=$xml;Capped=($xml.Count -ge 256);Query=$xpath;LogStatus=$status}
}finally{if($record){$record.Dispose()};if($reader){$reader.Dispose()}}
}
function Test-WelaDnsClientProbeEvent {
param([string]$Xml,$Operation,$State)
$reader=$null
try{
$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','EventRecordID','Channel','Computer','TimeCreated','Execution')){$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 $State.Schema.Provider -or $system.Provider.GetAttribute('Guid').Trim('{}') -ine $State.Schema.ProviderGuid.Trim('{}') -or $system.EventID.InnerText -cne '3008' -or $system.Version.InnerText -cne '0' -or $system.Channel.InnerText -cne $State.Channel.Name -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.StartedUtc) -or $time -gt (ConvertTo-WelaArrivalUtc $Operation.CompletedUtc)){return $false}
# Capture the native emitter PID but do not equate service-broker PID with caller identity.
if($system.Execution.GetAttribute('ProcessID') -cnotmatch '^[1-9][0-9]*$' -or [uint32]$system.Execution.GetAttribute('ProcessID') -eq 0){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') -or @($node.ChildNodes|Where-Object NodeType -eq Element).Count){return $false};$name=$node.GetAttribute('Name');if(-not $name -or $data.ContainsKey($name)){return $false};$data[$name]=$node.InnerText}
if($data.Count -ne 5 -or $data.QueryName.TrimEnd('.') -cne $Operation.Query.QueryName.TrimEnd('.') -or $data.QueryType -cne '1' -or $data.QueryStatus -cne [string]$Operation.Query.Status -or -not $data.ContainsKey('QueryResults')){return $false}
$options=if($data.QueryOptions -match '^0x[0-9a-fA-F]+$'){[Convert]::ToUInt64($data.QueryOptions.Substring(2),16)}elseif($data.QueryOptions -match '^[0-9]+$'){[uint64]$data.QueryOptions}else{return $false}
if(($options -band [uint64]$Operation.Query.Options) -ne [uint64]$Operation.Query.Options){return $false}
return $true
}catch{return $false}finally{if($reader){$reader.Dispose()}}
}
function Invoke-WelaDnsClientProbe {
param([ValidateSet('Plan','Run')][string]$Action='Plan',[string]$Resolver,[string]$OutputPath,[ValidateRange(1,30)][int]$TimeoutSeconds=15)
$ErrorActionPreference='Stop';Assert-WelaDnsClientResolver $Resolver
if(($Action -eq 'Run') -ne (-not [string]::IsNullOrWhiteSpace($OutputPath))){throw 'Run requires a new output directory; Plan writes no files.'}
$report=[pscustomobject][ordered]@{Kind='WelaNativeDnsClientProbe';SchemaVersion=1;Action=$Action;Status='Unverified';ExitCode=1;Resolver=$Resolver;QueryPattern='wela-<random-guid>.wela.test.';QueryType='A';Transport='DNS TCP port53; recursion disabled';Before=$null;After=$null;Operation=$null;Query=$null;QueryLogStatus=@();ReaderBefore=$null;ReaderAfter=$null;ReaderInterval='After all initial metadata/output preparation and record boundary, through worker/event I/O and continuity read; before final metadata inventory.';Candidates=0;Matches=0;Artifacts=@();OutputPath=$null;Diagnostic='';ReadyRuleCredit=0;ConfigurationChanges=0;RuleChannelMismatch='Pinned DNS rules use Microsoft-Windows-DNS Client Events/Operational; actual source is Microsoft-Windows-DNS-Client/Operational. No alias rewrite or rule credit.';Correlation='Random query name, native outcome, source/host, record boundary and operation time. Emitter PID is retained but may be a service broker. Event3008 does not independently prove resolver wire identity or exclusive request attribution.';Scope='One fixed native DNS Client lookup completion; no DNS configuration, cache flush, policy/channel/service changes, forwarding or backend execution. Sysmon excluded.'}
if($Action -eq 'Run'){$report.OutputPath=New-WelaArrivalOutput $OutputPath $PSScriptRoot}
try{
$before=Get-WelaDnsClientProbeState;$report.Before=$before;$key=Get-WelaDnsClientProbeStateKey $before
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 24)
$queryName='wela-'+[guid]::NewGuid().ToString('N')+'.wela.test.'
$operation=Start-WelaDnsClientProbeQuery $before $Resolver $queryName $report;$report.Operation=$operation;$report.ReaderBefore=$operation.CallerBefore
$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'operation.json' ($operation|ConvertTo-Json -Depth 15)
$timer=[Diagnostics.Stopwatch]::StartNew();$matches=@()
do{$batch=Read-WelaDnsClientProbeEvents $operation;$report.Query=$batch.Query;$report.QueryLogStatus=@($batch.LogStatus);Assert-WelaChannelQueryStatus -Channel 'Microsoft-Windows-DNS-Client/Operational' -LogStatus $report.QueryLogStatus;$report.Candidates=@($batch.Xml).Count;if($batch.Capped -isnot [bool] -or $batch.Capped){throw 'DNS event query cap reached or completeness unknown.'};$matches=@($batch.Xml|Where-Object {Test-WelaDnsClientProbeEvent $_ $operation $before});if($matches.Count){break};Start-Sleep -Milliseconds 250}while($timer.Elapsed.TotalSeconds -lt $TimeoutSeconds)
$report.Matches=$matches.Count;if($matches.Count -gt 16){throw 'DNS matching event set exceeds sixteen records.'}
$i=0;foreach($xml in $matches){$i++;$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath ('event-'+$i+'.xml') $xml}
if(-not $matches.Count){$i=0;foreach($xml in @($batch.Xml|Select-Object -First 4)){$i++;$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath ('candidate-'+$i+'.xml') $xml};throw 'No matching native DNS3008 completion event was observed.'}
if((Get-WelaDnsClientProbeWatermark) -lt $operation.RecordIdBefore){throw 'DNS log record boundary moved backwards; continuity unverified.'}
$report.ReaderAfter=Get-WelaChannelReader;if((Get-WelaChannelReadKey $report.ReaderAfter) -cne (Get-WelaChannelReadKey $report.ReaderBefore)){throw 'Reader primary token changed during query/event collection.'}
$after=Get-WelaDnsClientProbeState;$report.After=$after;if((Get-WelaDnsClientProbeStateKey $after) -cne $key){throw 'DNS host, token, schema, channel or source changed during collection.'}
$report.Status='NativeDnsLookupObserved';$report.ExitCode=0
}catch{$report.Diagnostic=$_.Exception.Message}
finally{if($report.Before -and -not $report.After){try{$report.After=Get-WelaDnsClientProbeState}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 24)}}
if($report.OutputPath){$null=Write-WelaArrivalArtifact $report.OutputPath 'manifest.json' ($report|ConvertTo-Json -Depth 28)}
$report
}
+71
View File
@@ -0,0 +1,71 @@
// One fixed DNS query, with an explicit IPv4 resolver and no configuration writes.
using System;
using System.Collections.Generic;
using System.Net;
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
namespace Wela.DnsClientProbe {
public sealed class Answer { public string Name, Address; public ushort Type; public uint Flags; }
public sealed class Result { public uint Status, ResultStatus; public ulong Options; public string QueryName, Resolver; public Answer[] Answers; }
public static class Native {
public const string SourceSha256="__WELA_DNS_CLIENT_SOURCE_SHA256__";
// TCP, no recursion; bypass cache/local-name/hosts/NetBT/multicast/suffixes/IDN.
public const ulong Options=0x002019ee;
[StructLayout(LayoutKind.Sequential,CharSet=CharSet.Unicode)] struct Request {
public uint Version; [MarshalAs(UnmanagedType.LPWStr)] public string Name; public ushort Type;
public ulong Options; public IntPtr Servers; public uint Interface; public IntPtr Callback,Context;
}
[StructLayout(LayoutKind.Sequential)] struct QueryResult { public uint Version,Status; public ulong Options; public IntPtr Records,Reserved; }
[StructLayout(LayoutKind.Sequential)] struct Record { public IntPtr Next,Name; public ushort Type,Length; public uint Flags,Ttl,Reserved; }
// DnsQueryEx is the documented exact export; do not allow a W-suffixed name probe.
[DllImport("dnsapi.dll",EntryPoint="DnsQueryEx",ExactSpelling=true)] static extern uint DnsQueryEx(ref Request request,ref QueryResult result,IntPtr cancel);
[DllImport("dnsapi.dll")] static extern void DnsRecordListFree(IntPtr records,int freeType);
[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); }
public static string ValidateResolver(string resolver) {
if(resolver==null||!Regex.IsMatch(resolver,@"^(0|[1-9][0-9]{0,2})(\.(0|[1-9][0-9]{0,2})){3}$"))throw new ArgumentException("One canonical dotted-decimal IPv4 resolver is required.");
IPAddress address;if(!IPAddress.TryParse(resolver,out address)||address.AddressFamily!=System.Net.Sockets.AddressFamily.InterNetwork||address.ToString()!=resolver)throw new ArgumentException("Invalid IPv4 resolver.");
byte[] bytes=address.GetAddressBytes();if(bytes[0]==0||bytes[0]>=224||resolver=="255.255.255.255")throw new ArgumentException("Unspecified, multicast and reserved/broadcast resolver addresses are refused.");
return resolver;
}
static byte[] BuildServerArray(string resolver) {
ValidateResolver(resolver);
// SDK DNS_ADDR_ARRAY header32 + one DNS_ADDR64; sockaddr_in in its first16 bytes.
// Match Microsoft Windows-classic-samples/DNSAsyncQuery CreateDnsServerList:
// one address, unspecified aggregate family, sockaddr IPv4 with default DNS port.
byte[] server=new byte[96];BitConverter.GetBytes((uint)1).CopyTo(server,0);BitConverter.GetBytes((uint)1).CopyTo(server,4);
BitConverter.GetBytes((ushort)2).CopyTo(server,32);
IPAddress.Parse(resolver).GetAddressBytes().CopyTo(server,36);
return server;
}
public static Result Query(string name,string resolver) {
if(IntPtr.Size!=8)throw new InvalidOperationException("Native 64-bit process required.");
if(name==null||!Regex.IsMatch(name,@"^wela-[a-f0-9]{32}\.wela\.test\.\z"))throw new ArgumentException("Only the fixed random probe name is accepted.");
byte[] server=BuildServerArray(resolver);
IntPtr servers=Marshal.AllocHGlobal(server.Length);QueryResult result=new QueryResult {Version=1};
try {
Marshal.Copy(server,0,servers,server.Length);
Request request=new Request {Version=1,Name=name,Type=1,Options=Options,Servers=servers};
uint status=DnsQueryEx(ref request,ref result,IntPtr.Zero);
if(status==9506)throw new InvalidOperationException("Unexpected asynchronous query response.");
List<Answer> answers=new List<Answer>();HashSet<IntPtr> seen=new HashSet<IntPtr>();IntPtr current=result.Records;
while(current!=IntPtr.Zero) {
if(!seen.Add(current)||seen.Count>64)throw new InvalidOperationException("DNS result record bound exceeded.");
Record record=(Record)Marshal.PtrToStructure(current,typeof(Record));
string recordName=Marshal.PtrToStringUni(record.Name);if(recordName==null||recordName.Length>255)throw new InvalidOperationException("Invalid DNS result name.");
// Only A data is interpreted. Unexpected answer aliases/types cannot establish a fixed A result.
if((record.Flags&3)==1) {
if(record.Type!=1||!String.Equals(recordName.TrimEnd('.'),name.TrimEnd('.'),StringComparison.OrdinalIgnoreCase))throw new InvalidOperationException("Unexpected DNS answer name/type; no follow-up application connection is made.");
if(record.Length<4)throw new InvalidOperationException("Truncated DNS A result.");
byte[] address=new byte[4];Marshal.Copy(IntPtr.Add(current,Marshal.SizeOf(typeof(Record))),address,0,4);
answers.Add(new Answer {Name=recordName,Type=record.Type,Flags=record.Flags,Address=new IPAddress(address).ToString()});
if(answers.Count>16)throw new InvalidOperationException("DNS A answer bound exceeded.");
}
current=record.Next;
}
if((status==0 && answers.Count==0) || (status!=0 && answers.Count!=0))throw new InvalidOperationException("DNS status and A answers disagree.");
return new Result {Status=status,ResultStatus=result.Status,Options=request.Options,QueryName=name,Resolver=resolver,Answers=answers.ToArray()};
}finally{if(result.Records!=IntPtr.Zero)DnsRecordListFree(result.Records,1);Marshal.FreeHGlobal(servers);}
}
}
}
+15
View File
@@ -0,0 +1,15 @@
param([Parameter(Mandatory)][string]$Resolver,[Parameter(Mandatory)][string]$QueryName)
$ErrorActionPreference='Stop';$script:ScriptRoot=Split-Path $PSScriptRoot -Parent
[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)
. (Join-Path $PSScriptRoot 'WefArrival.ps1')
. (Join-Path $PSScriptRoot 'ChannelRead.ps1')
. (Join-Path $PSScriptRoot 'DnsClientProbe.ps1')
Initialize-WelaDnsClientProbeNative
if((Get-Service Dnscache -ErrorAction Stop).Status -ne 'Running'){throw 'DNS Client must already be running.'}
$before=Get-WelaChannelReader
$start=[Wela.DnsClientProbe.Native]::UtcNow().ToString('o')
$query=[Wela.DnsClientProbe.Native]::Query($QueryName,$Resolver)
$end=[Wela.DnsClientProbe.Native]::UtcNow().ToString('o')
$after=Get-WelaChannelReader
if((Get-WelaChannelReadKey $before) -cne (Get-WelaChannelReadKey $after)){throw 'Worker primary token changed during DNS query.'}
[pscustomobject]@{Query=$query;StartedUtc=$start;CompletedUtc=$end;Clock='GetSystemTimePreciseAsFileTime';ProcessId=$PID;BeforeToken=$before;AfterToken=$after}|ConvertTo-Json -Depth 12 -Compress
+71 -12
View File
@@ -182,21 +182,50 @@ function Get-WelaEvtxReader {
try {$reader=[pscustomobject]@{Sid=$identity.User.Value;Name=$identity.Name;AuthenticationType=$identity.AuthenticationType;ImpersonationLevel=[string]$identity.ImpersonationLevel;Groups=@($identity.Groups | ForEach-Object {$_.Value} | Sort-Object)}} finally {$identity.Dispose()}
[pscustomobject]@{Computer=[Environment]::MachineName;HostKey=(Get-WelaDefaultContextKey $hostState);Reader=$reader}
}
function Get-WelaEvtxRecoverySources {
$root=Split-Path $PSScriptRoot -Parent;$sources=[ordered]@{}
foreach ($path in @('WELA.ps1','scripts/EvtxRecovery.ps1','scripts/ChannelRead.ps1','scripts/ChannelReadNative.cs','scripts/WefArrival.ps1','scripts/NativeValidation.ps1','scripts/ControlApplicability.ps1','modules/AuditProfiles.psm1','config/audit_profiles.json')) {
$sources[$path]=(Get-FileHash -LiteralPath (Join-Path $root $path) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()
}
[pscustomobject]$sources
}
function Get-WelaEvtxRecoveryKey {param($Value) ConvertTo-Json -InputObject $Value -Depth 24 -Compress}
function Get-WelaEvtxRecoveryHost {
# An archive reader does not need administrator-only installed-feature inventory.
$hostState=Get-WelaChannelReadHost
$consistent=($hostState.ProductType -eq 1 -and $hostState.DomainRole -in @(0,1)) -or
($hostState.ProductType -eq 2 -and $hostState.DomainRole -in @(4,5)) -or ($hostState.ProductType -eq 3 -and $hostState.DomainRole -in @(2,3))
if (-not $consistent -or $hostState.DomainJoined -ne ($hostState.DomainRole -in @(1,3,4,5)) -or
$hostState.UBR -isnot [int] -or $hostState.UBR -lt 0 -or [string]::IsNullOrWhiteSpace($hostState.Edition) -or [string]::IsNullOrWhiteSpace($hostState.Domain)) {throw 'Incomplete or conflicting actual archive-reader host context.'}
$hostState
}
function Get-WelaEvtxRecoveryReader {
# Reuse the source-bound native token statistics adapter, not the legacy
# metadata-only reader used by event-measurement before output preparation.
Get-WelaChannelReader
}
function Assert-WelaEvtxQueryStatus {
param([string]$Path,[object[]]$LogStatus)
if ($LogStatus.Count -ne 1 -or -not [string]::Equals($LogStatus[0].LogName,$Path,[StringComparison]::OrdinalIgnoreCase) -or $LogStatus[0].StatusCode -isnot [int]) {throw ('Native EVTX query status is incomplete, mismatched or mistyped: '+(ConvertTo-Json -InputObject $LogStatus -Compress))}
if ($LogStatus[0].StatusCode -ne 0) {throw [ComponentModel.Win32Exception]::new($LogStatus[0].StatusCode)}
}
function Read-WelaEvtxNative {
param([string]$Path,[switch]$Live,[string]$Query='*')
$kind=if ($Live) {[System.Diagnostics.Eventing.Reader.PathType]::LogName} else {[System.Diagnostics.Eventing.Reader.PathType]::FilePath}
$request=New-Object System.Diagnostics.Eventing.Reader.EventLogQuery($Path,$kind,$Query)
$request.TolerateQueryErrors=$false
$reader=New-Object System.Diagnostics.Eventing.Reader.EventLogReader($request)
$reader=New-Object System.Diagnostics.Eventing.Reader.EventLogReader($request);$reader.BatchSize=2
$events=New-Object 'System.Collections.Generic.List[string]'
try {
# Read every exported record, up to two: this probe archive must contain exactly one.
for ($i=0;$i -lt 2;$i++) {
$record=$reader.ReadEvent([timespan]::FromSeconds(5))
if ($null -eq $record) {break}
try {$events.Add($record.ToXml())} finally {$record.Dispose()}
try {$xml=$record.ToXml();if ([Text.Encoding]::UTF8.GetByteCount($xml) -gt 4194304) {throw 'Recovered event XML exceeds the four MiB bound.'};$events.Add($xml)} finally {$record.Dispose()}
}
return [pscustomobject]@{Xml=@($events.ToArray());Limit=2}
$status=@($reader.LogStatus|ForEach-Object {[pscustomobject]@{LogName=$_.LogName;StatusCode=$_.StatusCode}})
Assert-WelaEvtxQueryStatus -Path $Path -LogStatus $status
return [pscustomobject]@{Xml=@($events.ToArray());Limit=2;LogStatus=$status}
} finally {$reader.Dispose()}
}
function Export-WelaEvtxNative {
@@ -214,6 +243,7 @@ function Invoke-WelaEvtxRecovery {
param([ValidateSet('Export','Verify')][string]$Action='Verify',[Parameter(Mandatory)][string]$ProbePath,[string]$ArchivePath,[Parameter(Mandatory)][string]$OutputPath)
$ErrorActionPreference='Stop'
if (($Action -eq 'Export' -and $ArchivePath) -or ($Action -eq 'Verify' -and -not $ArchivePath)) {throw 'Export creates probe.evtx in a new output directory; Verify requires ArchivePath.'}
$sources=Get-WelaEvtxRecoverySources;$sourceKey=Get-WelaEvtxRecoveryKey $sources
$source=Import-WelaEvtxProbe $ProbePath
if ($Action -eq 'Verify') {
$archive=Resolve-WelaEvtxPath $ArchivePath
@@ -222,11 +252,10 @@ function Invoke-WelaEvtxRecovery {
}
$output=New-WelaEvtxOutput $OutputPath $source.Path
if ($Action -eq 'Export') {$archive=Join-Path $output 'probe.evtx'}
$report=[pscustomobject][ordered]@{Kind='WelaNativeEvtxRecovery';SchemaVersion=1;Action=$Action;Status='Unverified';ExitCode=1;StartedUtc=[datetime]::UtcNow.ToString('o');CompletedUtc=$null;SourceBundlePath=$source.Path;SourceFingerprint=$source.Fingerprint;ArchivePath=$archive;ArchiveSha256=$null;ReaderBefore=$null;ReaderAfter=$null;ExportQuery=$null;RecoveredEvents=0;Artifacts=@();Diagnostic='';OutputPath=$output;ReadyRuleCredit=0;PolicyChanges=0;Scope='One exact native probe event readable from this EVTX by the recorded current reader. No archive completeness, duration, other-principal access or Sigma readiness claim.'}
$lock=$null
$report=[pscustomobject][ordered]@{Kind='WelaNativeEvtxRecovery';SchemaVersion=2;Action=$Action;Status='Unverified';ExitCode=1;StartedUtc=[datetime]::UtcNow.ToString('o');CompletedUtc=$null;SourceBundlePath=$source.Path;SourceFingerprint=$source.Fingerprint;SourceComputer=$source.Event.Computer;ArchivePath=$archive;ArchiveSha256=$null;ArchiveBytes=$null;ReaderHostBefore=$null;ReaderHostAfter=$null;ReaderBefore=$null;ReaderAfter=$null;ReaderStable=$false;ReaderInterval='After output/source preparation, immediately before event access through archive hashing/native query and source-file verification; final host/policy inventory is outside this token interval.';Sources=$sources;FileReadAccess='NotAttempted';NativeQuery='NotAttempted';NativeLogStatus=@();FailureStage=$null;NativeError=$null;ExportQuery=$null;RecoveredEvents=0;Artifacts=@();Diagnostic='';OutputPath=$output;ReadyRuleCredit=0;PolicyChanges=0;Scope='Actual primary-token access to one exact local EVTX probe at observation time. Source producer and archive reader are distinct identities. No archive completeness, duration, other-principal access or Sigma readiness claim.'}
$lock=$null;$stage='Preparation';$beforeKey=$null
try {
$before=Get-WelaEvtxReader;$report.ReaderBefore=$before
$beforeKey=ConvertTo-Json -InputObject $before -Depth 16 -Compress
$report.ReaderHostBefore=Get-WelaEvtxRecoveryHost;$hostKey=Get-WelaEvtxRecoveryKey $report.ReaderHostBefore
$report.Artifacts+=Write-WelaEvtxArtifact $output 'source-event.xml' $source.Files['event.xml'].Text
if ($Action -eq 'Export') {
$expected=ConvertTo-WelaEvtxState $source.Manifest.BeforeState
@@ -237,30 +266,60 @@ function Invoke-WelaEvtxRecovery {
$number=[long]::Parse($source.Event.RecordId,[Globalization.CultureInfo]::InvariantCulture)
$query="*[System[EventRecordID=$number and EventID=4688 and Provider[@Name='Microsoft-Windows-Security-Auditing']]]"
$report.ExportQuery=$query
}
# ACL setup and native audit-policy preparation can temporarily adjust
# privileges. Capture the primary token after that work, before event I/O.
$before=Get-WelaEvtxRecoveryReader;$report.ReaderBefore=$before;$beforeKey=Get-WelaEvtxRecoveryKey $before
if ($Action -eq 'Export') {
$stage='LiveSourceQuery'
Assert-WelaEvtxSingleEvent (Read-WelaEvtxNative -Path Security -Live -Query $query) $source
if ((Get-WelaEvtxRecoveryKey (Get-WelaEvtxRecoveryReader)) -cne $beforeKey) {throw 'Reader token changed during live source query.'}
if ((Import-WelaEvtxProbe $ProbePath).Fingerprint -cne $source.Fingerprint) {throw 'Source evidence changed before export.'}
$stage='Export'
Export-WelaEvtxNative -Query $query -Path $archive
}
$stage='ArchiveFileOpen'
if ((Get-WelaEvtxRecoveryKey (Get-WelaEvtxRecoveryReader)) -cne $beforeKey) {throw 'Reader token changed before archive access.'}
$null=Resolve-WelaEvtxPath $archive
# Keep the exact file open without write/delete sharing throughout hashing and native reopen.
$lock=New-Object IO.FileStream($archive,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read)
$report.FileReadAccess='Allowed';$stage='ArchiveHash'
if ($lock.Length -lt 1 -or $lock.Length -gt 16777216) {throw 'Exported probe archive exceeds size bounds.'}
$report.ArchiveBytes=$lock.Length
$sha=[Security.Cryptography.SHA256]::Create()
try {$report.ArchiveSha256=([BitConverter]::ToString($sha.ComputeHash($lock))).Replace('-','').ToLowerInvariant()} finally {$sha.Dispose()}
$stage='ArchiveNativeQuery';$report.NativeQuery='Unverified'
$batch=Read-WelaEvtxNative -Path $archive
$report.NativeLogStatus=@($batch.LogStatus)
$report.RecoveredEvents=@($batch.Xml).Count
Assert-WelaEvtxSingleEvent $batch $source
$report.NativeQuery='ExactEventRecovered';$stage='EvidenceVerification'
$report.Artifacts+=Write-WelaEvtxArtifact $output 'recovered-event.xml' $batch.Xml[0]
$after=Get-WelaEvtxReader;$report.ReaderAfter=$after
if ((ConvertTo-Json -InputObject $after -Depth 16 -Compress) -cne $beforeKey) {throw 'Reader identity or host changed during EVTX readback.'}
if ($Action -eq 'Export' -and (Get-WelaProbeStateKey (Get-WelaProbeState)) -cne (Get-WelaProbeStateKey $expected)) {throw 'Source host or prerequisites drifted during export.'}
if ((Import-WelaEvtxProbe $ProbePath).Fingerprint -cne $source.Fingerprint) {throw 'Source evidence changed during EVTX verification.'}
if ((Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant() -cne $report.ArchiveSha256) {throw 'Archive path/bytes changed during native readback.'}
$report.ReaderAfter=Get-WelaEvtxRecoveryReader
if ((Get-WelaEvtxRecoveryKey $report.ReaderAfter) -cne $beforeKey) {throw 'Reader token changed during EVTX access.'}
$report.ReaderStable=$true;$stage='FinalContext'
# Final policy inventory may adjust privileges; it runs after the recorded
# token interval, with no later native event query or archive export.
if ($Action -eq 'Export' -and (Get-WelaProbeStateKey (Get-WelaProbeState)) -cne (Get-WelaProbeStateKey $expected)) {throw 'Source host or prerequisites drifted during export.'}
$report.ReaderHostAfter=Get-WelaEvtxRecoveryHost
if ((Get-WelaEvtxRecoveryKey $report.ReaderHostAfter) -cne $hostKey) {throw 'Actual archive-reader host changed during recovery.'}
if ((Get-WelaEvtxRecoveryKey (Get-WelaEvtxRecoverySources)) -cne $sourceKey) {throw 'Recovery implementation changed during observation.'}
foreach ($artifact in $report.Artifacts) {if ((Get-FileHash -LiteralPath (Join-Path $output $artifact.Name) -Algorithm SHA256).Hash.ToLowerInvariant() -cne $artifact.Sha256) {throw 'Saved recovery evidence changed before the manifest.'}}
$report.Status='NativeEventRecovered';$report.ExitCode=0
} catch {$report.Diagnostic=$_.Exception.Message}
} catch {
$failure=Get-WelaChannelReadFailure $_.Exception
$report.Diagnostic=$_.Exception.Message;$report.FailureStage=$stage;$report.NativeError=$failure.NativeError
if ($stage -eq 'ArchiveFileOpen' -and $failure.Status -eq 'Denied') {$report.FileReadAccess='Denied'}
if ($stage -eq 'ArchiveNativeQuery' -and $failure.Status -eq 'Denied') {$report.NativeQuery='Denied'}
}
finally {
if ($report.ReaderBefore -and -not $report.ReaderAfter) {
try {$report.ReaderAfter=Get-WelaEvtxRecoveryReader;$report.ReaderStable=(Get-WelaEvtxRecoveryKey $report.ReaderAfter) -ceq $beforeKey}
catch {$report.Diagnostic+=' Final reader observation failed: '+$_.Exception.Message}
}
if ($lock) {$lock.Dispose()}
if ($report.ReaderBefore -and -not $report.ReaderAfter) {try {$report.ReaderAfter=Get-WelaEvtxReader} catch {$report.Diagnostic+=' Final reader observation failed: '+$_.Exception.Message}}
}
$report.CompletedUtc=[datetime]::UtcNow.ToString('o')
$null=Write-WelaEvtxArtifact $output 'manifest.json' ($report | ConvertTo-Json -Depth 24)
+183
View File
@@ -0,0 +1,183 @@
# Recovery is limited to a single proven explicit addition on an existing leaf file.
function Get-WelaFileSaclRecoveryKey {param($Value) ConvertTo-Json -InputObject $Value -Depth 30 -Compress}
function Initialize-WelaFileSaclRecoveryNative {
$path=Join-Path $PSScriptRoot 'FileSaclRecoveryNative.cs';$bytes=[IO.File]::ReadAllBytes($path);$hash=Get-WelaArrivalHash $bytes
if (-not ('Wela.FileSaclRecovery.Descriptor' -as [type])) {
$source=[Text.UTF8Encoding]::new($false,$true).GetString($bytes).TrimStart([char]0xfeff)
$marker='__WELA_FILE_SACL_RECOVERY_SOURCE_SHA256__'
if (($source.Split(@($marker),[StringSplitOptions]::None)).Count -ne 2) {throw 'Unexpected native recovery source binding.'}
Add-Type -TypeDefinition $source.Replace($marker,$hash) -ErrorAction Stop
}
if ([Wela.FileSaclRecovery.Descriptor]::SourceSha256 -cne $hash) {throw 'Loaded file recovery helper differs from current source; start a fresh PowerShell process.'}
}
function Get-WelaFileSaclRecoverySources {
$sources=[ordered]@{}
foreach ($path in @('WELA.ps1','scripts/FileSaclRecovery.ps1','scripts/FileSaclRecoveryNative.cs','scripts/SelectedSaclConfiguration.ps1','scripts/SelectedSaclNative.cs','scripts/SelectedSaclDescendants.ps1','scripts/TargetedSaclPlanning.ps1','scripts/ControlApplicability.ps1','scripts/Configuration.ps1','config/control_applicability.json','modules/NativeProviders.psm1','scripts/EvtxRecovery.ps1','scripts/WefArrival.ps1','modules/AuditProfiles.psm1','modules/AuditCatalog.psm1','config/audit_profiles.json','config/audit_sacl_targets.json')) {
$sources[$path]=(Get-FileHash -LiteralPath (Join-Path (Split-Path $PSScriptRoot -Parent) $path) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()
}
[pscustomobject]$sources
}
function Get-WelaFileSaclRecoveryOperator {
if ($env:OS -ne 'Windows_NT' -or -not [Environment]::Is64BitProcess) {throw 'File SACL recovery requires native 64-bit Windows.'}
$thread=[Security.Principal.WindowsIdentity]::GetCurrent($true)
if ($thread) {$thread.Dispose();throw 'Impersonated recovery is unsupported.'}
$identity=[Security.Principal.WindowsIdentity]::GetCurrent()
try {
if (-not ([Security.Principal.WindowsPrincipal]::new($identity)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {throw 'File SACL recovery requires the actual elevated operator.'}
$key=[Microsoft.Win32.Registry]::LocalMachine.OpenSubKey('SOFTWARE\Microsoft\Cryptography',$false)
if (-not $key) {throw 'Machine identity is unavailable.'}
try {$machine=$key.GetValue('MachineGuid');if ($key.GetValueKind('MachineGuid') -ne 'String' -or $machine -isnot [string]) {throw 'Machine identity is mistyped.'}} finally {$key.Dispose()}
[guid]$parsed=[guid]::Empty;if (-not [guid]::TryParse($machine,[ref]$parsed) -or $parsed -eq [guid]::Empty) {throw 'Machine identity is invalid.'}
[pscustomobject][ordered]@{Computer=[Environment]::MachineName;MachineGuid=$parsed.ToString();UserSid=$identity.User.Value;Groups=@($identity.Groups|ForEach-Object Value|Sort-Object);ElevatedAdministrator=$true;Impersonation='Absent'}
} finally {$identity.Dispose()}
}
function Read-WelaFileSaclRecoveryInput {
param([string]$Path)
$full=Resolve-WelaArrivalPath $Path
$stream=[IO.File]::Open($full,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read)
try {
if ($stream.Length -lt 1 -or $stream.Length -gt 4194304) {throw 'Recovery JSON must contain 1 byte through four MiB.'}
$bytes=New-Object byte[] ([int]$stream.Length);$offset=0
while ($offset -lt $bytes.Length) {$count=$stream.Read($bytes,$offset,$bytes.Length-$offset);if ($count -eq 0) {throw 'Recovery input changed during reading.'};$offset+=$count}
if ($stream.Length -ne $bytes.Length) {throw 'Recovery input length changed.'}
} finally {$stream.Dispose()}
$text=[Text.UTF8Encoding]::new($false,$true).GetString($bytes).TrimStart([char]0xfeff)
[pscustomobject]@{Path=$full;Sha256=(Get-WelaArrivalHash $bytes);Bytes=$bytes.Length;Data=(ConvertFrom-WelaEvtxJson $text)}
}
function Assert-WelaFileSaclRecoverySnapshot {
param($Snapshot,$Definition)
Assert-WelaEvtxObject $Snapshot @('Path','Kind','Identity','IsDirectory','DescriptorBase64','Owner','Group','DaclBase64','ControlFlags','SecurityInformation','DescriptorScope','Aces')
if ($Snapshot.Kind -cne 'FileSystem' -or $Snapshot.IsDirectory -isnot [bool] -or $Snapshot.IsDirectory -or $Snapshot.Path -cne $Definition.Path -or $Snapshot.Identity -cnotmatch '^[0-9]+:[0-9]+:[0-9]+:[0-9]+$' -or $Snapshot.Aces -isnot [array]) {throw 'Only exact historical leaf-file snapshots are supported.'}
$null=Get-WelaSelectedSaclSnapshotKey $Snapshot
foreach ($ace in $Snapshot.Aces) {
Assert-WelaEvtxObject $ace @('Binary','Type','Flags','Mask','Sid','Ordinary')
if ($ace.Ordinary -isnot [bool]) {throw 'Mistyped ACE metadata.'}
foreach ($name in @('Type','Flags','Mask')) {if ($ace.$name -isnot [int] -and $ace.$name -isnot [long]) {throw 'Mistyped ACE metadata.'}}
}
Initialize-WelaFileSaclRecoveryNative
$parsed=[Wela.FileSaclRecovery.Descriptor]::Observe($Snapshot.Path,$Snapshot.Identity,[Convert]::FromBase64String($Snapshot.DescriptorBase64))
if ((Get-WelaSelectedSaclSnapshotKey $parsed) -cne (Get-WelaSelectedSaclSnapshotKey $Snapshot)) {throw 'Historical snapshot metadata differs from its actual descriptor bytes.'}
}
function Get-WelaFileSaclRecoverySnapshot {
param($Definition)
if ($Definition.Kind -cne 'FileSystem') {throw 'Only leaf FileSystem targets are supported.'}
$path=Resolve-WelaSelectedSaclNativePath $Definition;Initialize-WelaFileSaclRecoveryNative
$target=[Wela.FileSaclRecovery.Target]::new($path)
try {$target.Read()} finally {$target.Dispose()}
}
function Get-WelaFileSaclRecoveryAddition {
param($Before,$After,$Ace)
Initialize-WelaFileSaclRecoveryNative
[Wela.FileSaclRecovery.Descriptor]::AddedAce($Before.DescriptorBase64,$After.DescriptorBase64,$Ace.Sid,$Ace.Mask,$Ace.Flags)
}
function New-WelaFileSaclRecoveryPlan {
param([string]$OriginalPlanPath,[string]$PendingPath,[string]$ConfirmedPath,[string]$ResultsPath)
$operator=Get-WelaFileSaclRecoveryOperator;$context=Get-WelaSelectedSaclContext;$sources=Get-WelaFileSaclRecoverySources
$files=[ordered]@{};foreach ($entry in @(@('OriginalPlan',$OriginalPlanPath),@('Pending',$PendingPath),@('Confirmed',$ConfirmedPath),@('Results',$ResultsPath))) {$files[$entry[0]]=Read-WelaFileSaclRecoveryInput $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
Assert-WelaEvtxObject $plan @('SchemaVersion','Kind','CapturedUtc','Profile','IncludeOptional','IncludeChildren','Context','Sources','Rows','GenerationReadiness','UsableRuleCredit','Catalog','UserInventory')
foreach ($value in @($plan,$pending,$confirmed,$result)) {if (($value.SchemaVersion -isnot [int] -and $value.SchemaVersion -isnot [long]) -or $value.SchemaVersion -ne 1) {throw 'Unsupported original evidence schema.'}}
if ($plan.Kind -cne 'WelaSelectedSaclPlan' -or $plan.IncludeChildren -isnot [bool] -or $plan.IncludeChildren -or $plan.IncludeOptional -isnot [bool] -or $plan.Rows -isnot [array] -or $plan.Rows.Count -ne 1) {throw 'Require one original selected target, without child consent.'}
$row=$plan.Rows[0]
if ($row.Status -cne 'ChangeRequired' -or $row.After -or $row.DescendantsBefore -or $row.DescendantsAfter -or $row.DescendantVerification -or $row.Id -cnotmatch '^sacl-[a-f0-9]{24}$' -or $row.Definition.Kind -cne 'FileSystem' -or $row.Definition.Inheritance -cne 'None' -or $row.Definition.Propagation -cne 'None') {throw 'Original plan must describe one explicit leaf-file addition without inheritance.'}
Assert-WelaSelectedSaclSources $plan.Sources
if ($plan.Context.Key -cne $context.Key -or $plan.Context.Computer -cne $operator.Computer) {throw 'Original host context differs from the actual recovery host.'}
$catalog=Get-WelaSelectedSaclCatalog -Profile $plan.Profile -IncludeOptional:$plan.IncludeOptional -Context $context
$selected=@($catalog.Rows|Where-Object Id -CEQ $row.Id)
if ($selected.Count -ne 1 -or $selected[0].DefinitionKey -cne $row.DefinitionKey -or (Get-WelaSelectedSaclDefinitionKey $row.Definition) -cne $row.DefinitionKey -or (Get-WelaFileSaclRecoveryKey $selected[0].Definition) -cne (Get-WelaFileSaclRecoveryKey $row.Definition)) {throw 'Original target is not the exact currently source-bound catalog selection.'}
Assert-WelaFileSaclRecoverySnapshot $row.Before $row.Definition
$ace=Get-WelaSelectedSaclAce $row.Definition $row.Before
if ((Get-WelaFileSaclRecoveryKey $ace) -cne (Get-WelaFileSaclRecoveryKey $row.Ace) -or $ace.Flags -notin @(64,128,192) -or (Test-WelaSelectedSaclAce $row.Before $ace)) {throw 'Original selected audit 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
if ($receipt.Kind -cne 'WelaSelectedSaclReceipt' -or $receipt.Computer -cne $operator.Computer -or $receipt.ContextKey -cne $context.Key -or $receipt.Id -cne $row.Id -or $receipt.DescendantsBefore -or $receipt.DescendantsAfter -or $receipt.DescendantVerification -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 is unsupported.'}
Assert-WelaSelectedSaclSources $receipt.Sources
foreach ($name in @('Definition','Ace')) {if ((Get-WelaFileSaclRecoveryKey $receipt.$name) -cne (Get-WelaFileSaclRecoveryKey $row.$name)) {throw 'Original receipt differs from the selected plan.'}}
Assert-WelaFileSaclRecoverySnapshot $receipt.Before $row.Definition
if ((Get-WelaSelectedSaclSnapshotKey $receipt.Before) -cne (Get-WelaSelectedSaclSnapshotKey $row.Before)) {throw 'Original before-state differs across records.'}
}
if ($pending.State -cne 'Pending' -or $pending.After -or $confirmed.State -cne 'Confirmed' -or -not $confirmed.After -or $pending.RecordedUtc -cne $confirmed.RecordedUtc) {throw 'A matching pending and confirmed receipt pair is required.'}
Assert-WelaFileSaclRecoverySnapshot $confirmed.After $row.Definition
if ($confirmed.After.Identity -cne $row.Before.Identity) {throw 'The original operation changed file identity.'}
$added=Get-WelaFileSaclRecoveryAddition $row.Before $confirmed.After $ace
Assert-WelaEvtxObject $result @('SchemaVersion','Kind','ExitCode','DryRun','BackupPath','Plan','Results','GenerationReadiness','UsableRuleCredit')
if ($result.Kind -cne 'WelaSelectedSaclResult' -or ($result.ExitCode -isnot [int] -and $result.ExitCode -isnot [long]) -or $result.ExitCode -ne 0 -or $result.DryRun -isnot [bool] -or $result.DryRun -or $result.Results -isnot [array] -or $result.Results.Count -ne 1 -or $result.Results[0].Status -cne 'Applied') {throw 'Require a completed successful, non-dry-run selected operation.'}
$applied=$result.Results[0]
if ($result.Plan.Kind -cne 'WelaSelectedSaclPlan' -or $result.Plan.Rows -isnot [array] -or $result.Plan.Rows.Count -ne 1 -or (Get-WelaFileSaclRecoveryKey $applied) -cne (Get-WelaFileSaclRecoveryKey $result.Plan.Rows[0]) -or $applied.Id -cne $row.Id -or $applied.DefinitionKey -cne $row.DefinitionKey -or $applied.DescendantsBefore -or $applied.DescendantsAfter -or $applied.DescendantVerification) {throw 'Completed result rows or scope disagree.'}
foreach ($name in @('Definition','Ace')) {if ((Get-WelaFileSaclRecoveryKey $applied.$name) -cne (Get-WelaFileSaclRecoveryKey $row.$name)) {throw 'Completed selection differs from original plan.'}}
if ((Get-WelaSelectedSaclSnapshotKey $applied.Before) -cne (Get-WelaSelectedSaclSnapshotKey $row.Before) -or (Get-WelaSelectedSaclSnapshotKey $applied.After) -cne (Get-WelaSelectedSaclSnapshotKey $confirmed.After)) {throw 'Completed descriptor evidence disagrees.'}
foreach ($name in @('Profile','IncludeOptional','IncludeChildren','Context','Sources')) {if ((Get-WelaFileSaclRecoveryKey $result.Plan.$name) -cne (Get-WelaFileSaclRecoveryKey $plan.$name)) {throw 'Completed plan context differs from the 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 the original recorded backup directory.'}
$originalTime=ConvertTo-WelaEvtxUtc $plan.CapturedUtc;$configuredTime=ConvertTo-WelaEvtxUtc $result.Plan.CapturedUtc;$receiptTime=ConvertTo-WelaEvtxUtc $pending.RecordedUtc
if ($originalTime -gt $configuredTime -or $configuredTime -gt $receiptTime -or $receiptTime -gt [DateTimeOffset]::UtcNow) {throw 'Original evidence timestamps are out of order or in the future.'}
$current=Get-WelaFileSaclRecoverySnapshot $row.Definition
if ((Get-WelaSelectedSaclSnapshotKey $current) -cne (Get-WelaSelectedSaclSnapshotKey $confirmed.After)) {throw 'Current file identity or descriptor differs from the completed operation; manual review required.'}
if ((Get-WelaSelectedSaclSnapshotKey (Get-WelaFileSaclRecoverySnapshot $row.Definition)) -cne (Get-WelaSelectedSaclSnapshotKey $current)) {throw 'File changed during recovery planning.'}
$inputFiles=[ordered]@{};foreach ($name in $files.Keys) {$file=$files[$name];$inputFiles[$name]=[pscustomobject]@{Path=$file.Path;Sha256=$file.Sha256;Bytes=$file.Bytes}}
$recovery=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaFileSaclRecoveryPlan';Id=$row.Id;Profile=$plan.Profile;Operator=$operator;ContextKey=$context.Key;Sources=$sources;OriginalFiles=[pscustomobject]$inputFiles;Definition=$row.Definition;BeforeAddition=$row.Before;Expected=$current;AddedAce=$added;Outcome='Remove one proven explicit ordinary audit ACE; an empty or null present SACL can remain.';ReadyRuleCredit=0}
Assert-WelaFileSaclRecoveryFresh $recovery
$recovery
}
function Assert-WelaFileSaclRecoveryFresh {
param($Plan)
if ((Get-WelaFileSaclRecoveryKey (Get-WelaFileSaclRecoverySources)) -cne (Get-WelaFileSaclRecoveryKey $Plan.Sources) -or (Get-WelaFileSaclRecoveryKey (Get-WelaFileSaclRecoveryOperator)) -cne (Get-WelaFileSaclRecoveryKey $Plan.Operator) -or (Get-WelaSelectedSaclContext).Key -cne $Plan.ContextKey) {throw 'Recovery implementation, operator or host context changed.'}
foreach ($entry in $Plan.OriginalFiles.PSObject.Properties) {$file=Read-WelaFileSaclRecoveryInput $entry.Value.Path;if ($file.Sha256 -cne $entry.Value.Sha256 -or $file.Bytes -ne $entry.Value.Bytes) {throw 'Original recovery evidence changed.'}}
if ((Get-WelaSelectedSaclSnapshotKey (Get-WelaFileSaclRecoverySnapshot $Plan.Definition)) -cne (Get-WelaSelectedSaclSnapshotKey $Plan.Expected)) {throw 'Reviewed file changed before removal.'}
}
function Invoke-WelaFileSaclRecovery {
param([ValidateSet('Plan','Restore')][string]$Action='Plan',[string]$OriginalPlanPath,[string]$PendingPath,[string]$ConfirmedPath,[string]$ResultsPath,[string]$PlanPath,[string]$PlanHash,[string]$OutputPath,[switch]$Auto,[switch]$DryRun)
if ($Action -eq 'Plan') {
if ($PlanPath -or $PlanHash -or $Auto -or $DryRun -or -not $OriginalPlanPath -or -not $PendingPath -or -not $ConfirmedPath -or -not $ResultsPath -or -not $OutputPath) {throw 'Plan requires four original evidence paths and a new output directory only.'}
$plan=New-WelaFileSaclRecoveryPlan $OriginalPlanPath $PendingPath $ConfirmedPath $ResultsPath
$output=New-WelaArrivalOutput -Path $OutputPath -SourcePath (Split-Path $PSScriptRoot -Parent)
$artifact=Write-WelaFileSaclRecoveryArtifact $output 'plan.json' (Get-WelaFileSaclRecoveryKey $plan)
return [pscustomobject]@{Status='Planned';ExitCode=0;PlanPath=(Join-Path $output 'plan.json');PlanHash=$artifact.Sha256;ReadyRuleCredit=0}
}
if ($OriginalPlanPath -or $PendingPath -or $ConfirmedPath -or $ResultsPath -or -not $PlanPath -or $PlanHash -cnotmatch '^[a-f0-9]{64}$' -or ($DryRun -and ($OutputPath -or $Auto)) -or (-not $DryRun -and (-not $Auto -or -not $OutputPath))) {throw 'Restore requires PlanPath/PlanHash and either DryRun or Auto with a new output directory.'}
$reviewed=Read-WelaFileSaclRecoveryInput $PlanPath
if ($reviewed.Sha256 -cne $PlanHash -or $reviewed.Data.Kind -cne 'WelaFileSaclRecoveryPlan') {throw 'Reviewed recovery plan hash or kind differs.'}
$plan=$reviewed.Data;$inputs=$plan.OriginalFiles
$rebuilt=New-WelaFileSaclRecoveryPlan $inputs.OriginalPlan.Path $inputs.Pending.Path $inputs.Confirmed.Path $inputs.Results.Path
if ((Get-WelaFileSaclRecoveryKey $plan) -cne (Get-WelaFileSaclRecoveryKey $rebuilt)) {throw 'Reviewed recovery plan is stale or modified.'}
if ($DryRun) {return [pscustomobject]@{Status='WouldRemoveAddedAce';ExitCode=0;Target=$plan.Definition.Path;ReadyRuleCredit=0}}
$output=New-WelaArrivalOutput -Path $OutputPath -SourcePath (Split-Path $reviewed.Path -Parent)
$report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaFileSaclRecoveryResult';Status='Refused';ExitCode=1;StartedUtc=[DateTime]::UtcNow.ToString('o');CompletedUtc=$null;PlanHash=$PlanHash;Before=$plan.Expected;After=$null;SaclBefore=[Wela.FileSaclRecovery.Descriptor]::SaclRepresentation($plan.Expected.DescriptorBase64);SaclAfter=$null;WriteAttempted=$false;Artifacts=@();OriginalDescriptorBytesMatch=$false;Diagnostic='';OutputPath=$output;ReadyRuleCredit=0;PolicyChanges=0;Scope='Remove only one proven explicit leaf-file audit ACE; preserve other ACE bytes/counts and observed descriptor components. No descendant, exact historical descriptor, event or Sigma claim.'}
$target=$null
try {
$report.Artifacts+=Write-WelaFileSaclRecoveryArtifact $output 'reviewed-plan.json' (Get-WelaFileSaclRecoveryKey $plan)
$report.Artifacts+=Write-WelaFileSaclRecoveryArtifact $output 'pending.json' (Get-WelaFileSaclRecoveryKey ([pscustomobject]@{Kind='WelaFileSaclRecoveryIntent';PlanHash=$PlanHash;Before=$plan.Expected;RemoveAce=$plan.AddedAce;RecordedUtc=[DateTime]::UtcNow.ToString('o')}))
Assert-WelaFileSaclRecoveryFresh $plan
if ((Read-WelaFileSaclRecoveryInput $reviewed.Path).Sha256 -cne $PlanHash) {throw 'Reviewed recovery plan changed before write.'}
Initialize-WelaFileSaclRecoveryNative
$target=[Wela.FileSaclRecovery.Target]::new((Resolve-WelaSelectedSaclNativePath $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;$report.SaclAfter=[Wela.FileSaclRecovery.Descriptor]::SaclRepresentation($report.After.DescriptorBase64)}}
$target.Dispose();$target=$null
$fresh=Get-WelaFileSaclRecoverySnapshot $plan.Definition
if ((Get-WelaSelectedSaclSnapshotKey $fresh) -cne (Get-WelaSelectedSaclSnapshotKey $report.After)) {throw 'File identity or descriptor changed after removal.'}
if ((Get-WelaFileSaclRecoveryKey (Get-WelaFileSaclRecoverySources)) -cne (Get-WelaFileSaclRecoveryKey $plan.Sources) -or (Get-WelaFileSaclRecoveryKey (Get-WelaFileSaclRecoveryOperator)) -cne (Get-WelaFileSaclRecoveryKey $plan.Operator) -or (Get-WelaSelectedSaclContext).Key -cne $plan.ContextKey) {throw 'Recovery context changed after removal.'}
foreach ($entry in $plan.OriginalFiles.PSObject.Properties) {if ((Read-WelaFileSaclRecoveryInput $entry.Value.Path).Sha256 -cne $entry.Value.Sha256) {throw 'Original recovery evidence changed after removal.'}}
if ((Read-WelaFileSaclRecoveryInput $reviewed.Path).Sha256 -cne $PlanHash) {throw 'Reviewed plan changed after removal.'}
foreach ($artifact in $report.Artifacts) {if ((Get-FileHash -LiteralPath (Join-Path $output $artifact.Name) -Algorithm SHA256).Hash.ToLowerInvariant() -cne $artifact.Sha256) {throw 'Recovery artifact changed after writing.'}}
if ((Get-WelaSelectedSaclSnapshotKey (Get-WelaFileSaclRecoverySnapshot $plan.Definition)) -cne (Get-WelaSelectedSaclSnapshotKey $report.After)) {throw 'Final reopened file differs after recovery.'}
$report.OriginalDescriptorBytesMatch=$report.After.DescriptorBase64 -ceq $plan.BeforeAddition.DescriptorBase64
$report.Status='AddedAceRemoved';$report.ExitCode=0
} catch {$report.Diagnostic=$_.Exception.Message;if ($report.WriteAttempted) {$report.Status='WriteAttemptedUnverified'}}
finally {if ($target) {try {$target.Dispose()} catch {$report.Status='WriteAttemptedUnverified';$report.ExitCode=1;$report.Diagnostic+=' Native cleanup failed: '+$_.Exception.Message}}}
$report.CompletedUtc=[DateTime]::UtcNow.ToString('o')
$null=Write-WelaFileSaclRecoveryArtifact $output 'result.json' (Get-WelaFileSaclRecoveryKey $report)
$report
}
function Write-WelaFileSaclRecoveryArtifact {
param([string]$Root,[string]$Name,[string]$Text)
$null=Resolve-WelaArrivalPath $Root
$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 -ErrorAction Stop).Hash.ToLowerInvariant() -cne $hash) {throw 'Recovery artifact readback differs.'}
[pscustomobject]@{Name=$Name;Sha256=$hash;Bytes=$bytes.Length}
}
+118
View File
@@ -0,0 +1,118 @@
// Narrow leaf-file recovery: remove one proven explicit ordinary 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.FileSaclRecovery {
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_FILE_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))throw new InvalidOperationException("Only an explicit ordinary non-inherited selected 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.");
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.");
}
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="FileSystem",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 {
[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)] static extern IntPtr CreateFile(string name,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 FileInfo info);
[DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern uint GetFinalPathNameByHandle(IntPtr handle,StringBuilder path,uint size,uint flags);
[DllImport("kernel32.dll")] static extern IntPtr LocalFree(IntPtr value);
[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;IntPtr handle;Privilege privilege;public bool WriteAttempted {get;private set;}public Snapshot AfterObservation {get;private set;}
public Target(string path){this.path=path;try{privilege=new Privilege();handle=CreateFile(path,0x01020000,3,IntPtr.Zero,3,0x02200000,IntPtr.Zero);if(handle==new IntPtr(-1)){int error=Marshal.GetLastWin32Error();handle=IntPtr.Zero;throw new Win32Exception(error);}Check();}catch{Dispose();throw;}}
string Check(){if(handle==IntPtr.Zero)throw new ObjectDisposedException("Target");FileInfo info;if(!GetFileInformationByHandle(handle,out info))throw new Win32Exception(Marshal.GetLastWin32Error());if((info.Attributes&0x410)!=0)throw new InvalidOperationException("Directories and reparse files are unsupported.");StringBuilder final=new StringBuilder(32768);uint length=GetFinalPathNameByHandle(handle,final,(uint)final.Capacity,0);if(length==0||length>=final.Capacity||!String.Equals(final.ToString(),"\\\\?\\"+path,StringComparison.OrdinalIgnoreCase))throw new InvalidOperationException("Final held file path differs from the reviewed path.");return info.Volume+":"+info.IndexHigh+":"+info.IndexLow+":"+info.Created;}
public Snapshot Read(){string identity=Check();IntPtr owner,group,dacl,sacl,descriptor;uint error=GetSecurityInfo(handle,1,511,out owner,out group,out dacl,out sacl,out descriptor);if(error!=0)throw new Win32Exception((int)error,"Full SDK-defined file descriptor read failed.");byte[] bytes;try{uint size=GetSecurityDescriptorLength(descriptor);if(size<20||size>1048576)throw new InvalidOperationException("Invalid descriptor size.");bytes=new byte[size];Marshal.Copy(descriptor,bytes,0,(int)size);}finally{LocalFree(descriptor);}if(Check()!=identity)throw new InvalidOperationException("Held file identity changed.");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 file 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))throw new InvalidOperationException("Only an explicit ordinary 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);WriteAttempted=true;uint error=SetSecurityInfo(handle,1,8,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,buffer);if(error!=0)throw new Win32Exception((int)error,"SACL-only removal failed.");}finally{Marshal.FreeHGlobal(buffer);}
Snapshot after=Read();AfterObservation=after;if(after.Identity!=before.Identity)throw new InvalidOperationException("File identity changed during removal.");Descriptor.Removed(before.DescriptorBase64,after.DescriptorBase64,added);return after;
}
public void Dispose(){try{if(handle!=IntPtr.Zero){CloseHandle(handle);handle=IntPtr.Zero;}}finally{if(privilege!=null){privilege.Dispose();privilege=null;}}}
}
}
+74
View File
@@ -0,0 +1,74 @@
# Value-only recovery for three built-in logging switches. No arbitrary registry replay.
function Get-WelaNamedRecoveryCatalog {
foreach ($item in @(
@('HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System\Audit','ProcessCreationIncludeCmdLine_Enabled'),
@('HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging','EnableScriptBlockLogging'),
@('HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ModuleLogging','EnableModuleLogging')
)) {[pscustomobject]@{Id=('Registry/'+$item[0]+'/'+$item[1]);Path=$item[0];Name=$item[1]}}
}
function Get-WelaNamedRecoverySources {
foreach ($relative in @('scripts/NamedRegistryRecovery.ps1','scripts/NamedRegistryRecoveryNative.cs','scripts/AuditRecovery.ps1','scripts/Configuration.ps1')) {
[pscustomobject]@{Path=$relative;Sha256=(Get-FileHash -LiteralPath (Join-Path $PSScriptRoot ('../'+$relative)) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()}
}
}
function Initialize-WelaNamedRecoveryNative {
$path=Join-Path $PSScriptRoot 'NamedRegistryRecoveryNative.cs'
$bytes=[IO.File]::ReadAllBytes($path);$hash=Get-WelaRecoveryHash $bytes
if ('Wela.NamedRegistryRecovery.Key' -as [type]) {
if ([Wela.NamedRegistryRecovery.Key]::SourceSha256 -cne $hash) {throw 'Loaded named-registry native source differs; start a fresh process.'}
return
}
$source=(New-Object Text.UTF8Encoding($false,$true)).GetString($bytes).Replace('__WELA_SOURCE_SHA256__',$hash)
Add-Type -TypeDefinition $source -ErrorAction Stop
}
function Assert-WelaNamedRecoveryValue {
param($State)
if ($State.KeyExists -isnot [bool] -or $State.ValueExists -isnot [bool]) {throw 'Logging registry state requires typed existence flags.'}
if ($State.ValueExists) {
if (-not $State.KeyExists -or $State.Type -cne 'DWord' -or ($State.Value -isnot [int] -and $State.Value -isnot [long]) -or $State.Value -notin @(0,1)) {throw 'Only prior DWORD 0/1 or value absence is supported.'}
} elseif ($null -ne $State.Value -or $null -ne $State.Type) {throw 'Absent logging value has inconsistent state.'}
}
function Get-WelaNamedRecoveryGuard {
param($Observation)
[pscustomobject][ordered]@{ObjectName=$Observation.ObjectName;OtherValues=$Observation.OtherValues;Children=$Observation.Children;Security=$Observation.Security}
}
function Get-WelaNamedRecoveryState {
param($Observation)
[pscustomobject]@{KeyExists=$true;ValueExists=[bool]$Observation.Exists;Value=$(if ($Observation.Exists) {[int]$Observation.Value} else {$null});Type=$(if ($Observation.Exists) {'DWord'} else {$null})}
}
function Open-WelaNamedRecoveryKey {
param($Target,[bool]$Write=$false)
$known=@(Get-WelaNamedRecoveryCatalog | Where-Object {$_.Path -ceq $Target.Path -and $_.Name -ceq $Target.Name})
if ($known.Count -ne 1) {throw 'Unknown logging recovery target.'}
Initialize-WelaNamedRecoveryNative
[Wela.NamedRegistryRecovery.Key]::new($Target.Path,$Write)
}
function Get-WelaNamedRecoveryObservation {
param($Target)
$key=Open-WelaNamedRecoveryKey $Target
try {
$observation=$key.Read($Target.Name)
if ($observation.ObjectName -ine ('\REGISTRY\MACHINE\'+$Target.Path.Substring(6))) {throw 'Native registry name does not match the selected path.'}
$observation
} finally {$key.Dispose()}
}
function Assert-WelaNamedRecoveryGuard {
param($Control,$Observation)
if ((Get-WelaRecoveryKey (Get-WelaNamedRecoveryGuard $Observation)) -cne (Get-WelaRecoveryKey $Control.RegistryGuard)) {throw 'Logging registry path, other values, children or security changed since planning.'}
}
function Set-WelaNamedRecoveryValue {
param($Control)
$key=Open-WelaNamedRecoveryKey $Control.Target $true
try {
$before=$key.Read($Control.Target.Name)
Assert-WelaNamedRecoveryGuard $Control $before
if ((Get-WelaRecoveryKey (Get-WelaNamedRecoveryState $before)) -cne (Get-WelaRecoveryKey $Control.Expected)) {throw 'Logging value changed before recovery.'}
$value=if ($Control.RecoverTo.ValueExists) {[int]$Control.RecoverTo.Value} else {0}
$after=$key.Restore($Control.Target.Name,$before,$Control.RecoverTo.ValueExists,$value)
Assert-WelaNamedRecoveryGuard $Control $after
# Reopen the selected path after the handle-based write to detect visible path drift.
$fresh=Get-WelaNamedRecoveryObservation $Control.Target
Assert-WelaNamedRecoveryGuard $Control $fresh
if ((Get-WelaRecoveryKey (Get-WelaNamedRecoveryState $fresh)) -cne (Get-WelaRecoveryKey $Control.RecoverTo)) {throw 'Reopened logging value differs after recovery.'}
} finally {$key.Dispose()}
}
+89
View File
@@ -0,0 +1,89 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
namespace Wela.NamedRegistryRecovery {
public sealed class Observation {
public bool Exists; public int Value; public string ObjectName, OtherValues, Children, Security, LastWrite;
}
public sealed class Key : IDisposable {
public const string SourceSha256 = "__WELA_SOURCE_SHA256__";
IntPtr handle;
[DllImport("advapi32.dll",CharSet=CharSet.Unicode)] static extern int RegOpenKeyEx(IntPtr key,string sub,uint options,uint access,out IntPtr result);
[DllImport("advapi32.dll")] static extern int RegCloseKey(IntPtr key);
[DllImport("advapi32.dll",CharSet=CharSet.Unicode)] static extern int RegQueryValueEx(IntPtr key,string name,IntPtr reserved,out uint type,byte[] data,ref uint size);
[DllImport("advapi32.dll",CharSet=CharSet.Unicode)] static extern int RegEnumValue(IntPtr key,uint index,StringBuilder name,ref uint nameLength,IntPtr reserved,out uint type,byte[] data,ref uint size);
[DllImport("advapi32.dll",CharSet=CharSet.Unicode)] static extern int RegEnumKeyEx(IntPtr key,uint index,StringBuilder name,ref uint nameLength,IntPtr reserved,IntPtr cls,IntPtr clsLength,out long lastWrite);
[DllImport("advapi32.dll",CharSet=CharSet.Unicode)] static extern int RegQueryInfoKey(IntPtr key,IntPtr cls,IntPtr clsLength,IntPtr reserved,out uint subkeys,IntPtr maxSub,IntPtr maxClass,out uint values,IntPtr maxName,IntPtr maxValue,IntPtr security,out long lastWrite);
[DllImport("advapi32.dll")] static extern int RegGetKeySecurity(IntPtr key,uint information,byte[] descriptor,ref uint size);
[DllImport("advapi32.dll",CharSet=CharSet.Unicode)] static extern int RegSetValueEx(IntPtr key,string name,int reserved,uint type,byte[] data,uint size);
[DllImport("advapi32.dll",CharSet=CharSet.Unicode)] static extern int RegDeleteValue(IntPtr key,string name);
[DllImport("ntdll.dll")] static extern int NtQueryKey(IntPtr key,int informationClass,byte[] information,int length,out int resultLength);
static void Check(int error){if(error!=0)throw new Win32Exception(error);}
static string Hash(byte[] bytes){using(var sha=SHA256.Create())return BitConverter.ToString(sha.ComputeHash(bytes)).Replace("-","").ToLowerInvariant();}
static string HashStrings(List<string> values){values.Sort(StringComparer.Ordinal);return Hash(Encoding.UTF8.GetBytes(String.Join("\n",values.ToArray())));}
static string Enc(string value){return Convert.ToBase64String(Encoding.UTF8.GetBytes(value));}
public Key(string path,bool write) {
if(!Environment.Is64BitProcess || !path.StartsWith("HKLM:\\SOFTWARE\\",StringComparison.Ordinal) || path.IndexOfAny(new char[]{'/', '*','?','\0'})>=0)throw new InvalidOperationException("Only reviewed native HKLM SOFTWARE paths are supported.");
string[] parts=path.Substring(6).Split('\\');IntPtr parent=new IntPtr(unchecked((int)0x80000002));bool owned=false;
try {
for(int i=0;i<parts.Length;i++) {
if(parts[i].Length==0 || parts[i]=="." || parts[i]=="..")throw new InvalidOperationException("Ambiguous registry path.");
IntPtr next;Check(RegOpenKeyEx(parent,parts[i],8,0x20119U | ((write && i==parts.Length-1)?2U:0U),out next));
if(owned)RegCloseKey(parent);parent=next;owned=true;
uint type,size=0;int error=RegQueryValueEx(parent,"SymbolicLinkValue",IntPtr.Zero,out type,null,ref size);
if(error!=0 && error!=2 && error!=234)Check(error);
if((error==0 || error==234) && type==6)throw new InvalidOperationException("Registry links are unsupported.");
}
handle=parent;owned=false;
} finally {if(owned)RegCloseKey(parent);}
}
string Name() {
int required;int status=NtQueryKey(handle,3,null,0,out required);
if(status!=unchecked((int)0xC0000023) && status!=unchecked((int)0x80000005))throw new InvalidOperationException("Cannot size native registry identity: "+status);
if(required<4 || required>65536)throw new InvalidOperationException("Native registry name bound exceeded.");
byte[] bytes=new byte[required];status=NtQueryKey(handle,3,bytes,bytes.Length,out required);
if(status!=0)throw new InvalidOperationException("Cannot read native registry identity: "+status);
int length=BitConverter.ToInt32(bytes,0);if(length<0 || length>bytes.Length-4 || (length%2)!=0)throw new InvalidOperationException("Invalid native registry name.");
return Encoding.Unicode.GetString(bytes,4,length);
}
public Observation Read(string selected) {
var result=new Observation();result.ObjectName=Name();
uint subkeys,values;long time;Check(RegQueryInfoKey(handle,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,out subkeys,IntPtr.Zero,IntPtr.Zero,out values,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,out time));
if(subkeys>256 || values>256)throw new InvalidOperationException("Registry inventory exceeds 256 children/values.");result.LastWrite=time.ToString(System.Globalization.CultureInfo.InvariantCulture);
var other=new List<string>();long total=0;
for(uint i=0;i<values;i++) {
var name=new StringBuilder(16384);uint nameLength=16384,type,size=65536;byte[] data=new byte[size];Check(RegEnumValue(handle,i,name,ref nameLength,IntPtr.Zero,out type,data,ref size));
total+=size;if(total>1048576)throw new InvalidOperationException("Registry value inventory exceeds one MiB.");Array.Resize(ref data,(int)size);
if(String.Equals(name.ToString(),selected,StringComparison.OrdinalIgnoreCase)) {
if(name.ToString()!=selected || type!=4 || size!=4)throw new InvalidOperationException("Selected logging value has an unknown name/type/length.");
uint value=BitConverter.ToUInt32(data,0);if(value>1)throw new InvalidOperationException("Selected logging DWORD is outside 0/1.");result.Exists=true;result.Value=(int)value;
} else other.Add(Enc(name.ToString())+"|"+type+"|"+size+"|"+Hash(data));
}
result.OtherValues=HashStrings(other);
var children=new List<string>();
for(uint i=0;i<subkeys;i++){var name=new StringBuilder(256);uint length=256;long childTime;Check(RegEnumKeyEx(handle,i,name,ref length,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,out childTime));children.Add(Enc(name.ToString()));}
result.Children=HashStrings(children);
uint securitySize=0;int code=RegGetKeySecurity(handle,7,null,ref securitySize);if(code!=122)Check(code);
if(securitySize<20 || securitySize>65536)throw new InvalidOperationException("Registry security descriptor size is unsupported.");
byte[] security=new byte[securitySize];Check(RegGetKeySecurity(handle,7,security,ref securitySize));Array.Resize(ref security,(int)securitySize);result.Security=Hash(security);
uint endSubkeys,endValues;long endTime;Check(RegQueryInfoKey(handle,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,out endSubkeys,IntPtr.Zero,IntPtr.Zero,out endValues,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,out endTime));
if(endTime!=time || endSubkeys!=subkeys || endValues!=values || result.ObjectName!=Name())throw new InvalidOperationException("Registry key changed during bounded observation.");
return result;
}
public static bool Preserved(Observation a,Observation b){return a.ObjectName==b.ObjectName && a.OtherValues==b.OtherValues && a.Children==b.Children && a.Security==b.Security;}
public Observation Restore(string name,Observation expected,bool exists,int value) {
if(value<0 || value>1)throw new InvalidOperationException("Unknown recovery value.");
Observation before=Read(name);
if(!Preserved(before,expected) || before.LastWrite!=expected.LastWrite || before.Exists!=expected.Exists || (before.Exists && before.Value!=expected.Value))throw new InvalidOperationException("Registry guard changed before value-only recovery.");
if(exists)Check(RegSetValueEx(handle,name,0,4,BitConverter.GetBytes(value),4));else Check(RegDeleteValue(handle,name));
Observation after=Read(name);
if(!Preserved(before,after) || after.Exists!=exists || (exists && after.Value!=value))throw new InvalidOperationException("Registry recovery readback or preservation failed.");
return after;
}
public void Dispose(){if(handle!=IntPtr.Zero){RegCloseKey(handle);handle=IntPtr.Zero;}}
}
}
+214
View File
@@ -0,0 +1,214 @@
# Fixed native automatic-transcription evidence; never provisions a destination or changes policy.
function Initialize-WelaTranscriptProbe {
if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'transcript-probe requires native 64-bit Windows.'}
$bytes=[IO.File]::ReadAllBytes((Join-Path $PSScriptRoot 'TranscriptProbeNative.cs'));$hash=Get-WelaArrivalHash $bytes
if(-not ('Wela.TranscriptProbe.Item' -as [type])){Add-Type -TypeDefinition ([Text.UTF8Encoding]::new($false,$true).GetString($bytes).Replace('__WELA_TRANSCRIPT_SOURCE_SHA256__',$hash)) -ErrorAction Stop}
if([Wela.TranscriptProbe.Item]::SourceSha256 -cne $hash){throw 'Loaded transcript helper differs from source; start a fresh PowerShell process.'}
Initialize-WelaWmiProbeNative
}
function Get-WelaTranscriptProbeKey {param($Value) ConvertTo-Json -InputObject $Value -Depth 20 -Compress}
function Get-WelaTranscriptProbeSources {
$result=[ordered]@{}
foreach($name in @('WELA.ps1','scripts/TranscriptProbe.ps1','scripts/TranscriptProbeWorker.ps1','scripts/TranscriptProbeNative.cs','scripts/PowerShellTranscription.ps1','scripts/WmiProbe.ps1','scripts/WmiProbeNative.cs','scripts/ChannelRead.ps1','scripts/WefArrival.ps1','modules/AuditProfiles.psm1')){$result[$name]=(Get-FileHash -LiteralPath (Join-Path $script:ScriptRoot $name) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()}
[pscustomobject]$result
}
function Get-WelaTranscriptProbeObjectKey {
param($Observation,[switch]$Directory)
$value=[ordered]@{Path=$Observation.Path;Identity=$Observation.Identity;CreatedUtc=$Observation.CreatedUtc;Attributes=$Observation.Attributes;Descriptor=$Observation.Descriptor}
if(-not $Directory){$value.Length=$Observation.Length;$value.WrittenUtc=$Observation.WrittenUtc;$value.Links=$Observation.Links}
Get-WelaTranscriptProbeKey ([pscustomobject]$value)
}
function Assert-WelaTranscriptProbePolicy {
param([array]$Policy,[string]$Directory)
Test-WelaTranscriptSharedPolicy $Policy
if($Policy.Count -ne 2 -or $Policy[0].View -cne 'Registry64' -or $Policy[1].View -cne 'Registry32'){throw 'Both canonical shared policy views are required.'}
foreach($view in $Policy){
$machine=$view.Machine
if(-not $machine.EnableTranscripting.ValueExists -or $machine.EnableTranscripting.Type -cne 'DWord' -or $machine.EnableTranscripting.Value -ne 1 -or -not $machine.OutputDirectory.ValueExists -or $machine.OutputDirectory.Type -cne 'String' -or $machine.OutputDirectory.Value -isnot [string]){throw 'An already enabled machine transcription policy with explicit literal output is required.'}
$path=Resolve-WelaArrivalPath $machine.OutputDirectory.Value
if(-not $path.Equals($Directory,[StringComparison]::OrdinalIgnoreCase)){throw 'Selected destination does not match the current machine transcription policy.'}
foreach($hive in @('Machine','CurrentUser')){
foreach($name in @('EnableTranscripting','EnableInvocationHeader')){$value=$view.$hive.$name;if($value.ValueExists -and ($value.Type -cne 'DWord' -or $value.Value -notin @(0,1))){throw 'Unknown typed transcription policy value.'}}
$value=$view.$hive.OutputDirectory;if($value.ValueExists -and ($value.Type -cne 'String' -or $value.Value -isnot [string])){throw 'Unknown transcription destination value type.'}
}
}
}
function Get-WelaTranscriptProbeState {
param([string]$Directory,$Handle)
if((Get-Service Winmgmt -ErrorAction Stop).Status -ne 'Running'){throw 'Winmgmt must already be running for host observations; no service is started.'}
$capability=Get-WelaTranscriptCapability;if($capability.Status -cne 'Supported'){throw $capability.Diagnostic}
$engine=Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::Windows)) 'System32\WindowsPowerShell\v1.0\powershell.exe'
$engine=Resolve-WelaArrivalPath $engine
$policy=@(Get-WelaTranscriptPolicy @('Registry64','Registry32'));Assert-WelaTranscriptProbePolicy $policy $Directory
[pscustomobject][ordered]@{Host=(Get-WelaChannelReadHost);Engine=$engine;EngineHash=(Get-FileHash -LiteralPath $engine -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant();InstalledVersion=$capability.EngineVersion;Policy=$policy;Directory=$Handle.Snapshot();TimeZone=[TimeZoneInfo]::Local.Id;OffsetMinutes=[DateTimeOffset]::Now.Offset.TotalMinutes;Sources=(Get-WelaTranscriptProbeSources)}
}
function Get-WelaTranscriptProbeStateKey {
param($State)
Get-WelaTranscriptProbeKey ([pscustomobject][ordered]@{Host=$State.Host;Engine=$State.Engine;EngineHash=$State.EngineHash;InstalledVersion=$State.InstalledVersion;Policy=$State.Policy;Directory=(Get-WelaTranscriptProbeObjectKey $State.Directory -Directory);TimeZone=$State.TimeZone;OffsetMinutes=$State.OffsetMinutes;Sources=$State.Sources})
}
function Get-WelaTranscriptProbeInventory {
param([string]$Directory,[string[]]$Dates)
$folders=@();$files=@()
foreach($date in $Dates){
if($date -cnotmatch '^\d{8}$'){throw 'Invalid bounded transcript date scope.'}
$path=Join-Path $Directory $date
if(-not [IO.Directory]::Exists($path)){if(Test-Path -LiteralPath $path){throw 'Expected date folder is not a directory.'};$folders+=[pscustomobject]@{Date=$date;Exists=$false;Observation=$null};continue}
$null=Resolve-WelaArrivalPath $path;$handle=[Wela.TranscriptProbe.Item]::Directory($path)
try{
$observation=$handle.Snapshot();$folders+=[pscustomobject]@{Date=$date;Exists=$true;Observation=$observation}
foreach($entry in [IO.Directory]::EnumerateFileSystemEntries($path)){
if($files.Count -ge 256){throw 'Current-date inventory reached its 256-entry limit.'}
$item=Get-Item -LiteralPath $entry -Force -ErrorAction Stop
if($item -isnot [IO.FileInfo] -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)){throw 'Unexpected directory or reparse entry in the current-date scope.'}
$file=[Wela.TranscriptProbe.Item]::Metadata($entry)
try{$files+=$file.Snapshot()}finally{$file.Dispose()}
}
if((Get-WelaTranscriptProbeObjectKey $handle.Snapshot() -Directory) -cne (Get-WelaTranscriptProbeObjectKey $observation -Directory)){throw 'Date-directory identity or descriptor changed during enumeration.'}
}finally{$handle.Dispose()}
}
[pscustomobject]@{Dates=$Dates;Folders=$folders;Files=@($files|Sort-Object Path)}
}
function Assert-WelaTranscriptProbeInventory {
param($Before,$After)
foreach($folder in $Before.Folders|Where-Object Exists){
$match=@($After.Folders|Where-Object Date -eq $folder.Date)
if($match.Count -ne 1 -or -not $match[0].Exists -or (Get-WelaTranscriptProbeObjectKey $folder.Observation -Directory) -cne (Get-WelaTranscriptProbeObjectKey $match[0].Observation -Directory)){throw 'An existing date directory changed or disappeared.'}
}
foreach($file in $Before.Files){
$match=@($After.Files|Where-Object Path -eq $file.Path)
# Existing sessions can append to their transcripts. Their bytes are never read.
if($match.Count -ne 1 -or $match[0].Identity -cne $file.Identity -or $match[0].CreatedUtc -cne $file.CreatedUtc -or $match[0].Descriptor -cne $file.Descriptor){throw 'An existing transcript was replaced, removed or had its descriptor changed.'}
}
}
function Start-WelaTranscriptProbeWorker {
param($State,[string]$Nonce,$ParentToken,$LaunchEvidence)
$worker=Join-Path $PSScriptRoot 'TranscriptProbeWorker.ps1'
$arguments=@('-NoLogo','-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-File',$worker,'-Nonce',$Nonce)
$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=[Text.UTF8Encoding]::new($false,$true)
$process=[Diagnostics.Process]::new();$process.StartInfo=$info;$started=$false;$launched=[DateTime]::UtcNow
try{
if(-not $process.Start()){throw 'Fixed transcript worker did not start.'};$started=$true
$LaunchEvidence.ProcessId=$process.Id;$LaunchEvidence.LaunchedUtc=$launched.ToString('o')
$stdout=[Wela.TranscriptProbe.Item]::Drain($process.StandardOutput,65536);$stderr=[Wela.TranscriptProbe.Item]::Drain($process.StandardError,65536)
if(-not $process.WaitForExit(30000)){throw 'Fixed transcript worker exceeded thirty seconds.'}
$exited=[DateTime]::UtcNow;$LaunchEvidence.ExitedUtc=$exited.ToString('o');$LaunchEvidence.ExitCode=$process.ExitCode
if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),3000)){throw 'Worker output pipes did not close within their bound.'}
$LaunchEvidence.Stdout=$stdout.Result;$LaunchEvidence.Stderr=$stderr.Result
if($stdout.Result.Exceeded -or $stderr.Result.Exceeded -or $stdout.Result.Error -or $stderr.Result.Error){throw 'Worker output is oversized or incomplete.'}
if($process.ExitCode -ne 0 -or $stderr.Result.Text){throw ('Fixed native5.1 worker failed; exit '+$process.ExitCode+'. No transcript fallback was attempted.')}
$lines=@(($stdout.Result.Text -replace "`r`n","`n").TrimEnd("`r","`n") -split "`n")
$json=@($lines|Where-Object{$_ -clike 'WELA-WORKER-JSON:*'})
if($lines.Count -ne 3 -or $json.Count -ne 1){throw 'Unexpected worker output framing.'}
$operation=ConvertFrom-WelaArrivalJson $json[0].Substring('WELA-WORKER-JSON:'.Length)
if($operation.Nonce -cne $Nonce -or $operation.ProcessId -ne $process.Id -or $operation.Engine -ine $State.Engine -or $operation.Edition -cne 'Desktop' -or $operation.EngineVersion -cnotmatch '^5\.1\.\d+\.\d+$'){throw 'Fixed worker engine/identity response differs.'}
$actualArgs=@($operation.Arguments|Select-Object -Skip 1)
if((Get-WelaTranscriptProbeKey $actualArgs) -cne (Get-WelaTranscriptProbeKey $arguments) -or $operation.Arguments[0] -ine $State.Engine -or $operation.HeaderCommandLine -cne ($operation.Arguments -join ' ')){throw 'Worker command arguments differ from the fixed launch.'}
if(@($lines|Where-Object{$_ -ceq ('WELA-TRANSCRIPT-BEGIN:'+${Nonce}+':'+$process.Id)}).Count -ne 1 -or @($lines|Where-Object{$_ -ceq ('WELA-TRANSCRIPT-END:'+${Nonce}+':'+$process.Id)}).Count -ne 1){throw 'Fixed worker output markers are missing or ambiguous.'}
if((Get-WelaWmiProbeTokenKey $operation.BeforeToken -AuthorizationOnly) -cne (Get-WelaWmiProbeTokenKey $ParentToken -AuthorizationOnly) -or (Get-WelaWmiProbeTokenKey $operation.BeforeToken) -cne (Get-WelaWmiProbeTokenKey $operation.AfterToken)){throw 'Worker identity/logon/group attributes differ from the parent or changed during output.'}
if((Get-WelaTranscriptProbeKey $operation.PolicyBefore) -cne (Get-WelaTranscriptProbeKey $State.Policy) -or (Get-WelaTranscriptProbeKey $operation.PolicyAfter) -cne (Get-WelaTranscriptProbeKey $State.Policy)){throw 'Native worker policy differs from the observed policy.'}
$begin=ConvertTo-WelaArrivalUtc $operation.StartedUtc;$end=ConvertTo-WelaArrivalUtc $operation.CompletedUtc
if($begin -lt $launched -or $end -lt $begin -or $end -gt $exited -or $operation.StartOffsetMinutes -ne $State.OffsetMinutes -or $operation.EndOffsetMinutes -ne $State.OffsetMinutes -or $operation.Computer -ine $State.Host.Computer -or $operation.HeaderUser -ine $operation.BeforeToken.Name){throw 'Worker time, time-zone or host context differs.'}
$assembly=Resolve-WelaArrivalPath $operation.Assembly.Path
$windows=[Environment]::GetFolderPath([Environment+SpecialFolder]::Windows).TrimEnd('\')+'\'
if(-not $assembly.StartsWith($windows,[StringComparison]::OrdinalIgnoreCase) -or [IO.Path]::GetFileName($assembly) -ine 'System.Management.Automation.dll' -or $operation.Assembly.FullName -cnotlike 'System.Management.Automation, Version=3.0.0.0,*' -or (Get-FileHash -LiteralPath $assembly -Algorithm SHA256).Hash.ToLowerInvariant() -cne $operation.Assembly.Sha256){throw 'Native worker assembly evidence differs.'}
$operation|Add-Member NoteProperty LaunchedUtc $launched.ToString('o');$operation|Add-Member NoteProperty ExitedUtc $exited.ToString('o')
$operation
}finally{try{if($started -and -not $process.HasExited){$process.Kill();if(-not $process.WaitForExit(3000)){throw 'Fixed worker termination was not confirmed.'}}}finally{$process.Dispose()}}
}
function ConvertFrom-WelaTranscriptProbeBytes {
param([byte[]]$Bytes)
$offset=0;$encoding=[Text.UTF8Encoding]::new($false,$true)
if($Bytes.Length -ge 3 -and $Bytes[0] -eq 239 -and $Bytes[1] -eq 187 -and $Bytes[2] -eq 191){$offset=3}
elseif($Bytes.Length -ge 2 -and $Bytes[0] -eq 255 -and $Bytes[1] -eq 254){$offset=2;$encoding=[Text.UnicodeEncoding]::new($false,$true,$true)}
elseif($Bytes.Length -ge 2 -and $Bytes[0] -eq 254 -and $Bytes[1] -eq 255){$offset=2;$encoding=[Text.UnicodeEncoding]::new($true,$true,$true)}
$text=$encoding.GetString($Bytes,$offset,$Bytes.Length-$offset)
if($text.Contains([string][char]0)){throw 'Transcript contains embedded NUL characters.'}
$text -replace "`r`n","`n"
}
function Test-WelaTranscriptProbeText {
param([string]$Text,$Operation)
$header=($Operation.Resources.TranscriptPrologue -replace "`r`n","`n").TrimEnd("`r","`n")
$footer=($Operation.Resources.TranscriptEpilogue -replace "`r`n","`n").TrimEnd("`r","`n")
if(-not $header -or -not $footer -or $header.Length -gt 8192 -or $footer.Length -gt 8192){throw 'Unknown native transcript resource templates.'}
$pattern=[regex]::Escape($header);$tail=[regex]::Escape($footer)
$fields=[ordered]@{'{0:yyyyMMddHHmmss}'='(?<Start>\d{14})';'{1}'='(?<User>[^\n]{1,512})';'{2}'='(?<RunAs>[^\n]{1,512})';'{3}'='(?<Configuration>[^\n]{0,512})';'{4}'='(?<Machine>[^\n]{1,255})';'{5}'='(?<OS>[^\n]{1,512})';'{6}'='(?<Command>[^\n]{1,4096})';'{7}'='(?<Pid>\d{1,10})';'{8}'='(?<Versions>[\s\S]{1,8192}?)'}
foreach($key in $fields.Keys){$escaped=[regex]::Escape($key);if(-not $pattern.Contains($escaped)){throw 'Unrecognized native transcript prologue schema.'};$pattern=$pattern.Replace($escaped,$fields[$key])}
$tail=$tail.Replace([regex]::Escape('{0:yyyyMMddHHmmss}'),'(?<End>\d{14})')
$match=[regex]::Match($Text,'\A'+$pattern+'\n(?<Body>[\s\S]*?)\n'+$tail+'\n*\z',[Text.RegularExpressions.RegexOptions]::CultureInvariant,[TimeSpan]::FromSeconds(1))
if(-not $match.Success){return $false}
foreach($template in @($header,$footer)){$prefix=(@($template -split "`n"|Select-Object -First 2) -join "`n");if([regex]::Matches($Text,[regex]::Escape($prefix)).Count -ne 1){return $false}}
if($match.Groups['User'].Value -ine $Operation.HeaderUser -or $match.Groups['RunAs'].Value -ine $Operation.BeforeToken.Name -or $match.Groups['Configuration'].Value -cne '' -or $match.Groups['Machine'].Value -ine $Operation.Computer -or $match.Groups['OS'].Value -cne $Operation.OsVersion -or $match.Groups['Command'].Value -cne $Operation.HeaderCommandLine -or [long]$match.Groups['Pid'].Value -ne $Operation.ProcessId){return $false}
$versions=@($match.Groups['Versions'].Value -split "`n")
if(@($versions|Where-Object{$_ -ceq ('PSVersion: '+$Operation.EngineVersion)}).Count -ne 1 -or @($versions|Where-Object{$_ -ceq 'PSEdition: Desktop'}).Count -ne 1){return $false}
$body=@($match.Groups['Body'].Value -split "`n");$begin='WELA-TRANSCRIPT-BEGIN:'+$Operation.Nonce+':'+$Operation.ProcessId;$end='WELA-TRANSCRIPT-END:'+$Operation.Nonce+':'+$Operation.ProcessId
if(@($body|Where-Object{$_ -ceq $begin}).Count -ne 1 -or @($body|Where-Object{$_ -ceq $end}).Count -ne 1 -or [Array]::IndexOf($body,$begin) -ge [Array]::IndexOf($body,$end)){return $false}
$offset=[TimeSpan]::FromMinutes($Operation.StartOffsetMinutes)
$first=[DateTimeOffset]::new([DateTime]::ParseExact($match.Groups['Start'].Value,'yyyyMMddHHmmss',[Globalization.CultureInfo]::InvariantCulture),$offset)
$last=[DateTimeOffset]::new([DateTime]::ParseExact($match.Groups['End'].Value,'yyyyMMddHHmmss',[Globalization.CultureInfo]::InvariantCulture),$offset)
return $first -ge (ConvertTo-WelaArrivalUtc $Operation.LaunchedUtc).AddSeconds(-1) -and $first -le (ConvertTo-WelaArrivalUtc $Operation.StartedUtc).AddSeconds(1) -and $last -ge (ConvertTo-WelaArrivalUtc $Operation.CompletedUtc).AddSeconds(-1) -and $last -le (ConvertTo-WelaArrivalUtc $Operation.ExitedUtc).AddSeconds(1) -and $last -ge $first
}
function Write-WelaTranscriptProbeArtifact {
param([string]$Root,[string]$Name,[byte[]]$Bytes)
if($Bytes.Length -gt 4194304){throw 'Evidence artifact exceeds four MiB.'}
$stream=[IO.File]::Open((Join-Path $Root $Name),[IO.FileMode]::CreateNew,[IO.FileAccess]::ReadWrite,[IO.FileShare]::None)
try{$stream.Write($Bytes,0,$Bytes.Length);$stream.Flush($true);$stream.Position=0;$sha=[Security.Cryptography.SHA256]::Create();try{$hash=([BitConverter]::ToString($sha.ComputeHash($stream))).Replace('-','').ToLowerInvariant()}finally{$sha.Dispose()};if($hash -cne (Get-WelaArrivalHash $Bytes)){throw 'Written evidence bytes differ.'}}finally{$stream.Dispose()}
[pscustomobject]@{Name=$Name;Bytes=$Bytes.Length;Sha256=$hash}
}
function Invoke-WelaTranscriptProbe {
param([ValidateSet('Plan','Run')][string]$Action='Plan',[string]$Directory,[string]$OutputPath)
if(($Action -eq 'Run') -ne (-not [string]::IsNullOrWhiteSpace($OutputPath))){throw 'Run requires a new TranscriptProbeOutputPath; Plan starts no worker or explicit output.'}
if(-not $Directory){throw 'Select the existing local TranscriptProbeDirectory.'}
Initialize-WelaTranscriptProbe
$directoryPath=Resolve-WelaArrivalPath $Directory
if(-not [IO.Directory]::Exists($directoryPath)){throw 'Selected transcript directory must already exist.'}
$handle=[Wela.TranscriptProbe.Item]::Directory($directoryPath);$heldFiles=@();$heldFolders=@();$output=$null
try{
$state=Get-WelaTranscriptProbeState $directoryPath $handle;$stateKey=Get-WelaTranscriptProbeStateKey $state
$dates=@(-1,0,1|ForEach-Object{[DateTime]::Today.AddDays($_).ToString('yyyyMMdd',[Globalization.CultureInfo]::InvariantCulture)})
$before=Get-WelaTranscriptProbeInventory $directoryPath $dates
if($Action -eq 'Plan'){return [pscustomobject]@{SchemaVersion=1;Kind='WelaAutomaticTranscriptPlan';Action='Plan';ExitCode=0;Status='ReadyToProbe';State=$state;Inventory=$before;Token=[Wela.WmiProbe.Native]::Snapshot();ReadyRuleCredit=0;SigmaEvtxCredit=0;WriterAuthorization='Unverified';Scope='One new native Windows PowerShell5.1 automatic transcript under this local current identity only'}}
$output=New-WelaArrivalOutput $OutputPath $directoryPath
$report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaAutomaticTranscriptProbe';Action='Run';Status='Unverified';ExitCode=1;RecordedUtc=[DateTime]::UtcNow.ToString('o');Before=$state;After=$null;InventoryBefore=$before;InventoryAfter=$null;ParentBefore=$null;ParentAfter=$null;Worker=$null;WorkerLaunch=[pscustomobject]@{ProcessId=$null;LaunchedUtc=$null;ExitedUtc=$null;ExitCode=$null;Stdout=$null;Stderr=$null};Transcript=$null;Artifacts=@();Diagnostic='';OutputPath=$output;ReadyRuleCredit=0;SigmaEvtxCredit=0;ConfigurationChanges=0;WriterAuthorization='Unverified';PowerShell7Sessions='Not assessed';Collection='Not verified';Scope='One fixed native5.1 completed automatic text transcript; no retention, immutable-storage or EVTX/Sigma claim'}
try{
# Prepare metadata and evidence storage before capturing the actual process-token interval.
foreach($folder in $before.Folders|Where-Object Exists){$held=[Wela.TranscriptProbe.Item]::Directory($folder.Observation.Path);$heldFolders+= $held;if((Get-WelaTranscriptProbeObjectKey $held.Snapshot() -Directory) -cne (Get-WelaTranscriptProbeObjectKey $folder.Observation -Directory)){throw 'Date-directory changed before worker.'}}
$fresh=Get-WelaTranscriptProbeState $directoryPath $handle;if((Get-WelaTranscriptProbeStateKey $fresh) -cne $stateKey){throw 'Policy, destination, source or host changed before worker.'}
$inventory=Get-WelaTranscriptProbeInventory $directoryPath $dates
Assert-WelaTranscriptProbeInventory $before $inventory
# Newly created unrelated files during preparation become baseline, never candidate evidence.
$before=$inventory;$report.InventoryBefore=$before
$token=[Wela.WmiProbe.Native]::Snapshot();$report.ParentBefore=$token
$operation=Start-WelaTranscriptProbeWorker $state ([guid]::NewGuid().ToString('N')) $token $report.WorkerLaunch;$report.Worker=$operation
$after=Get-WelaTranscriptProbeInventory $directoryPath $dates;$report.InventoryAfter=$after;Assert-WelaTranscriptProbeInventory $before $after
foreach($folder in $after.Folders|Where-Object Exists){$held=[Wela.TranscriptProbe.Item]::Directory($folder.Observation.Path);$heldFolders+=$held;if((Get-WelaTranscriptProbeObjectKey $held.Snapshot() -Directory) -cne (Get-WelaTranscriptProbeObjectKey $folder.Observation -Directory)){throw 'Date directory changed after worker.'}}
$candidates=@($after.Files|Where-Object{$_.Identity -cnotin @($before.Files.Identity)})
if($candidates.Count -gt 32){throw 'Fresh transcript candidates exceed the 32-file bound.'}
$matches=@();$total=0
foreach($candidate in $candidates){
if([IO.Path]::GetFileName($candidate.Path) -cnotlike 'PowerShell_transcript*.txt'){throw 'Unexpected fresh file in the selected date scope.'}
if((ConvertTo-WelaArrivalUtc $candidate.CreatedUtc) -lt (ConvertTo-WelaArrivalUtc $operation.LaunchedUtc).AddSeconds(-2) -or (ConvertTo-WelaArrivalUtc $candidate.WrittenUtc) -gt (ConvertTo-WelaArrivalUtc $operation.ExitedUtc).AddSeconds(2)){throw 'Fresh transcript file timestamps are outside the worker interval.'}
$file=[Wela.TranscriptProbe.Item]::File($candidate.Path);$heldFiles+=$file
$observation=$file.Snapshot();if((Get-WelaTranscriptProbeObjectKey $observation) -cne (Get-WelaTranscriptProbeObjectKey $candidate)){throw 'Candidate identity or contents changed after enumeration.'}
$bytes=$file.Read(1048576);$total+=$bytes.Length;if($total -gt 4194304){throw 'Fresh transcript reads exceed four MiB.'}
$text=ConvertFrom-WelaTranscriptProbeBytes $bytes
if(Test-WelaTranscriptProbeText $text $operation){$matches+=[pscustomobject]@{Observation=$observation;Bytes=$bytes;Handle=$file}}
}
if($matches.Count -ne 1){throw ('Expected one fresh completed automatic transcript; matching files: '+$matches.Count+'. Writer authorization remains unverified.')}
$report.After=Get-WelaTranscriptProbeState $directoryPath $handle
if((Get-WelaTranscriptProbeStateKey $report.After) -cne $stateKey){throw 'Policy, destination, source or host changed during the probe.'}
$final=Get-WelaTranscriptProbeInventory $directoryPath $dates;Assert-WelaTranscriptProbeInventory $after $final
if((Get-WelaTranscriptProbeKey @($after.Files.Path)) -cne (Get-WelaTranscriptProbeKey @($final.Files.Path))){throw 'Candidate inventory changed before final verification.'}
if((Get-WelaTranscriptProbeObjectKey $matches[0].Handle.Snapshot()) -cne (Get-WelaTranscriptProbeObjectKey $matches[0].Observation)){throw 'Matching transcript changed before evidence capture.'}
$report.ParentAfter=[Wela.WmiProbe.Native]::Snapshot()
if((Get-WelaWmiProbeTokenKey $report.ParentBefore) -cne (Get-WelaWmiProbeTokenKey $report.ParentAfter)){throw 'Parent authorization context changed during the probe.'}
$report.Artifacts+=Write-WelaTranscriptProbeArtifact $output 'transcript.txt' $matches[0].Bytes
$report.Transcript=$matches[0].Observation;$report.Status='CompletedAutomaticTranscript';$report.WriterAuthorization='ObservedForThisChild';$report.ExitCode=0
}catch{$report.Diagnostic=$_.Exception.Message}
$report.Artifacts+=Write-WelaTranscriptProbeArtifact $output 'worker.json' ([Text.UTF8Encoding]::new($false).GetBytes((ConvertTo-Json -InputObject $report.Worker -Depth 18)))
$null=Write-WelaTranscriptProbeArtifact $output 'result.json' ([Text.UTF8Encoding]::new($false).GetBytes(($report|ConvertTo-Json -Depth 22)))
return $report
}finally{foreach($file in $heldFiles){$file.Dispose()};foreach($folder in $heldFolders){$folder.Dispose()};$handle.Dispose()}
}
+66
View File
@@ -0,0 +1,66 @@
// Read-only local identity/descriptor/file access and bounded pipe drains.
using System;
using System.ComponentModel;
using System.IO;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Win32.SafeHandles;
namespace Wela.TranscriptProbe {
public sealed class Observation {
public string Path, Identity, CreatedUtc, WrittenUtc, Descriptor;
public uint Attributes, Links; public long Length;
}
public sealed class Capture {public string Text, Error;public bool Exceeded;}
public sealed class Item : IDisposable {
[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)] static extern SafeFileHandle CreateFile(string path,uint access,uint share,IntPtr security,uint disposition,uint flags,IntPtr template);
[DllImport("kernel32.dll",SetLastError=true)] static extern bool GetFileInformationByHandle(SafeFileHandle handle,out Info value);
[DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern uint GetFinalPathNameByHandle(SafeFileHandle handle,StringBuilder text,uint length,uint flags);
[DllImport("advapi32.dll")] static extern uint GetSecurityInfo(SafeFileHandle 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("kernel32.dll")] static extern IntPtr LocalFree(IntPtr memory);
SafeFileHandle handle; FileStream stream; string path; bool directory;
public const string SourceSha256 = "__WELA_TRANSCRIPT_SOURCE_SHA256__";
Item(string path,bool directory,bool content) {
this.path=System.IO.Path.GetFullPath(path);this.directory=directory;
handle=CreateFile(this.path,content?0x80020000u:0x20080u,directory?3u:(content?1u:7u),IntPtr.Zero,3,0x02200000,IntPtr.Zero);
if(handle.IsInvalid){int error=Marshal.GetLastWin32Error();handle.Dispose();throw new Win32Exception(error);}
try {Snapshot();if(content)stream=new FileStream(handle,FileAccess.Read,4096,false);}catch{Dispose();throw;}
}
public static Item Directory(string path){return new Item(path,true,false);}
public static Item Metadata(string path){return new Item(path,false,false);}
public static Item File(string path){return new Item(path,false,true);}
public Observation Snapshot() {
Info value;if(!GetFileInformationByHandle(handle,out value))throw new Win32Exception(Marshal.GetLastWin32Error());
if((value.Attributes&1024)!=0||((value.Attributes&16)!=0)!=directory)throw new InvalidOperationException("Unexpected reparse point or object type.");
if(!directory&&value.Links!=1)throw new InvalidOperationException("Transcript files must have one link.");
StringBuilder buffer=new StringBuilder(32768);uint length=GetFinalPathNameByHandle(handle,buffer,(uint)buffer.Capacity,0);
if(length==0||length>=buffer.Capacity)throw new InvalidOperationException("Unknown native object path.");
string final=buffer.ToString();if(final.StartsWith(@"\\?\",StringComparison.Ordinal))final=final.Substring(4);
if(!String.Equals(final.TrimEnd('\\'),path.TrimEnd('\\'),StringComparison.OrdinalIgnoreCase))throw new InvalidOperationException("Native object path changed or resolves elsewhere.");
IntPtr owner,group,dacl,sacl,sd;uint error=GetSecurityInfo(handle,1,7,out owner,out group,out dacl,out sacl,out sd);
if(error!=0)throw new Win32Exception((int)error);
string descriptor;
try {uint size=GetSecurityDescriptorLength(sd);if(size<20||size>65536)throw new InvalidOperationException("Invalid descriptor bound.");byte[] bytes=new byte[size];Marshal.Copy(sd,bytes,0,bytes.Length);descriptor=Convert.ToBase64String(bytes);}finally{LocalFree(sd);}
return new Observation{Path=final,Identity=value.Volume.ToString("x8")+":"+value.IndexHigh.ToString("x8")+value.IndexLow.ToString("x8"),CreatedUtc=DateTime.FromFileTimeUtc(value.Created).ToString("o"),WrittenUtc=DateTime.FromFileTimeUtc(value.Written).ToString("o"),Attributes=value.Attributes,Links=value.Links,Length=((long)value.SizeHigh<<32)|value.SizeLow,Descriptor=descriptor};
}
public byte[] Read(int maximum) {
if(stream==null)throw new InvalidOperationException("Object was not opened for content.");
Observation before=Snapshot();if(before.Length<1||before.Length>maximum)throw new InvalidOperationException("Transcript is empty or exceeds its byte bound.");
byte[] bytes=new byte[(int)before.Length];stream.Position=0;int offset=0;
while(offset<bytes.Length){int read=stream.Read(bytes,offset,bytes.Length-offset);if(read==0)throw new EndOfStreamException();offset+=read;}
if(stream.ReadByte()!=-1)throw new InvalidOperationException("Transcript grew while reading.");
Observation after=Snapshot();if(before.Length!=after.Length||before.Identity!=after.Identity||before.WrittenUtc!=after.WrittenUtc||before.Descriptor!=after.Descriptor)throw new InvalidOperationException("Transcript changed while reading.");
return bytes;
}
public void Dispose(){if(stream!=null){stream.Dispose();stream=null;}if(handle!=null){handle.Dispose();handle=null;}}
public static Task<Capture> Drain(TextReader reader,int maximum) {
return Task.Factory.StartNew(()=>{Capture result=new Capture();StringBuilder text=new StringBuilder();char[] buffer=new char[2048];
try {int count;while((count=reader.Read(buffer,0,buffer.Length))>0){int retain=Math.Min(count,Math.Max(0,maximum-text.Length));if(retain<count)result.Exceeded=true;if(retain>0)text.Append(buffer,0,retain);}}
catch(Exception error){result.Error=error.GetType().FullName;}
result.Text=text.ToString();return result;});
}
}
}
+45
View File
@@ -0,0 +1,45 @@
# Fixed native5.1 worker. Automatic policy is the sole transcription producer.
param([Parameter(Mandatory)][ValidatePattern('^[a-f0-9]{32}$')][string]$Nonce)
$ErrorActionPreference='Stop'
[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)
if($PSVersionTable.PSEdition -cne 'Desktop' -or $PSVersionTable.PSVersion.Major -ne 5 -or $PSVersionTable.PSVersion.Minor -ne 1 -or -not [Environment]::Is64BitProcess){throw 'Native Windows PowerShell5.1 is required.'}
# A PowerShell7 parent can pass a PSModulePath without the native5.1 modules.
# Load only the fixed installed native modules, independent of caller module search paths.
foreach($module in @('Microsoft.PowerShell.Utility','Microsoft.PowerShell.Management')){
Import-Module ([IO.Path]::Combine($PSHOME,'Modules',$module,($module+'.psd1'))) -ErrorAction Stop
}
$script:ScriptRoot=Split-Path $PSScriptRoot -Parent
. (Join-Path $PSScriptRoot 'WmiProbe.ps1')
. (Join-Path $PSScriptRoot 'PowerShellTranscription.ps1')
Initialize-WelaWmiProbeNative
$assembly=[psobject].Assembly
$types=@($assembly.GetTypes()|Where-Object Name -eq 'InternalHostUserInterfaceStrings')
if($types.Count -ne 1){throw 'Native transcript resource type is unknown.'}
$resources=[ordered]@{}
foreach($name in @('TranscriptPrologue','TranscriptEpilogue')){
$property=$types[0].GetProperty($name,[Reflection.BindingFlags]'Public,NonPublic,Static')
if(-not $property){throw 'Native transcript resource is unavailable.'}
$value=$property.GetValue($null,$null)
if($value -isnot [string] -or $value.Length -gt 8192 -or -not $value.Contains('{0:yyyyMMddHHmmss}')){throw 'Unrecognized native transcript resource.'}
$resources[$name]=$value
}
$assemblyPath=$assembly.Location;$assemblyHash=(Get-FileHash -LiteralPath $assemblyPath -Algorithm SHA256).Hash.ToLowerInvariant()
$policyBefore=@(Get-WelaTranscriptPolicy @('Registry64','Registry32'));Test-WelaTranscriptSharedPolicy $policyBefore
$before=[Wela.WmiProbe.Native]::Snapshot();$start=[DateTimeOffset]::Now
Microsoft.PowerShell.Utility\Write-Output ('WELA-TRANSCRIPT-BEGIN:'+${Nonce}+':'+$PID)
$policyAfter=@(Get-WelaTranscriptPolicy @('Registry64','Registry32'))
if(($policyBefore|ConvertTo-Json -Depth 12 -Compress) -cne ($policyAfter|ConvertTo-Json -Depth 12 -Compress)){throw 'Worker policy changed.'}
if((Get-FileHash -LiteralPath $assemblyPath -Algorithm SHA256).Hash.ToLowerInvariant() -cne $assemblyHash){throw 'Worker engine assembly changed.'}
$after=[Wela.WmiProbe.Native]::Snapshot()
if((Get-WelaWmiProbeTokenKey $before) -cne (Get-WelaWmiProbeTokenKey $after)){throw 'Worker token changed.'}
Microsoft.PowerShell.Utility\Write-Output ('WELA-TRANSCRIPT-END:'+${Nonce}+':'+$PID)
$end=[DateTimeOffset]::Now
$operation=[pscustomobject][ordered]@{
Nonce=$Nonce;ProcessId=$PID;Engine=(Get-Process -Id $PID).Path;EngineVersion=$PSVersionTable.PSVersion.ToString();Edition=$PSVersionTable.PSEdition
StartedUtc=$start.UtcDateTime.ToString('o');CompletedUtc=$end.UtcDateTime.ToString('o');StartOffsetMinutes=$start.Offset.TotalMinutes;EndOffsetMinutes=$end.Offset.TotalMinutes
BeforeToken=$before;AfterToken=$after;PolicyBefore=$policyBefore;PolicyAfter=$policyAfter
Computer=[Environment]::MachineName;HeaderUser=([Environment]::UserDomainName+'\'+[Environment]::UserName);OsVersion=[Environment]::OSVersion.VersionString
CommandLine=[Environment]::CommandLine;HeaderCommandLine=([Environment]::GetCommandLineArgs() -join ' ');Arguments=@([Environment]::GetCommandLineArgs());UiCulture=[Globalization.CultureInfo]::CurrentUICulture.Name
Assembly=[pscustomobject]@{Path=$assemblyPath;FullName=$assembly.FullName;Sha256=$assemblyHash};Resources=[pscustomobject]$resources
}
[Console]::WriteLine('WELA-WORKER-JSON:'+($operation|ConvertTo-Json -Depth 16 -Compress))