Merge pull request #456 from Shirofune-Security/test/373-native-registry-sacl-lifecycle

test: validate public registry SACL lifecycle on owned native hives
This commit is contained in:
田中ザック Isaac Mathis authored and GitHub committed 2026-09-22 10:11:25 +09:00
commit af28883f64
13 files changed
+354 -1

No files matched your search

+2
View File
@@ -74,6 +74,8 @@ tests/SelectedSaclFixtureProtection.cs text eol=lf
/scripts/FileAccessProbe* text eol=lf
/tests/FileAccessProbe* text eol=lf
# Disposable public registry lifecycle fixture bytes are retained in evidence.
/tests/RegistrySacl* text eol=lf
/scripts/WecAuthorization* text eol=lf
/tests/WecAuthorization* text eol=lf
# Reviewed channel restoration binds exact installed source bytes.
@@ -0,0 +1,44 @@
name: Native public registry SACL lifecycle
on:
push:
branches: ['**']
paths:
- 'tests/RegistrySacl*'
- 'scripts/SelectedSacl*'
- 'scripts/TargetedSaclPlanning.ps1'
- 'WELA.ps1'
- '.github/workflows/registry-sacl-lifecycle.yml'
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
registry-sacl-lifecycle:
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
os: [windows-2022, windows-2025]
engine: [powershell, pwsh]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
- name: Actual public registry lifecycle in Windows PowerShell 5.1
if: matrix.engine == 'powershell'
shell: powershell
run: |
./tests/RegistrySaclLifecycle.Tests.ps1
./tests/RegistrySaclLifecycle.Windows.Tests.ps1 -AllowDisposableHiveWrite
- name: Actual public registry lifecycle in PowerShell 7
if: matrix.engine == 'pwsh'
shell: pwsh
run: |
./tests/RegistrySaclLifecycle.Tests.ps1
./tests/RegistrySaclLifecycle.Windows.Tests.ps1 -AllowDisposableHiveWrite
- name: Retain public receipts, actual XML and exact cleanup
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: registry-sacl-${{ matrix.os }}-${{ matrix.engine }}
path: ${{ runner.temp }}/wela-registry-sacl-*/
if-no-files-found: error
+1 -1
View File
@@ -41,7 +41,7 @@ jobs:
Copy-Item -Recurse -Path ./scripts -Destination release-binaries/
Copy-Item -Recurse -Path ./modules -Destination release-binaries/
New-Item -ItemType Directory -Path release-binaries/docs -Force | Out-Null
Copy-Item -Path ./docs/gpo-audit-packages.md, ./docs/gpo-package-deployment.md, ./docs/gpo-creation.md, ./docs/wmi-probe.md, ./docs/firewall-logging.md, ./docs/firewall-logging-recovery.md, ./docs/ipsec-prerequisites.md, ./docs/wec-state.md, ./docs/wec-authorization.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-ingress.md, ./docs/capi2-probe.md, ./docs/powershell-transcription.md, ./docs/transcription-recovery.md, ./docs/eventlog-recovery.md, ./docs/failed-logon-probe.md, ./docs/wec-listener.md, ./docs/file-access-probe.md, ./docs/applocker-script-probe.md, ./docs/applocker-probe.md, ./docs/channel-recovery.md -Destination release-binaries/docs/
Copy-Item -Path ./docs/gpo-audit-packages.md, ./docs/gpo-package-deployment.md, ./docs/gpo-creation.md, ./docs/wmi-probe.md, ./docs/firewall-logging.md, ./docs/firewall-logging-recovery.md, ./docs/ipsec-prerequisites.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-ingress.md, ./docs/capi2-probe.md, ./docs/powershell-transcription.md, ./docs/transcription-recovery.md, ./docs/eventlog-recovery.md, ./docs/failed-logon-probe.md, ./docs/wec-listener.md, ./docs/file-access-probe.md, ./docs/applocker-script-probe.md, ./docs/applocker-probe.md, ./docs/selected-sacl-configuration.md, ./docs/targeted-sacl-planning.md, ./docs/native-registry-sacl-validation.md, ./docs/wec-authorization.md, ./docs/channel-recovery.md -Destination release-binaries/docs/
- name: Set Artifact Name
if: contains(matrix.info.os, 'windows') == true
+2
View File
@@ -4,6 +4,8 @@
**改善:**
- Server 2022/2025 と両 PowerShell エンジンで、公開 `targeted-sacl` のレジストリ操作を検証する使い捨てテストを追加しました。テスト専用の新規ハイブをマウントし、対象選択、DryRun、監査 ACE の追加、古い計画・前提条件不足の拒否、冪等性と厳密に対応付けた Security4657 を確認します。無関係な ACE・型付き値の保持、監査ポリシー・トークンの復元、所有ハイブのアンロードと削除の証跡を保存します。製品側のハイブ読み込みや Sigma 準備完了の判定は追加しません。(関連 #373) (@Shirofune-Security)
- 既存の無効なネイティブサブスクリプション1件の明示的なソースSID一覧を変更する、オプトインの `wec-authorization` Plan/Apply を追加しました。レビュー済みハッシュ、ホスト・トークン・実装・定義全体の照合、永続化した変更前証跡、認可プロパティのみのネイティブ更新と読み戻しにより他の設定を保持し、変更不要と部分失敗を区別します。使い捨てWindowsテストで追加・削除・復元・拒否・後処理を検証しますが、SID解決、ドメイン認証、転送、Sigmaの有効性は主張しません。(@Shirofune-Security)
- カスタム監査プロファイルの公開 Plan、DryRun、Configure、任意項目、再実行、Audit を Server 2022/2025 と Windows PowerShell 5.1/PowerShell 7 の破棄可能な環境で検証します。実際の優先設定、厳密値・最小値・維持の動作、変更前ジャーナル、全59監査マスクと復元を確認します。 (@Shirofune-Security)
+2
View File
@@ -4,6 +4,8 @@
**Improvements:**
- Added disposable public `targeted-sacl` registry lifecycle validation on Server 2022/2025 and both PowerShell engines. A fixture-owned mounted hive exercises reviewed selection, DryRun, additive configuration, stale/prerequisite refusal and idempotence, followed by one precisely attributed Security4657. Retained native receipts verify unrelated ACE/value preservation and exact audit-policy, token and owned-hive cleanup; production does not load hives or gain Sigma credit. (Related #373) (@Shirofune-Security)
- Added opt-in `wec-authorization` Plan/Apply for the explicit source SID list of one existing disabled native subscription. Reviewed hashes, host/token/source and full-definition guards, durable pending evidence, one native authorization setter and readback preserve other settings; no-op and partial-save outcomes stay explicit. Native disposable tests cover add/remove/restore, refusals and cleanup without SID-resolution, domain authentication, forwarding or Sigma claims. (@Shirofune-Security)
- Add disposable native acceptance for public custom-profile Plan, DryRun, Configure, optional controls, idempotence and Audit on Server 2022/2025 under Windows PowerShell 5.1/PowerShell 7. Verify exact/minimum/preserve semantics, real precedence, original journals and all 59 masks, with exact fixture restoration. (@Shirofune-Security)
+34
View File
@@ -0,0 +1,34 @@
# Public registry SACL lifecycle acceptance
The `Native public registry SACL lifecycle` workflow tests the existing public `targeted-sacl` command on disposable GitHub-hosted Windows Server 2022 and 2025 runners, each with native Windows PowerShell 5.1 and PowerShell 7. It is an acceptance fixture, not a new configuration command. Native job results and retained artifacts must be reviewed before claiming a particular matrix passed.
## Owned target and public lifecycle
The fixture creates a nonce-marked seed key below its own HKCU, saves it to a new private file, and loads that file under a new synthetic SID below HKU. It never loads an existing user's offline hive or modifies a catalog system key. Existing backup/restore privileges are enabled only around the native save/load/unload calls and their prior attributes are restored. Impersonation and name collisions are refused. The unchanged public catalog resolves the loaded SID's RunOnce definition from `asd-native-2021-10`; the missing ProfileList metadata remains explicit in user-inventory diagnostics.
Only the fixture prepares typed audit precedence and the Registry success/failure subcategory. It creates a sentinel DWORD and a distinct SYSTEM QueryValue success audit ACE before exercising the selected target through actual `WELA.ps1` processes:
- Plan with missing inheritance consent is blocked and Configure refuses before creating a journal.
- A reviewed Plan captures the exact selected SID/path, native descriptor and complete empty descendant inventory.
- DryRun leaves the descriptor unchanged and creates no write journal.
- Configure appends exactly the reviewed audit ACE. Independent native readback verifies original owner/group/DACL/control flags, original binary audit ACEs and the unrelated typed value. Pending, Confirmed and descendant-observation receipts agree with the independent observations.
- Replaying the stale plan fails before another journal. A fresh plan and Configure report `AlreadyCompliant`, preserve exact state and write no mutation receipts.
- Removing the prerequisite Registry audit bits makes planning blocked and Configure fail before journaling; the public command does not enable auditing.
The chosen RunOnce target has no child keys. Populated and protected subtree behavior remains covered separately by the existing descendant fixture. This fixture does not establish production-tree, redirected-user, DC/CA or future-child behavior.
## One actual registry event
After public Configure succeeds, the fixture records a native Security event watermark, then performs exactly one `RegSetValueExW` call to create a fresh nonce REG_SZ. It reads the value's type and exact bytes back on that same native handle. Precise UTC receipts separately record write start, return and completion of this measured write/readback phase; no timestamp padding is added.
A bounded native Security query must return exactly one matching 4657 from the observed phase, with the exact provider/version/task/success keyword, computer, newer record ID, subject SID/logon ID, process ID/executable, raw registry handle, native object path, value name, creation operation, REG_SZ type and nonce value. Event candidates, exact XML, operation receipt and artifact hashes are retained. Portable negative fixtures reject wrong attribution, old/out-of-window records, duplicate fields and DTD-bearing XML. A missing or ambiguous event fails acceptance; it does not relax attribution.
This is evidence for one local registry **value** creation under the fixture's prepared policy. It does not prove all registry operations, production persistence, downstream forwarding, collector access or Sigma execution. Public reports retain `GenerationReadiness=Conditional` and `UsableRuleCredit=0`.
## Cleanup and evidence
Cleanup runs even after an assertion fails. It restores the original selected Registry audit mask and original precedence type/value or absence, then compares every one of the 59 audit masks. It checks the entire primary-token groups/privilege snapshot, unloads only the marker-verified owned hive, removes only its exact unchanged seed, and compares the complete original HKU mount inventory. The private backing files are deleted only after unload and inventory verification. Failure to restore or unload fails the job and remains explicit in `cleanup.json`.
The artifact retains public reports/journals, independent before/after descriptors, exact event XML, native operation and cleanup evidence, and SHA-256 hashes. Successful cleanup retains no backing hive file. This test-only helper is not imported by WELA and is not packaged as a product hive-management feature.
Primary references: Microsoft [RegSaveKeyExW](https://learn.microsoft.com/en-us/windows/win32/api/winreg/nf-winreg-regsavekeyexw), [RegLoadKeyW](https://learn.microsoft.com/en-us/windows/win32/api/winreg/nf-winreg-regloadkeyw), [RegUnLoadKeyW](https://learn.microsoft.com/en-us/windows/win32/api/winreg/nf-winreg-regunloadkeyw), and [Security event 4657](https://learn.microsoft.com/en-us/windows/security/threat-protection/auditing/event-4657).
+2
View File
@@ -84,6 +84,8 @@ The Windows disposable fixture now uses populated file and registry trees, verif
Mocked tests cover selection, source-specific masks, unsupported consent, source/plan/target races, denied reads, partial writes, non-SACL drift, pending/confirmed receipts, idempotence and public command guards. The Windows workflow explicitly permits mutations only on GitHub-hosted disposable Server 2022/2025 runners: it creates owned temporary file/registry targets, temporarily enables their two audit subcategories and precedence, adds audit ACEs through the real adapter, and searches for benign 4663/4657 events matching the exact targets. It restores all original audit masks and typed precedence and removes only owned targets. This fixture does not modify any catalog system target.
The separate [public registry lifecycle fixture](native-registry-sacl-validation.md) mounts a newly saved, fixture-owned hive under a fresh synthetic user SID. The unchanged public catalog resolves its RunOnce key, then actual CLI Plan/DryRun/Configure calls exercise the reviewed lifecycle and one exact local 4657. Only the fixture loads/unloads hives and prepares auditing; the product behavior above is unchanged. This leaf fixture does not replace populated-tree inheritance validation.
Native CI results must be reviewed before claiming those test cases passed. Windows 11, DC/CA, user redirection, large/changing production trees, forwarding and actual Sigma/backend execution remain separate acceptance work. Every report remains `GenerationReadiness=Conditional` with `UsableRuleCredit=0`.
Primary API references: [GetSecurityInfo](https://learn.microsoft.com/en-us/windows/win32/api/aclapi/nf-aclapi-getsecurityinfo), [SetSecurityInfo and inheritance](https://learn.microsoft.com/en-us/windows/win32/api/aclapi/nf-aclapi-setsecurityinfo), [registry open/link behavior](https://learn.microsoft.com/en-us/windows/win32/api/winreg/nf-winreg-regopenkeyexw), [file handle and sharing flags](https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilew).
+82
View File
@@ -0,0 +1,82 @@
// Disposable CI fixture only. Never imported by WELA product code.
using System;
using System.ComponentModel;
using System.IO;
using System.Runtime.InteropServices;
using Microsoft.Win32;
namespace Wela.RegistrySaclFixture {
sealed class Privilege : IDisposable {
[StructLayout(LayoutKind.Sequential)] struct Luid {public uint Low;public int High;}
[StructLayout(LayoutKind.Sequential)] struct Privileges {public uint Count;public Luid Id;public uint Attributes;}
[DllImport("kernel32.dll")] static extern IntPtr GetCurrentProcess();
[DllImport("kernel32.dll")] static extern IntPtr GetCurrentThread();
[DllImport("kernel32.dll",SetLastError=true)] static extern bool CloseHandle(IntPtr handle);
[DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenProcessToken(IntPtr process,uint access,out IntPtr token);
[DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenThreadToken(IntPtr thread,uint access,bool self,out IntPtr token);
[DllImport("advapi32.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern bool LookupPrivilegeValue(string system,string name,out Luid luid);
[DllImport("advapi32.dll",SetLastError=true)] static extern bool AdjustTokenPrivileges(IntPtr token,bool all,ref Privileges requested,uint size,out Privileges previous,out uint required);
IntPtr token;Privileges previous;
public Privilege(string name){
if(name!="SeBackupPrivilege"&&name!="SeRestorePrivilege")throw new InvalidOperationException("Unreviewed fixture privilege.");
IntPtr thread;if(OpenThreadToken(GetCurrentThread(),8,true,out thread)){CloseHandle(thread);throw new InvalidOperationException("Impersonated fixture refused.");}int error=Marshal.GetLastWin32Error();if(error!=1008)throw new Win32Exception(error);
if(!OpenProcessToken(GetCurrentProcess(),0x28,out token))throw new Win32Exception(Marshal.GetLastWin32Error());
try{Luid id;if(!LookupPrivilegeValue(null,name,out id))throw new Win32Exception(Marshal.GetLastWin32Error());Privileges request=new Privileges{Count=1,Id=id,Attributes=2};uint needed;bool ok=AdjustTokenPrivileges(token,false,ref request,(uint)Marshal.SizeOf(typeof(Privileges)),out previous,out needed);error=Marshal.GetLastWin32Error();if(!ok||error!=0)throw new Win32Exception(error,"Existing fixture privilege is required: "+name);}catch{CloseHandle(token);token=IntPtr.Zero;throw;}
}
public void Dispose(){if(token==IntPtr.Zero)return;try{Privileges ignored;uint needed;bool ok=AdjustTokenPrivileges(token,false,ref previous,(uint)Marshal.SizeOf(typeof(Privileges)),out ignored,out needed);int error=Marshal.GetLastWin32Error();if(!ok||error!=0)throw new Win32Exception(error,"Fixture privilege restoration failed.");}finally{CloseHandle(token);token=IntPtr.Zero;}}
}
public sealed class WriteReceipt {public string StartedUtc,ReturnedUtc,CompletedUtc,HandleId,ValueName,Value;public int Calls;public bool Success;}
public sealed class Hive : IDisposable {
[DllImport("kernel32.dll",ExactSpelling=true)] static extern void GetSystemTimePreciseAsFileTime(out long value);
static DateTime UtcNow(){long value;GetSystemTimePreciseAsFileTime(out value);return DateTime.FromFileTimeUtc(value);}
static readonly IntPtr HKCU=new IntPtr(unchecked((int)0x80000001)),HKU=new IntPtr(unchecked((int)0x80000003));
[DllImport("advapi32.dll",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegCreateKeyExW(IntPtr root,string path,int reserved,string cls,uint options,uint access,IntPtr security,out IntPtr result,out uint disposition);
[DllImport("advapi32.dll",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegOpenKeyExW(IntPtr root,string path,uint options,uint access,out IntPtr key);
[DllImport("advapi32.dll",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegSetValueExW(IntPtr key,string name,uint reserved,uint type,byte[] data,uint size);
[DllImport("advapi32.dll",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegQueryValueExW(IntPtr key,string name,IntPtr reserved,out uint type,byte[] data,ref uint size);
[DllImport("advapi32.dll")] static extern int RegCloseKey(IntPtr key);
[DllImport("advapi32.dll",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegSaveKeyExW(IntPtr key,string file,IntPtr security,uint flags);
[DllImport("advapi32.dll",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegLoadKeyW(IntPtr root,string name,string file);
[DllImport("advapi32.dll",CharSet=CharSet.Unicode,ExactSpelling=true)] static extern int RegUnLoadKeyW(IntPtr root,string name);
public readonly string Nonce,Sid,SeedPath,FilePath;
public bool SeedCreated{get;private set;} public bool Saved{get;private set;} public bool Loaded{get;private set;}
public Hive(string nonce,string file){
if(!System.Text.RegularExpressions.Regex.IsMatch(nonce??"","^[a-f0-9]{32}$"))throw new InvalidOperationException("Exact fixture nonce required.");
Nonce=nonce;Sid="S-1-5-21-"+Convert.ToUInt32(nonce.Substring(0,8),16)+"-"+Convert.ToUInt32(nonce.Substring(8,8),16)+"-"+Convert.ToUInt32(nonce.Substring(16,8),16)+"-1001";
SeedPath="Software\\WELARegistrySaclSeed_"+nonce;FilePath=Path.GetFullPath(file);
if(File.Exists(FilePath))throw new InvalidOperationException("Fixture hive file must be new.");
}
static void Check(int status,string operation){if(status!=0)throw new Win32Exception(status,operation);}
static bool Exists(RegistryKey root,string name){using(RegistryKey key=root.OpenSubKey(name)){return key!=null;}}
public void Prepare(){
if(SeedCreated||Saved||Loaded||Exists(Registry.Users,Sid))throw new InvalidOperationException("Fixture identity already exists.");
IntPtr key;uint disposition;Check(RegCreateKeyExW(HKCU,SeedPath,0,null,0,0xF003F,IntPtr.Zero,out key,out disposition),"Create owned seed");
try{if(disposition!=1)throw new InvalidOperationException("Seed collided with an existing key.");SeedCreated=true;
using(RegistryKey seed=Registry.CurrentUser.OpenSubKey(SeedPath,true)){seed.SetValue("WelaFixtureOwner",Nonce,RegistryValueKind.String);seed.Flush();}
using(new Privilege("SeBackupPrivilege")){Check(RegSaveKeyExW(key,FilePath,IntPtr.Zero,2),"Save owned seed to a new hive file");Saved=true;}
}finally{RegCloseKey(key);}
if(Exists(Registry.Users,Sid))throw new InvalidOperationException("Fixture HKU mount collided.");
using(new Privilege("SeBackupPrivilege"))using(new Privilege("SeRestorePrivilege")){Check(RegLoadKeyW(HKU,Sid,FilePath),"Load owned hive under its fresh SID");Loaded=true;}
AssertOwned();
}
public void AssertOwned(){using(RegistryKey key=Registry.Users.OpenSubKey(Sid)){if(!Loaded||key==null||key.GetValueKind("WelaFixtureOwner")!=RegistryValueKind.String||!String.Equals(key.GetValue("WelaFixtureOwner") as string,Nonce,StringComparison.Ordinal))throw new InvalidOperationException("Owned hive marker changed.");}}
public void CreateRunOnce(){AssertOwned();IntPtr key;uint disposition;Check(RegCreateKeyExW(HKU,Sid+"\\Software\\Microsoft\\Windows\\CurrentVersion\\RunOnce",0,null,0,0xF003F,IntPtr.Zero,out key,out disposition),"Create owned catalog RunOnce target");try{if(disposition!=1)throw new InvalidOperationException("Owned target unexpectedly exists.");}finally{RegCloseKey(key);}
using(RegistryKey target=Registry.Users.OpenSubKey(Sid+"\\Software\\Microsoft\\Windows\\CurrentVersion\\RunOnce",true)){target.SetValue("KeepTypedDword",321,RegistryValueKind.DWord);}
}
public void AssertValues(bool probe){using(RegistryKey key=Registry.Users.OpenSubKey(Sid+"\\Software\\Microsoft\\Windows\\CurrentVersion\\RunOnce")){if(key==null||key.ValueCount!=(probe?2:1)||key.GetValueKind("KeepTypedDword")!=RegistryValueKind.DWord||!(key.GetValue("KeepTypedDword") is int)||(int)key.GetValue("KeepTypedDword")!=321)throw new InvalidOperationException("Unrelated owned typed values changed.");if(probe&&(key.GetValueKind("WelaProbe_"+Nonce)!=RegistryValueKind.String||!String.Equals(key.GetValue("WelaProbe_"+Nonce) as string,Nonce,StringComparison.Ordinal)))throw new InvalidOperationException("Owned nonce value mismatch.");}}
public WriteReceipt WriteProbe(){
AssertOwned();AssertValues(false);string name="WelaProbe_"+Nonce;IntPtr key;
Check(RegOpenKeyExW(HKU,Sid+"\\Software\\Microsoft\\Windows\\CurrentVersion\\RunOnce",0,0x103,out key),"Open owned value writer");
try{
byte[] bytes=System.Text.Encoding.Unicode.GetBytes(Nonce+"\0");string handle="0x"+unchecked((ulong)key.ToInt64()).ToString("x");
DateTime start=UtcNow();Check(RegSetValueExW(key,name,0,1,bytes,(uint)bytes.Length),"Write one owned nonce REG_SZ");DateTime returned=UtcNow();
uint type,size=(uint)bytes.Length;byte[] actual=new byte[size];Check(RegQueryValueExW(key,name,IntPtr.Zero,out type,actual,ref size),"Read back same-handle owned nonce");
if(type!=1||size!=bytes.Length||Convert.ToBase64String(actual)!=Convert.ToBase64String(bytes))throw new InvalidOperationException("Probe write readback failed.");DateTime completed=UtcNow();
return new WriteReceipt{StartedUtc=start.ToString("o"),ReturnedUtc=returned.ToString("o"),CompletedUtc=completed.ToString("o"),HandleId=handle,ValueName=name,Value=Nonce,Calls=1,Success=true};
}finally{RegCloseKey(key);}
}
public void Dispose(){
if(Loaded){AssertOwned();using(new Privilege("SeBackupPrivilege"))using(new Privilege("SeRestorePrivilege")){Check(RegUnLoadKeyW(HKU,Sid),"Unload owned fixture hive");Loaded=false;}}
if(SeedCreated){using(RegistryKey seed=Registry.CurrentUser.OpenSubKey(SeedPath)){if(seed==null||seed.SubKeyCount!=0||seed.ValueCount!=1||seed.GetValueKind("WelaFixtureOwner")!=RegistryValueKind.String||!String.Equals(seed.GetValue("WelaFixtureOwner") as string,Nonce,StringComparison.Ordinal))throw new InvalidOperationException("Owned seed changed; refuse deletion.");}Registry.CurrentUser.DeleteSubKey(SeedPath,true);SeedCreated=false;}
}
}
}
+13
View File
@@ -0,0 +1,13 @@
$ErrorActionPreference='Stop';$root=Split-Path $PSScriptRoot -Parent
. (Join-Path $root 'scripts/WefArrival.ps1');. (Join-Path $PSScriptRoot 'RegistrySaclLifecycleEvidence.ps1')
$operation=[pscustomobject]@{Computer='FIXTURE';ProcessId=1234;Engine='C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe';NativePath='\REGISTRY\USER\S-1-5-21-1-2-3-1001\Software\Microsoft\Windows\CurrentVersion\RunOnce';RecordIdBefore=10;Token=[pscustomobject]@{Sid='S-1-5-21-4-5-6-500';AuthenticationId='0x1234'};Write=[pscustomobject]@{ValueName='WelaProbe_nonce';Value='nonce';HandleId='0x456';StartedUtc='2026-09-22T00:00:00.0001000Z';ReturnedUtc='2026-09-22T00:00:00.0001500Z';CompletedUtc='2026-09-22T00:00:00.0002000Z'}}
$xml=@"
<Event xmlns="http://schemas.microsoft.com/win/2004/08/events/event"><System><Provider Name="Microsoft-Windows-Security-Auditing" Guid="{54849625-5478-4994-a5ba-3e3b0328c30d}"/><EventID>4657</EventID><Version>0</Version><Task>12801</Task><Keywords>0x8020000000000000</Keywords><Channel>Security</Channel><Computer>FIXTURE</Computer><EventRecordID>11</EventRecordID><TimeCreated SystemTime="2026-09-22T00:00:00.0001800Z"/></System><EventData><Data Name="SubjectUserSid">S-1-5-21-4-5-6-500</Data><Data Name="SubjectUserName">Reader</Data><Data Name="SubjectDomainName">FIXTURE</Data><Data Name="SubjectLogonId">0x1234</Data><Data Name="ObjectName">$($operation.NativePath)</Data><Data Name="ObjectValueName">WelaProbe_nonce</Data><Data Name="HandleId">0x456</Data><Data Name="OperationType">%%1904</Data><Data Name="OldValueType">-</Data><Data Name="OldValue">-</Data><Data Name="NewValueType">%%1873</Data><Data Name="NewValue">nonce</Data><Data Name="ProcessId">0x4d2</Data><Data Name="ProcessName">$($operation.Engine)</Data></EventData></Event>
"@
$count=0
function Assert($value,$message){if(-not $value){throw $message};$script:count++}
Assert (Test-WelaRegistrySaclFixtureEvent $xml $operation) 'Exact native-schema creation must match the measured write/readback phase.'
foreach($change in @(@('4657','4663'),@('%%1904','%%1905'),@('%%1873','%%1874'),@('0x1234','0x1235'),@('0x456','0x457'),@('0x4d2','0x4d3'),@('6-500','6-501'),@('RunOnce','Other'),@('>nonce<','>other<'),@('0001800Z','0000999Z'),@('0001800Z','0002001Z'),@('EventRecordID>11','EventRecordID>10'),@('12801','12800'),@('8020000000000000','8010000000000000'))){Assert (-not(Test-WelaRegistrySaclFixtureEvent $xml.Replace($change[0],$change[1]) $operation)) ('Changed attribution must fail: '+$change[0])}
Assert (-not(Test-WelaRegistrySaclFixtureEvent $xml.Replace('</EventData>','<Data Name="ProcessId">0x4d2</Data></EventData>') $operation)) 'Duplicate identity fields must fail.'
Assert (-not(Test-WelaRegistrySaclFixtureEvent ('<!DOCTYPE Event [<!ENTITY x "x">]>'+$xml) $operation)) 'DTD input must fail.'
Write-Host "Passed $count registry SACL fixture evidence assertions."
@@ -0,0 +1,147 @@
# Mutating test fixture only: public WELA never loads hives or prepares audit policy.
param([switch]$AllowDisposableHiveWrite)
$ErrorActionPreference='Stop'
if(-not $AllowDisposableHiveWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted' -or [Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'Explicit disposable GitHub-hosted native Windows fixture only.'}
$script:ScriptRoot=Split-Path $PSScriptRoot -Parent
Import-Module (Join-Path $script:ScriptRoot 'modules/AuditProfiles.psm1') -ErrorAction Stop
foreach($name in @('Configuration','WefArrival','WmiProbe','ChannelRead','SelectedSaclConfiguration')){. (Join-Path $script:ScriptRoot ('scripts/'+$name+'.ps1'))}
. (Join-Path $PSScriptRoot 'RegistrySaclLifecycleEvidence.ps1')
Initialize-WelaWmiProbeNative
Add-Type -Path (Join-Path $PSScriptRoot 'RegistrySaclFixtureNative.cs') -ErrorAction Stop
$root=New-WelaArrivalOutput (Join-Path $env:RUNNER_TEMP ('wela-registry-sacl-'+[guid]::NewGuid().ToString('N'))) $script:ScriptRoot
$files=Join-Path $root 'owned-hive-files';$null=New-Item -ItemType Directory $files
function Save([string]$Name,$Value){[IO.File]::WriteAllText((Join-Path $root $Name),($Value|ConvertTo-Json -Depth 28),[Text.UTF8Encoding]::new($false))}
function Read-Receipt([string]$Name){ConvertFrom-WelaArrivalJson ([IO.File]::ReadAllText((Join-Path $root $Name)))}
function Hives {@([Microsoft.Win32.Registry]::Users.GetSubKeyNames()|Sort-Object)}
function Key($Value){ConvertTo-Json -InputObject $Value -Depth 24 -Compress}
$script:assertions=0
function Assert($Condition,[string]$Message){if(-not $Condition){throw $Message};$script:assertions++}
function Invoke-PublicFixture([string]$Name,[string[]]$Arguments,[int]$ExpectedExit=0,[string]$Diagnostic=''){
$old=$ErrorActionPreference
try{$ErrorActionPreference='Continue';$output=@(& $engine -NoLogo -NoProfile -NonInteractive -File (Join-Path $script:ScriptRoot 'WELA.ps1') targeted-sacl @Arguments -ResultsPath (Join-Path $root ($Name+'.json')) 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$old;$global:LASTEXITCODE=0}
Save ($Name+'-output.json') @($output|ForEach-Object {[string]$_})
Assert ($code -eq $ExpectedExit) ("Public $Name exited $code : "+($output -join ' '))
if($Diagnostic){Assert (($output -join ' ') -match $Diagnostic) ("Public $Name did not report the expected refusal: "+($output -join ' '))}
if($ExpectedExit -eq 0){Read-Receipt ($Name+'.json')}
}
function Assert-SelectedRow($Plan,[string]$Status){
Assert ($Plan.Kind -is [string] -and $Plan.Kind -ceq 'WelaSelectedSaclPlan' -and @($Plan.Rows).Count -eq 1 -and $Plan.Rows[0].Id -is [string] -and $Plan.Rows[0].Id -ceq $selected.Id -and $Plan.Rows[0].Status -is [string] -and $Plan.Rows[0].Status -ceq $Status) ('Exact public selected row must be '+$Status)
Assert ($Plan.Rows[0].Definition.UserSid -ceq $hive.Sid -and $Plan.Rows[0].Definition.Path -ieq $providerPath -and $Plan.GenerationReadiness -ceq 'Conditional' -and $Plan.UsableRuleCredit -eq 0) 'Public plan must remain bound to the owned target without generation/Sigma credit.'
}
function Assert-PreparedState {
Assert ((Key ((Get-WelaEffectiveAuditPolicy).GetEnumerator()|Sort-Object Key)) -ceq $preparedMasks) 'Public selected-SACL calls must preserve all 59 prepared audit masks.'
Assert ((Key (Get-WelaRegistryState $precedencePath $precedenceName)) -ceq $preparedPrecedence) 'Public selected-SACL calls must preserve typed precedence.'
Assert ((Key ([Wela.WmiProbe.Native]::Snapshot())) -ceq (Key $beforeToken)) 'Current process token groups and privilege attributes must remain exact.'
$hive.AssertValues($false)
}
$engine=(Get-Process -Id $PID).Path;$guid='0CCE921E-69AE-11D9-BED3-505054503030'
$precedencePath='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa';$precedenceName='SCENoApplyLegacyAuditPolicy'
$beforeHives=Hives;$beforeToken=[Wela.WmiProbe.Native]::Snapshot();$beforeMasks=Get-WelaEffectiveAuditPolicy;$beforePrecedence=Get-WelaRegistryState $precedencePath $precedenceName
Save 'before-hives.json' $beforeHives;Save 'before-token.json' $beforeToken;Save 'before-masks.json' $beforeMasks;Save 'before-precedence.json' $beforePrecedence
$hive=[Wela.RegistrySaclFixture.Hive]::new([guid]::NewGuid().ToString('N'),(Join-Path $files 'owned.dat'));$failure=$null;$cleanupErrors=@();$policyTouched=$false
try {
Assert ($beforeMasks.Count -eq 59) 'All 59 native audit subcategories must be observed before fixture mutation.'
$hive.Prepare();$hive.CreateRunOnce();$hive.AssertOwned();$hive.AssertValues($false)
Assert ((Key (Hives)) -ceq (Key (@($beforeHives)+$hive.Sid|Sort-Object))) 'Only the fresh owned SID hive may appear in HKU.'
$providerPath='Registry::HKEY_USERS\'+$hive.Sid+'\Software\Microsoft\Windows\CurrentVersion\RunOnce'
Save 'mounted.json' ([pscustomobject]@{Sid=$hive.Sid;File=$hive.FilePath;Seed=$hive.SeedPath;Loaded=$hive.Loaded;Target=$providerPath})
Assert ((Key ([Wela.WmiProbe.Native]::Snapshot())) -ceq (Key $beforeToken)) 'Fixture save/load must restore existing backup/restore privilege attributes.'
$catalog=Invoke-PublicFixture 'catalog' @('-TargetSaclProfile','asd-native-2021-10','-IncludeOptional')
$selectedRows=@($catalog.Catalog|Where-Object {$_.Definition.UserSid -ceq $hive.Sid -and $_.Definition.Path -ieq $providerPath})
Assert ($selectedRows.Count -eq 1 -and $selectedRows[0].Id -cmatch '^sacl-[a-f0-9]{24}$' -and $selectedRows[0].Definition.Resolution -ceq 'Resolved') 'Actual public catalog must resolve exactly one owned registry target.'
$selected=$selectedRows[0];Save 'selected.json' $selected
# Seed a distinct explicit SYSTEM QueryValue audit ACE to prove additive preservation.
Initialize-WelaSelectedSaclNative;$privilege=[Wela.SelectedSacl.Privilege]::new();$target=$null
try{$target=[Wela.SelectedSacl.Target]::new('Registry',(Resolve-WelaSelectedSaclNativePath $selected.Definition));$original=$target.Read();$seeded=$target.Add($original.Identity,$original.DescriptorBase64,'S-1-5-18',1,64)}finally{if($target){$target.Dispose()};$privilege.Dispose()}
Save 'before-public-snapshot.json' $seeded
$policyTouched=$true
Set-ItemProperty -LiteralPath $precedencePath -Name $precedenceName -Type DWord -Value 1
Set-WelaEffectiveAuditPolicy -Guid $guid -Mask 3 -Mode exact
$preparedMasks=Key ((Get-WelaEffectiveAuditPolicy).GetEnumerator()|Sort-Object Key);$preparedPrecedence=Key (Get-WelaRegistryState $precedencePath $precedenceName)
$selection=@('-TargetSaclProfile','asd-native-2021-10','-TargetSaclId',$selected.Id,'-IncludeOptional')
$noConsent=Invoke-PublicFixture 'no-consent-plan' ($selection+@('-TargetSaclAction','Plan'))
Assert-SelectedRow $noConsent 'Blocked'
$refusedBackup=Join-Path $root 'refused-no-consent'
Invoke-PublicFixture 'no-consent-configure' ($selection+@('-TargetSaclAction','Configure','-TargetSaclPlanPath',(Join-Path $root 'no-consent-plan.json'),'-BackupPath',$refusedBackup,'-Auto')) 1 'inheritance requires explicit'
Assert (-not(Test-Path -LiteralPath $refusedBackup)) 'Missing inheritance consent must fail before journal creation.'
$selection+=@('-TargetSaclIncludeChildren')
$plan=Invoke-PublicFixture 'plan' ($selection+@('-TargetSaclAction','Plan'))
Assert-SelectedRow $plan 'ChangeRequired'
Assert ($plan.Rows[0].DescendantsBefore.Status -ceq 'Complete' -and @($plan.Rows[0].DescendantsBefore.Entries).Count -eq 0) 'Owned RunOnce must have a complete empty descendant capture.'
Assert ((Get-WelaSelectedSaclSnapshotKey $plan.Rows[0].Before) -ceq (Get-WelaSelectedSaclSnapshotKey $seeded)) 'Read-only planning must preserve the entire native target descriptor/identity.'
$configure=$selection+@('-TargetSaclAction','Configure','-TargetSaclPlanPath',(Join-Path $root 'plan.json'),'-Auto')
$dry=Invoke-PublicFixture 'dry-run' ($configure+@('-DryRun'))
Assert ($dry.Kind -ceq 'WelaSelectedSaclResult' -and $dry.DryRun -is [bool] -and $dry.DryRun -and $dry.Results[0].Status -is [string] -and $dry.Results[0].Status -ceq 'Skipped' -and $null -eq $dry.BackupPath) 'Public DryRun must skip mutation and journal creation.'
Assert ((Get-WelaSelectedSaclSnapshotKey (Get-WelaSelectedSaclSnapshot $selected.Definition)) -ceq (Get-WelaSelectedSaclSnapshotKey $seeded)) 'DryRun must leave the exact native descriptor unchanged.'
Assert-PreparedState
$backup=Join-Path $root 'applied-journal'
$applied=Invoke-PublicFixture 'applied' ($configure+@('-BackupPath',$backup))
Assert ($applied.Results.Count -eq 1 -and $applied.Results[0].Status -is [string] -and $applied.Results[0].Status -ceq 'Applied' -and $applied.GenerationReadiness -ceq 'Conditional' -and $applied.UsableRuleCredit -eq 0) 'Exactly the owned target must be Applied without event or rule credit.'
$after=Get-WelaSelectedSaclSnapshot $selected.Definition;Save 'after-public-snapshot.json' $after
Assert-WelaSelectedSaclPreserved $seeded $after $plan.Rows[0].Ace
Assert ($after.Aces.Count -eq $seeded.Aces.Count+1) 'Public Configure must append exactly one ACE and preserve the unrelated explicit audit ACE.'
Assert ((Get-WelaSelectedSaclSnapshotKey $after) -ceq (Get-WelaSelectedSaclSnapshotKey $applied.Results[0].After)) 'Public result must match independent native final readback.'
$pending=Read-Receipt ('applied-journal/'+$selected.Id+'.pending.json');$confirmed=Read-Receipt ('applied-journal/'+$selected.Id+'.confirmed.json');$desc=Read-Receipt ('applied-journal/'+$selected.Id+'.descendants-observed.json')
Assert ($pending.State -is [string] -and $pending.State -ceq 'Pending' -and $null -eq $pending.After -and $confirmed.State -is [string] -and $confirmed.State -ceq 'Confirmed' -and $pending.Id -ceq $selected.Id -and $confirmed.Id -ceq $selected.Id -and $desc.Verification.Status -ceq 'Observed') 'Durable pending/confirmed and descendant receipts must name the exact selected target.'
Assert ((Get-WelaSelectedSaclSnapshotKey $pending.Before) -ceq (Get-WelaSelectedSaclSnapshotKey $seeded) -and (Get-WelaSelectedSaclSnapshotKey $confirmed.After) -ceq (Get-WelaSelectedSaclSnapshotKey $after)) 'Journal snapshots must agree with both independent native observations.'
Assert-PreparedState
$staleBackup=Join-Path $root 'refused-stale'
Invoke-PublicFixture 'stale-configure' ($configure+@('-BackupPath',$staleBackup)) 1 'changed; review a new plan'
Assert (-not(Test-Path -LiteralPath $staleBackup)) 'Replaying the old descriptor must fail before another journal.'
$fresh=Invoke-PublicFixture 'idempotent-plan' ($selection+@('-TargetSaclAction','Plan'))
Assert-SelectedRow $fresh 'AlreadyCompliant'
$idemBackup=Join-Path $root 'idempotent-journal'
$idempotent=Invoke-PublicFixture 'idempotent' ($selection+@('-TargetSaclAction','Configure','-TargetSaclPlanPath',(Join-Path $root 'idempotent-plan.json'),'-BackupPath',$idemBackup,'-Auto'))
Assert ($idempotent.Results[0].Status -is [string] -and $idempotent.Results[0].Status -ceq 'AlreadyCompliant' -and @(Get-ChildItem -LiteralPath $idemBackup -Force).Count -eq 0) 'Fresh idempotent public Configure must make no write receipts.'
Assert ((Get-WelaSelectedSaclSnapshotKey (Get-WelaSelectedSaclSnapshot $selected.Definition)) -ceq (Get-WelaSelectedSaclSnapshotKey $after)) 'Stale refusal and idempotent Configure must preserve exact native state.'
Set-WelaEffectiveAuditPolicy -Guid $guid -Mask 0 -Mode exact
$blocked=Invoke-PublicFixture 'missing-policy-plan' ($selection+@('-TargetSaclAction','Plan'))
Assert-SelectedRow $blocked 'Blocked'
$policyBackup=Join-Path $root 'refused-policy'
Invoke-PublicFixture 'missing-policy-configure' ($selection+@('-TargetSaclAction','Configure','-TargetSaclPlanPath',(Join-Path $root 'idempotent-plan.json'),'-BackupPath',$policyBackup,'-Auto')) 1 'outcomes are not already effective'
Assert (-not(Test-Path -LiteralPath $policyBackup) -and (Get-WelaEffectiveAuditPolicy)[$guid] -eq 0) 'Public Configure must refuse ineffective auditing without preparing policy or a journal.'
Set-WelaEffectiveAuditPolicy -Guid $guid -Mask 3 -Mode exact
Assert-PreparedState
$boundary=Read-WelaChannelLatest 'Security';Save 'security-boundary.json' $boundary
Assert ($boundary.Status -ceq 'EventObserved' -and $boundary.Event.RecordId -gt 0) 'Actual Security watermark must be observed before the single value write.'
$operation=[pscustomobject]@{Phase='OneRegSetValueAndSameHandleTypedReadback';Computer=$boundary.Event.Computer;ProcessId=$PID;Engine=$engine;NativePath=('\REGISTRY\USER\'+$hive.Sid+'\Software\Microsoft\Windows\CurrentVersion\RunOnce');RecordIdBefore=$boundary.Event.RecordId;Token=[Wela.WmiProbe.Native]::Snapshot();Write=$hive.WriteProbe();ObservedUtc=[Wela.WmiProbe.Native]::UtcNow().ToString('o')}
Save 'operation.json' $operation
Assert ($operation.Write.Calls -eq 1 -and $operation.Write.Success -is [bool] -and $operation.Write.Success -and (ConvertTo-WelaArrivalUtc $operation.Write.StartedUtc) -le (ConvertTo-WelaArrivalUtc $operation.Write.ReturnedUtc) -and (ConvertTo-WelaArrivalUtc $operation.Write.ReturnedUtc) -le (ConvertTo-WelaArrivalUtc $operation.Write.CompletedUtc) -and (ConvertTo-WelaArrivalUtc $operation.Write.CompletedUtc) -le (ConvertTo-WelaArrivalUtc $operation.ObservedUtc)) 'Exactly one native write and its same-handle typed readback must have ordered measured times.'
$hive.AssertValues($true)
$found=@{};$candidates=@{};$deadline=[DateTime]::UtcNow.AddSeconds(20)
$xpath="*[System[Provider[@Name='Microsoft-Windows-Security-Auditing'] and EventID=4657 and EventRecordID > $($boundary.Event.RecordId)]] and *[EventData[Data[@Name='ObjectName']='$($operation.NativePath)']]"
do {
$events=@();try{$events=@(Get-WinEvent -LogName Security -FilterXPath $xpath -MaxEvents 256 -ErrorAction Stop)}catch{if($_.FullyQualifiedErrorId -notmatch 'NoMatchingEventsFound'){throw}}
try {
if($events.Count -ge 256){throw 'Owned-target native event query reached its bound.'}
foreach($event in $events){$xml=$event.ToXml();if($xml.Length -gt 131072){throw 'Owned-target event exceeds its XML bound.'};$candidates[[string]$event.RecordId]=$xml;if(Test-WelaRegistrySaclFixtureEvent $xml $operation){$found[[string]$event.RecordId]=$xml}}
}finally{foreach($event in $events){if($event -is [IDisposable]){$event.Dispose()}}}
if($found.Count -eq 0){Start-Sleep -Milliseconds 250}
}while($found.Count -eq 0 -and [DateTime]::UtcNow -lt $deadline)
Save 'event-candidates.json' $candidates
Assert ($found.Count -eq 1) ('Expected exactly one attributable native4657; candidates='+$candidates.Count+' matches='+$found.Count)
[IO.File]::WriteAllText((Join-Path $root 'event.xml'),[string]@($found.Values)[0],[Text.UTF8Encoding]::new($false))
Assert ((Get-WelaSelectedSaclSnapshot $selected.Definition).DescriptorBase64 -ceq $after.DescriptorBase64) 'The value operation must preserve every native descriptor section.'
Assert ((Key ((Get-WelaEffectiveAuditPolicy).GetEnumerator()|Sort-Object Key)) -ceq $preparedMasks -and (Key (Get-WelaRegistryState $precedencePath $precedenceName)) -ceq $preparedPrecedence) 'Observed event delivery must not alter prepared auditing.'
Assert ((Key ([Wela.WmiProbe.Native]::Snapshot())) -ceq (Key $beforeToken)) 'The actual native value operation must preserve the full primary token.'
}catch{$failure=$_}finally {
if($policyTouched){
try{Set-WelaEffectiveAuditPolicy -Guid $guid -Mask $beforeMasks[$guid] -Mode exact}catch{$cleanupErrors+='Audit restore: '+$_.Exception.Message}
try{if($beforePrecedence.ValueExists){Set-ItemProperty -LiteralPath $precedencePath -Name $precedenceName -Type $beforePrecedence.Type -Value $beforePrecedence.Value}else{Remove-ItemProperty -LiteralPath $precedencePath -Name $precedenceName -ErrorAction Stop}}catch{$cleanupErrors+='Precedence restore: '+$_.Exception.Message}
}
try{$hive.Dispose()}catch{$cleanupErrors+='Hive unload/seed removal: '+$_.Exception.Message}
$hivesOk=$false;$tokenOk=$false;$masksOk=$false;$precedenceOk=$false
$afterHives=$null;$afterToken=$null;$masks=$null;$afterPrecedence=$null
try{$afterHives=Hives;$hivesOk=(Key $afterHives) -ceq (Key $beforeHives)}catch{$cleanupErrors+='HKU verification: '+$_.Exception.Message}
try{$afterToken=[Wela.WmiProbe.Native]::Snapshot();$tokenOk=(Key $afterToken) -ceq (Key $beforeToken)}catch{$cleanupErrors+='Token verification: '+$_.Exception.Message}
try{$masks=Get-WelaEffectiveAuditPolicy;$masksOk=(Key ($masks.GetEnumerator()|Sort-Object Key)) -ceq (Key ($beforeMasks.GetEnumerator()|Sort-Object Key))}catch{$cleanupErrors+='Audit verification: '+$_.Exception.Message}
try{$afterPrecedence=Get-WelaRegistryState $precedencePath $precedenceName;$precedenceOk=(Key $afterPrecedence) -ceq (Key $beforePrecedence)}catch{$cleanupErrors+='Precedence verification: '+$_.Exception.Message}
if(-not $hive.Loaded -and $hivesOk){try{Remove-Item -LiteralPath $files -Recurse -Force -ErrorAction Stop}catch{$cleanupErrors+='Owned file removal: '+$_.Exception.Message}}
$cleanup=[pscustomobject]@{Complete=($hivesOk -and $tokenOk -and $masksOk -and $precedenceOk -and -not $hive.SeedCreated -and -not(Test-Path -LiteralPath $files) -and $cleanupErrors.Count -eq 0);HivesRestored=$hivesOk;TokenRestored=$tokenOk;AuditMasksCompared=$beforeMasks.Count;AuditMasksRestored=$masksOk;PrecedenceRestored=$precedenceOk;HiveUnloaded=(-not $hive.Loaded);SeedRemoved=(-not $hive.SeedCreated);FilesRemoved=(-not(Test-Path -LiteralPath $files));Errors=$cleanupErrors;Failure=$(if($failure){$failure.Exception.Message}else{$null});Assertions=$script:assertions;AfterHives=$afterHives;AfterToken=$afterToken;AfterMasks=$masks;AfterPrecedence=$afterPrecedence}
Save 'cleanup.json' $cleanup
$artifacts=@(Get-ChildItem -LiteralPath $root -Recurse -File |Where-Object Name -ne 'artifact-hashes.json'|Sort-Object FullName|ForEach-Object {[pscustomobject]@{Name=$_.FullName.Substring($root.Length+1).Replace('\','/');Sha256=(Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant()}})
Save 'artifact-hashes.json' $artifacts
}
if($failure){throw $failure};if(-not $cleanup.Complete){throw ('Owned registry fixture cleanup incomplete: '+(Key $cleanup))}
Write-Host "Passed $script:assertions actual public registry SACL lifecycle assertions and one exact4657; all cleanup confirmed. Evidence: $root"
$global:LASTEXITCODE=0
+21
View File
@@ -0,0 +1,21 @@
# Test-only attribution for the single fixture-owned REG_SZ creation.
function Test-WelaRegistrySaclFixtureEvent {
param([string]$Xml,$Operation)
$reader=$null
try {
if($Xml.Length -gt 131072){return $false}
$settings=[Xml.XmlReaderSettings]::new();$settings.DtdProcessing=[Xml.DtdProcessing]::Prohibit;$settings.XmlResolver=$null;$settings.MaxCharactersInDocument=131072
$reader=[Xml.XmlReader]::Create([IO.StringReader]::new($Xml),$settings);$doc=[Xml.XmlDocument]::new();$doc.XmlResolver=$null;$doc.Load($reader)
$ns=[Xml.XmlNamespaceManager]::new($doc.NameTable);$ns.AddNamespace('e','http://schemas.microsoft.com/win/2004/08/events/event')
if($doc.DocumentElement.LocalName -cne 'Event' -or $doc.DocumentElement.NamespaceURI -cne $ns.LookupNamespace('e') -or $doc.SelectNodes('/e:Event/e:System',$ns).Count -ne 1 -or $doc.SelectNodes('/e:Event/e:EventData',$ns).Count -ne 1){return $false}
$system=@{};foreach($name in @('Provider','EventID','Version','Task','Keywords','Channel','Computer','EventRecordID','TimeCreated')){$nodes=$doc.SelectNodes("/e:Event/e:System/e:$name",$ns);if($nodes.Count -ne 1){return $false};$system[$name]=$nodes[0]}
if($system.Provider.GetAttribute('Name') -cne 'Microsoft-Windows-Security-Auditing' -or $system.Provider.GetAttribute('Guid').Trim('{}') -ine '54849625-5478-4994-a5ba-3e3b0328c30d' -or $system.EventID.InnerText -cne '4657' -or $system.Version.InnerText -cne '0' -or $system.Task.InnerText -cne '12801' -or $system.Keywords.InnerText -ine '0x8020000000000000' -or $system.Channel.InnerText -cne 'Security' -or $system.Computer.InnerText -ine $Operation.Computer -or [long]$system.EventRecordID.InnerText -le $Operation.RecordIdBefore){return $false}
$time=ConvertTo-WelaArrivalUtc $system.TimeCreated.GetAttribute('SystemTime');if($time -lt (ConvertTo-WelaArrivalUtc $Operation.Write.StartedUtc) -or $time -gt (ConvertTo-WelaArrivalUtc $Operation.Write.CompletedUtc)){return $false}
$fields=@{};foreach($node in $doc.SelectSingleNode('/e:Event/e:EventData',$ns).ChildNodes){if($node.NodeType -eq 'Whitespace'){continue};if($node.NodeType -ne 'Element' -or $node.LocalName -cne 'Data' -or $node.NamespaceURI -cne $ns.LookupNamespace('e') -or @($node.ChildNodes|Where-Object NodeType -eq Element).Count){return $false};$name=$node.GetAttribute('Name');if(-not $name -or $fields.ContainsKey($name)){return $false};$fields[$name]=$node.InnerText}
if($fields.Count -ne 14){return $false};foreach($name in @('SubjectUserSid','SubjectUserName','SubjectDomainName','SubjectLogonId','ObjectName','ObjectValueName','HandleId','OperationType','OldValueType','OldValue','NewValueType','NewValue','ProcessId','ProcessName')){if(-not $fields.ContainsKey($name)){return $false}}
if($fields.SubjectUserSid -cne $Operation.Token.Sid -or $fields.ObjectName -ine $Operation.NativePath -or $fields.ObjectValueName -cne $Operation.Write.ValueName -or $fields.OperationType -cne '%%1904' -or $fields.NewValueType -cne '%%1873' -or $fields.NewValue -cne $Operation.Write.Value -or $fields.ProcessName -ine $Operation.Engine){return $false}
foreach($name in @('SubjectLogonId','ProcessId','HandleId')){if($fields[$name] -cnotmatch '^0x[0-9a-fA-F]+$'){return $false}}
if([Convert]::ToUInt64($fields.SubjectLogonId.Substring(2),16) -ne [Convert]::ToUInt64($Operation.Token.AuthenticationId.Substring(2),16) -or [Convert]::ToUInt64($fields.ProcessId.Substring(2),16) -ne $Operation.ProcessId -or [Convert]::ToUInt64($fields.HandleId.Substring(2),16) -ne [Convert]::ToUInt64($Operation.Write.HandleId.Substring(2),16)){return $false}
$true
}catch{$false}finally{if($reader){$reader.Dispose()}}
}
+2
View File
@@ -7,6 +7,8 @@
**改善:**
- Server 2022/2025 と両 PowerShell エンジンで、公開 `targeted-sacl` のレジストリ操作を検証する使い捨てテストを追加しました。テスト専用の新規ハイブをマウントし、対象選択、DryRun、監査 ACE の追加、古い計画・前提条件不足の拒否、冪等性と厳密に対応付けた Security4657 を確認します。無関係な ACE・型付き値の保持、監査ポリシー・トークンの復元、所有ハイブのアンロードと削除の証跡を保存します。製品側のハイブ読み込みや Sigma 準備完了の判定は追加しません。(関連 #373) (@Shirofune-Security)
- 既存の無効なネイティブサブスクリプション1件の明示的なソースSID一覧を変更する、オプトインの `wec-authorization` Plan/Apply を追加しました。レビュー済みハッシュ、ホスト・トークン・実装・定義全体の照合、永続化した変更前証跡、認可プロパティのみのネイティブ更新と読み戻しにより他の設定を保持し、変更不要と部分失敗を区別します。使い捨てWindowsテストで追加・削除・復元・拒否・後処理を検証しますが、SID解決、ドメイン認証、転送、Sigmaの有効性は主張しません。(@Shirofune-Security)
- カスタム監査プロファイルの公開 Plan、DryRun、Configure、任意項目、再実行、Audit を Server 2022/2025 と Windows PowerShell 5.1/PowerShell 7 の破棄可能な環境で検証します。実際の優先設定、厳密値・最小値・維持の動作、変更前ジャーナル、全59監査マスクと復元を確認します。 (@Shirofune-Security)
+2
View File
@@ -7,6 +7,8 @@
**Improvements:**
- Added disposable public `targeted-sacl` registry lifecycle validation on Server 2022/2025 and both PowerShell engines. A fixture-owned mounted hive exercises reviewed selection, DryRun, additive configuration, stale/prerequisite refusal and idempotence, followed by one precisely attributed Security4657. Retained native receipts verify unrelated ACE/value preservation and exact audit-policy, token and owned-hive cleanup; production does not load hives or gain Sigma credit. (Related #373) (@Shirofune-Security)
- Added opt-in `wec-authorization` Plan/Apply for the explicit source SID list of one existing disabled native subscription. Reviewed hashes, host/token/source and full-definition guards, durable pending evidence, one native authorization setter and readback preserve other settings; no-op and partial-save outcomes stay explicit. Native disposable tests cover add/remove/restore, refusals and cleanup without SID-resolution, domain authentication, forwarding or Sigma claims. (@Shirofune-Security)
- Add disposable native acceptance for public custom-profile Plan, DryRun, Configure, optional controls, idempotence and Audit on Server 2022/2025 under Windows PowerShell 5.1/PowerShell 7. Verify exact/minimum/preserve semantics, real precedence, original journals and all 59 masks, with exact fixture restoration. (@Shirofune-Security)