mirror of
https://github.com/Yamato-Security/WELA.git
synced 2026-10-08 15:25:24 +02:00
Merge pull request #435 from Shirofune-Security/feat/365-named-registry-recovery
Recover named logging DWORDs from completed configuration journals
This commit is contained in:
12 files changed
+391
-6
No files matched your search
@@ -64,6 +64,10 @@ tests/SelectedSaclFixtureProtection.cs text eol=lf
|
||||
/tests/EvtxRecovery*.ps1 text eol=lf
|
||||
/tests/fixtures/EvtxReader*.ps1 text eol=lf
|
||||
|
||||
# Named registry recovery binds implementation bytes across checkouts.
|
||||
/scripts/NamedRegistryRecovery* text eol=lf
|
||||
/scripts/AuditRecovery.ps1 text eol=lf
|
||||
/tests/NamedRegistryRecovery* text eol=lf
|
||||
/scripts/Capi2Probe* text eol=lf
|
||||
/tests/Capi2Probe* text eol=lf
|
||||
|
||||
|
||||
@@ -5,10 +5,12 @@ on:
|
||||
paths:
|
||||
- 'WELA.ps1'
|
||||
- 'scripts/AuditRecovery.ps1'
|
||||
- 'scripts/NamedRegistryRecovery*'
|
||||
- 'scripts/ControlApplicability.ps1'
|
||||
- 'scripts/Configuration.ps1'
|
||||
- 'modules/AuditProfiles.psm1'
|
||||
- 'tests/AuditRecovery*'
|
||||
- 'tests/NamedRegistryRecovery*'
|
||||
- '.github/workflows/audit-recovery.yml'
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
@@ -35,3 +37,15 @@ jobs:
|
||||
- name: Native recovery from PowerShell 7 with restoration
|
||||
shell: pwsh
|
||||
run: ./tests/AuditRecovery.Windows.Tests.ps1 -AllowDisposablePolicyWrite
|
||||
- name: Named logging registry recovery regressions in Windows PowerShell 5.1
|
||||
shell: powershell
|
||||
run: ./tests/NamedRegistryRecovery.Tests.ps1
|
||||
- name: Native named logging registry recovery and safety restoration in Windows PowerShell 5.1
|
||||
shell: powershell
|
||||
run: ./tests/NamedRegistryRecovery.Windows.Tests.ps1 -AllowDisposablePolicyWrite
|
||||
- name: Named logging registry recovery regressions in PowerShell 7
|
||||
shell: pwsh
|
||||
run: ./tests/NamedRegistryRecovery.Tests.ps1
|
||||
- name: Native named logging registry recovery and safety restoration in PowerShell 7
|
||||
shell: pwsh
|
||||
run: ./tests/NamedRegistryRecovery.Windows.Tests.ps1 -AllowDisposablePolicyWrite
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
## 2.2.0 [2026/xx/xx] - Dev Release
|
||||
|
||||
- 完了したジャーナルから、プロセス作成・PowerShell ログ用の 3 つの DWORD 値を `audit-recovery` で復元できるようにしました。ネイティブ API による値のみの復元、他の値の変更検知、レジストリキーの保持に対応します。 ([#435](https://github.com/Yamato-Security/WELA/pull/435))
|
||||
|
||||
**改善:**
|
||||
|
||||
- 明示した IPv4 リゾルバーに固定の無害な `wela-<nonce>.wela.test.` A クエリを送信し、Windows 標準イベント 3008 と照合する任意実行の `dns-client-probe` を追加しました。既定では前提条件の計画だけを行い、実行時は時間・件数を制限した証拠をハッシュ付きで保護します。Windows 設定は変更せず、元の DNS ルールのチャネル不一致と相関の限界を保持し、Sigma の評価には加算しません。Server 2022/2025 と PowerShell 5.1/7 向けに使い捨ての権威 DNS・ループバック検証を追加しました。 (#434) (@Shirofune-Security)
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
## 2.2.0 [2026/xx/xx] - Dev Release
|
||||
|
||||
- Extend `audit-recovery` to restore three named process/PowerShell logging DWORDs from completed journals, with native value-only writes, neighboring-data guards and retained registry keys. ([#435](https://github.com/Yamato-Security/WELA/pull/435))
|
||||
|
||||
**Improvements:**
|
||||
|
||||
- Added opt-in `dns-client-probe` for one fixed benign `wela-<nonce>.wela.test.` A lookup to an explicitly selected IPv4 resolver, with default prerequisite planning, bounded native DNS execution and exact local event 3008 correlation. Private hashed evidence preserves native status, context and correlation limits without changing Windows settings. The original DNS rule/channel mismatch remains explicit and Sigma credit stays zero. Added disposable authoritative-loopback DNS acceptance tests for Server 2022/2025 and PowerShell 5.1/7. (#434) (@Shirofune-Security)
|
||||
|
||||
+19
-1
@@ -1,6 +1,6 @@
|
||||
# Guarded audit recovery
|
||||
|
||||
Related to #365. `audit-recovery` restores **explicitly selected** advanced audit subcategories and the typed `SCENoApplyLegacyAuditPolicy` value from a completed WELA configuration journal and its matching JSON results. Sysmon is out of scope. Other journal kinds remain manual recovery tasks.
|
||||
Related to #365. `audit-recovery` restores **explicitly selected** advanced audit subcategories and the typed `SCENoApplyLegacyAuditPolicy` value from a completed WELA configuration journal and its matching JSON results. Sysmon is out of scope. The three named logging switches below are also supported. Other journal controls remain manual recovery tasks.
|
||||
|
||||
```powershell
|
||||
# Save results during the original configuration.
|
||||
@@ -22,3 +22,21 @@ Subcategory recovery requires enabled DWORD precedence. To restore precedence it
|
||||
Version-1 journals identify the historical host only by ComputerName. The review plan additionally binds the current MachineGuid and observed build/patch/join/role context. This does **not** prove historical image identity; use only your trusted original evidence. Hashes establish byte consistency, not signatures or authenticity. Reports describe point-in-time local restoration, not GPO persistence, generated events or Sigma readiness.
|
||||
|
||||
Tests cover minimum-mask truth tables, evidence/host/plan tampering, drift, ordering, partial failure, readback and idempotence. Explicitly gated disposable Server 2022/2025 CI exercises actual completed journals and exact audit-policy restoration under PowerShell 5.1/7, with independent safety restoration. Domain policy refresh and Windows 11/DC/ADCS deployment checks remain separate.
|
||||
|
||||
## Named logging DWORD recovery
|
||||
|
||||
The same Plan/Restore flow accepts exactly these additional `RecoveryControlId` values:
|
||||
|
||||
- `Registry/HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System\Audit/ProcessCreationIncludeCmdLine_Enabled`
|
||||
- `Registry/HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging/EnableScriptBlockLogging`
|
||||
- `Registry/HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ModuleLogging/EnableModuleLogging`
|
||||
|
||||
Each must have a matching completed `Applied` DWORD-1 write. Supported original states are DWORD 0/1 or value absence; strings, other integer values/types, incomplete writes, module-name lists, transcription settings, NTLM and arbitrary keys are refused. Recovery changes or removes only the selected value. **Existing keys are retained**, including keys created by the original configuration: `OriginalKeyExisted` reports that distinction. Missing current keys require manual review. This does not restore an entire PowerShell logging configuration or provide event/Sigma credit.
|
||||
|
||||
Planning records the native path plus bounded hashes of all other values, direct child names and owner/group/DACL. Restoration reopens existing native 64-bit HKLM SOFTWARE keys component by component without following registry links, checks those guards, then changes the selected value through the same held handle. Immediate readback and a fresh path reopen must agree. Inventories are bounded to 256 values/children, 64 KiB per value/security descriptor and 1 MiB total value data; unsupported inventories fail closed. No key, child, owner/group/DACL or SACL is intentionally modified by recovery. The guard observes owner/group/DACL, **not the SACL or descendant contents**.
|
||||
|
||||
The reviewed plan also binds current recovery implementation hashes; changed or previously loaded mismatched native code requires a new plan/process. Guards pin observations at recovery planning time; the original version-1 journal does not contain historical registry object identities or neighboring data. Native names are not durable identities. Repeated recovery reports `AlreadyRecovered` when the selected value is already at the reviewed target and guards still match, without claiming who restored it. Concurrent replacement with identical observations, change-and-change-back, and policy/admin writes cannot be excluded atomically. Use a quiet maintenance window; there is no automatic rollback after a failed post-write check.
|
||||
|
||||
Portable regressions exercise the three-value allowlist, typed/absent states, source/evidence tampering, neighboring-data drift, dry-run, receipts and idempotence. Gated native Server 2022/2025 runs under Windows PowerShell 5.1 and PowerShell 7 create real configuration journals for each switch from DWORD 0 and absence, verify value-only restoration and neighboring-data preservation, and restore the runner's original typed states. These are disposable local tests, not domain-policy persistence or Windows 11 deployment evidence.
|
||||
|
||||
Native API contracts: [RegOpenKeyEx](https://learn.microsoft.com/en-us/windows/win32/api/winreg/nf-winreg-regopenkeyexw) opens existing keys, and [RegGetKeySecurity](https://learn.microsoft.com/en-us/windows/win32/api/winreg/nf-winreg-reggetkeysecurity) distinguishes owner/group/DACL access from SACL access.
|
||||
@@ -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,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,82 @@
|
||||
$ErrorActionPreference='Stop'
|
||||
$repo=Split-Path $PSScriptRoot -Parent
|
||||
Import-Module (Join-Path $repo 'modules/AuditProfiles.psm1') -Force
|
||||
. (Join-Path $repo 'scripts/Configuration.ps1')
|
||||
. (Join-Path $repo 'scripts/AuditRecovery.ps1')
|
||||
$script:n=0;$script:writes=0
|
||||
function Assert($Value,$Message) {if (-not $Value) {throw $Message};$script:n++}
|
||||
function Throws($Action,$Pattern) {$message='';try {& $Action | Out-Null} catch {$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected $Pattern, received $message"}
|
||||
function Get-WelaRecoveryHost {[pscustomobject][ordered]@{Computer='TEST';MachineGuid='11111111-1111-1111-1111-111111111111';ContextKey='test'}}
|
||||
function Get-WelaNamedRecoveryObservation {param($Target) $script:observation}
|
||||
function Set-WelaNamedRecoveryValue {
|
||||
param($Control)
|
||||
Assert (Test-Path -LiteralPath (Join-Path $script:destination '001-before.json')) 'A durable receipt precedes mutation.'
|
||||
Assert-WelaNamedRecoveryGuard $Control $script:observation
|
||||
$script:writes++;$script:observation.Exists=$Control.RecoverTo.ValueExists;$script:observation.Value=$Control.RecoverTo.Value
|
||||
}
|
||||
$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-named-recovery-'+[guid]::NewGuid().ToString('N'))
|
||||
$null=New-Item -ItemType Directory -Path $root
|
||||
$journal=Join-Path $root 'before.jsonl';$original=Join-Path $root 'original.json'
|
||||
function Save-Fixture($Definition,$Before) {
|
||||
$script:observation=[pscustomobject]@{Exists=$true;Value=1;ObjectName=('\REGISTRY\MACHINE\'+$Definition.Path.Substring(6));OtherValues='other';Children='children';Security='security';LastWrite='42'}
|
||||
$script:entry=[pscustomobject]@{Version=1;ComputerName='TEST';RecordedUtc=[datetime]::UtcNow.ToString('o');Id=$Definition.Id;Kind='Registry';Target=[pscustomobject]@{Path=$Definition.Path;Name=$Definition.Name};Before=$Before;Desired=[pscustomobject]@{Value=1;Type='DWord'}}
|
||||
$script:final=[pscustomobject]@{Id=$entry.Id;Kind='Registry';Target=$entry.Target;Before=$Before;Desired=$entry.Desired;After=(Get-WelaNamedRecoveryState $observation);Status='Applied'}
|
||||
Save-Evidence
|
||||
}
|
||||
function Save-Evidence {
|
||||
$entry | ConvertTo-Json -Depth 20 -Compress | Set-Content -LiteralPath $journal -Encoding UTF8
|
||||
[pscustomobject]@{DryRun=$false;Results=@($final)} | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $original -Encoding UTF8
|
||||
}
|
||||
try {
|
||||
$catalog=@(Get-WelaNamedRecoveryCatalog)
|
||||
Assert ($catalog.Count -eq 3) 'Only three fixed logging switches are admitted.'
|
||||
foreach ($definition in $catalog) {
|
||||
foreach ($before in @(
|
||||
[pscustomobject]@{KeyExists=$true;ValueExists=$true;Value=0;Type='DWord'},
|
||||
[pscustomobject]@{KeyExists=$true;ValueExists=$false;Value=$null;Type=$null},
|
||||
[pscustomobject]@{KeyExists=$false;ValueExists=$false;Value=$null;Type=$null}
|
||||
)) {
|
||||
Save-Fixture $definition $before
|
||||
$count=$writes
|
||||
$planned=Invoke-WelaAuditRecovery -JournalPath $journal -OriginalResultsPath $original -ControlId $definition.Id -OutputPath (Join-Path $root ([guid]::NewGuid().ToString('N')))
|
||||
$planPath=Join-Path $planned.OutputPath 'plan.json'
|
||||
Assert ($writes -eq $count -and $planned.Status -eq 'Planned') 'Planning does not mutate registry.'
|
||||
$plan=ConvertFrom-WelaRecoveryJson (Get-WelaRecoveryFile $planPath).Text
|
||||
Assert ($plan.Controls[0].Kind -eq 'NamedLoggingRegistry' -and $plan.Controls[0].RecoverTo.KeyExists -and $plan.Controls[0].OriginalKeyExisted -eq $before.KeyExists) 'Value-only recovery retains keys and reports original absence.'
|
||||
$dry=Invoke-WelaAuditRecovery -Action Restore -PlanPath $planPath -DryRun
|
||||
Assert ($dry.Results[0].Status -eq 'WouldRestore' -and $writes -eq $count) 'Dry-run has no mutation.'
|
||||
foreach ($field in @('ObjectName','OtherValues','Children','Security')) {
|
||||
$old=$observation.$field;$observation.$field='changed'
|
||||
Throws {Invoke-WelaAuditRecovery -Action Restore -PlanPath $planPath -DryRun} 'independently rebuilt'
|
||||
$observation.$field=$old
|
||||
}
|
||||
$script:destination=Join-Path $root ([guid]::NewGuid().ToString('N'))
|
||||
$result=Invoke-WelaAuditRecovery -Action Restore -PlanPath $planPath -OutputPath $destination -Auto
|
||||
Assert ($result.ExitCode -eq 0 -and $result.Results[0].Status -eq 'Restored' -and $writes -eq $count+1 -and $result.ReadyRuleCredit -eq 0) 'Selected typed value restores without readiness credit.'
|
||||
$script:destination=Join-Path $root ([guid]::NewGuid().ToString('N'))
|
||||
$again=Invoke-WelaAuditRecovery -Action Restore -PlanPath $planPath -OutputPath $destination -Auto
|
||||
Assert ($again.Results[0].Status -eq 'AlreadyRecovered' -and $writes -eq $count+1) 'Observation of restored value is idempotent.'
|
||||
}
|
||||
}
|
||||
$zero=[pscustomobject]@{KeyExists=$true;ValueExists=$true;Value=0;Type='DWord'}
|
||||
foreach ($invalid in @(
|
||||
[pscustomobject]@{KeyExists=$true;ValueExists=$true;Value='0';Type='DWord'},
|
||||
[pscustomobject]@{KeyExists=$true;ValueExists=$true;Value=2;Type='DWord'},
|
||||
[pscustomobject]@{KeyExists=$true;ValueExists=$true;Value=0;Type='QWord'},
|
||||
[pscustomobject]@{KeyExists=$false;ValueExists=$true;Value=0;Type='DWord'},
|
||||
[pscustomobject]@{KeyExists=$true;ValueExists=$false;Value=0;Type=$null}
|
||||
)) {Save-Fixture $catalog[0] $invalid;Throws {New-WelaRecoveryPlan $journal $original @($entry.Id)} 'Only prior|inconsistent'}
|
||||
Save-Fixture $catalog[0] $zero;$final.Status='Failed';Save-Evidence
|
||||
Throws {New-WelaRecoveryPlan $journal $original @($entry.Id)} 'Applied'
|
||||
Save-Fixture $catalog[0] $zero;$entry.Target.Path+='\Other';Save-Evidence
|
||||
Throws {New-WelaRecoveryPlan $journal $original @($entry.Id)} 'Unsupported'
|
||||
Save-Fixture $catalog[0] $zero;$entry.Desired.Value=$true;Save-Evidence
|
||||
Throws {New-WelaRecoveryPlan $journal $original @($entry.Id)} 'Unsupported'
|
||||
Save-Fixture $catalog[0] $zero;$plan=New-WelaRecoveryPlan $journal $original @($entry.Id);$plan.NamedSources[0].Sha256='bad'
|
||||
Throws {Assert-WelaRecoverySources $plan} 'implementation changed'
|
||||
Throws {Open-WelaNamedRecoveryKey ([pscustomobject]@{Path='HKLM:\SOFTWARE\Other';Name='Unknown'})} 'Unknown'
|
||||
Initialize-WelaNamedRecoveryNative
|
||||
Assert ([Wela.NamedRegistryRecovery.Key]::SourceSha256 -eq (Get-FileHash (Join-Path $repo 'scripts/NamedRegistryRecoveryNative.cs')).Hash.ToLowerInvariant()) 'Compiled native helper binds exact source bytes.'
|
||||
} finally {Remove-Item -LiteralPath $root -Recurse -Force}
|
||||
$global:LASTEXITCODE=0
|
||||
Write-Host "Named registry recovery: $script:n assertions passed."
|
||||
@@ -0,0 +1,72 @@
|
||||
param([switch]$AllowDisposablePolicyWrite)
|
||||
$ErrorActionPreference='Stop'
|
||||
if ($env:OS -ne 'Windows_NT') {Write-Host 'Skipped: native Windows required.';exit 0}
|
||||
if (-not $AllowDisposablePolicyWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted') {throw 'Explicit opt-in on a disposable GitHub-hosted runner is required.'}
|
||||
$repo=Split-Path $PSScriptRoot -Parent
|
||||
Import-Module (Join-Path $repo 'modules/AuditProfiles.psm1') -Force
|
||||
. (Join-Path $repo 'scripts/Configuration.ps1')
|
||||
. (Join-Path $repo 'scripts/ControlApplicability.ps1')
|
||||
. (Join-Path $repo 'scripts/AuditRecovery.ps1')
|
||||
$temp=Join-Path ([IO.Path]::GetTempPath()) ('wela-native-named-recovery-'+[guid]::NewGuid().ToString('N'))
|
||||
$null=New-Item -ItemType Directory -Path $temp
|
||||
$catalog=@(Get-WelaNamedRecoveryCatalog)
|
||||
$safety=@(foreach ($item in $catalog) {[pscustomobject]@{Definition=$item;Before=(Get-WelaRegistryState $item.Path $item.Name)}})
|
||||
$created=New-Object 'System.Collections.Generic.List[string]'
|
||||
foreach ($item in $catalog) {
|
||||
$path=$item.Path
|
||||
while (-not (Test-Path -LiteralPath $path)) {if (-not $created.Contains($path)) {$created.Add($path)};$path=$path.Substring(0,$path.LastIndexOf('\'))}
|
||||
}
|
||||
Write-WelaRecoveryArtifact (Join-Path $temp 'safety-before.json') $safety
|
||||
$sentinel='WelaRecoveryFixture_'+[guid]::NewGuid().ToString('N');$sentinelPath=$null
|
||||
try {
|
||||
$sequence=0
|
||||
foreach ($definition in $catalog) {
|
||||
foreach ($absent in @($false,$true)) {
|
||||
$sequence++;$case=Join-Path $temp ('case-'+$sequence);$null=New-Item -ItemType Directory $case
|
||||
New-WelaRegistryKey $definition.Path
|
||||
if ($absent) {Remove-ItemProperty -LiteralPath $definition.Path -Name $definition.Name -ErrorAction SilentlyContinue}
|
||||
else {$null=New-ItemProperty -LiteralPath $definition.Path -Name $definition.Name -Value 0 -PropertyType DWord -Force}
|
||||
$before=Get-WelaNamedRecoveryObservation $definition
|
||||
$context=New-WelaConfigurationContext -Auto -BackupPath (Join-Path $case 'backup')
|
||||
Set-WelaRegistryControl -Context $context -Path $definition.Path -Name $definition.Name -Value 1 -Type DWord
|
||||
$original=Join-Path $case 'original.json'
|
||||
$report=Complete-WelaConfiguration -Context $context -ResultsPath $original
|
||||
if ($report.ExitCode -ne 0 -or $report.Results[0].Status -ne 'Applied') {throw 'Native configuration did not create Applied evidence.'}
|
||||
$plan=Invoke-WelaAuditRecovery -JournalPath (Join-Path $context.BackupPath 'before.jsonl') -OriginalResultsPath $original -ControlId $definition.Id -OutputPath (Join-Path $case 'plan')
|
||||
$planPath=Join-Path $plan.OutputPath 'plan.json'
|
||||
$dry=Invoke-WelaAuditRecovery -Action Restore -PlanPath $planPath -DryRun
|
||||
if ($dry.Results[0].Status -ne 'WouldRestore' -or (Get-WelaRegistryState $definition.Path $definition.Name).Value -ne 1) {throw 'Native dry-run changed the selected value.'}
|
||||
# A neighboring value change must block before any recovery mutation.
|
||||
$sentinelPath=$definition.Path;$null=New-ItemProperty -LiteralPath $sentinelPath -Name $sentinel -Value 'owned-fixture' -PropertyType String
|
||||
$refused=$false
|
||||
try {$null=Invoke-WelaAuditRecovery -Action Restore -PlanPath $planPath -DryRun} catch {if ($_.Exception.Message -notmatch 'independently rebuilt') {throw};$refused=$true}
|
||||
if (-not $refused -or (Get-WelaRegistryState $definition.Path $definition.Name).Value -ne 1) {throw 'Neighbor drift did not refuse safely.'}
|
||||
Remove-ItemProperty -LiteralPath $sentinelPath -Name $sentinel;$sentinelPath=$null
|
||||
$result=Invoke-WelaAuditRecovery -Action Restore -PlanPath $planPath -OutputPath (Join-Path $case 'recovered') -Auto
|
||||
$after=Get-WelaNamedRecoveryObservation $definition
|
||||
if ($result.ExitCode -ne 0 -or $result.Results[0].Status -ne 'Restored' -or (Get-WelaRecoveryKey (Get-WelaNamedRecoveryState $after)) -cne (Get-WelaRecoveryKey (Get-WelaNamedRecoveryState $before)) -or -not [Wela.NamedRegistryRecovery.Key]::Preserved($before,$after)) {throw ($result | ConvertTo-Json -Depth 20)}
|
||||
$again=Invoke-WelaAuditRecovery -Action Restore -PlanPath $planPath -OutputPath (Join-Path $case 'again') -Auto
|
||||
if ($again.ExitCode -ne 0 -or $again.Results[0].Status -ne 'AlreadyRecovered') {throw 'Native named-value recovery is not idempotent.'}
|
||||
Write-Host "Native named recovery passed: $($definition.Name), prior absence=$absent; typed value, neighboring values, children, owner/group/DACL preserved."
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
$errors=@()
|
||||
if ($sentinelPath) {try {Remove-ItemProperty -LiteralPath $sentinelPath -Name $sentinel -ErrorAction Stop} catch {$errors+=$_.Exception.Message}}
|
||||
foreach ($saved in $safety) {
|
||||
try {
|
||||
$definition=$saved.Definition;$before=$saved.Before
|
||||
if ($before.ValueExists) {$null=New-ItemProperty -LiteralPath $definition.Path -Name $definition.Name -Value $before.Value -PropertyType $before.Type -Force}
|
||||
elseif (Test-Path -LiteralPath $definition.Path) {Remove-ItemProperty -LiteralPath $definition.Path -Name $definition.Name -ErrorAction SilentlyContinue}
|
||||
} catch {$errors+=$_.Exception.Message}
|
||||
}
|
||||
foreach ($path in ($created | Sort-Object Length -Descending)) {
|
||||
try {if (Test-Path -LiteralPath $path) {$key=Get-Item -LiteralPath $path;if ($key.GetValueNames().Count -or $key.GetSubKeyNames().Count) {throw "Owned fixture-created key is no longer empty: $path"};Remove-Item -LiteralPath $path -ErrorAction Stop}} catch {$errors+=$_.Exception.Message}
|
||||
}
|
||||
foreach ($saved in $safety) {
|
||||
try {if ((Get-WelaRecoveryKey (Get-WelaRegistryState $saved.Definition.Path $saved.Definition.Name)) -cne (Get-WelaRecoveryKey $saved.Before)) {throw "Safety restoration differs: $($saved.Definition.Name)"}} catch {$errors+=$_.Exception.Message}
|
||||
}
|
||||
if ($errors.Count) {throw "Native registry safety restoration failed; evidence retained at $temp : $($errors -join '; ')"}
|
||||
Remove-Item -LiteralPath $temp -Recurse -Force
|
||||
}
|
||||
$global:LASTEXITCODE=0
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
## 2.2.0 [2026/xx/xx] - Dev Release
|
||||
|
||||
- 完了したジャーナルから、プロセス作成・PowerShell ログ用の 3 つの DWORD 値を `audit-recovery` で復元できるようにしました。ネイティブ API による値のみの復元、他の値の変更検知、レジストリキーの保持に対応します。 ([#435](https://github.com/Yamato-Security/WELA/pull/435))
|
||||
|
||||
**改善:**
|
||||
|
||||
- 明示した IPv4 リゾルバーに固定の無害な `wela-<nonce>.wela.test.` A クエリを送信し、Windows 標準イベント 3008 と照合する任意実行の `dns-client-probe` を追加しました。既定では前提条件の計画だけを行い、実行時は時間・件数を制限した証拠をハッシュ付きで保護します。Windows 設定は変更せず、元の DNS ルールのチャネル不一致と相関の限界を保持し、Sigma の評価には加算しません。Server 2022/2025 と PowerShell 5.1/7 向けに使い捨ての権威 DNS・ループバック検証を追加しました。 (#434) (@Shirofune-Security)
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
## 2.2.0 [2026/xx/xx] - Dev Release
|
||||
|
||||
- Extend `audit-recovery` to restore three named process/PowerShell logging DWORDs from completed journals, with native value-only writes, neighboring-data guards and retained registry keys. ([#435](https://github.com/Yamato-Security/WELA/pull/435))
|
||||
|
||||
**Improvements:**
|
||||
|
||||
- Added opt-in `dns-client-probe` for one fixed benign `wela-<nonce>.wela.test.` A lookup to an explicitly selected IPv4 resolver, with default prerequisite planning, bounded native DNS execution and exact local event 3008 correlation. Private hashed evidence preserves native status, context and correlation limits without changing Windows settings. The original DNS rule/channel mismatch remains explicit and Sigma credit stays zero. Added disposable authoritative-loopback DNS acceptance tests for Server 2022/2025 and PowerShell 5.1/7. (#434) (@Shirofune-Security)
|
||||
|
||||
Reference in new issue
Block a user