From 9327d04fc9a4a3297c310e3c549f31f8776a8e6f Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:34:13 +0900 Subject: [PATCH 1/2] Add guarded value-only recovery for named logging DWORDs --- .gitattributes | 5 ++ .github/workflows/audit-recovery.yml | 14 +++ CHANGELOG-Japanese.md | 2 + CHANGELOG.md | 2 + docs/audit-recovery.md | 18 +++- scripts/AuditRecovery.ps1 | 34 +++++-- scripts/NamedRegistryRecovery.ps1 | 74 +++++++++++++++ scripts/NamedRegistryRecoveryNative.cs | 89 +++++++++++++++++++ tests/NamedRegistryRecovery.Tests.ps1 | 82 +++++++++++++++++ tests/NamedRegistryRecovery.Windows.Tests.ps1 | 72 +++++++++++++++ website/docs/resources/changelog.ja.md | 2 + website/docs/resources/changelog.md | 2 + 12 files changed, 390 insertions(+), 6 deletions(-) create mode 100644 scripts/NamedRegistryRecovery.ps1 create mode 100644 scripts/NamedRegistryRecoveryNative.cs create mode 100644 tests/NamedRegistryRecovery.Tests.ps1 create mode 100644 tests/NamedRegistryRecovery.Windows.Tests.ps1 diff --git a/.gitattributes b/.gitattributes index 7f0dff24..308635ec 100644 --- a/.gitattributes +++ b/.gitattributes @@ -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 diff --git a/.github/workflows/audit-recovery.yml b/.github/workflows/audit-recovery.yml index fdc1f6a1..c714d907 100644 --- a/.github/workflows/audit-recovery.yml +++ b/.github/workflows/audit-recovery.yml @@ -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 diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index fbb5f432..a7c39f38 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -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) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f47af61..b1e5179b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/docs/audit-recovery.md b/docs/audit-recovery.md index f2a0812a..7dffda2b 100644 --- a/docs/audit-recovery.md +++ b/docs/audit-recovery.md @@ -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. diff --git a/scripts/AuditRecovery.ps1 b/scripts/AuditRecovery.ps1 index f0d826ec..a76be859 100644 --- a/scripts/AuditRecovery.ps1 +++ b/scripts/AuditRecovery.ps1 @@ -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 } diff --git a/scripts/NamedRegistryRecovery.ps1 b/scripts/NamedRegistryRecovery.ps1 new file mode 100644 index 00000000..99d12405 --- /dev/null +++ b/scripts/NamedRegistryRecovery.ps1 @@ -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()} +} diff --git a/scripts/NamedRegistryRecoveryNative.cs b/scripts/NamedRegistryRecoveryNative.cs new file mode 100644 index 00000000..fcff9c15 --- /dev/null +++ b/scripts/NamedRegistryRecoveryNative.cs @@ -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 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;i65536)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();long total=0; + for(uint i=0;i1048576)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(); + for(uint i=0;i65536)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;}} + } +} diff --git a/tests/NamedRegistryRecovery.Tests.ps1 b/tests/NamedRegistryRecovery.Tests.ps1 new file mode 100644 index 00000000..087f6ba1 --- /dev/null +++ b/tests/NamedRegistryRecovery.Tests.ps1 @@ -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." diff --git a/tests/NamedRegistryRecovery.Windows.Tests.ps1 b/tests/NamedRegistryRecovery.Windows.Tests.ps1 new file mode 100644 index 00000000..ebd7ff2c --- /dev/null +++ b/tests/NamedRegistryRecovery.Windows.Tests.ps1 @@ -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 diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index b91d1c7d..c6d03a14 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -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) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index ccaa1794..3124cfd9 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -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) From 1002f14b81de9cdb78c746f7a0f26e80501de7e6 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:35:10 +0900 Subject: [PATCH 2/2] Link named registry recovery changelog and native API contracts --- CHANGELOG-Japanese.md | 2 +- CHANGELOG.md | 2 +- docs/audit-recovery.md | 2 ++ website/docs/resources/changelog.ja.md | 2 +- website/docs/resources/changelog.md | 2 +- 5 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index a7c39f38..526548eb 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -2,7 +2,7 @@ ## 2.2.0 [2026/xx/xx] - Dev Release -- 完了したジャーナルから、プロセス作成・PowerShell ログ用の 3 つの DWORD 値を `audit-recovery` で復元できるようにしました。ネイティブ API による値のみの復元、他の値の変更検知、レジストリキーの保持に対応します。 +- 完了したジャーナルから、プロセス作成・PowerShell ログ用の 3 つの DWORD 値を `audit-recovery` で復元できるようにしました。ネイティブ API による値のみの復元、他の値の変更検知、レジストリキーの保持に対応します。 ([#435](https://github.com/Yamato-Security/WELA/pull/435)) **改善:** diff --git a/CHANGELOG.md b/CHANGELOG.md index b1e5179b..81c437a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 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. +- 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:** diff --git a/docs/audit-recovery.md b/docs/audit-recovery.md index 7dffda2b..0f5e8acd 100644 --- a/docs/audit-recovery.md +++ b/docs/audit-recovery.md @@ -38,3 +38,5 @@ Planning records the native path plus bounded hashes of all other values, direct 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. diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index c6d03a14..7757d89c 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -5,7 +5,7 @@ ## 2.2.0 [2026/xx/xx] - Dev Release -- 完了したジャーナルから、プロセス作成・PowerShell ログ用の 3 つの DWORD 値を `audit-recovery` で復元できるようにしました。ネイティブ API による値のみの復元、他の値の変更検知、レジストリキーの保持に対応します。 +- 完了したジャーナルから、プロセス作成・PowerShell ログ用の 3 つの DWORD 値を `audit-recovery` で復元できるようにしました。ネイティブ API による値のみの復元、他の値の変更検知、レジストリキーの保持に対応します。 ([#435](https://github.com/Yamato-Security/WELA/pull/435)) **改善:** diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index 3124cfd9..ac8836bb 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -5,7 +5,7 @@ ## 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. +- 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:**