mirror of
https://github.com/Yamato-Security/WELA.git
synced 2026-10-08 07:15:25 +02:00
Integrate reviewed logging and recovery base for WMI tree safeguards
This commit is contained in:
commit
66162232b6
46 files changed
+2449
-47
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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;}}}
|
||||
}
|
||||
}
|
||||
@@ -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()}
|
||||
}
|
||||
@@ -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;}}
|
||||
}
|
||||
}
|
||||
@@ -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()}
|
||||
}
|
||||
@@ -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;});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
Reference in new issue
Block a user