mirror of
https://github.com/Yamato-Security/WELA.git
synced 2026-11-15 09:24:11 +01:00
Add guarded value-only recovery for named logging DWORDs
This commit is contained in:
1 parent
fd7a7924aa
commit
9327d04fc9
12 files changed
+390
-6
No files matched your search
@@ -58,3 +58,8 @@ tests/SelectedSaclFixtureProtection.cs text eol=lf
|
||||
/scripts/WmiNamespaceAuditing.ps1 text eol=lf
|
||||
/scripts/WefArrival.ps1 text eol=lf
|
||||
/tests/WmiProbe*.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
|
||||
@@ -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 による値のみの復元、他の値の変更検知、レジストリキーの保持に対応します。
|
||||
|
||||
**改善:**
|
||||
|
||||
- 読み取り専用の `channel-read` を追加し、実際の実行トークンで選択したWindows標準ローカルログを確認します。メタデータやACLとは独立して拒否・不在・空・イベント読取を区別し、トークンと環境の変化を検出して保護された限定レポートに記録します。Windows設定の変更やSigma利用可能数への加算は行いません。使い捨て標準ユーザーでServer 2022/2025・PowerShell 5.1/7の拒否と読取を検証します。 (#432) (@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.
|
||||
|
||||
**Improvements:**
|
||||
|
||||
- Added read-only `channel-read` to test actual current-token access to selected built-in local logs. Native query outcomes distinguish denied, missing, empty and observed-event reads independently of metadata/ACL inspection; token/context checks and bounded private reports preserve uncertainty without changing Windows settings or granting Sigma credit. Disposable standard-user denial/read tests cover Server 2022/2025 and PowerShell 5.1/7. (#432) (@Shirofune-Security)
|
||||
|
||||
+17
-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,19 @@ 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.
|
||||
@@ -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 による値のみの復元、他の値の変更検知、レジストリキーの保持に対応します。
|
||||
|
||||
**改善:**
|
||||
|
||||
- 読み取り専用の `channel-read` を追加し、実際の実行トークンで選択したWindows標準ローカルログを確認します。メタデータやACLとは独立して拒否・不在・空・イベント読取を区別し、トークンと環境の変化を検出して保護された限定レポートに記録します。Windows設定の変更やSigma利用可能数への加算は行いません。使い捨て標準ユーザーでServer 2022/2025・PowerShell 5.1/7の拒否と読取を検証します。 (#432) (@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.
|
||||
|
||||
**Improvements:**
|
||||
|
||||
- Added read-only `channel-read` to test actual current-token access to selected built-in local logs. Native query outcomes distinguish denied, missing, empty and observed-event reads independently of metadata/ACL inspection; token/context checks and bounded private reports preserve uncertainty without changing Windows settings or granting Sigma credit. Disposable standard-user denial/read tests cover Server 2022/2025 and PowerShell 5.1/7. (#432) (@Shirofune-Security)
|
||||
|
||||
Reference in new issue
Block a user