mirror of
https://github.com/Yamato-Security/WELA.git
synced 2026-10-23 22:53:00 +02:00
Integrate reviewed recovery and failed-logon commands
This commit is contained in:
commit
2f442af4da
27 files changed
+1585
-2
No files matched your search
@@ -0,0 +1,148 @@
|
||||
# Restore one completed profile size/mode write; never replay arbitrary wevtutil arguments.
|
||||
function Get-WelaEventRecoverySources {
|
||||
$sources=[ordered]@{}
|
||||
foreach($name in @('WELA.ps1','scripts/EventLogRecovery.ps1','scripts/EventLogConfiguration.ps1','modules/EventLogSettings.psm1','config/eventlog_profiles.json','scripts/Configuration.ps1','scripts/ControlApplicability.ps1','scripts/AuditRecovery.ps1','scripts/ChannelRead.ps1','scripts/ChannelReadNative.cs','scripts/WefArrival.ps1','scripts/WecUpdate.ps1','modules/AuditProfiles.psm1','scripts/CustomAuditProfiles.ps1')){
|
||||
$sources[$name]=(Get-FileHash -LiteralPath (Join-Path $script:ScriptRoot $name) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()
|
||||
}
|
||||
$sources|ConvertTo-Json -Compress
|
||||
}
|
||||
function Get-WelaEventRecoveryContext {
|
||||
foreach($name in @('Winmgmt','EventLog')){if((Get-Service -Name $name -ErrorAction Stop).Status -ne 'Running'){throw 'Native observation services must already be running.'}}
|
||||
$reader=Get-WelaChannelReader
|
||||
if(-not $reader.ElevatedAdministrator){throw 'An elevated native Windows operator is required.'}
|
||||
[pscustomobject][ordered]@{Host=(Get-WelaRecoveryHost);ReviewedHost=(Get-WelaChannelReadHost);Reader=[ordered]@{Sid=$reader.UserSid;Logon=$reader.AuthenticationId;Groups=$reader.GroupSids}}
|
||||
}
|
||||
function Read-WelaEventRecoveryChannel {
|
||||
param([string]$Log)
|
||||
$channel=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new($Log)
|
||||
try {
|
||||
if($channel.LogName -cne $Log -or [string]$channel.LogType -notin @('Administrative','Operational')){throw 'An exact administrative or operational channel is required.'}
|
||||
[pscustomobject][ordered]@{
|
||||
Log=$channel.LogName;MaximumSizeInBytes=[long]$channel.MaximumSizeInBytes;LogMode=[string]$channel.LogMode
|
||||
Guard=[ordered]@{IsEnabled=[bool]$channel.IsEnabled;LogType=[string]$channel.LogType;Isolation=[string]$channel.LogIsolation;Path=[string]$channel.LogFilePath;SecurityDescriptor=[string]$channel.SecurityDescriptor;Provider=[string]$channel.OwningProviderName;Classic=[bool]$channel.IsClassicLog}
|
||||
}
|
||||
}finally{$channel.Dispose()}
|
||||
}
|
||||
function Assert-WelaEventRecoveryText {
|
||||
param($Value,[string[]]$Names)
|
||||
foreach($name in $Names){if($Value.$name -isnot [string]){throw ('Missing or mistyped recovery text field: '+$name)}}
|
||||
}
|
||||
function Assert-WelaEventRecoveryState {
|
||||
param($State,[string]$Log)
|
||||
Assert-WelaEventRecoveryText $State @('Log','ReadStatus','Diagnostic','LogMode')
|
||||
if($State.Log -cne $Log -or $State.ReadStatus -cne 'Available' -or $State.Diagnostic -cne '' -or $State.IsEnabled -isnot [bool] -or
|
||||
($State.MaximumSizeInBytes -isnot [int] -and $State.MaximumSizeInBytes -isnot [long]) -or $State.MaximumSizeInBytes -lt 1048576 -or $State.MaximumSizeInBytes -gt 2199023255552 -or $State.MaximumSizeInBytes % 65536 -ne 0 -or $State.LogMode -cnotin @('Circular','Retain','AutoBackup')){throw 'Original channel state is unavailable, mistyped or unsupported.'}
|
||||
}
|
||||
function Get-WelaEventRecoveryPair {param($Value) [pscustomobject][ordered]@{MaximumSizeInBytes=[long]$Value.MaximumSizeInBytes;LogMode=[string]$Value.LogMode}}
|
||||
function Get-WelaEventRecoveryDefinition {
|
||||
param([string]$JournalPath,[string]$ResultsPath,[string]$Log)
|
||||
$catalog=Import-WelaEventLogProfiles
|
||||
if($Log -cnotin @($catalog.profiles.controls.log)){throw 'Select an exact channel in the bundled event-log profiles.'}
|
||||
$context=Get-WelaEventRecoveryContext
|
||||
$journal=Read-WelaWecUpdateFile $JournalPath;$resultFile=Read-WelaWecUpdateFile $ResultsPath
|
||||
$entries=@($journal.Text -split '\r?\n'|Where-Object {$_ -match '\S'}|ForEach-Object {ConvertFrom-WelaArrivalJson $_})
|
||||
if($entries.Count -lt 1 -or $entries.Count -gt 1024){throw 'Expected 1-1024 bounded journal entries.'}
|
||||
$result=ConvertFrom-WelaArrivalJson $resultFile.Text
|
||||
Assert-WelaEventRecoveryText $result @('Scope')
|
||||
if($result.DryRun -isnot [bool] -or $result.DryRun -or $result.Results -isnot [array] -or $result.Results.Count -gt 2048 -or $result.Scope -cnotin @('native-windows-configuration','event-log-size-and-mode-only')){throw 'Expected original non-dry-run event-log configuration results.'}
|
||||
$id='EventLog/'+$Log+'/ProfileSettings'
|
||||
$rows=@($result.Results|Where-Object Id -eq $id);$matching=@($entries|Where-Object Id -eq $id)
|
||||
if($rows.Count -ne 1 -or $matching.Count -ne 2){throw 'Exactly one result and its original/immediate-prewrite journal pair are required.'}
|
||||
$row=$rows[0];$initial=$matching[0];$fresh=$matching[1]
|
||||
Assert-WelaEventRecoveryText $row @('Status','Kind','Id')
|
||||
Assert-WelaEventRecoveryText $fresh @('Phase')
|
||||
if($initial.PSObject.Properties['Phase'] -or $fresh.Phase -cne 'ImmediatePreWrite' -or $row.Status -cne 'Applied' -or $row.Kind -cne 'EventLog' -or $row.Id -cne $id){throw 'Only completed Applied profile writes with ordered immediate-prewrite evidence are supported.'}
|
||||
foreach($entry in $matching){
|
||||
Assert-WelaEventRecoveryText $entry @('ComputerName','Kind','Id')
|
||||
if(($entry.Version -isnot [int] -and $entry.Version -isnot [long]) -or $entry.Version -ne 1 -or $entry.ComputerName -ine $context.Host.Computer -or $entry.Kind -cne 'EventLog' -or $entry.Id -cne $id){throw 'Unknown or wrong-host event-log journal.'}
|
||||
$time=ConvertTo-WelaArrivalUtc $entry.RecordedUtc;if($time -gt [DateTimeOffset]::UtcNow.AddMinutes(1)){throw 'Future journal timestamp.'}
|
||||
}
|
||||
if((ConvertTo-WelaArrivalUtc $fresh.RecordedUtc) -lt (ConvertTo-WelaArrivalUtc $initial.RecordedUtc)){throw 'Journal times are reversed.'}
|
||||
foreach($field in @('Before','Target','Desired')){if((Get-WelaRecoveryKey $initial.$field) -cne (Get-WelaRecoveryKey $row.$field)){throw "Original/result $field differs."}}
|
||||
Assert-WelaArrivalObject $initial.Target @('Log','Profile');Assert-WelaArrivalObject $fresh.Target @('Log')
|
||||
Assert-WelaEventRecoveryText $initial.Target @('Log','Profile');Assert-WelaEventRecoveryText $fresh.Target @('Log')
|
||||
if($initial.Target.Log -cne $Log -or $fresh.Target.Log -cne $Log -or $initial.Target.Profile -isnot [string]){throw 'Contradictory channel identity.'}
|
||||
$profile=Get-WelaEventLogProfile $initial.Target.Profile;$control=@($profile.controls|Where-Object log -ceq $Log)
|
||||
if($control.Count -ne 1){throw 'Channel is not selected by the original bundled profile.'}
|
||||
Assert-WelaArrivalObject $initial.Desired @('MaximumSizeInBytes','SizeMode','LogMode')
|
||||
Assert-WelaEventRecoveryText $initial.Desired @('SizeMode');Assert-WelaEventRecoveryText $fresh.Desired @('SizeMode')
|
||||
if($null -ne $initial.Desired.LogMode){Assert-WelaEventRecoveryText $initial.Desired @('LogMode')}
|
||||
if((Get-WelaRecoveryKey $initial.Desired) -cne (Get-WelaRecoveryKey $fresh.Desired) -or $initial.Desired.SizeMode -cnotin @('Exact','Minimum') -or ($null -ne $initial.Desired.LogMode -and $initial.Desired.LogMode -cne $control[0].mode) -or
|
||||
($initial.Desired.MaximumSizeInBytes -isnot [int] -and $initial.Desired.MaximumSizeInBytes -isnot [long]) -or $initial.Desired.MaximumSizeInBytes -ne (ConvertTo-WelaEventLogBytes $control[0].minimumBytes)){throw 'Desired configuration differs from the canonical profile operation.'}
|
||||
foreach($state in @($initial.Before,$fresh.Before,$row.After)){Assert-WelaEventRecoveryState $state $Log}
|
||||
if((Get-WelaRecoveryKey $fresh.Before) -cne (Get-WelaRecoveryKey $row.BeforeWrite)){throw 'Immediate prewrite and final BeforeWrite evidence differ.'}
|
||||
if($row.After.IsEnabled -ne $fresh.Before.IsEnabled){throw 'Channel enable state changed during original operation.'}
|
||||
$bytes=if($initial.Desired.SizeMode -ceq 'Exact'){$initial.Desired.MaximumSizeInBytes}else{[math]::Max([long]$fresh.Before.MaximumSizeInBytes,[long]$initial.Desired.MaximumSizeInBytes)}
|
||||
$mode=if($null -ne $initial.Desired.LogMode){$initial.Desired.LogMode}else{$fresh.Before.LogMode}
|
||||
if($row.After.MaximumSizeInBytes -ne $bytes -or $row.After.LogMode -cne $mode){throw 'Final state includes unexplained drift beyond the original size/mode write.'}
|
||||
$expected=Get-WelaEventRecoveryPair $row.After;$recover=Get-WelaEventRecoveryPair $fresh.Before
|
||||
if((Get-WelaRecoveryKey $expected) -ceq (Get-WelaRecoveryKey $recover)){throw 'No completed size/mode change exists to recover.'}
|
||||
[pscustomobject][ordered]@{
|
||||
Log=$Log;Profile=$profile.id;Journal=[ordered]@{Path=$journal.Path;Hash=$journal.Hash};OriginalResults=[ordered]@{Path=$resultFile.Path;Hash=$resultFile.Hash}
|
||||
Expected=$expected;RecoverTo=$recover;ExpectedEnabled=$row.After.IsEnabled
|
||||
RequiresShrinkConsent=($recover.MaximumSizeInBytes -lt $expected.MaximumSizeInBytes);RequiresModeConsent=($recover.LogMode -cne $expected.LogMode)
|
||||
HistoricalIdentity='Version1 records bind historical ComputerName only. Current host/logon and source hashes do not authenticate historical ownership or configuration.'
|
||||
}
|
||||
}
|
||||
function Assert-WelaEventRecoveryCurrent {
|
||||
param($Definition,$Observed,$Guard)
|
||||
if($Observed.Log -cne $Definition.Log -or $Observed.Guard.IsEnabled -ne $Definition.ExpectedEnabled -or
|
||||
(Get-WelaRecoveryKey (Get-WelaEventRecoveryPair $Observed)) -cne (Get-WelaRecoveryKey $Definition.Expected) -or
|
||||
($null -ne $Guard -and (Get-WelaRecoveryKey $Observed.Guard) -cne (Get-WelaRecoveryKey $Guard))){throw 'Current channel size, mode, identity or preserved properties differ from reviewed post-configuration state.'}
|
||||
}
|
||||
function Set-WelaEventRecoveryChannel {
|
||||
param($Definition)
|
||||
$arguments=@('sl',$Definition.Log)
|
||||
if($Definition.RecoverTo.MaximumSizeInBytes -ne $Definition.Expected.MaximumSizeInBytes){$arguments+='/ms:'+ $Definition.RecoverTo.MaximumSizeInBytes}
|
||||
if($Definition.RecoverTo.LogMode -cne $Definition.Expected.LogMode){
|
||||
switch($Definition.RecoverTo.LogMode){'Circular'{$arguments+=@('/rt:false','/ab:false')};'Retain'{$arguments+=@('/rt:true','/ab:false')};'AutoBackup'{$arguments+=@('/rt:true','/ab:true')};default{throw 'Unsupported recovery mode.'}}
|
||||
}
|
||||
if($arguments.Count -le 2){throw 'No fixed recovery argument was selected.'}
|
||||
$null=Invoke-WelaNative -FilePath (Join-Path ([Environment]::GetFolderPath('System')) 'wevtutil.exe') -Arguments $arguments
|
||||
}
|
||||
function Invoke-WelaEventLogRecovery {
|
||||
param([ValidateSet('Plan','Restore')][string]$Action='Plan',[string]$JournalPath,[string]$OriginalResultsPath,[string]$Log,[string]$PlanPath,[string]$PlanHash,[Parameter(Mandatory)][string]$OutputPath,[switch]$AllowShrink,[switch]$AllowRetentionChange)
|
||||
$ErrorActionPreference='Stop'
|
||||
if($Action -eq 'Plan'){
|
||||
if(-not $JournalPath -or -not $OriginalResultsPath -or -not $Log -or $PlanPath -or $PlanHash -or $AllowShrink -or $AllowRetentionChange){throw 'Plan requires original journal/results, exact channel and new output; restore-only options are not accepted.'}
|
||||
$source=Read-WelaWecUpdateFile $JournalPath
|
||||
}else{
|
||||
if(-not $PlanPath -or $PlanHash -cnotmatch '^[a-f0-9]{64}$' -or $JournalPath -or $OriginalResultsPath -or $Log){throw 'Restore requires only reviewed plan path/hash, new output and applicable explicit loss/retention consent.'}
|
||||
$source=Read-WelaWecUpdateFile $PlanPath
|
||||
}
|
||||
$output=New-WelaArrivalOutput $OutputPath $source.Path
|
||||
$report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaEventLogRecovery';Action=$Action;Status='Refused';ExitCode=1;OutputPath=$output;PlanHash=$null;NativeWriteAttempted=$false;After=$null;Artifacts=@();Diagnostic='';ReadyRuleCredit=0;Scope='One completed profile size/mode operation. Shrinking or changing retention may discard events or stop archival; existing records and sustained retention are not proven. Sysmon excluded.'}
|
||||
try{
|
||||
$context=Get-WelaEventRecoveryContext;$contextKey=Get-WelaRecoveryKey $context;$sources=Get-WelaEventRecoverySources
|
||||
if($Action -eq 'Plan'){
|
||||
$definition=Get-WelaEventRecoveryDefinition $JournalPath $OriginalResultsPath $Log
|
||||
$observed=Read-WelaEventRecoveryChannel $Log;Assert-WelaEventRecoveryCurrent $definition $observed $null
|
||||
$plan=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaEventLogRecoveryPlan';Definition=$definition;ContextKey=$contextKey;Sources=$sources;Guard=$observed.Guard}
|
||||
if((Get-WelaRecoveryKey (Get-WelaEventRecoveryDefinition $JournalPath $OriginalResultsPath $Log)) -cne (Get-WelaRecoveryKey $definition) -or (Get-WelaRecoveryKey (Get-WelaEventRecoveryContext)) -cne $contextKey -or (Get-WelaEventRecoverySources) -cne $sources){throw 'Input, host or code changed while planning.'}
|
||||
Assert-WelaEventRecoveryCurrent $definition (Read-WelaEventRecoveryChannel $Log) $plan.Guard
|
||||
$artifact=Write-WelaWecUpdateArtifact $output 'plan.json' ($plan|ConvertTo-Json -Depth 20);$report.Artifacts+=$artifact;$report.PlanHash=$artifact.Sha256;$report.Status='ReviewRequired';$report.ExitCode=0
|
||||
}else{
|
||||
if($source.Hash -cne $PlanHash){throw 'Reviewed plan hash differs.'}
|
||||
$plan=ConvertFrom-WelaArrivalJson $source.Text;Assert-WelaArrivalObject $plan @('SchemaVersion','Kind','Definition','ContextKey','Sources','Guard')
|
||||
Assert-WelaEventRecoveryText $plan @('Kind','ContextKey','Sources')
|
||||
if(($plan.SchemaVersion -isnot [int] -and $plan.SchemaVersion -isnot [long]) -or $plan.SchemaVersion -ne 1 -or $plan.Kind -cne 'WelaEventLogRecoveryPlan' -or $plan.ContextKey -cne $contextKey -or $plan.Sources -cne $sources){throw 'Reviewed plan schema, context or code differs.'}
|
||||
$definition=Get-WelaEventRecoveryDefinition $plan.Definition.Journal.Path $plan.Definition.OriginalResults.Path $plan.Definition.Log
|
||||
if((Get-WelaRecoveryKey $definition) -cne (Get-WelaRecoveryKey $plan.Definition)){throw 'Recovery plan differs from independently rebuilt original evidence.'}
|
||||
if($definition.RequiresShrinkConsent -and -not $AllowShrink){throw 'Restoring the original smaller buffer requires explicit AllowShrink; existing events may be discarded.'}
|
||||
if($definition.RequiresModeConsent -and -not $AllowRetentionChange){throw 'Restoring a different retention mode requires explicit AllowRetentionChange.'}
|
||||
Assert-WelaEventRecoveryCurrent $definition (Read-WelaEventRecoveryChannel $definition.Log) $plan.Guard
|
||||
$report.PlanHash=$PlanHash;$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'reviewed-plan.json' $source.Text
|
||||
$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'before-restore.json' ([ordered]@{Status='Pending';Definition=$definition;Guard=$plan.Guard;Context=$context;AllowShrink=[bool]$AllowShrink;AllowRetentionChange=[bool]$AllowRetentionChange;RecordedUtc=[DateTime]::UtcNow.ToString('o')}|ConvertTo-Json -Depth 20)
|
||||
if((Read-WelaWecUpdateFile $PlanPath).Hash -cne $PlanHash -or (Get-WelaEventRecoverySources) -cne $sources -or (Get-WelaRecoveryKey (Get-WelaEventRecoveryContext)) -cne $contextKey -or (Get-WelaRecoveryKey (Get-WelaEventRecoveryDefinition $definition.Journal.Path $definition.OriginalResults.Path $definition.Log)) -cne (Get-WelaRecoveryKey $definition)){throw 'Plan, source, context or original evidence changed immediately before restore.'}
|
||||
foreach($artifact in $report.Artifacts){if((Read-WelaWecUpdateFile (Join-Path $output $artifact.Name)).Hash -cne $artifact.Sha256){throw 'Saved recovery evidence changed before write.'}}
|
||||
Assert-WelaEventRecoveryCurrent $definition (Read-WelaEventRecoveryChannel $definition.Log) $plan.Guard
|
||||
$report.NativeWriteAttempted=$true;Set-WelaEventRecoveryChannel $definition
|
||||
$report.After=Read-WelaEventRecoveryChannel $definition.Log
|
||||
$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'after.json' ($report.After|ConvertTo-Json -Depth 12)
|
||||
if((Get-WelaRecoveryKey (Get-WelaEventRecoveryPair $report.After)) -cne (Get-WelaRecoveryKey $definition.RecoverTo) -or (Get-WelaRecoveryKey $report.After.Guard) -cne (Get-WelaRecoveryKey $plan.Guard) -or (Get-WelaRecoveryKey (Get-WelaEventRecoveryContext)) -cne $contextKey -or (Get-WelaEventRecoverySources) -cne $sources -or (Read-WelaWecUpdateFile $PlanPath).Hash -cne $PlanHash){throw 'Restored size/mode or preserved properties, context or sources differ.'}
|
||||
$report.Status='RestoredAndVerified';$report.ExitCode=0
|
||||
}
|
||||
}catch{$report.Status=if($report.NativeWriteAttempted){'RestoreAttemptedUnverified'}else{'Refused'};$report.Diagnostic=$_.Exception.Message}
|
||||
$null=Write-WelaWecUpdateArtifact $output 'manifest.json' ($report|ConvertTo-Json -Depth 24)
|
||||
$report
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
# One local nonexistent-account attempt under already configured failure auditing.
|
||||
function Initialize-WelaFailedLogonNative {
|
||||
if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'Native 64-bit Windows is required.'}
|
||||
$path=Join-Path $PSScriptRoot 'FailedLogonProbeNative.cs';$hash=(Get-FileHash -LiteralPath $path -Algorithm SHA256 -ErrorAction Stop).Hash
|
||||
if(-not ('Wela.FailedLogonProbe.Native' -as [type])){Add-Type -Path $path -ErrorAction Stop;$script:WelaFailedLogonHash=$hash}
|
||||
if($script:WelaFailedLogonHash -cne $hash){throw 'Loaded failed-logon helper differs from source; start a fresh process.'}
|
||||
}
|
||||
function Get-WelaFailedLogonSources {
|
||||
$sources=[ordered]@{}
|
||||
foreach($name in @('WELA.ps1','scripts/FailedLogonProbe.ps1','scripts/FailedLogonProbeWorker.ps1','scripts/FailedLogonProbeNative.cs','scripts/ChannelRead.ps1','scripts/ChannelReadNative.cs','scripts/Configuration.ps1','scripts/WefArrival.ps1','scripts/WecUpdate.ps1','modules/AuditProfiles.psm1','scripts/CustomAuditProfiles.ps1')){$sources[$name]=(Get-FileHash -LiteralPath (Join-Path $script:ScriptRoot $name) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()}
|
||||
$sources|ConvertTo-Json -Compress
|
||||
}
|
||||
function Get-WelaFailedLogonTokenKey {
|
||||
param($Token,[switch]$AuthorizationOnly)
|
||||
foreach($name in @('UserSid','AuthenticationId','TokenId','ModifiedId','TokenType','Impersonation')){if($Token.$name -isnot [string]){throw 'Incomplete elevated primary-token observation.'}}
|
||||
if($Token.ElevatedAdministrator -isnot [bool]){throw 'Incomplete elevated primary-token observation.'}
|
||||
if($Token.UserSid -cnotmatch '^S-1-\d+(-\d+)+$' -or $Token.AuthenticationId -cnotmatch '^[a-f0-9]{16}$' -or -not $Token.ElevatedAdministrator -or $Token.TokenType -cne 'Primary' -or $Token.Impersonation -cne 'Absent' -or $Token.GroupSids -isnot [array]){throw 'Incomplete elevated primary-token observation.'}
|
||||
foreach($name in @('TokenId','ModifiedId')){if($Token.$name -cnotmatch '^[a-f0-9]{16}$'){throw 'Incomplete elevated primary-token observation.'}}
|
||||
foreach($name in @('GroupCount','PrivilegeCount','ProcessId')){if($Token.$name -isnot [int] -and $Token.$name -isnot [long] -and $Token.$name -isnot [uint32]){throw 'Incomplete elevated primary-token observation.'};if($Token.$name -lt 1){throw 'Incomplete elevated primary-token observation.'}}
|
||||
if(@($Token.GroupSids|Where-Object {$_ -isnot [string] -or $_ -cnotmatch '^S-1-\d+(-\d+)+$'}).Count){throw 'Incomplete elevated primary-token observation.'}
|
||||
$key=[ordered]@{Sid=$Token.UserSid;Logon=$Token.AuthenticationId;Groups=$Token.GroupSids;GroupCount=$Token.GroupCount;PrivilegeCount=$Token.PrivilegeCount}
|
||||
if(-not $AuthorizationOnly){$key.TokenId=$Token.TokenId;$key.ModifiedId=$Token.ModifiedId;$key.ProcessId=$Token.ProcessId}
|
||||
$key|ConvertTo-Json -Depth 8 -Compress
|
||||
}
|
||||
function Get-WelaFailedLogonState {
|
||||
Initialize-WelaFailedLogonNative
|
||||
foreach($name in @('EventLog','Winmgmt','SamSs','RpcSs')){if((Get-Service -Name $name -ErrorAction Stop).Status -ne 'Running'){throw 'Required native observation/authentication services must already be running.'}}
|
||||
$reader=Get-WelaChannelReader;$hostState=Get-WelaChannelReadHost
|
||||
if($hostState.ProductType -notin @(1,3) -or $hostState.DomainRole -notin @(0,1,2,3)){throw 'A local SAM Windows client or member/standalone server is required; domain controllers are excluded.'}
|
||||
$policies=Get-WelaEffectiveAuditPolicy;$precedence=Get-WelaRegistryState 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' SCENoApplyLegacyAuditPolicy
|
||||
$channel=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('Security')
|
||||
try{$log=[ordered]@{Name=$channel.LogName;Enabled=$channel.IsEnabled;Size=$channel.MaximumSizeInBytes;Mode=[string]$channel.LogMode;Path=$channel.LogFilePath;SecurityDescriptor=$channel.SecurityDescriptor}}finally{$channel.Dispose()}
|
||||
$engine=(Get-Process -Id $PID).Path
|
||||
$state=[pscustomobject][ordered]@{Host=$hostState;Token=$reader;AuditPolicies=$policies;Precedence=$precedence;Channel=$log;Engine=$engine;EngineHash=(Get-FileHash -LiteralPath $engine -Algorithm SHA256).Hash;Sources=(Get-WelaFailedLogonSources)}
|
||||
if((Get-WelaFailedLogonTokenKey (Get-WelaChannelReader)) -cne (Get-WelaFailedLogonTokenKey $reader)){throw 'Reader changed during prerequisite observation.'}
|
||||
$state
|
||||
}
|
||||
function Get-WelaFailedLogonStateKey {
|
||||
param($State)
|
||||
$null=Get-WelaFailedLogonTokenKey $State.Token
|
||||
$mask=$State.AuditPolicies['0cce9215-69ae-11d9-bed3-505054503030']
|
||||
if(($mask -isnot [int] -and $mask -isnot [long]) -or $mask -notin @(2,3) -or $State.Precedence.Type -cne 'DWord' -or -not $State.Precedence.ValueExists -or $State.Precedence.Value -ne 1 -or -not $State.Channel.Enabled){throw 'Logon failure auditing, DWORD1 audit precedence and enabled/readable Security channel must already be configured.'}
|
||||
$State|ConvertTo-Json -Depth 12 -Compress
|
||||
}
|
||||
function Get-WelaFailedLogonWatermark {
|
||||
$record=Get-WinEvent -LogName Security -MaxEvents 1 -ErrorAction Stop
|
||||
try{if($null -eq $record.RecordId -or $record.RecordId -lt 1){throw 'Unknown Security record boundary.'};[long]$record.RecordId}finally{$record.Dispose()}
|
||||
}
|
||||
function Assert-WelaFailedLogonOperation {
|
||||
param($Operation,$State,[string]$Nonce,[int]$ProcessId,[DateTimeOffset]$Launch,[DateTimeOffset]$Observed)
|
||||
$a=$Operation.Attempt
|
||||
foreach($name in @('Nonce','Executable')){if($Operation.$name -isnot [string]){throw 'Unexpected fixed local authentication receipt type.'}}
|
||||
foreach($name in @('UserName','Domain','Clock')){if($a.$name -isnot [string]){throw 'Unexpected fixed local authentication receipt type.'}}
|
||||
foreach($name in @('MissingAccountStatus','LogonType','LogonProvider','NativeError')){if($a.$name -isnot [int] -and $a.$name -isnot [long]){throw 'Unexpected fixed local authentication receipt type.'}}
|
||||
if($Operation.ProcessId -isnot [int] -and $Operation.ProcessId -isnot [long]){throw 'Unexpected fixed local authentication receipt type.'}
|
||||
if($Operation.Nonce -cne $Nonce -or $Operation.ProcessId -ne $ProcessId -or $Operation.Executable -ine $State.Engine -or $a.UserName -cne ('WL'+$Nonce.Substring(0,18)) -or $a.Domain -cne '.' -or $a.MissingAccountStatus -ne 2221 -or $a.LogonType -ne 3 -or $a.LogonProvider -ne 2 -or $a.Succeeded -isnot [bool] -or $a.Succeeded -or $a.NativeError -ne 1326 -or $a.Clock -cne 'GetSystemTimePreciseAsFileTime'){throw 'Unexpected fixed local authentication result; no failed-logon proof is granted.'}
|
||||
$start=ConvertTo-WelaArrivalUtc $a.StartedUtc;$end=ConvertTo-WelaArrivalUtc $a.CompletedUtc
|
||||
if($Launch -gt $Observed -or $start -lt $Launch -or $start -gt $end -or $end -gt $Observed -or ($end-$start).TotalSeconds -gt 20){throw 'Invalid exact native operation interval.'}
|
||||
if((Get-WelaFailedLogonTokenKey $Operation.BeforeToken) -cne (Get-WelaFailedLogonTokenKey $Operation.AfterToken) -or (Get-WelaFailedLogonTokenKey $Operation.BeforeToken -AuthorizationOnly) -cne (Get-WelaFailedLogonTokenKey $State.Token -AuthorizationOnly)){throw 'Worker token changed or differs from the observed caller.'}
|
||||
$a.StartedUtc=$start.UtcDateTime.ToString('o');$a.CompletedUtc=$end.UtcDateTime.ToString('o')
|
||||
}
|
||||
function Start-WelaFailedLogonAttempt {
|
||||
param($State,[string]$OutputPath)
|
||||
if((Get-WelaFailedLogonStateKey (Get-WelaFailedLogonState)) -cne (Get-WelaFailedLogonStateKey $State)){throw 'Prerequisites changed before the fixed attempt.'}
|
||||
$nonce=[guid]::NewGuid().ToString('N');$watermark=Get-WelaFailedLogonWatermark
|
||||
$null=Write-WelaWecUpdateArtifact $OutputPath 'intent.json' ([ordered]@{Nonce=$nonce;LocalAccount=('WL'+$nonce.Substring(0,18));Domain='.';Attempts=1;SecurityRecordIdBefore=$watermark}|ConvertTo-Json)
|
||||
$worker=Join-Path $PSScriptRoot 'FailedLogonProbeWorker.ps1'
|
||||
$info=New-Object Diagnostics.ProcessStartInfo;$info.FileName=$State.Engine;$info.Arguments='-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "'+$worker+'" -Nonce '+$nonce
|
||||
$info.UseShellExecute=$false;$info.CreateNoWindow=$true;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true
|
||||
$info.StandardOutputEncoding=[Text.UTF8Encoding]::new($false,$true);$info.StandardErrorEncoding=[Text.UTF8Encoding]::new($false,$true)
|
||||
$process=$null
|
||||
try{
|
||||
$launch=[DateTimeOffset][Wela.FailedLogonProbe.Native]::UtcNow()
|
||||
$process=[Diagnostics.Process]::Start($info);$output=$process.StandardOutput.ReadToEndAsync();$errors=$process.StandardError.ReadToEndAsync()
|
||||
if(-not $process.WaitForExit(20000)){$process.Kill();$null=$process.WaitForExit(1000);throw 'Fixed local authentication worker exceeded twenty seconds.'}
|
||||
if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($output,$errors),1000)){throw 'Worker output did not complete.'}
|
||||
if($output.Result.Length -gt 65536 -or $errors.Result.Length -gt 65536){throw 'Worker output exceeded its bound.'}
|
||||
if($process.ExitCode -ne 0 -or $errors.Result){throw ('Fixed local authentication worker failed: '+$errors.Result)}
|
||||
$operation=ConvertFrom-WelaArrivalJson $output.Result
|
||||
Assert-WelaFailedLogonOperation $operation $State $nonce $process.Id $launch ([DateTimeOffset][Wela.FailedLogonProbe.Native]::UtcNow())
|
||||
$operation|Add-Member NoteProperty SecurityRecordIdBefore $watermark
|
||||
$operation
|
||||
}finally{if($process){try{if(-not $process.HasExited){$process.Kill();$null=$process.WaitForExit(1000)}}finally{$process.Dispose()}}}
|
||||
}
|
||||
function Read-WelaFailedLogonEvents {
|
||||
param($Operation)
|
||||
$a=$Operation.Attempt
|
||||
$query="*[System[Provider[@Name='Microsoft-Windows-Security-Auditing'] and EventID=4625 and EventRecordID>$($Operation.SecurityRecordIdBefore) and TimeCreated[@SystemTime>='$($a.StartedUtc)' and @SystemTime<='$($a.CompletedUtc)']]]"
|
||||
$records=@();$xml=@()
|
||||
try{
|
||||
try{$records=@(Get-WinEvent -LogName Security -FilterXPath $query -MaxEvents 256 -ErrorAction Stop)}catch{if($_.FullyQualifiedErrorId -notlike 'NoMatchingEventsFound*'){throw}}
|
||||
foreach($record in $records){$text=[string]$record.ToXml();if($text.Length -gt 131072){throw 'Native event exceeded its bound.'};$xml+=$text}
|
||||
[pscustomobject]@{Xml=$xml;Capped=($records.Count -ge 256);Query=$query}
|
||||
}finally{foreach($record in $records){$record.Dispose()}}
|
||||
}
|
||||
function Test-WelaFailedLogonEvent {
|
||||
param([string]$Xml,$Operation,$State)
|
||||
$reader=$null
|
||||
try{
|
||||
if($Xml.Length -gt 131072){return $false}
|
||||
$settings=New-Object Xml.XmlReaderSettings;$settings.DtdProcessing=[Xml.DtdProcessing]::Prohibit;$settings.XmlResolver=$null;$settings.MaxCharactersInDocument=131072
|
||||
$reader=[Xml.XmlReader]::Create([IO.StringReader]::new($Xml),$settings);$doc=New-Object Xml.XmlDocument;$doc.XmlResolver=$null;$doc.Load($reader)
|
||||
$ns=New-Object Xml.XmlNamespaceManager($doc.NameTable);$ns.AddNamespace('e','http://schemas.microsoft.com/win/2004/08/events/event')
|
||||
if($doc.DocumentElement.LocalName -cne 'Event' -or $doc.DocumentElement.NamespaceURI -cne $ns.LookupNamespace('e') -or $doc.SelectNodes('/e:Event/e:System',$ns).Count -ne 1 -or $doc.SelectNodes('/e:Event/e:EventData',$ns).Count -ne 1 -or $doc.SelectNodes('/e:Event/e:UserData',$ns).Count){return $false}
|
||||
$system=@{};foreach($name in @('Provider','EventID','Version','Keywords','EventRecordID','Channel','Computer','TimeCreated')){$nodes=$doc.SelectNodes("/e:Event/e:System/e:$name",$ns);if($nodes.Count -ne 1){return $false};$system[$name]=$nodes[0]}
|
||||
if($system.Provider.GetAttribute('Name') -cne 'Microsoft-Windows-Security-Auditing' -or $system.Provider.GetAttribute('Guid').Trim('{}') -ine '54849625-5478-4994-a5ba-3e3b0328c30d' -or $system.EventID.InnerText -cne '4625' -or $system.Version.InnerText -cne '0' -or $system.Channel.InnerText -cne 'Security' -or $system.Keywords.InnerText -ine '0x8010000000000000' -or $system.EventRecordID.InnerText -cnotmatch '^[1-9][0-9]*$' -or [long]$system.EventRecordID.InnerText -le $Operation.SecurityRecordIdBefore){return $false}
|
||||
$computers=@($State.Host.Computer);if($State.Host.DomainJoined){$computers+=$State.Host.Computer+'.'+$State.Host.Domain};if($system.Computer.InnerText -notin $computers){return $false}
|
||||
$time=ConvertTo-WelaArrivalUtc $system.TimeCreated.GetAttribute('SystemTime')
|
||||
if($time -lt (ConvertTo-WelaArrivalUtc $Operation.Attempt.StartedUtc) -or $time -gt (ConvertTo-WelaArrivalUtc $Operation.Attempt.CompletedUtc)){return $false}
|
||||
$map=@{}
|
||||
foreach($node in $doc.SelectSingleNode('/e:Event/e:EventData',$ns).ChildNodes){
|
||||
if($node.NodeType -eq 'Whitespace'){continue}
|
||||
if($node.NodeType -ne 'Element' -or $node.LocalName -cne 'Data' -or $node.NamespaceURI -cne $ns.LookupNamespace('e') -or @($node.ChildNodes|Where-Object NodeType -eq Element).Count){return $false}
|
||||
$name=$node.GetAttribute('Name');if(-not $name -or $map.ContainsKey($name)){return $false};$map[$name]=$node.InnerText
|
||||
}
|
||||
if($map.TargetUserName -cne $Operation.Attempt.UserName -or $map.TargetDomainName -notin @('.',$State.Host.Computer) -or $map.TargetUserSid -cne 'S-1-0-0' -or $map.LogonType -cne '3' -or $map.AuthenticationPackageName -cne 'MICROSOFT_AUTHENTICATION_PACKAGE_V1_0' -or $map.Status -ine '0xc000006d' -or $map.SubStatus -ine '0xc0000064' -or $map.ProcessName -ine $Operation.Executable -or $map.SubjectUserSid -cne $Operation.BeforeToken.UserSid){return $false}
|
||||
if($map.ProcessId -cnotmatch '^0x[0-9a-fA-F]+$' -or $map.SubjectLogonId -cnotmatch '^0x[0-9a-fA-F]+$' -or [Convert]::ToInt64($map.ProcessId.Substring(2),16) -ne $Operation.ProcessId -or [Convert]::ToUInt64($map.SubjectLogonId.Substring(2),16) -ne [Convert]::ToUInt64($Operation.BeforeToken.AuthenticationId,16)){return $false}
|
||||
return $true
|
||||
}catch{return $false}finally{if($reader){$reader.Dispose()}}
|
||||
}
|
||||
function Invoke-WelaFailedLogonProbe {
|
||||
param([ValidateSet('Plan','Run')][string]$Action='Plan',[string]$OutputPath,[ValidateRange(1,30)][int]$TimeoutSeconds=15)
|
||||
$ErrorActionPreference='Stop'
|
||||
if(($Action -eq 'Run') -ne (-not [string]::IsNullOrWhiteSpace($OutputPath))){throw 'Run requires a new FailedLogonOutputPath; Plan creates no files.'}
|
||||
$report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaLocalFailedLogonProbe';Action=$Action;Status='Unverified';ExitCode=1;Before=$null;After=$null;Operation=$null;Candidates=0;Matches=0;Artifacts=@();Diagnostic='';OutputPath=$null;PolicyChanges=0;AccountChanges=0;ReadyRuleCredit=0;Scope='One fixed local SAM nonexistent-account network-logon-type attempt only. No remote/domain authentication, real credentials, account creation, impersonation, forwarding or Sigma proof. Sysmon excluded.'}
|
||||
if($Action -eq 'Run'){$report.OutputPath=New-WelaArrivalOutput $OutputPath $PSScriptRoot}
|
||||
try{
|
||||
$before=Get-WelaFailedLogonState;$report.Before=$before;$key=Get-WelaFailedLogonStateKey $before
|
||||
if($Action -eq 'Plan'){$null=Get-WelaFailedLogonWatermark;$report.After=$before;$report.Status='PrerequisitesObserved';$report.ExitCode=0;return $report}
|
||||
$report.Artifacts+=Write-WelaWecUpdateArtifact $report.OutputPath 'before.json' ($before|ConvertTo-Json -Depth 16)
|
||||
$operation=Start-WelaFailedLogonAttempt $before $report.OutputPath;$report.Operation=$operation
|
||||
$report.Artifacts+=Write-WelaWecUpdateArtifact $report.OutputPath 'operation.json' ($operation|ConvertTo-Json -Depth 12)
|
||||
$timer=[Diagnostics.Stopwatch]::StartNew();$matches=@()
|
||||
do{
|
||||
$batch=Read-WelaFailedLogonEvents $operation;$report.Candidates=@($batch.Xml).Count
|
||||
if($batch.Capped -isnot [bool] -or $batch.Capped){throw 'Candidate completeness is unknown or the 256-event cap was reached.'}
|
||||
$matches=@($batch.Xml|Where-Object {Test-WelaFailedLogonEvent $_ $operation $before})
|
||||
if($matches.Count){break};Start-Sleep -Milliseconds 250
|
||||
}while($timer.Elapsed.TotalSeconds -lt $TimeoutSeconds)
|
||||
$report.Matches=$matches.Count
|
||||
if($matches.Count -ne 1){$i=0;foreach($xml in @($batch.Xml|Select-Object -First 4)){$i++;$report.Artifacts+=Write-WelaWecUpdateArtifact $report.OutputPath ('candidate-'+$i+'.xml') $xml};throw 'Exactly one matching local nonexistent-account Security4625 was not observed.'}
|
||||
$report.Artifacts+=Write-WelaWecUpdateArtifact $report.OutputPath 'event.xml' $matches[0]
|
||||
if((Get-WelaFailedLogonWatermark) -lt $operation.SecurityRecordIdBefore){throw 'Security record boundary moved backwards; continuity is unknown.'}
|
||||
$after=Get-WelaFailedLogonState;$report.After=$after
|
||||
if((Get-WelaFailedLogonStateKey $after) -cne $key){throw 'Host, token, policies, channel, engine or sources changed during collection.'}
|
||||
$report.Status='LocalFailedLogonObserved';$report.ExitCode=0
|
||||
}catch{$report.Diagnostic=$_.Exception.Message}
|
||||
finally{if($report.Before -and -not $report.After){try{$report.After=Get-WelaFailedLogonState}catch{$report.Diagnostic+=' Final observation failed: '+$_.Exception.Message}}}
|
||||
if($report.OutputPath){if($report.After){$report.Artifacts+=Write-WelaWecUpdateArtifact $report.OutputPath 'after.json' ($report.After|ConvertTo-Json -Depth 16)};$null=Write-WelaWecUpdateArtifact $report.OutputPath 'manifest.json' ($report|ConvertTo-Json -Depth 24)}
|
||||
$report
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.RegularExpressions;
|
||||
namespace Wela.FailedLogonProbe {
|
||||
public sealed class Attempt {
|
||||
public string UserName, Domain, StartedUtc, CompletedUtc, Clock;
|
||||
public int MissingAccountStatus, LogonType, LogonProvider, NativeError;
|
||||
public bool Succeeded;
|
||||
}
|
||||
public static class Native {
|
||||
[DllImport("kernel32.dll", ExactSpelling=true)] private static extern void GetSystemTimePreciseAsFileTime(out long value);
|
||||
[DllImport("Netapi32.dll", CharSet=CharSet.Unicode, ExactSpelling=true)] private static extern int NetUserGetInfo(string server,string user,int level,out IntPtr buffer);
|
||||
[DllImport("Netapi32.dll", ExactSpelling=true)] private static extern int NetApiBufferFree(IntPtr buffer);
|
||||
[DllImport("advapi32.dll", CharSet=CharSet.Unicode, ExactSpelling=true, SetLastError=true)]
|
||||
[return:MarshalAs(UnmanagedType.Bool)] private static extern bool LogonUserW(string user,string domain,string password,int type,int provider,out IntPtr token);
|
||||
[DllImport("kernel32.dll", ExactSpelling=true, SetLastError=true)]
|
||||
[return:MarshalAs(UnmanagedType.Bool)] private static extern bool CloseHandle(IntPtr handle);
|
||||
public static DateTime UtcNow(){long value;GetSystemTimePreciseAsFileTime(out value);return DateTime.FromFileTimeUtc(value);}
|
||||
public static Attempt Run(string nonce){
|
||||
if(!Regex.IsMatch(nonce??"","\\A[a-f0-9]{32}\\z"))throw new ArgumentException("A generated lowercase GUID nonce is required.");
|
||||
string user="WL"+nonce.Substring(0,18);IntPtr buffer=IntPtr.Zero;
|
||||
int missing;
|
||||
try{missing=NetUserGetInfo(null,user,0,out buffer);}finally{if(buffer!=IntPtr.Zero)NetApiBufferFree(buffer);}
|
||||
// Never attempt a known or unreadable real account, and never query a domain server.
|
||||
if(missing!=2221)throw new InvalidOperationException("Exact local account absence is not established; NetUserGetInfo="+missing);
|
||||
Attempt result=new Attempt();result.UserName=user;result.Domain=".";result.MissingAccountStatus=missing;result.LogonType=3;result.LogonProvider=2;result.Clock="GetSystemTimePreciseAsFileTime";
|
||||
IntPtr token=IntPtr.Zero;
|
||||
result.StartedUtc=UtcNow().ToString("o");
|
||||
try{
|
||||
// This fixed public dummy is not a credential. There is exactly one attempt.
|
||||
result.Succeeded=LogonUserW(user,".","WELA-public-noncredential",3,2,out token);
|
||||
result.NativeError=result.Succeeded?0:Marshal.GetLastWin32Error();
|
||||
result.CompletedUtc=UtcNow().ToString("o");
|
||||
}finally{if(token!=IntPtr.Zero && !CloseHandle(token))throw new Win32Exception(Marshal.GetLastWin32Error());}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
param([Parameter(Mandatory)][ValidatePattern('^[a-f0-9]{32}$')][string]$Nonce)
|
||||
$ErrorActionPreference='Stop';[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)
|
||||
$script:ScriptRoot=Split-Path $PSScriptRoot -Parent
|
||||
. "$PSScriptRoot/WefArrival.ps1"
|
||||
. "$PSScriptRoot/ChannelRead.ps1"
|
||||
. "$PSScriptRoot/FailedLogonProbe.ps1"
|
||||
Initialize-WelaFailedLogonNative
|
||||
foreach($name in @('EventLog','Winmgmt','SamSs','RpcSs')){if((Get-Service -Name $name -ErrorAction Stop).Status -ne 'Running'){throw 'Required native services must already be running.'}}
|
||||
$hostState=Get-WelaChannelReadHost
|
||||
if($hostState.ProductType -notin @(1,3) -or $hostState.DomainRole -notin @(0,1,2,3)){throw 'A local SAM client or member/standalone server is required.'}
|
||||
$before=Get-WelaChannelReader
|
||||
$result=[Wela.FailedLogonProbe.Native]::Run($Nonce)
|
||||
$after=Get-WelaChannelReader
|
||||
[pscustomobject][ordered]@{Nonce=$Nonce;ProcessId=$PID;Executable=(Get-Process -Id $PID).Path;BeforeToken=$before;AfterToken=$after;Attempt=$result}|ConvertTo-Json -Depth 10 -Compress
|
||||
@@ -0,0 +1,250 @@
|
||||
# Explicit recovery of one completed Windows PowerShell transcription policy write.
|
||||
function Copy-WelaTranscriptRecoveryValue {
|
||||
param($Value)
|
||||
# Windows PowerShell 5.1 annotates a root array emitted by ConvertFrom-Json;
|
||||
# serializing that annotated array can introduce synthetic value/count keys.
|
||||
# Keep arrays nested during the JSON roundtrip and emit their actual items.
|
||||
$holder=ConvertFrom-WelaRecoveryJson (Get-WelaRecoveryKey ([pscustomobject]@{Data=$Value}))
|
||||
$holder.Data
|
||||
}
|
||||
function Get-WelaTranscriptRecoverySources {
|
||||
$sources=[ordered]@{}
|
||||
foreach($name in @('WELA.ps1','scripts/TranscriptionRecovery.ps1','scripts/PowerShellTranscription.ps1','scripts/Configuration.ps1','scripts/AuditRecovery.ps1','scripts/ControlApplicability.ps1','scripts/ChannelRead.ps1','scripts/ChannelReadNative.cs','scripts/WefArrival.ps1')) {
|
||||
$sources[$name]=(Get-FileHash -LiteralPath (Join-Path $script:ScriptRoot $name) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()
|
||||
}
|
||||
[pscustomobject]$sources
|
||||
}
|
||||
function Get-WelaTranscriptRecoveryContext {
|
||||
$hostState=Get-WelaRecoveryHost
|
||||
$reader=Get-WelaChannelReader
|
||||
if(-not $reader.ElevatedAdministrator){throw 'Transcription recovery requires an elevated administrator primary token.'}
|
||||
# A reviewed plan can be consumed by a new process in the same logon session.
|
||||
[pscustomobject][ordered]@{Host=$hostState;Reader=($reader|Select-Object UserSid,UserName,AuthenticationId,GroupSids,ElevatedAdministrator,TokenType,Impersonation)}
|
||||
}
|
||||
function Assert-WelaTranscriptRecoveryLocalPath {
|
||||
param([string]$Path)
|
||||
Test-WelaTranscriptDirectoryPath $Path
|
||||
if($Path -notmatch '^[A-Za-z]:\\' -or (Get-WelaRecoveryOutputDriveType ([IO.Path]::GetPathRoot($Path))) -ne [IO.DriveType]::Fixed){throw 'Transcription recovery supports ordinary local fixed-drive paths only; UNC and mapped drives require manual recovery.'}
|
||||
}
|
||||
function Read-WelaTranscriptRecoveryFile {
|
||||
param([string]$Path)
|
||||
Assert-WelaTranscriptRecoveryLocalPath $Path
|
||||
Get-WelaRecoveryFile $Path
|
||||
}
|
||||
function Get-WelaTranscriptRecoveryProtectedPolicy {
|
||||
# Inventory the complete PowerShell policy tree, excluding only the two owned
|
||||
# machine values. No policy, header, module/script-block or user writes occur.
|
||||
$rows=New-Object 'System.Collections.Generic.List[object]'
|
||||
foreach($hive in @('LocalMachine','CurrentUser')) {
|
||||
$base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::$hive,[Microsoft.Win32.RegistryView]::Registry64)
|
||||
try {
|
||||
$queue=New-Object 'System.Collections.Generic.Queue[string]';$queue.Enqueue('')
|
||||
while($queue.Count) {
|
||||
$relative=$queue.Dequeue();$path='SOFTWARE\Policies\Microsoft\Windows\PowerShell'+$relative
|
||||
$key=$base.OpenSubKey($path,$false)
|
||||
try {
|
||||
$values=@();$children=@()
|
||||
if($null -ne $key) {
|
||||
$children=@($key.GetSubKeyNames()|Sort-Object)
|
||||
foreach($name in ($key.GetValueNames()|Sort-Object)) {
|
||||
if($hive -eq 'LocalMachine' -and $relative -eq '\Transcription' -and $name -in @('EnableTranscripting','OutputDirectory')){continue}
|
||||
$values += [pscustomobject][ordered]@{Name=$name;Type=$key.GetValueKind($name).ToString();Value=$key.GetValue($name,$null,[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)}
|
||||
}
|
||||
}
|
||||
$rows.Add([pscustomobject][ordered]@{Hive=$hive;Path=$relative;Exists=($null -ne $key);Values=$values;Children=$children})
|
||||
if($rows.Count -gt 128 -or $queue.Count+$children.Count -gt 128 -or $relative.Length -gt 1024 -or $values.Count -gt 256){throw 'PowerShell policy inventory exceeded bounded recovery scope.'}
|
||||
foreach($child in $children){$queue.Enqueue($relative+'\'+$child)}
|
||||
} finally {if($key){$key.Dispose()}}
|
||||
}
|
||||
} finally {$base.Dispose()}
|
||||
}
|
||||
$result=@($rows.ToArray())
|
||||
if((Get-WelaRecoveryKey $result).Length -gt 1048576){throw 'PowerShell policy inventory exceeded 1 MiB.'}
|
||||
return ,$result
|
||||
}
|
||||
function Assert-WelaTranscriptRecoveryValue {
|
||||
param($Value,[string]$Name)
|
||||
if($null -eq $Value -or $Value.KeyExists -isnot [bool] -or $Value.ValueExists -isnot [bool]){throw 'Missing typed transcription value state.'}
|
||||
if(-not $Value.ValueExists) {
|
||||
if($null -ne $Value.Type -or $null -ne $Value.Value){throw 'Absent transcription value has inconsistent state.'}
|
||||
} elseif(-not $Value.KeyExists){throw 'A present transcription value requires an existing key.'}
|
||||
elseif($Name -eq 'EnableTranscripting') {
|
||||
if($Value.Type -isnot [string] -or $Value.Type -cne 'DWord' -or ($Value.Value -isnot [int] -and $Value.Value -isnot [long]) -or $Value.Value -notin @(0,1)){throw 'Only DWORD 0/1 or absent enablement can be restored; other types require manual recovery.'}
|
||||
} elseif($Value.Type -isnot [string] -or $Value.Type -cne 'String' -or $Value.Value -isnot [string] -or -not $Value.Value){throw 'Only a nonempty REG_SZ or absent output directory can be restored.'}
|
||||
}
|
||||
function Get-WelaTranscriptRecoveryTypedKey {
|
||||
param($Value)
|
||||
Get-WelaRecoveryKey ($Value|Select-Object ValueExists,Type,Value)
|
||||
}
|
||||
function Get-WelaTranscriptRecoveryDestinations {
|
||||
param([string[]]$Paths)
|
||||
foreach($path in ($Paths|Sort-Object -Unique)) {
|
||||
Assert-WelaTranscriptRecoveryLocalPath $path
|
||||
$directory=Get-WelaTranscriptDestination $path
|
||||
if(-not $directory.ConfigureAllowed -or $directory.Status -cne 'Observed'){throw "Recovery destination cannot be verified: $($directory.Diagnostic)"}
|
||||
$directory
|
||||
}
|
||||
}
|
||||
function New-WelaTranscriptRecoveryPlan {
|
||||
param([string]$JournalPath,[string]$OriginalResultsPath)
|
||||
$context=Get-WelaTranscriptRecoveryContext;$sources=Get-WelaTranscriptRecoverySources
|
||||
$journal=Read-WelaTranscriptRecoveryFile $JournalPath;$resultFile=Read-WelaTranscriptRecoveryFile $OriginalResultsPath
|
||||
$entries=@($journal.Text -split '\r?\n'|Where-Object {$_ -match '\S'}|ForEach-Object {ConvertFrom-WelaRecoveryJson $_})
|
||||
$results=ConvertFrom-WelaRecoveryJson $resultFile.Text
|
||||
foreach($field in @('ExitCode','Failed','Skipped')) {
|
||||
if(($results.$field -isnot [int] -and $results.$field -isnot [long]) -or $results.$field -ne 0){throw 'Completed transcription history requires integer zero exit/failure/skipped counters.'}
|
||||
}
|
||||
if($entries.Count -ne 1 -or $results.Results -isnot [array] -or $results.Results.Count -ne 1 -or $results.DryRun -isnot [bool] -or $results.DryRun -or
|
||||
$results.Action -isnot [string] -or $results.Action -cne 'Configure' -or $results.Scope -isnot [string] -or $results.Scope -cne 'windows-powershell-transcription-policy-only'){throw 'Recovery requires one completed Applied transcription Configure journal/result, without other controls or partial outcomes.'}
|
||||
$entry=$entries[0];$last=$results.Results[0]
|
||||
if(($entry.Version -isnot [int] -and $entry.Version -isnot [long]) -or $entry.Version -ne 1 -or $entry.ComputerName -isnot [string] -or $entry.ComputerName -ine $context.Host.Computer -or
|
||||
$entry.Id -isnot [string] -or $entry.Id -cne 'PowerShellTranscription/CisV4L2' -or $entry.Kind -isnot [string] -or $entry.Kind -cne 'PowerShellTranscription' -or
|
||||
$last.Status -isnot [string] -or $last.Status -cne 'Applied' -or $last.Id -isnot [string] -or $last.Id -cne $entry.Id -or $last.Kind -isnot [string] -or $last.Kind -cne $entry.Kind){throw 'Wrong host, control, schema or incomplete transcription history.'}
|
||||
$time=ConvertTo-WelaArrivalUtc $entry.RecordedUtc
|
||||
if($time -gt [datetimeoffset]::UtcNow.AddMinutes(1)){throw 'Original journal requires a valid UTC timestamp.'}
|
||||
foreach($field in @('Before','Target','Desired')){if((Get-WelaRecoveryKey $entry.$field) -cne (Get-WelaRecoveryKey $last.$field)){throw "Original journal/result $field differs."}}
|
||||
if($entry.Target.Hive -isnot [string] -or $entry.Target.Hive -cne 'LocalMachine' -or $entry.Target.SubKey -isnot [string] -or $entry.Target.SubKey -cne 'SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription' -or $entry.Target.OutputDirectory -isnot [string] -or
|
||||
$entry.Desired.EnableTranscripting.Type -isnot [string] -or $entry.Desired.EnableTranscripting.Type -cne 'DWord' -or ($entry.Desired.EnableTranscripting.Value -isnot [int] -and $entry.Desired.EnableTranscripting.Value -isnot [long]) -or $entry.Desired.EnableTranscripting.Value -ne 1 -or
|
||||
$entry.Desired.OutputDirectory.Type -isnot [string] -or $entry.Desired.OutputDirectory.Type -cne 'String' -or $entry.Desired.OutputDirectory.Value -isnot [string] -or $entry.Desired.OutputDirectory.Value -cne $entry.Target.OutputDirectory -or
|
||||
$entry.Desired.EnableInvocationHeader -isnot [string] -or $entry.Desired.EnableInvocationHeader -cne 'Preserve'){throw 'Unsupported transcription target or desired state.'}
|
||||
$before=$entry.Before;$after=$last.After
|
||||
foreach($snapshot in @($before,$after)) {
|
||||
if($snapshot.Capability.Status -isnot [string] -or $snapshot.Capability.Status -cne 'Supported' -or $snapshot.Policy -isnot [array] -or $snapshot.Policy.Count -ne 2 -or
|
||||
$snapshot.Policy[0].View -isnot [string] -or $snapshot.Policy[0].View -cne 'Registry64' -or $snapshot.Policy[1].View -isnot [string] -or $snapshot.Policy[1].View -cne 'Registry32'){
|
||||
$policyType=if($null -eq $snapshot.Policy){'<null>'}else{$snapshot.Policy.GetType().FullName}
|
||||
throw "Both canonical shared registry views are required. Capability=$($snapshot.Capability.Status); PolicyType=$policyType; Count=$(@($snapshot.Policy).Count); Views=$(@($snapshot.Policy.View) -join ','); Observation=$(Get-WelaRecoveryKey $snapshot)"
|
||||
}
|
||||
Test-WelaTranscriptSharedPolicy $snapshot.Policy
|
||||
foreach($name in @('EnableTranscripting','OutputDirectory')){Assert-WelaTranscriptRecoveryValue $snapshot.Policy[0].Machine.$name $name}
|
||||
}
|
||||
if(-not (Test-WelaTranscriptConfigured $after $entry.Target.OutputDirectory)){throw 'Final transcription policy was not the requested enabled state.'}
|
||||
if((Get-WelaRecoveryKey $before.Policy[0].CurrentUser) -cne (Get-WelaRecoveryKey $after.Policy[0].CurrentUser) -or
|
||||
(Get-WelaTranscriptRecoveryTypedKey $before.Policy[0].Machine.EnableInvocationHeader) -cne (Get-WelaTranscriptRecoveryTypedKey $after.Policy[0].Machine.EnableInvocationHeader)){throw 'Original configuration did not preserve user/header policy.'}
|
||||
$current=Get-WelaTranscriptState $entry.Target.OutputDirectory
|
||||
if((Get-WelaRecoveryKey $current.Policy) -cne (Get-WelaRecoveryKey $after.Policy) -or (Get-WelaRecoveryKey $current.Destination) -cne (Get-WelaRecoveryKey $after.Destination)){throw 'Current policy/destination differs from the original final After state.'}
|
||||
$target=Copy-WelaTranscriptRecoveryValue $after.Policy
|
||||
foreach($view in $target){foreach($name in @('EnableTranscripting','OutputDirectory')) {
|
||||
$view.Machine.$name=Copy-WelaTranscriptRecoveryValue $before.Policy[0].Machine.$name
|
||||
# Keep the existing key; absence recovery removes only the selected value.
|
||||
$view.Machine.$name.KeyExists=$true
|
||||
}}
|
||||
$prior=$before.Policy[0].Machine;$paths=@([string]$entry.Target.OutputDirectory)
|
||||
if($prior.OutputDirectory.ValueExists){$paths += [string]$prior.OutputDirectory.Value}
|
||||
elseif(-not ($prior.EnableTranscripting.ValueExists -and $prior.EnableTranscripting.Value -eq 0)) {
|
||||
throw 'Restoring an absent output directory requires explicit prior DWORD 0; user/default destinations require manual recovery.'
|
||||
}
|
||||
$directories=@(Get-WelaTranscriptRecoveryDestinations $paths)
|
||||
$outputChanges=(Get-WelaTranscriptRecoveryTypedKey $prior.OutputDirectory) -cne (Get-WelaTranscriptRecoveryTypedKey $after.Policy[0].Machine.OutputDirectory)
|
||||
$suspend=$outputChanges -and -not ($prior.EnableTranscripting.ValueExists -and $prior.EnableTranscripting.Value -eq 0)
|
||||
$steps=New-Object 'System.Collections.Generic.List[object]'
|
||||
if($outputChanges) {
|
||||
$off=if($suspend){[pscustomobject]@{KeyExists=$true;ValueExists=$true;Value=0;Type='DWord'}}else{$target[0].Machine.EnableTranscripting}
|
||||
$steps.Add([pscustomobject]@{Name='EnableTranscripting';Value=$off;Purpose=$(if($suspend){'Explicit temporary suspension'}else{'Restore disabled state before destination'})})
|
||||
$steps.Add([pscustomobject]@{Name='OutputDirectory';Value=$target[0].Machine.OutputDirectory;Purpose='Restore original destination value or absence'})
|
||||
if($suspend){$steps.Add([pscustomobject]@{Name='EnableTranscripting';Value=$target[0].Machine.EnableTranscripting;Purpose='Restore original enablement value or absence'})}
|
||||
} elseif((Get-WelaTranscriptRecoveryTypedKey $prior.EnableTranscripting) -cne (Get-WelaTranscriptRecoveryTypedKey $after.Policy[0].Machine.EnableTranscripting)) {
|
||||
$steps.Add([pscustomobject]@{Name='EnableTranscripting';Value=$target[0].Machine.EnableTranscripting;Purpose='Restore original enablement value or absence'})
|
||||
}
|
||||
if(-not $steps.Count){throw 'Original Applied evidence contains no recoverable typed changes.'}
|
||||
$protected=Get-WelaTranscriptRecoveryProtectedPolicy
|
||||
[pscustomobject][ordered]@{Kind='WelaTranscriptionRecoveryPlan';SchemaVersion=1;Context=$context;Sources=$sources;
|
||||
Journal=[pscustomobject]@{Path=$journal.Path;Sha256=$journal.Sha256};OriginalResults=[pscustomobject]@{Path=$resultFile.Path;Sha256=$resultFile.Sha256};
|
||||
ExpectedPolicy=$after.Policy;RecoverTo=$target;Directories=$directories;ProtectedPolicy=$protected;RequiresTemporarySuspension=[bool]$suspend;Steps=@($steps.ToArray());
|
||||
HistoricalIdentity='Version-1 configuration journals record ComputerName only. Current host/reader/code bindings do not authenticate historical identity or evidence.';SigmaEvtxCredit=0}
|
||||
}
|
||||
function Assert-WelaTranscriptRecoveryBindings {
|
||||
param($Plan,$Policy,[string]$PlanPath,[string]$PlanHash)
|
||||
foreach($source in @($Plan.Journal,$Plan.OriginalResults)){if((Read-WelaTranscriptRecoveryFile $source.Path).Sha256 -cne $source.Sha256){throw 'Original transcription recovery evidence changed.'}}
|
||||
if($PlanPath -and (Read-WelaTranscriptRecoveryFile $PlanPath).Sha256 -cne $PlanHash){throw 'Reviewed transcription recovery plan changed.'}
|
||||
if((Get-WelaRecoveryKey (Get-WelaTranscriptRecoveryContext)) -cne (Get-WelaRecoveryKey $Plan.Context) -or (Get-WelaRecoveryKey (Get-WelaTranscriptRecoverySources)) -cne (Get-WelaRecoveryKey $Plan.Sources)){throw 'Actual host, reader or recovery implementation changed.'}
|
||||
if((Get-WelaRecoveryKey (Get-WelaTranscriptRecoveryProtectedPolicy)) -cne (Get-WelaRecoveryKey $Plan.ProtectedPolicy)){throw 'Preserved PowerShell policy changed; recovery stopped.'}
|
||||
if((Get-WelaRecoveryKey @(Get-WelaTranscriptRecoveryDestinations @($Plan.Directories.RequestedPath))) -cne (Get-WelaRecoveryKey $Plan.Directories)){throw 'A reviewed transcript directory changed.'}
|
||||
$capability=Get-WelaTranscriptCapability
|
||||
if($capability.Status -cne 'Supported'){throw 'Windows PowerShell capability changed.'}
|
||||
$current=@(Get-WelaTranscriptPolicy $capability.Views);Test-WelaTranscriptSharedPolicy $current
|
||||
if((Get-WelaRecoveryKey $current) -cne (Get-WelaRecoveryKey $Policy)){throw 'Current typed transcription policy drifted from the expected recovery step.'}
|
||||
}
|
||||
function Set-WelaTranscriptRecoveryValue {
|
||||
param([ValidateSet('EnableTranscripting','OutputDirectory')][string]$Name,$Value)
|
||||
Assert-WelaTranscriptRecoveryValue $Value $Name
|
||||
$base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64)
|
||||
$key=$null
|
||||
try {
|
||||
$key=$base.OpenSubKey('SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription',$true)
|
||||
if($null -eq $key){throw 'Existing transcription key disappeared; it will not be recreated.'}
|
||||
if($Value.ValueExists){$key.SetValue($Name,$Value.Value,[Microsoft.Win32.RegistryValueKind]([string]$Value.Type))}
|
||||
else{$key.DeleteValue($Name,$false)}
|
||||
$key.Flush()
|
||||
} finally {if($key){$key.Dispose()};$base.Dispose()}
|
||||
}
|
||||
function Write-WelaTranscriptRecoveryArtifact {
|
||||
param($Directory,[string]$Name,$Value)
|
||||
$fresh=Get-WelaTranscriptDestination $Directory.RequestedPath
|
||||
if(-not $fresh.ConfigureAllowed -or (Get-WelaRecoveryKey $fresh) -cne (Get-WelaRecoveryKey $Directory)){throw 'Private recovery output directory changed.'}
|
||||
Write-WelaRecoveryArtifact (Join-Path $Directory.Path $Name) $Value
|
||||
}
|
||||
function Invoke-WelaTranscriptRecovery {
|
||||
param([ValidateSet('Plan','Restore')][string]$Action='Plan',[string]$JournalPath,[string]$OriginalResultsPath,[string]$PlanPath,[string]$PlanHash,[string]$OutputPath,[switch]$AllowTemporarySuspension,[switch]$Auto,[switch]$DryRun)
|
||||
$ErrorActionPreference='Stop'
|
||||
if($Action -eq 'Plan') {
|
||||
if($PlanPath -or $PlanHash -or $Auto -or $DryRun -or $AllowTemporarySuspension){throw 'Plan takes original journal/results and new output only; consent flags are Restore-only.'}
|
||||
$plan=New-WelaTranscriptRecoveryPlan $JournalPath $OriginalResultsPath
|
||||
Assert-WelaTranscriptRecoveryBindings $plan $plan.ExpectedPolicy
|
||||
Assert-WelaTranscriptRecoveryLocalPath $OutputPath
|
||||
$output=New-WelaRecoveryOutput $OutputPath
|
||||
$outputObservation=Get-WelaTranscriptDestination $output
|
||||
Write-WelaTranscriptRecoveryArtifact $outputObservation 'plan.json' $plan
|
||||
$hash=(Read-WelaTranscriptRecoveryFile (Join-Path $output 'plan.json')).Sha256
|
||||
return [pscustomobject]@{Status='Planned';ExitCode=0;OutputPath=$output;PlanSha256=$hash;RequiresTemporarySuspension=$plan.RequiresTemporarySuspension;SigmaEvtxCredit=0}
|
||||
}
|
||||
if($JournalPath -or $OriginalResultsPath -or -not $PlanPath -or $PlanHash -cnotmatch '^[0-9a-f]{64}$'){throw 'Restore consumes a reviewed plan path, its exact SHA-256 and a new output directory.'}
|
||||
$source=Read-WelaTranscriptRecoveryFile $PlanPath
|
||||
if($source.Sha256 -cne $PlanHash){throw 'Supplied reviewed plan hash differs.'}
|
||||
$plan=ConvertFrom-WelaRecoveryJson $source.Text
|
||||
if($plan.Kind -isnot [string] -or $plan.Kind -cne 'WelaTranscriptionRecoveryPlan' -or ($plan.SchemaVersion -isnot [int] -and $plan.SchemaVersion -isnot [long]) -or $plan.SchemaVersion -ne 1){throw 'Unsupported transcription recovery plan.'}
|
||||
$rebuilt=New-WelaTranscriptRecoveryPlan $plan.Journal.Path $plan.OriginalResults.Path
|
||||
if((Get-WelaRecoveryKey $rebuilt) -cne (Get-WelaRecoveryKey $plan)){throw 'Reviewed plan differs from independently rebuilt original evidence and current observations.'}
|
||||
Assert-WelaTranscriptRecoveryBindings $plan $plan.ExpectedPolicy $source.Path $source.Sha256
|
||||
if($plan.RequiresTemporarySuspension -and -not $AllowTemporarySuspension){throw 'Restoring this destination requires explicit -TranscriptRecoveryAllowTemporarySuspension consent, including for preview.'}
|
||||
if($DryRun) {
|
||||
if($OutputPath){throw 'DryRun writes no directory; omit OutputPath.'}
|
||||
return [pscustomobject]@{Status='WouldRestore';ExitCode=0;DryRun=$true;Steps=$plan.Steps;SigmaEvtxCredit=0}
|
||||
}
|
||||
Assert-WelaTranscriptRecoveryLocalPath $OutputPath
|
||||
$output=New-WelaRecoveryOutput $OutputPath
|
||||
$outputObservation=Get-WelaTranscriptDestination $output
|
||||
$report=[pscustomobject][ordered]@{Status='Failed';ExitCode=1;OutputPath=$output;PlanSha256=$source.Sha256;Steps=@();Before=$plan.ExpectedPolicy;After=$null;Diagnostic='';SigmaEvtxCredit=0;Scope='Two typed Windows PowerShell machine transcription values only; no transcript, session adoption, central collection or policy persistence proof.'}
|
||||
$expected=Copy-WelaTranscriptRecoveryValue $plan.ExpectedPolicy
|
||||
try {
|
||||
if(-not $Auto -and (Read-Host 'Restore the reviewed transcription values, including any explicitly consented temporary suspension? (y/N)') -cnotin @('y','Y')){$report.Status='Declined';$report.ExitCode=0}
|
||||
else {
|
||||
Write-WelaTranscriptRecoveryArtifact $outputObservation 'plan.json' $plan
|
||||
$sequence=0
|
||||
foreach($step in $plan.Steps) {
|
||||
$sequence++
|
||||
Assert-WelaTranscriptRecoveryBindings $plan $expected $source.Path $source.Sha256
|
||||
$receipt=[pscustomobject]@{Sequence=$sequence;Status='Pending';RecordedUtc=[datetime]::UtcNow.ToString('o');PlanSha256=$source.Sha256;Step=$step;Before=(Copy-WelaTranscriptRecoveryValue $expected);After=$null}
|
||||
Write-WelaTranscriptRecoveryArtifact $outputObservation ('{0:d3}-pending.json' -f $sequence) $receipt
|
||||
Assert-WelaTranscriptRecoveryBindings $plan $expected $source.Path $source.Sha256
|
||||
Set-WelaTranscriptRecoveryValue $step.Name $step.Value
|
||||
foreach($view in $expected){$view.Machine.($step.Name)=Copy-WelaTranscriptRecoveryValue $step.Value}
|
||||
Assert-WelaTranscriptRecoveryBindings $plan $expected $source.Path $source.Sha256
|
||||
$receipt.Status='Confirmed';$receipt.After=Copy-WelaTranscriptRecoveryValue $expected
|
||||
Write-WelaTranscriptRecoveryArtifact $outputObservation ('{0:d3}-confirmed.json' -f $sequence) $receipt
|
||||
$report.Steps += [pscustomobject]@{Sequence=$sequence;Name=$step.Name;Status='Confirmed';Value=$step.Value}
|
||||
}
|
||||
Assert-WelaTranscriptRecoveryBindings $plan $plan.RecoverTo $source.Path $source.Sha256
|
||||
$report.Status='Restored';$report.ExitCode=0
|
||||
}
|
||||
} catch {$report.Diagnostic=$_.Exception.Message}
|
||||
try {
|
||||
$report.After=@(Get-WelaTranscriptPolicy (Get-WelaTranscriptCapability).Views)
|
||||
if($report.Status -eq 'Restored') {
|
||||
if((Get-WelaRecoveryKey $report.After) -cne (Get-WelaRecoveryKey $plan.RecoverTo)){throw 'Final returned policy differs from the recovery target.'}
|
||||
Assert-WelaTranscriptRecoveryBindings $plan $plan.RecoverTo $source.Path $source.Sha256
|
||||
}
|
||||
}catch{$report.Diagnostic+=' Final policy verification failed: '+$_.Exception.Message;$report.Status='Failed';$report.ExitCode=1}
|
||||
# A failed result write fails outward; pending/confirmed receipts remain intact.
|
||||
Write-WelaTranscriptRecoveryArtifact $outputObservation 'result.json' $report
|
||||
return $report
|
||||
}
|
||||
Reference in new issue
Block a user