From 26313314063ef42d33b4cc67fc261e745bb3f2d2 Mon Sep 17 00:00:00 2001
From: Shirofune-Security
<43838376+Shirofune-Security@users.noreply.github.com>
Date: Mon, 21 Sep 2026 09:30:59 +0900
Subject: [PATCH 01/18] Verify native EVTX recovery under the actual primary
reader token
---
.gitattributes | 5 ++
.github/workflows/evtx-recovery.yml | 9 +-
CHANGELOG-Japanese.md | 2 +
CHANGELOG.md | 2 +
docs/evtx-recovery.md | 10 ++-
scripts/EvtxRecovery.ps1 | 83 ++++++++++++++++---
tests/EvtxRecovery.Tests.ps1 | 36 ++++++--
tests/EvtxRecovery.Windows.Tests.ps1 | 23 +++--
tests/fixtures/EvtxReader.Windows.Fixture.ps1 | 81 ++++++++++++++++++
website/docs/resources/changelog.ja.md | 2 +
website/docs/resources/changelog.md | 2 +
11 files changed, 224 insertions(+), 31 deletions(-)
create mode 100644 tests/fixtures/EvtxReader.Windows.Fixture.ps1
diff --git a/.gitattributes b/.gitattributes
index 7f0dff24..f811e6ee 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
+# Actual archive-reader evidence binds implementation and native-token source bytes.
+/scripts/EvtxRecovery.ps1 text eol=lf
+/scripts/NativeValidation.ps1 text eol=lf
+/tests/EvtxRecovery*.ps1 text eol=lf
+/tests/fixtures/EvtxReader*.ps1 text eol=lf
diff --git a/.github/workflows/evtx-recovery.yml b/.github/workflows/evtx-recovery.yml
index e74d4b95..aff2c789 100644
--- a/.github/workflows/evtx-recovery.yml
+++ b/.github/workflows/evtx-recovery.yml
@@ -5,11 +5,14 @@ on:
paths:
- 'WELA.ps1'
- 'scripts/EvtxRecovery.ps1'
+ - 'scripts/ChannelRead.ps1'
+ - 'scripts/ChannelReadNative.cs'
+ - 'scripts/WefArrival.ps1'
- 'scripts/ControlApplicability.ps1'
- 'scripts/Configuration.ps1'
- 'modules/AuditProfiles.psm1'
- 'tests/EvtxRecovery*'
- - 'tests/fixtures/EvtxRecovery*'
+ - 'tests/fixtures/Evtx*'
- 'scripts/NativeValidation.ps1'
- 'scripts/CustomAuditProfiles.ps1'
- '.github/workflows/evtx-recovery.yml'
@@ -31,10 +34,10 @@ jobs:
run: ./tests/EvtxRecovery.Tests.ps1
- name: Native EVTX export and recovery with policy restoration
shell: powershell
- run: ./tests/EvtxRecovery.Windows.Tests.ps1 -AllowDisposablePolicyWrite
+ run: ./tests/EvtxRecovery.Windows.Tests.ps1 -AllowDisposablePolicyWrite -AllowDisposableAccount
- name: Synthetic rejection regressions in PowerShell 7
shell: pwsh
run: ./tests/EvtxRecovery.Tests.ps1
- name: Native EVTX recovery from PowerShell 7 with restoration
shell: pwsh
- run: ./tests/EvtxRecovery.Windows.Tests.ps1 -AllowDisposablePolicyWrite
+ run: ./tests/EvtxRecovery.Windows.Tests.ps1 -AllowDisposablePolicyWrite -AllowDisposableAccount
diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md
index fbb5f432..130660fe 100644
--- a/CHANGELOG-Japanese.md
+++ b/CHANGELOG-Japanese.md
@@ -4,6 +4,8 @@
**改善:**
+- `evtx-recovery` を強化し、実際のプライマリトークンと通常のホスト情報を使って、標準ユーザーでも既存のネイティブアーカイブを検証できるようにしました。バージョン2のレポートはファイル読取拒否と厳密なイベント復元を区別し、トークン・ログオン・変更IDと実装の指紋を照合します。元のイベント生成者と読取者を分離し、既存の権限変更やSigma評価への加算は行いません。使い捨てWindowsテストでは独立した標準ユーザーログオンで実際の拒否と4688 EVTX復元を確認し、テスト用アカウント・ファイルACL・監査設定の復元を検証します。 (@Shirofune-Security)
+
- 読み取り専用の `channel-read` を追加し、実際の実行トークンで選択したWindows標準ローカルログを確認します。メタデータやACLとは独立して拒否・不在・空・イベント読取を区別し、トークンと環境の変化を検出して保護された限定レポートに記録します。Windows設定の変更やSigma利用可能数への加算は行いません。使い捨て標準ユーザーでServer 2022/2025・PowerShell 5.1/7の拒否と読取を検証します。 (#432) (@Shirofune-Security)
- 専用 CA 監査設定の再起動待ちを確認し、明示的に再開する `adcs-resume` を追加。元の記録・結果、出典、現在の CA と実行者、永続的な意図記録、変更直前・最終確認により古い計画の再実行を拒否し、観測した設定を保持します。ドライランと失敗を区別し、イベントや Sigma の証明は加算しません。使い捨て CA テストでは最初の再起動拒否のみを注入し、公開 CLI による実際の再起動と要求イベントを検証します。既存の専用 CA Configure のドライラン引数制限も修正しました。 (#431) (@Shirofune-Security)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5f47af61..0cc4615c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,8 @@
**Improvements:**
+- Strengthened `evtx-recovery` with actual primary-token and ordinary host observations so intended standard users can verify existing native archives. Version 2 reports distinguish file-open denial from exact native event recovery, pin token/logon/modification and implementation identities, and retain independent producer/reader evidence without changing existing permissions or granting Sigma credit. Disposable Windows tests use fresh owned standard-user sessions for real denial and exact 4688 EVTX recovery, with verified account, file-ACL and policy cleanup. (@Shirofune-Security)
+
- 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)
- Added `adcs-resume` to review and explicitly resume a dedicated pending CA auditing restart. Original journal/results, source and current CA/operator fingerprints, durable intent receipts and fresh/final checks prevent stale-plan replay and preserve observed settings. Dry-run and failed attempts remain explicit, with no event/Sigma credit. Disposable CA tests inject the initial refusal then verify an actual public-CLI restart and request events. Also fixed the existing dedicated CA Configure dry-run CLI guard. (#431) (@Shirofune-Security)
diff --git a/docs/evtx-recovery.md b/docs/evtx-recovery.md
index 01730979..f1b05bb5 100644
--- a/docs/evtx-recovery.md
+++ b/docs/evtx-recovery.md
@@ -15,10 +15,16 @@ Related to #382. `evtx-recovery` exports one validated native Security 4688 prob
The importer requires the exact five native-probe files, four matching hashes, strict JSON, consistent embedded metadata, all 59 typed audit masks, valid native process/event identities and unchanged source prerequisites. Imported evidence is operator supplied; hashes prove consistency, not authenticity. Export compares the live source host and policy context to the original probe and verifies the actual Security record before copying it. The exported file is reopened even when Windows reports a successful export: an empty EVTX is not success.
-The archive stays open without write/delete sharing during hashing and native readback. Exactly one event must match the original System and EventData semantics. XML namespace/attribute order and optional RenderingInfo are handled without ignoring original fields. Empty, corrupt, denied, duplicate or changed records remain `Unverified`, as do reader/host/source changes. The report records actual archive bytes/hash, reader SID/groups/session identity, host context, source identity, query, timestamps and recovered raw XML. Readback observes the current token, not hypothetical access by a supplied SID.
+The archive stays open without write/delete sharing during hashing and native readback. Exactly one event must match the original System and EventData semantics, and the native query status must identify that exact file with a zero status code. XML namespace/attribute order and optional RenderingInfo are handled without ignoring original fields. Empty, corrupt, denied, duplicate or changed records remain `Unverified`, as do reader/host/source changes. Each recovered XML record is capped at four MiB; the archive is capped at sixteen MiB. The report records actual archive bytes/hash, reader SID/groups/logon identity, host context, source identity, query, timestamps and recovered raw XML.
+
+Version 2 recovery reports contain `ReaderBefore` and `ReaderAfter` primary-token snapshots with the actual user, process, token ID, authentication/logon ID and modification ID. The native helper rejects impersonation and requires its loaded C# source to match the current file. The recorded interval starts after private output/ACL and source-policy preparation, before event access; it ends after archive hashing, native query and input-file verification. Token changes, including privilege adjustments that change the modification ID, invalidate the interval. Final host and source-policy inventory runs outside that interval because those APIs may adjust available privileges. Implementation fingerprints and private evidence hashes are checked before the final manifest. These observations are not signatures or an atomic transaction against another administrator.
+
+`Verify` reads ordinary host metadata and does not require administrator-only installed-feature inventory. Run it in the intended account's own Windows session with existing read access to the unchanged probe bundle and EVTX plus permission to create its private output. `FileReadAccess=Denied` records an actual denied file-open attempt; `NativeQuery=NotAttempted` makes clear that no event query followed. An opened file records `FileReadAccess=Allowed`, while `NativeQuery=ExactEventRecovered` requires the actual Windows event API and matching event content. Native query denial is recorded separately. A later token/context/evidence failure keeps the overall report `Unverified`, even if earlier I/O observations succeeded. The event's original producer is independent of the archive reader: opening a Security EVTX does not establish access to the live Security channel, an Event Log Readers membership requirement, or access by a WEC service token. The product offers no credential, impersonation, group-membership or privilege-granting options and does not grant archive permissions.
`NativeEventRecovered` proves only that this recorded reader recovered this one event at the observation time. It does not establish completeness, eighteen-month retention, rollover behavior, storage capacity, other-principal access, disaster recovery or Sigma readiness. It does not archive localized message resources or clear the source log. The new archive is a probe artifact, not a full-log backup.
-Focused fixtures exercise source/event tampering, empty/duplicate/corrupt/denied readback, drift, paths and CLI guards. Explicitly gated disposable Server 2022/2025 tests under PowerShell 5.1/7 collect a genuine 4688 event, export/reopen it, independently verify it, reject an actual empty EVTX and restore all temporary audit settings. Windows 11/DC/ADCS, alternate-reader and long-term recovery exercises remain deployment checks.
+Focused fixtures exercise source/event tampering, empty/duplicate/corrupt/denied readback, native query status, primary-token/logon/modification/host/source drift, paths and CLI guards. Explicitly gated disposable Server 2022/2025 tests under PowerShell 5.1/7 collect a genuine 4688 event, export/reopen it, independently verify it and reject an actual empty EVTX. A separate owned standard account uses two fresh primary-token logons to prove file-read denial and exact native recovery after removing a deny ACE from an owned archive copy. The account is neither an administrator nor an Event Log Readers member. Its credentials pass only through the process API, and its temporary files, outputs and ACL changes stay in the owned fixture. The test restores that file ACL, removes only the owned account, and checks all 59 original audit masks and typed registry values/absence. It requires both `-AllowDisposablePolicyWrite` and `-AllowDisposableAccount` on an ephemeral GitHub-hosted runner. Windows 11/DC/ADCS, service/network-reader and long-term recovery exercises remain deployment checks.
Implementation references: [Microsoft EventLogSession.ExportLog](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.eventing.reader.eventlogsession.exportlog) selects events without message resources; [EvtExportLog](https://learn.microsoft.com/en-us/windows/win32/api/winevt/nf-winevt-evtexportlog) requires a new target and can create a header-only file for an empty query.
+
+[TOKEN_STATISTICS](https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-token_statistics) defines token, logon and modification identities; [WindowsIdentity.GetCurrent](https://learn.microsoft.com/en-us/dotnet/api/system.security.principal.windowsidentity.getcurrent) distinguishes a process primary token from thread impersonation; [EvtQuery](https://learn.microsoft.com/en-us/windows/win32/api/winevt/nf-winevt-evtquery) supports local file queries independently of live channel queries.
diff --git a/scripts/EvtxRecovery.ps1 b/scripts/EvtxRecovery.ps1
index dbe26454..583f5c11 100644
--- a/scripts/EvtxRecovery.ps1
+++ b/scripts/EvtxRecovery.ps1
@@ -182,21 +182,50 @@ function Get-WelaEvtxReader {
try {$reader=[pscustomobject]@{Sid=$identity.User.Value;Name=$identity.Name;AuthenticationType=$identity.AuthenticationType;ImpersonationLevel=[string]$identity.ImpersonationLevel;Groups=@($identity.Groups | ForEach-Object {$_.Value} | Sort-Object)}} finally {$identity.Dispose()}
[pscustomobject]@{Computer=[Environment]::MachineName;HostKey=(Get-WelaDefaultContextKey $hostState);Reader=$reader}
}
+function Get-WelaEvtxRecoverySources {
+ $root=Split-Path $PSScriptRoot -Parent;$sources=[ordered]@{}
+ foreach ($path in @('WELA.ps1','scripts/EvtxRecovery.ps1','scripts/ChannelRead.ps1','scripts/ChannelReadNative.cs','scripts/WefArrival.ps1','scripts/NativeValidation.ps1','scripts/ControlApplicability.ps1','modules/AuditProfiles.psm1','config/audit_profiles.json')) {
+ $sources[$path]=(Get-FileHash -LiteralPath (Join-Path $root $path) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()
+ }
+ [pscustomobject]$sources
+}
+function Get-WelaEvtxRecoveryKey {param($Value) ConvertTo-Json -InputObject $Value -Depth 24 -Compress}
+function Get-WelaEvtxRecoveryHost {
+ # An archive reader does not need administrator-only installed-feature inventory.
+ $hostState=Get-WelaChannelReadHost
+ $consistent=($hostState.ProductType -eq 1 -and $hostState.DomainRole -in @(0,1)) -or
+ ($hostState.ProductType -eq 2 -and $hostState.DomainRole -in @(4,5)) -or ($hostState.ProductType -eq 3 -and $hostState.DomainRole -in @(2,3))
+ if (-not $consistent -or $hostState.DomainJoined -ne ($hostState.DomainRole -in @(1,3,4,5)) -or
+ $hostState.UBR -isnot [int] -or $hostState.UBR -lt 0 -or [string]::IsNullOrWhiteSpace($hostState.Edition) -or [string]::IsNullOrWhiteSpace($hostState.Domain)) {throw 'Incomplete or conflicting actual archive-reader host context.'}
+ $hostState
+}
+function Get-WelaEvtxRecoveryReader {
+ # Reuse the source-bound native token statistics adapter, not the legacy
+ # metadata-only reader used by event-measurement before output preparation.
+ Get-WelaChannelReader
+}
+function Assert-WelaEvtxQueryStatus {
+ param([string]$Path,[object[]]$LogStatus)
+ if ($LogStatus.Count -ne 1 -or -not [string]::Equals($LogStatus[0].LogName,$Path,[StringComparison]::OrdinalIgnoreCase) -or $LogStatus[0].StatusCode -isnot [int]) {throw ('Native EVTX query status is incomplete, mismatched or mistyped: '+(ConvertTo-Json -InputObject $LogStatus -Compress))}
+ if ($LogStatus[0].StatusCode -ne 0) {throw [ComponentModel.Win32Exception]::new($LogStatus[0].StatusCode)}
+}
function Read-WelaEvtxNative {
param([string]$Path,[switch]$Live,[string]$Query='*')
$kind=if ($Live) {[System.Diagnostics.Eventing.Reader.PathType]::LogName} else {[System.Diagnostics.Eventing.Reader.PathType]::FilePath}
$request=New-Object System.Diagnostics.Eventing.Reader.EventLogQuery($Path,$kind,$Query)
$request.TolerateQueryErrors=$false
- $reader=New-Object System.Diagnostics.Eventing.Reader.EventLogReader($request)
+ $reader=New-Object System.Diagnostics.Eventing.Reader.EventLogReader($request);$reader.BatchSize=2
$events=New-Object 'System.Collections.Generic.List[string]'
try {
# Read every exported record, up to two: this probe archive must contain exactly one.
for ($i=0;$i -lt 2;$i++) {
$record=$reader.ReadEvent([timespan]::FromSeconds(5))
if ($null -eq $record) {break}
- try {$events.Add($record.ToXml())} finally {$record.Dispose()}
+ try {$xml=$record.ToXml();if ([Text.Encoding]::UTF8.GetByteCount($xml) -gt 4194304) {throw 'Recovered event XML exceeds the four MiB bound.'};$events.Add($xml)} finally {$record.Dispose()}
}
- return [pscustomobject]@{Xml=@($events.ToArray());Limit=2}
+ $status=@($reader.LogStatus|ForEach-Object {[pscustomobject]@{LogName=$_.LogName;StatusCode=$_.StatusCode}})
+ Assert-WelaEvtxQueryStatus -Path $Path -LogStatus $status
+ return [pscustomobject]@{Xml=@($events.ToArray());Limit=2;LogStatus=$status}
} finally {$reader.Dispose()}
}
function Export-WelaEvtxNative {
@@ -214,6 +243,7 @@ function Invoke-WelaEvtxRecovery {
param([ValidateSet('Export','Verify')][string]$Action='Verify',[Parameter(Mandatory)][string]$ProbePath,[string]$ArchivePath,[Parameter(Mandatory)][string]$OutputPath)
$ErrorActionPreference='Stop'
if (($Action -eq 'Export' -and $ArchivePath) -or ($Action -eq 'Verify' -and -not $ArchivePath)) {throw 'Export creates probe.evtx in a new output directory; Verify requires ArchivePath.'}
+ $sources=Get-WelaEvtxRecoverySources;$sourceKey=Get-WelaEvtxRecoveryKey $sources
$source=Import-WelaEvtxProbe $ProbePath
if ($Action -eq 'Verify') {
$archive=Resolve-WelaEvtxPath $ArchivePath
@@ -222,11 +252,10 @@ function Invoke-WelaEvtxRecovery {
}
$output=New-WelaEvtxOutput $OutputPath $source.Path
if ($Action -eq 'Export') {$archive=Join-Path $output 'probe.evtx'}
- $report=[pscustomobject][ordered]@{Kind='WelaNativeEvtxRecovery';SchemaVersion=1;Action=$Action;Status='Unverified';ExitCode=1;StartedUtc=[datetime]::UtcNow.ToString('o');CompletedUtc=$null;SourceBundlePath=$source.Path;SourceFingerprint=$source.Fingerprint;ArchivePath=$archive;ArchiveSha256=$null;ReaderBefore=$null;ReaderAfter=$null;ExportQuery=$null;RecoveredEvents=0;Artifacts=@();Diagnostic='';OutputPath=$output;ReadyRuleCredit=0;PolicyChanges=0;Scope='One exact native probe event readable from this EVTX by the recorded current reader. No archive completeness, duration, other-principal access or Sigma readiness claim.'}
- $lock=$null
+ $report=[pscustomobject][ordered]@{Kind='WelaNativeEvtxRecovery';SchemaVersion=2;Action=$Action;Status='Unverified';ExitCode=1;StartedUtc=[datetime]::UtcNow.ToString('o');CompletedUtc=$null;SourceBundlePath=$source.Path;SourceFingerprint=$source.Fingerprint;SourceComputer=$source.Event.Computer;ArchivePath=$archive;ArchiveSha256=$null;ArchiveBytes=$null;ReaderHostBefore=$null;ReaderHostAfter=$null;ReaderBefore=$null;ReaderAfter=$null;ReaderStable=$false;ReaderInterval='After output/source preparation, immediately before event access through archive hashing/native query and source-file verification; final host/policy inventory is outside this token interval.';Sources=$sources;FileReadAccess='NotAttempted';NativeQuery='NotAttempted';NativeLogStatus=@();FailureStage=$null;NativeError=$null;ExportQuery=$null;RecoveredEvents=0;Artifacts=@();Diagnostic='';OutputPath=$output;ReadyRuleCredit=0;PolicyChanges=0;Scope='Actual primary-token access to one exact local EVTX probe at observation time. Source producer and archive reader are distinct identities. No archive completeness, duration, other-principal access or Sigma readiness claim.'}
+ $lock=$null;$stage='Preparation';$beforeKey=$null
try {
- $before=Get-WelaEvtxReader;$report.ReaderBefore=$before
- $beforeKey=ConvertTo-Json -InputObject $before -Depth 16 -Compress
+ $report.ReaderHostBefore=Get-WelaEvtxRecoveryHost;$hostKey=Get-WelaEvtxRecoveryKey $report.ReaderHostBefore
$report.Artifacts+=Write-WelaEvtxArtifact $output 'source-event.xml' $source.Files['event.xml'].Text
if ($Action -eq 'Export') {
$expected=ConvertTo-WelaEvtxState $source.Manifest.BeforeState
@@ -237,30 +266,60 @@ function Invoke-WelaEvtxRecovery {
$number=[long]::Parse($source.Event.RecordId,[Globalization.CultureInfo]::InvariantCulture)
$query="*[System[EventRecordID=$number and EventID=4688 and Provider[@Name='Microsoft-Windows-Security-Auditing']]]"
$report.ExportQuery=$query
+ }
+ # ACL setup and native audit-policy preparation can temporarily adjust
+ # privileges. Capture the primary token after that work, before event I/O.
+ $before=Get-WelaEvtxRecoveryReader;$report.ReaderBefore=$before;$beforeKey=Get-WelaEvtxRecoveryKey $before
+ if ($Action -eq 'Export') {
+ $stage='LiveSourceQuery'
Assert-WelaEvtxSingleEvent (Read-WelaEvtxNative -Path Security -Live -Query $query) $source
+ if ((Get-WelaEvtxRecoveryKey (Get-WelaEvtxRecoveryReader)) -cne $beforeKey) {throw 'Reader token changed during live source query.'}
if ((Import-WelaEvtxProbe $ProbePath).Fingerprint -cne $source.Fingerprint) {throw 'Source evidence changed before export.'}
+ $stage='Export'
Export-WelaEvtxNative -Query $query -Path $archive
}
+ $stage='ArchiveFileOpen'
+ if ((Get-WelaEvtxRecoveryKey (Get-WelaEvtxRecoveryReader)) -cne $beforeKey) {throw 'Reader token changed before archive access.'}
$null=Resolve-WelaEvtxPath $archive
# Keep the exact file open without write/delete sharing throughout hashing and native reopen.
$lock=New-Object IO.FileStream($archive,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read)
+ $report.FileReadAccess='Allowed';$stage='ArchiveHash'
if ($lock.Length -lt 1 -or $lock.Length -gt 16777216) {throw 'Exported probe archive exceeds size bounds.'}
+ $report.ArchiveBytes=$lock.Length
$sha=[Security.Cryptography.SHA256]::Create()
try {$report.ArchiveSha256=([BitConverter]::ToString($sha.ComputeHash($lock))).Replace('-','').ToLowerInvariant()} finally {$sha.Dispose()}
+ $stage='ArchiveNativeQuery';$report.NativeQuery='Unverified'
$batch=Read-WelaEvtxNative -Path $archive
+ $report.NativeLogStatus=@($batch.LogStatus)
$report.RecoveredEvents=@($batch.Xml).Count
Assert-WelaEvtxSingleEvent $batch $source
+ $report.NativeQuery='ExactEventRecovered';$stage='EvidenceVerification'
$report.Artifacts+=Write-WelaEvtxArtifact $output 'recovered-event.xml' $batch.Xml[0]
- $after=Get-WelaEvtxReader;$report.ReaderAfter=$after
- if ((ConvertTo-Json -InputObject $after -Depth 16 -Compress) -cne $beforeKey) {throw 'Reader identity or host changed during EVTX readback.'}
- if ($Action -eq 'Export' -and (Get-WelaProbeStateKey (Get-WelaProbeState)) -cne (Get-WelaProbeStateKey $expected)) {throw 'Source host or prerequisites drifted during export.'}
if ((Import-WelaEvtxProbe $ProbePath).Fingerprint -cne $source.Fingerprint) {throw 'Source evidence changed during EVTX verification.'}
if ((Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant() -cne $report.ArchiveSha256) {throw 'Archive path/bytes changed during native readback.'}
+ $report.ReaderAfter=Get-WelaEvtxRecoveryReader
+ if ((Get-WelaEvtxRecoveryKey $report.ReaderAfter) -cne $beforeKey) {throw 'Reader token changed during EVTX access.'}
+ $report.ReaderStable=$true;$stage='FinalContext'
+ # Final policy inventory may adjust privileges; it runs after the recorded
+ # token interval, with no later native event query or archive export.
+ if ($Action -eq 'Export' -and (Get-WelaProbeStateKey (Get-WelaProbeState)) -cne (Get-WelaProbeStateKey $expected)) {throw 'Source host or prerequisites drifted during export.'}
+ $report.ReaderHostAfter=Get-WelaEvtxRecoveryHost
+ if ((Get-WelaEvtxRecoveryKey $report.ReaderHostAfter) -cne $hostKey) {throw 'Actual archive-reader host changed during recovery.'}
+ if ((Get-WelaEvtxRecoveryKey (Get-WelaEvtxRecoverySources)) -cne $sourceKey) {throw 'Recovery implementation changed during observation.'}
+ foreach ($artifact in $report.Artifacts) {if ((Get-FileHash -LiteralPath (Join-Path $output $artifact.Name) -Algorithm SHA256).Hash.ToLowerInvariant() -cne $artifact.Sha256) {throw 'Saved recovery evidence changed before the manifest.'}}
$report.Status='NativeEventRecovered';$report.ExitCode=0
- } catch {$report.Diagnostic=$_.Exception.Message}
+ } catch {
+ $failure=Get-WelaChannelReadFailure $_.Exception
+ $report.Diagnostic=$_.Exception.Message;$report.FailureStage=$stage;$report.NativeError=$failure.NativeError
+ if ($stage -eq 'ArchiveFileOpen' -and $failure.Status -eq 'Denied') {$report.FileReadAccess='Denied'}
+ if ($stage -eq 'ArchiveNativeQuery' -and $failure.Status -eq 'Denied') {$report.NativeQuery='Denied'}
+ }
finally {
+ if ($report.ReaderBefore -and -not $report.ReaderAfter) {
+ try {$report.ReaderAfter=Get-WelaEvtxRecoveryReader;$report.ReaderStable=(Get-WelaEvtxRecoveryKey $report.ReaderAfter) -ceq $beforeKey}
+ catch {$report.Diagnostic+=' Final reader observation failed: '+$_.Exception.Message}
+ }
if ($lock) {$lock.Dispose()}
- if ($report.ReaderBefore -and -not $report.ReaderAfter) {try {$report.ReaderAfter=Get-WelaEvtxReader} catch {$report.Diagnostic+=' Final reader observation failed: '+$_.Exception.Message}}
}
$report.CompletedUtc=[datetime]::UtcNow.ToString('o')
$null=Write-WelaEvtxArtifact $output 'manifest.json' ($report | ConvertTo-Json -Depth 24)
diff --git a/tests/EvtxRecovery.Tests.ps1 b/tests/EvtxRecovery.Tests.ps1
index fd2d87e4..f46f2f27 100644
--- a/tests/EvtxRecovery.Tests.ps1
+++ b/tests/EvtxRecovery.Tests.ps1
@@ -1,9 +1,12 @@
$ErrorActionPreference='Stop'
$repo=Split-Path $PSScriptRoot -Parent
+$script:ScriptRoot=$repo
Import-Module (Join-Path $repo 'modules/AuditProfiles.psm1') -Force
. (Join-Path $repo 'scripts/ControlApplicability.ps1')
. (Join-Path $repo 'scripts/NativeValidation.ps1')
. (Join-Path $repo 'scripts/EvtxRecovery.ps1')
+. (Join-Path $repo 'scripts/WefArrival.ps1')
+. (Join-Path $repo 'scripts/ChannelRead.ps1')
. (Join-Path $PSScriptRoot 'fixtures/EvtxRecovery.Fixture.ps1')
$script:checks=0
function Assert($Value,[string]$Message){if(-not $Value){throw "FAIL: $Message"};$script:checks++}
@@ -57,26 +60,37 @@ try {
Assert ((Read-WelaEvtxEvent ($fixture.Xml.Replace($change[0],$change[1]))).Key -cne $source.Event.Key) "Original event mutation stays unmatched: $($change[0])"
}
foreach($xml in @($fixture.Xml.Replace('',''),$fixture.Xml.Replace('',''),(']>'+$fixture.Xml))) {Reject {Read-WelaEvtxEvent $xml} 'System|DTD'}
- $script:scenario='match';$script:reads=0;$script:exports=0
- function Get-WelaEvtxReader {
+ $script:scenario='match';$script:reads=0;$script:exports=0;$script:hostReads=0;$script:sourceReads=0
+ $script:realRecoverySources=(Get-Command Get-WelaEvtxRecoverySources).ScriptBlock
+ function Get-WelaEvtxReader {throw 'Recovery must not require the legacy administrator feature-inventory reader'}
+ function Get-WelaEvtxRecoverySources {
+ $script:sourceReads++;$value=& $script:realRecoverySources
+ if ($scenario -eq 'implementation-drift' -and $sourceReads -gt 1) {$value.'scripts/EvtxRecovery.ps1'='changed'}
+ $value
+ }
+ function Get-WelaEvtxRecoveryHost {
+ $script:hostReads++
+ [pscustomobject]@{Computer='reader01';Build=26100;UBR=$(if ($scenario -eq 'host-drift' -and $hostReads -gt 1) {2}else{1});ProductType=3;DomainRole=2;DomainJoined=$false;Domain='WORKGROUP';Edition='ServerStandard'}
+ }
+ function Get-WelaEvtxRecoveryReader {
$script:reads++
- [pscustomobject]@{Computer='reader01';HostKey='WindowsServer2025';Reader=[pscustomobject]@{Sid=$(if ($scenario -eq 'reader-drift' -and $reads -gt 1) {'S-1-5-20'}else{'S-1-5-18'})}}
+ [pscustomobject]@{Computer='reader01';UserSid=$(if ($scenario -eq 'reader-drift' -and $reads -gt 1) {'S-1-5-20'}else{'S-1-5-18'});TokenId='one';AuthenticationId=$(if ($scenario -eq 'logon-drift' -and $reads -gt 1) {'different'}else{'logon'});ModifiedId=$(if ($scenario -eq 'token-drift' -and $reads -gt 1) {'changed'}else{'unchanged'});TokenType='Primary';Impersonation='Absent'}
}
function Get-WelaProbeState {ConvertTo-WelaEvtxState (Clone $fixture.Manifest.BeforeState)}
function Read-WelaEvtxNative {
param($Path,[switch]$Live,$Query)
- if ($scenario -eq 'denied') {throw 'Native reader denied'}
+ if ($scenario -eq 'denied') {throw [UnauthorizedAccessException]::new('Native reader denied')}
if ($scenario -eq 'corrupt') {throw 'Invalid native EVTX format'}
if ($scenario -eq 'source-change') {Add-Content -LiteralPath (Join-Path $fixture.Directory 'event.xml') 'tampered'}
$events=@($fixture.Xml)
if ($scenario -eq 'empty' -and -not $Live) {$events=@()}
if ($scenario -eq 'duplicate') {$events=@($fixture.Xml,$fixture.Xml)}
if ($scenario -eq 'wrong') {$events=@($fixture.Xml.Replace('100','101'))}
- [pscustomobject]@{Xml=$events;Limit=2}
+ [pscustomobject]@{Xml=$events;Limit=2;LogStatus=@([pscustomobject]@{LogName=$Path;StatusCode=0})}
}
function Export-WelaEvtxNative {param($Query,$Path) $script:exports++;Assert ($Query -match 'EventRecordID=100' -and $Query -match 'EventID=4688' -and $Query -match 'Security-Auditing') 'Export selects one source record only';[IO.File]::WriteAllBytes($Path,[byte[]](1,2,3,4))}
function Invoke-Case([string]$Name,[string]$Action='Verify') {
- $script:reads=0;$script:scenario=$Name
+ $script:reads=0;$script:hostReads=0;$script:sourceReads=0;$script:scenario=$Name
$args=@{Action=$Action;ProbePath=$fixture.Directory;OutputPath=(Join-Path $temp ([guid]::NewGuid().ToString('N')))}
if ($Action -eq 'Verify') {$args.ArchivePath=$script:archive}
Invoke-WelaEvtxRecovery @args
@@ -84,12 +98,20 @@ try {
$script:archive=Join-Path $temp 'fixture.evtx';[IO.File]::WriteAllBytes($archive,[byte[]](1,2,3,4))
$result=Invoke-Case match
Assert ($result.Status -eq 'NativeEventRecovered' -and $result.ExitCode -eq 0 -and $result.ReadyRuleCredit -eq 0 -and $result.PolicyChanges -eq 0 -and $result.RecoveredEvents -eq 1) 'Exact recovery records presence and keeps readiness separate'
+ Assert ($result.SchemaVersion -eq 2 -and $result.ReaderStable -and $result.ReaderBefore.TokenType -eq 'Primary' -and $result.ReaderHostBefore.Computer -eq 'reader01' -and $result.SourceComputer -eq 'source01.lab.test') 'Version two distinguishes actual archive reader and source producer'
+ Assert ($result.FileReadAccess -eq 'Allowed' -and $result.NativeQuery -eq 'ExactEventRecovered' -and $result.ArchiveBytes -eq 4 -and $result.Sources.'scripts/ChannelReadNative.cs' -match '^[a-f0-9]{64}$') 'File permission, matched native query and implementation identity remain explicit'
Assert ($result.ArchiveSha256 -ceq (Get-FileHash -LiteralPath $archive).Hash.ToLowerInvariant()) 'Receipt hashes actual archive bytes'
Assert (Test-Path (Join-Path $result.OutputPath 'recovered-event.xml')) 'Recovered raw XML retained'
- foreach ($case in @('empty','duplicate','wrong','denied','corrupt','reader-drift')) {
+ foreach ($case in @('empty','duplicate','wrong','denied','corrupt','reader-drift','token-drift','logon-drift','host-drift','implementation-drift')) {
$result=Invoke-Case $case
Assert ($result.Status -eq 'Unverified' -and $result.ExitCode -eq 1 -and $result.Diagnostic) "$case cannot establish recovery"
+ if ($case -in @('reader-drift','token-drift','logon-drift')) {Assert (-not $result.ReaderStable) 'Observed token/logon changes revoke reader stability'}
+ if ($case -eq 'denied') {Assert ($result.FileReadAccess -eq 'Allowed' -and $result.NativeQuery -eq 'Denied' -and $result.NativeError -eq 5 -and $result.FailureStage -eq 'ArchiveNativeQuery') 'Native query denial is distinct from a successfully opened file'}
}
+ Assert-WelaEvtxQueryStatus -Path 'C:\evidence\one.evtx' -LogStatus @([pscustomobject]@{LogName='C:\EVIDENCE\one.evtx';StatusCode=0});$checks++
+ foreach ($status in @(@(),@([pscustomobject]@{LogName='different.evtx';StatusCode=0}),@([pscustomobject]@{LogName='one.evtx';StatusCode='0'}))) {Reject {Assert-WelaEvtxQueryStatus -Path 'one.evtx' -LogStatus $status} 'incomplete|mismatched|mistyped'}
+ $statusError=$null;try {Assert-WelaEvtxQueryStatus -Path 'one.evtx' -LogStatus @([pscustomobject]@{LogName='one.evtx';StatusCode=5})} catch {$statusError=$_.Exception.NativeErrorCode}
+ Assert ($statusError -eq 5) 'Native query errors preserve the numeric code without localized parsing'
$result=Invoke-Case match Export
Assert ($result.Status -eq 'NativeEventRecovered' -and $exports -eq 1 -and (Test-Path $result.ArchivePath)) 'Export requires live source plus native reopening of output'
$result=Invoke-Case empty Export
diff --git a/tests/EvtxRecovery.Windows.Tests.ps1 b/tests/EvtxRecovery.Windows.Tests.ps1
index e4acc7d9..dc44c4e2 100644
--- a/tests/EvtxRecovery.Windows.Tests.ps1
+++ b/tests/EvtxRecovery.Windows.Tests.ps1
@@ -1,25 +1,32 @@
-param([switch]$AllowDisposablePolicyWrite)
+param([switch]$AllowDisposablePolicyWrite,[switch]$AllowDisposableAccount)
$ErrorActionPreference='Stop'
if ($env:OS -ne 'Windows_NT') { Write-Host 'Skipped: native Windows is required.'; exit 0 }
if (-not $AllowDisposablePolicyWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted') { throw 'This native event test requires explicit policy-write opt-in on a disposable GitHub-hosted runner.' }
+if (-not $AllowDisposableAccount) {throw 'Explicit disposable-account opt-in is required for native archive-reader tests.'}
$repo=Split-Path $PSScriptRoot -Parent
+$script:ScriptRoot=$repo
. (Join-Path $repo 'scripts/Configuration.ps1')
. (Join-Path $repo 'scripts/ControlApplicability.ps1')
. (Join-Path $repo 'scripts/NativeValidation.ps1')
. (Join-Path $repo 'scripts/EvtxRecovery.ps1')
+. (Join-Path $repo 'scripts/WefArrival.ps1')
+. (Join-Path $repo 'scripts/ChannelRead.ps1')
+. (Join-Path $PSScriptRoot 'fixtures/EvtxReader.Windows.Fixture.ps1')
Import-Module (Join-Path $repo 'modules/AuditProfiles.psm1') -Force
$guid='0cce922b-69ae-11d9-bed3-505054503030'
$controls=@(
[pscustomobject]@{Path='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa';Name='SCENoApplyLegacyAuditPolicy'},
[pscustomobject]@{Path='HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System\Audit';Name='ProcessCreationIncludeCmdLine_Enabled'}
)
-$beforeMask=(Get-WelaEffectiveAuditPolicy)[$guid]
+$beforeMasks=Get-WelaEffectiveAuditPolicy
+if ($beforeMasks.Count -ne 59) {throw 'Complete initial audit policy snapshot is unavailable.'}
+$beforeMask=$beforeMasks[$guid]
foreach ($control in $controls) { $control | Add-Member NoteProperty Before (Get-WelaRegistryState -Path $control.Path -Name $control.Name) }
$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-native-4688-'+[guid]::NewGuid().ToString('N'))
$null=New-Item -ItemType Directory -Path $root
$receipt=Join-Path $root 'policy-before.json'
-[pscustomobject]@{AuditMask=$beforeMask;Controls=$controls} | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $receipt -Encoding UTF8
-$touched=$false; $restored=$false
+[pscustomobject]@{AuditMasks=$beforeMasks;Controls=$controls} | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $receipt -Encoding UTF8
+$touched=$false; $restored=$false; $passed=$false
try {
$touched=$true
foreach ($control in $controls) {
@@ -39,15 +46,17 @@ try {
$export=Invoke-WelaEvtxRecovery -Action Export -ProbePath $destination -OutputPath (Join-Path $root 'export')
if ($export.ExitCode -ne 0 -or $export.Status -ne 'NativeEventRecovered') {throw ($export | ConvertTo-Json -Depth 24)}
$verify=Invoke-WelaEvtxRecovery -Action Verify -ProbePath $destination -ArchivePath $export.ArchivePath -OutputPath (Join-Path $root 'verify')
- if ($verify.ExitCode -ne 0 -or $verify.Status -ne 'NativeEventRecovered' -or $verify.ReaderBefore.Reader.Sid -ne [Security.Principal.WindowsIdentity]::GetCurrent().User.Value) {throw ($verify | ConvertTo-Json -Depth 24)}
+ if ($verify.ExitCode -ne 0 -or $verify.Status -ne 'NativeEventRecovered' -or $verify.ReaderBefore.UserSid -ne [Security.Principal.WindowsIdentity]::GetCurrent().User.Value -or -not $verify.ReaderStable) {throw ($verify | ConvertTo-Json -Depth 24)}
if ($verify.ArchiveSha256 -cne $export.ArchiveSha256 -or $verify.ReadyRuleCredit -ne 0) {throw 'Native readback lost artifact identity or claimed readiness.'}
# A natively generated empty EVTX must not be mistaken for recovered data.
$empty=Join-Path $root 'empty.evtx'
Export-WelaEvtxNative -Query '*[System[EventID=0 and Provider[@Name="Microsoft-Windows-Security-Auditing"]]]' -Path $empty
$emptyResult=Invoke-WelaEvtxRecovery -ProbePath $destination -ArchivePath $empty -OutputPath (Join-Path $root 'empty-check')
if ($emptyResult.ExitCode -ne 1 -or $emptyResult.Status -ne 'Unverified') {throw 'Empty native archive incorrectly accepted.'}
+ Invoke-WelaEvtxReaderFixture -ProbePath $destination -ArchivePath $export.ArchivePath -FixtureParent $root -EnginePath ((Get-Process -Id $PID).Path) -AllowDisposableAccount:$AllowDisposableAccount
Write-Host 'Native Security probe exported and recovered by actual reader from EVTX; empty native archive rejected.'
Write-Host "Native 4688 event observed on $($result.BeforeState.context.role) $($result.BeforeState.context.patch) under PowerShell $($PSVersionTable.PSVersion). Complete-rule, backend and other-role validation remain pending."
+ $passed=$true
} finally {
if ($touched) {
$errors=@()
@@ -64,11 +73,11 @@ try {
if (($after | ConvertTo-Json -Compress) -cne ($control.Before | ConvertTo-Json -Compress)) { throw "Registry restoration differs: $($control.Name)" }
} catch { $errors+=$_.Exception.Message }
}
- try { if ((Get-WelaEffectiveAuditPolicy)[$guid] -ne $beforeMask) { throw 'Audit mask restoration differs.' } } catch { $errors+=$_.Exception.Message }
+ try {$afterMasks=Get-WelaEffectiveAuditPolicy;if ($afterMasks.Count -ne 59) {throw 'Final audit policy snapshot is incomplete.'};foreach ($id in $beforeMasks.Keys) {if ($afterMasks[$id] -ne $beforeMasks[$id]) {throw "Audit mask restoration differs: $id"}}} catch { $errors+=$_.Exception.Message }
$restored=$errors.Count -eq 0
if (-not $restored) { throw "Policy restoration failed; receipt retained at $receipt : $($errors -join '; ')" }
}
- if ($restored) { Remove-Item -LiteralPath $root -Recurse -Force }
+ if ($restored -and $passed) { Remove-Item -LiteralPath $root -Recurse -Force } else {Write-Host "Incomplete native acceptance; fixture receipts retained at $root"}
}
$global:LASTEXITCODE=0
Write-Host 'Native EVTX export/reopen and exact policy restoration passed.'
diff --git a/tests/fixtures/EvtxReader.Windows.Fixture.ps1 b/tests/fixtures/EvtxReader.Windows.Fixture.ps1
new file mode 100644
index 00000000..ba57ead8
--- /dev/null
+++ b/tests/fixtures/EvtxReader.Windows.Fixture.ps1
@@ -0,0 +1,81 @@
+# Test-only account/owned-file ACL fixture; never loaded by the product.
+function Invoke-WelaEvtxReaderFixture {
+ param([string]$ProbePath,[string]$ArchivePath,[string]$FixtureParent,[string]$EnginePath,[switch]$AllowDisposableAccount)
+ if (-not $AllowDisposableAccount -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted' -or $env:OS -ne 'Windows_NT') {throw 'Explicit disposable account/file-ACL opt-in on a GitHub-hosted Windows runner is required.'}
+ $computer=Get-CimInstance Win32_ComputerSystem;$os=Get-CimInstance Win32_OperatingSystem
+ if ($computer.PartOfDomain -or $computer.DomainRole -ne 2 -or $os.ProductType -ne 3 -or [int]$os.BuildNumber -notin @(20348,26100)) {throw 'Archive reader fixture refuses domain/DC or unsupported hosts.'}
+ $repo=Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
+ $nonce=[guid]::NewGuid().ToString('N');$username='WelaE'+$nonce.Substring(0,12)
+ $fixture=New-WelaEvtxOutput -Path (Join-Path $FixtureParent ('archive-reader-'+$nonce)) -SourcePath $ProbePath
+ $codeRoot=Join-Path $fixture 'code';$null=New-Item -ItemType Directory $codeRoot
+ foreach ($path in @('WELA.ps1','scripts','modules','config')) {Copy-Item -LiteralPath (Join-Path $repo $path) -Destination $codeRoot -Recurse}
+ $probe=Join-Path $fixture 'probe';Copy-Item -LiteralPath $ProbePath -Destination $probe -Recurse
+ $archive=Join-Path $fixture 'probe.evtx';Copy-Item -LiteralPath $ArchivePath -Destination $archive
+ $readerHome=Join-Path $fixture 'reader';$null=New-Item -ItemType Directory $readerHome
+ $archiveHash=(Get-FileHash -LiteralPath $archive).Hash.ToLowerInvariant();$archiveBytes=(Get-Item -LiteralPath $archive).Length
+ $source=Import-WelaEvtxProbe $probe
+ $sourceSid=([xml]$source.Files['event.xml'].Text).GetElementsByTagName('Data')|Where-Object {$_.GetAttribute('Name') -ceq 'SubjectUserSid'}|ForEach-Object InnerText
+ $ownedSid=$null;$passed=$false;$beforeArchiveAcl=$null;$counter=[pscustomobject]@{Count=0}
+ function Check($Value,[string]$Message) {if (-not $Value) {throw $Message};$counter.Count++}
+ function Read-AsOwnedUser([string]$Label,[int]$ExpectedExit) {
+ $output=Join-Path $readerHome $Label
+ $start=[Diagnostics.ProcessStartInfo]::new();$start.FileName=$EnginePath
+ $start.Arguments='-NoProfile -ExecutionPolicy Bypass -File "'+(Join-Path $codeRoot 'WELA.ps1')+'" evtx-recovery -EvtxAction Verify -EvtxProbePath "'+$probe+'" -EvtxArchivePath "'+$archive+'" -EvtxOutputPath "'+$output+'"'
+ $start.UseShellExecute=$false;$start.CreateNoWindow=$true;$start.WorkingDirectory=$readerHome
+ $start.UserName=$username;$start.Domain=[Environment]::MachineName;$start.Password=$password;$start.LoadUserProfile=$true
+ $start.RedirectStandardOutput=$true;$start.RedirectStandardError=$true
+ $start.EnvironmentVariables['TEMP']=$readerHome;$start.EnvironmentVariables['TMP']=$readerHome
+ $process=[Diagnostics.Process]::new();$process.StartInfo=$start;$started=$false
+ try {
+ if (-not $process.Start()) {throw 'Owned archive-reader process did not start.'};$started=$true
+ $stdout=$process.StandardOutput.ReadToEndAsync();$stderr=$process.StandardError.ReadToEndAsync()
+ if (-not $process.WaitForExit(90000)) {$process.Kill();$null=$process.WaitForExit(5000);throw 'Owned archive-reader process exceeded 90 seconds.'}
+ if (-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),5000)) {throw 'Owned reader output pipes did not close.'}
+ $exitCode=$process.ExitCode
+ [IO.File]::WriteAllText((Join-Path $readerHome ($Label+'.stdout')),$stdout.GetAwaiter().GetResult())
+ [IO.File]::WriteAllText((Join-Path $readerHome ($Label+'.stderr')),$stderr.GetAwaiter().GetResult())
+ } finally {
+ try {if ($started -and -not $process.HasExited) {$process.Kill();if (-not $process.WaitForExit(5000)) {throw 'Owned reader termination was not confirmed.'}}} finally {$process.Dispose()}
+ }
+ if ($exitCode -ne $ExpectedExit) {Get-Content -LiteralPath (Join-Path $readerHome ($Label+'.stderr'))|Write-Host;Get-Content -LiteralPath (Join-Path $readerHome ($Label+'.stdout'))|Write-Host;throw "Owned archive-reader exit $exitCode expected $ExpectedExit"}
+ $report=ConvertFrom-WelaEvtxJson (Get-Content -LiteralPath (Join-Path $output 'manifest.json') -Raw)
+ if ($report.SchemaVersion -ne 2 -or $report.ReaderBefore.UserSid -cne $ownedSid -or $report.ReaderBefore.ElevatedAdministrator -or $report.ReaderBefore.GroupSids -contains 'S-1-5-32-544' -or $report.ReaderBefore.GroupSids -contains 'S-1-5-32-573' -or $report.ReaderBefore.TokenType -cne 'Primary' -or $report.ReaderBefore.Impersonation -cne 'Absent') {throw 'Archive query did not use the owned standard-user primary token.'}
+ if (-not $report.ReaderStable -or (Get-WelaEvtxRecoveryKey $report.ReaderBefore) -cne (Get-WelaEvtxRecoveryKey $report.ReaderAfter) -or $report.ReadyRuleCredit -ne 0 -or $report.PolicyChanges -ne 0) {throw 'Reader token changed or the report overclaimed configuration/readiness.'}
+ $report
+ }
+ try {
+ $password=ConvertTo-SecureString ('Wela!7'+[guid]::NewGuid().ToString('N')+'zA#') -AsPlainText -Force
+ $user=New-LocalUser -Name $username -Password $password -Description ('WELA EVTX reader '+$nonce) -AccountNeverExpires
+ $ownedSid=$user.SID.Value;Add-LocalGroupMember -SID 'S-1-5-32-545' -Member $user
+ $acl=Get-Acl -LiteralPath $fixture;$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($user.SID,'ReadAndExecute','ContainerInherit,ObjectInherit','None','Allow'));Set-Acl -LiteralPath $fixture -AclObject $acl
+ $acl=Get-Acl -LiteralPath $readerHome;$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($user.SID,'FullControl','ContainerInherit,ObjectInherit','None','Allow'));Set-Acl -LiteralPath $readerHome -AclObject $acl
+ # Only the owned copy is changed; source/producer ACLs and system logs remain intact.
+ $beforeArchiveAcl=(Get-Acl -LiteralPath $archive).Sddl
+ $deny=[Security.AccessControl.FileSystemAccessRule]::new($user.SID,'ReadData','Deny')
+ $acl=Get-Acl -LiteralPath $archive;$acl.AddAccessRule($deny);Set-Acl -LiteralPath $archive -AclObject $acl
+ $denied=Read-AsOwnedUser 'denied' 1
+ Check ($denied.Status -eq 'Unverified' -and $denied.FileReadAccess -eq 'Denied' -and $denied.NativeError -eq 5 -and $denied.FailureStage -eq 'ArchiveFileOpen' -and $denied.NativeQuery -eq 'NotAttempted' -and $denied.RecoveredEvents -eq 0 -and $null -eq $denied.ArchiveSha256) 'Real file-read denial was misreported as native query or recovery success.'
+ Check (-not (Test-Path -LiteralPath (Join-Path $denied.OutputPath 'recovered-event.xml'))) 'Denied reader emitted a recovered event.'
+ $acl=Get-Acl -LiteralPath $archive;$acl.RemoveAccessRuleSpecific($deny);Set-Acl -LiteralPath $archive -AclObject $acl
+ Check ((Get-Acl -LiteralPath $archive).Sddl -ceq $beforeArchiveAcl) 'Owned archive ACL differs after removing only the fixture deny.'
+ $allowed=Read-AsOwnedUser 'allowed' 0
+ Check ($allowed.Status -eq 'NativeEventRecovered' -and $allowed.FileReadAccess -eq 'Allowed' -and $allowed.NativeQuery -eq 'ExactEventRecovered' -and $allowed.RecoveredEvents -eq 1) 'Fresh standard user did not recover the exact native event.'
+ Check ($allowed.ArchiveSha256 -ceq $archiveHash -and $allowed.ArchiveBytes -eq $archiveBytes) 'Owned reader recovered different archive bytes.'
+ Check ($allowed.NativeLogStatus.Count -eq 1 -and $allowed.NativeLogStatus[0].StatusCode -eq 0 -and $allowed.NativeLogStatus[0].LogName -ieq $archive) 'Native file-query status was not bound to the exact archive.'
+ Check ($denied.ReaderBefore.AuthenticationId -cne $allowed.ReaderBefore.AuthenticationId -and $denied.ReaderBefore.TokenId -cne $allowed.ReaderBefore.TokenId) 'Expected independent fresh logon and token identities.'
+ Check ($sourceSid -and $sourceSid -cne $allowed.ReaderBefore.UserSid -and $allowed.SourceComputer -ceq $source.Event.Computer) 'Archive reader and original producer identities were conflated.'
+ $recovered=[IO.File]::ReadAllText((Join-Path $allowed.OutputPath 'recovered-event.xml'))
+ Check ((Read-WelaEvtxEvent $recovered).Key -ceq $source.Event.Key) 'Independently reopened event differs from the producer probe.'
+ foreach ($artifact in $allowed.Artifacts) {Check ((Get-FileHash -LiteralPath (Join-Path $allowed.OutputPath $artifact.Name)).Hash.ToLowerInvariant() -ceq $artifact.Sha256) 'Owned reader evidence hash differs.'}
+ Check ((Import-WelaEvtxProbe $probe).Fingerprint -ceq $source.Fingerprint -and (Get-FileHash -LiteralPath $archive).Hash.ToLowerInvariant() -ceq $archiveHash -and (Get-Acl -LiteralPath $archive).Sddl -ceq $beforeArchiveAcl) 'Read-only recovery changed source evidence or its file ACL.'
+ $passed=$true
+ } finally {
+ $errors=@()
+ if ($beforeArchiveAcl) {try {$acl=Get-Acl -LiteralPath $archive;$acl.SetSecurityDescriptorSddlForm($beforeArchiveAcl);Set-Acl -LiteralPath $archive -AclObject $acl;if ((Get-Acl -LiteralPath $archive).Sddl -cne $beforeArchiveAcl) {throw 'Owned archive ACL restoration differs.'}} catch {$errors+=[string]$_}}
+ if ($ownedSid) {try {$current=Get-LocalUser -Name $username -ErrorAction Stop;if ($current.SID.Value -cne $ownedSid) {throw 'Owned account identity changed; refusing deletion.'};Remove-LocalUser -SID $ownedSid -ErrorAction Stop;if (Get-LocalUser -SID $ownedSid -ErrorAction SilentlyContinue) {throw 'Owned account remains.'}} catch {$errors+=[string]$_}}
+ [pscustomobject]@{Passed=$passed;Checks=$counter.Count;CleanupErrors=$errors;AccountSid=$ownedSid;ArchiveSha256=$archiveHash;Scope='Fresh standard-user file denial and exact native 4688 EVTX recovery; no channel/service-token, backend, archive-duration or Sigma claim'}|ConvertTo-Json -Depth 8|Set-Content -LiteralPath (Join-Path $fixture 'acceptance.json') -Encoding UTF8
+ if ($errors.Count) {throw ($errors -join '; ')}
+ }
+ if (-not $passed) {throw 'Owned archive-reader acceptance incomplete.'}
+ Write-Host "Native EVTX standard-reader proof: $($counter.Count) assertions; fresh denied/allowed logons, exact 4688, original producer distinct, owned file ACL restored and account removed. Engine: $EnginePath"
+}
diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md
index b91d1c7d..a1432f29 100644
--- a/website/docs/resources/changelog.ja.md
+++ b/website/docs/resources/changelog.ja.md
@@ -7,6 +7,8 @@
**改善:**
+- `evtx-recovery` を強化し、実際のプライマリトークンと通常のホスト情報を使って、標準ユーザーでも既存のネイティブアーカイブを検証できるようにしました。バージョン2のレポートはファイル読取拒否と厳密なイベント復元を区別し、トークン・ログオン・変更IDと実装の指紋を照合します。元のイベント生成者と読取者を分離し、既存の権限変更やSigma評価への加算は行いません。使い捨てWindowsテストでは独立した標準ユーザーログオンで実際の拒否と4688 EVTX復元を確認し、テスト用アカウント・ファイルACL・監査設定の復元を検証します。 (@Shirofune-Security)
+
- 読み取り専用の `channel-read` を追加し、実際の実行トークンで選択したWindows標準ローカルログを確認します。メタデータやACLとは独立して拒否・不在・空・イベント読取を区別し、トークンと環境の変化を検出して保護された限定レポートに記録します。Windows設定の変更やSigma利用可能数への加算は行いません。使い捨て標準ユーザーでServer 2022/2025・PowerShell 5.1/7の拒否と読取を検証します。 (#432) (@Shirofune-Security)
- 専用 CA 監査設定の再起動待ちを確認し、明示的に再開する `adcs-resume` を追加。元の記録・結果、出典、現在の CA と実行者、永続的な意図記録、変更直前・最終確認により古い計画の再実行を拒否し、観測した設定を保持します。ドライランと失敗を区別し、イベントや Sigma の証明は加算しません。使い捨て CA テストでは最初の再起動拒否のみを注入し、公開 CLI による実際の再起動と要求イベントを検証します。既存の専用 CA Configure のドライラン引数制限も修正しました。 (#431) (@Shirofune-Security)
diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md
index ccaa1794..3d83a4b2 100644
--- a/website/docs/resources/changelog.md
+++ b/website/docs/resources/changelog.md
@@ -7,6 +7,8 @@
**Improvements:**
+- Strengthened `evtx-recovery` with actual primary-token and ordinary host observations so intended standard users can verify existing native archives. Version 2 reports distinguish file-open denial from exact native event recovery, pin token/logon/modification and implementation identities, and retain independent producer/reader evidence without changing existing permissions or granting Sigma credit. Disposable Windows tests use fresh owned standard-user sessions for real denial and exact 4688 EVTX recovery, with verified account, file-ACL and policy cleanup. (@Shirofune-Security)
+
- 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)
- Added `adcs-resume` to review and explicitly resume a dedicated pending CA auditing restart. Original journal/results, source and current CA/operator fingerprints, durable intent receipts and fresh/final checks prevent stale-plan replay and preserve observed settings. Dry-run and failed attempts remain explicit, with no event/Sigma credit. Disposable CA tests inject the initial refusal then verify an actual public-CLI restart and request events. Also fixed the existing dedicated CA Configure dry-run CLI guard. (#431) (@Shirofune-Security)
From a08f3d52f5aad3b8ca4cfe1aa5987d80be63e872 Mon Sep 17 00:00:00 2001
From: Shirofune-Security
<43838376+Shirofune-Security@users.noreply.github.com>
Date: Mon, 21 Sep 2026 09:33:49 +0900
Subject: [PATCH 02/18] Fit disposable account description within Windows limit
---
CHANGELOG-Japanese.md | 2 +-
CHANGELOG.md | 2 +-
tests/fixtures/EvtxReader.Windows.Fixture.ps1 | 2 +-
website/docs/resources/changelog.ja.md | 2 +-
website/docs/resources/changelog.md | 2 +-
5 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md
index 130660fe..b5b738be 100644
--- a/CHANGELOG-Japanese.md
+++ b/CHANGELOG-Japanese.md
@@ -4,7 +4,7 @@
**改善:**
-- `evtx-recovery` を強化し、実際のプライマリトークンと通常のホスト情報を使って、標準ユーザーでも既存のネイティブアーカイブを検証できるようにしました。バージョン2のレポートはファイル読取拒否と厳密なイベント復元を区別し、トークン・ログオン・変更IDと実装の指紋を照合します。元のイベント生成者と読取者を分離し、既存の権限変更やSigma評価への加算は行いません。使い捨てWindowsテストでは独立した標準ユーザーログオンで実際の拒否と4688 EVTX復元を確認し、テスト用アカウント・ファイルACL・監査設定の復元を検証します。 (@Shirofune-Security)
+- `evtx-recovery` を強化し、実際のプライマリトークンと通常のホスト情報を使って、標準ユーザーでも既存のネイティブアーカイブを検証できるようにしました。バージョン2のレポートはファイル読取拒否と厳密なイベント復元を区別し、トークン・ログオン・変更IDと実装の指紋を照合します。元のイベント生成者と読取者を分離し、既存の権限変更やSigma評価への加算は行いません。使い捨てWindowsテストでは独立した標準ユーザーログオンで実際の拒否と4688 EVTX復元を確認し、テスト用アカウント・ファイルACL・監査設定の復元を検証します。 (#433) (@Shirofune-Security)
- 読み取り専用の `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 0cc4615c..43b1f8b6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,7 +4,7 @@
**Improvements:**
-- Strengthened `evtx-recovery` with actual primary-token and ordinary host observations so intended standard users can verify existing native archives. Version 2 reports distinguish file-open denial from exact native event recovery, pin token/logon/modification and implementation identities, and retain independent producer/reader evidence without changing existing permissions or granting Sigma credit. Disposable Windows tests use fresh owned standard-user sessions for real denial and exact 4688 EVTX recovery, with verified account, file-ACL and policy cleanup. (@Shirofune-Security)
+- Strengthened `evtx-recovery` with actual primary-token and ordinary host observations so intended standard users can verify existing native archives. Version 2 reports distinguish file-open denial from exact native event recovery, pin token/logon/modification and implementation identities, and retain independent producer/reader evidence without changing existing permissions or granting Sigma credit. Disposable Windows tests use fresh owned standard-user sessions for real denial and exact 4688 EVTX recovery, with verified account, file-ACL and policy cleanup. (#433) (@Shirofune-Security)
- 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/tests/fixtures/EvtxReader.Windows.Fixture.ps1 b/tests/fixtures/EvtxReader.Windows.Fixture.ps1
index ba57ead8..f986b518 100644
--- a/tests/fixtures/EvtxReader.Windows.Fixture.ps1
+++ b/tests/fixtures/EvtxReader.Windows.Fixture.ps1
@@ -45,7 +45,7 @@ function Invoke-WelaEvtxReaderFixture {
}
try {
$password=ConvertTo-SecureString ('Wela!7'+[guid]::NewGuid().ToString('N')+'zA#') -AsPlainText -Force
- $user=New-LocalUser -Name $username -Password $password -Description ('WELA EVTX reader '+$nonce) -AccountNeverExpires
+ $user=New-LocalUser -Name $username -Password $password -Description ('WELA EVTX '+$nonce) -AccountNeverExpires
$ownedSid=$user.SID.Value;Add-LocalGroupMember -SID 'S-1-5-32-545' -Member $user
$acl=Get-Acl -LiteralPath $fixture;$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($user.SID,'ReadAndExecute','ContainerInherit,ObjectInherit','None','Allow'));Set-Acl -LiteralPath $fixture -AclObject $acl
$acl=Get-Acl -LiteralPath $readerHome;$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($user.SID,'FullControl','ContainerInherit,ObjectInherit','None','Allow'));Set-Acl -LiteralPath $readerHome -AclObject $acl
diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md
index a1432f29..1859041b 100644
--- a/website/docs/resources/changelog.ja.md
+++ b/website/docs/resources/changelog.ja.md
@@ -7,7 +7,7 @@
**改善:**
-- `evtx-recovery` を強化し、実際のプライマリトークンと通常のホスト情報を使って、標準ユーザーでも既存のネイティブアーカイブを検証できるようにしました。バージョン2のレポートはファイル読取拒否と厳密なイベント復元を区別し、トークン・ログオン・変更IDと実装の指紋を照合します。元のイベント生成者と読取者を分離し、既存の権限変更やSigma評価への加算は行いません。使い捨てWindowsテストでは独立した標準ユーザーログオンで実際の拒否と4688 EVTX復元を確認し、テスト用アカウント・ファイルACL・監査設定の復元を検証します。 (@Shirofune-Security)
+- `evtx-recovery` を強化し、実際のプライマリトークンと通常のホスト情報を使って、標準ユーザーでも既存のネイティブアーカイブを検証できるようにしました。バージョン2のレポートはファイル読取拒否と厳密なイベント復元を区別し、トークン・ログオン・変更IDと実装の指紋を照合します。元のイベント生成者と読取者を分離し、既存の権限変更やSigma評価への加算は行いません。使い捨てWindowsテストでは独立した標準ユーザーログオンで実際の拒否と4688 EVTX復元を確認し、テスト用アカウント・ファイルACL・監査設定の復元を検証します。 (#433) (@Shirofune-Security)
- 読み取り専用の `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 3d83a4b2..c2ae3a92 100644
--- a/website/docs/resources/changelog.md
+++ b/website/docs/resources/changelog.md
@@ -7,7 +7,7 @@
**Improvements:**
-- Strengthened `evtx-recovery` with actual primary-token and ordinary host observations so intended standard users can verify existing native archives. Version 2 reports distinguish file-open denial from exact native event recovery, pin token/logon/modification and implementation identities, and retain independent producer/reader evidence without changing existing permissions or granting Sigma credit. Disposable Windows tests use fresh owned standard-user sessions for real denial and exact 4688 EVTX recovery, with verified account, file-ACL and policy cleanup. (@Shirofune-Security)
+- Strengthened `evtx-recovery` with actual primary-token and ordinary host observations so intended standard users can verify existing native archives. Version 2 reports distinguish file-open denial from exact native event recovery, pin token/logon/modification and implementation identities, and retain independent producer/reader evidence without changing existing permissions or granting Sigma credit. Disposable Windows tests use fresh owned standard-user sessions for real denial and exact 4688 EVTX recovery, with verified account, file-ACL and policy cleanup. (#433) (@Shirofune-Security)
- 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 5967a6bc1e83f9d30fea51823b21e40b4bea94bb Mon Sep 17 00:00:00 2001
From: Shirofune-Security
<43838376+Shirofune-Security@users.noreply.github.com>
Date: Mon, 21 Sep 2026 09:33:53 +0900
Subject: [PATCH 03/18] Add fixed native DNS Client completion probe and
acceptance fixture
---
.github/workflows/dns-client-probe.yml | 34 +++++++
CHANGELOG-Japanese.md | 2 +
CHANGELOG.md | 2 +
WELA.ps1 | 16 ++-
docs/dns-client-probe.md | 25 +++++
docs/native-provider-packs.md | 2 +
scripts/DnsClientProbe.ps1 | 132 +++++++++++++++++++++++++
scripts/DnsClientProbeNative.cs | 62 ++++++++++++
scripts/DnsClientProbeWorker.ps1 | 15 +++
tests/DnsClientProbe.Cli.Tests.ps1 | 14 +++
tests/DnsClientProbe.Tests.ps1 | 29 ++++++
tests/DnsClientProbe.Windows.Tests.ps1 | 67 +++++++++++++
website/docs/resources/changelog.ja.md | 2 +
website/docs/resources/changelog.md | 2 +
14 files changed, 403 insertions(+), 1 deletion(-)
create mode 100644 .github/workflows/dns-client-probe.yml
create mode 100644 docs/dns-client-probe.md
create mode 100644 scripts/DnsClientProbe.ps1
create mode 100644 scripts/DnsClientProbeNative.cs
create mode 100644 scripts/DnsClientProbeWorker.ps1
create mode 100644 tests/DnsClientProbe.Cli.Tests.ps1
create mode 100644 tests/DnsClientProbe.Tests.ps1
create mode 100644 tests/DnsClientProbe.Windows.Tests.ps1
diff --git a/.github/workflows/dns-client-probe.yml b/.github/workflows/dns-client-probe.yml
new file mode 100644
index 00000000..67f91955
--- /dev/null
+++ b/.github/workflows/dns-client-probe.yml
@@ -0,0 +1,34 @@
+name: Native DNS Client completion probe
+on:
+ push:
+ branches: ['**']
+ pull_request:
+ workflow_dispatch:
+permissions:
+ contents: read
+jobs:
+ dns-client-probe:
+ 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: Fixed query validators and public CLI guards in Windows PowerShell5.1
+ if: matrix.engine == 'powershell'
+ shell: powershell
+ run: |
+ ./tests/DnsClientProbe.Tests.ps1
+ ./tests/DnsClientProbe.Cli.Tests.ps1
+ - name: Fixed query validators and public CLI guards in PowerShell7
+ if: matrix.engine == 'pwsh'
+ shell: pwsh
+ run: |
+ ./tests/DnsClientProbe.Tests.ps1
+ ./tests/DnsClientProbe.Cli.Tests.ps1
+ - name: Owned authoritative loopback DNS and real native3008
+ shell: powershell
+ run: ./tests/DnsClientProbe.Windows.Tests.ps1 -AllowDisposableDns -TestEngine '${{ matrix.engine }}'
diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md
index fbb5f432..b40f1d81 100644
--- a/CHANGELOG-Japanese.md
+++ b/CHANGELOG-Japanese.md
@@ -4,6 +4,8 @@
**改善:**
+- 明示した IPv4 リゾルバーに固定の無害な A クエリを送信し、Windows 標準イベント 3008 と照合する任意実行の `dns-client-probe` を追加しました。既定では前提条件の計画だけを行い、実行時は時間・件数を制限した証拠をハッシュ付きで保護します。Windows 設定は変更せず、元の DNS ルールのチャネル不一致と相関の限界を保持し、Sigma の評価には加算しません。Server 2022/2025 と PowerShell 5.1/7 向けに使い捨ての権威 DNS・ループバック検証を追加しました。 (@Shirofune-Security)
+
- 読み取り専用の `channel-read` を追加し、実際の実行トークンで選択したWindows標準ローカルログを確認します。メタデータやACLとは独立して拒否・不在・空・イベント読取を区別し、トークンと環境の変化を検出して保護された限定レポートに記録します。Windows設定の変更やSigma利用可能数への加算は行いません。使い捨て標準ユーザーでServer 2022/2025・PowerShell 5.1/7の拒否と読取を検証します。 (#432) (@Shirofune-Security)
- 専用 CA 監査設定の再起動待ちを確認し、明示的に再開する `adcs-resume` を追加。元の記録・結果、出典、現在の CA と実行者、永続的な意図記録、変更直前・最終確認により古い計画の再実行を拒否し、観測した設定を保持します。ドライランと失敗を区別し、イベントや Sigma の証明は加算しません。使い捨て CA テストでは最初の再起動拒否のみを注入し、公開 CLI による実際の再起動と要求イベントを検証します。既存の専用 CA Configure のドライラン引数制限も修正しました。 (#431) (@Shirofune-Security)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5f47af61..880f0ca6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,8 @@
**Improvements:**
+- Added opt-in `dns-client-probe` for one fixed benign A lookup to an explicitly selected IPv4 resolver, with default prerequisite planning, bounded native DNS execution and exact local event 3008 correlation. Private hashed evidence preserves native status, context and correlation limits without changing Windows settings. The original DNS rule/channel mismatch remains explicit and Sigma credit stays zero. Added disposable authoritative-loopback DNS acceptance tests for Server 2022/2025 and PowerShell 5.1/7. (@Shirofune-Security)
+
- 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)
- Added `adcs-resume` to review and explicitly resume a dedicated pending CA auditing restart. Original journal/results, source and current CA/operator fingerprints, durable intent receipts and fresh/final checks prevent stale-plan replay and preserve observed settings. Dry-run and failed attempts remain explicit, with no event/Sigma credit. Disposable CA tests inject the initial refusal then verify an actual public-CLI restart and request events. Also fixed the existing dedicated CA Configure dry-run CLI guard. (#431) (@Shirofune-Security)
diff --git a/WELA.ps1 b/WELA.ps1
index fb027456..b5a203d3 100644
--- a/WELA.ps1
+++ b/WELA.ps1
@@ -42,6 +42,10 @@
[ValidateSet('Audit', 'Plan', 'Import')][string]$AppLockerAction = 'Audit',
[string]$AppLockerPolicyPath,
[ValidateSet('List', 'Audit', 'Plan', 'Configure')][string]$WmiAction = 'List',
+ [ValidateSet('Plan','Run')][string]$DnsClientProbeAction = 'Plan',
+ [string]$DnsClientProbeResolver,
+ [string]$DnsClientProbeOutputPath,
+ [ValidateRange(1,30)][int]$DnsClientProbeTimeoutSeconds = 15,
[ValidateSet('Plan','Run')][string]$WmiProbeAction = 'Plan',
[string]$WmiProbeNamespace,
[string]$WmiProbeOutputPath,
@@ -167,6 +171,7 @@ $SaclTargetsPath = Join-Path $ScriptRoot "config/audit_sacl_targets.json"
. (Join-Path $ScriptRoot "scripts/AppLockerProbe.ps1")
. (Join-Path $ScriptRoot "scripts/WmiNamespaceAuditing.ps1")
. (Join-Path $ScriptRoot "scripts/WmiProbe.ps1")
+. (Join-Path $ScriptRoot "scripts/DnsClientProbe.ps1")
. (Join-Path $ScriptRoot "scripts/PowerShellTranscription.ps1")
Import-Module (Join-Path $ScriptRoot "modules/AuditProfiles.psm1") -ErrorAction Stop
Import-Module (Join-Path $ScriptRoot "modules/RuleEligibility.psm1") -ErrorAction Stop
@@ -1962,6 +1967,7 @@ Usage:
./WELA.ps1 intune-export -Help # Offline native audit OMA-URI/Graph artifacts; no tenant changes
./WELA.ps1 adcs-resume -Help # Review a pending CA auditing restart
./WELA.ps1 wec-update -Help # Review query/description updates on a disabled subscription
+ ./WELA.ps1 dns-client-probe -Help # Fixed native DNS lookup and matched Operational3008 evidence
./WELA.ps1 wmi-probe -Help # Fixed local read and matched namespace Security4662 evidence
./WELA.ps1 applocker-probe -Help # Collect a fixed native AppLocker EXE event
./WELA.ps1 wef-arrival -Help # Verify exact native probe presence on the local collector
@@ -1982,7 +1988,7 @@ if ($Cmd -eq 'channel-read' -and @($PSBoundParameters.Keys | Where-Object { $_ -
if ($Cmd -ne 'event-measurement' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'Measurement*'}).Count) {throw 'Measurement options require event-measurement. No command was run.'}
if ($Cmd -eq 'event-measurement' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','MeasurementAction','MeasurementChannel','MeasurementSeconds','MeasurementMaximumEvents','MeasurementOutputPath','MeasurementExportEvtx','Help')}).Count) {throw 'event-measurement accepts only its dedicated options. No command was run.'}
-if ($Cmd -ne 'dns-analytical' -and @($PSBoundParameters.Keys | Where-Object { $_ -like 'Dns*' -or $_ -eq 'AllowDnsTraceReset' }).Count) {
+if ($Cmd -ne 'dns-analytical' -and @($PSBoundParameters.Keys | Where-Object { ($_ -like 'Dns*' -and $_ -notlike 'DnsClientProbe*') -or $_ -eq 'AllowDnsTraceReset' }).Count) {
throw 'DNS analytical options require dns-analytical. No command was run.'
}
if ($Cmd -eq 'dns-analytical' -and @($PSBoundParameters.Keys | Where-Object { $_ -notin @('Cmd','DnsAction','DnsState','DnsRetention','DnsMinimumBytes','DnsArchiveMaximumBytes','AllowDnsTraceReset','Auto','DryRun','BackupPath','ResultsPath','Help') }).Count) {
@@ -2045,6 +2051,8 @@ if ($Cmd -ne 'wec-runtime' -and @($PSBoundParameters.Keys | Where-Object {$_ -li
if ($Cmd -eq 'wec-runtime' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','WecRuntimeId','WecRuntimeMaximumSources','ResultsPath','Help')}).Count) {
throw 'wec-runtime accepts only selected runtime IDs, source cap and a new result path. No command was run.'
}
+if ($Cmd -ne 'dns-client-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'DnsClientProbe*'}).Count) {throw 'DnsClientProbe options require dns-client-probe.'}
+if ($Cmd -eq 'dns-client-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','DnsClientProbeAction','DnsClientProbeResolver','DnsClientProbeOutputPath','DnsClientProbeTimeoutSeconds','Help')}).Count) {throw 'dns-client-probe accepts only dedicated probe options.'}
if ($Cmd -ne 'wmi-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'WmiProbe*'}).Count) {throw 'WmiProbe options require wmi-probe.'}
if ($Cmd -eq 'wmi-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','WmiProbeAction','WmiProbeNamespace','WmiProbeOutputPath','WmiProbeTimeoutSeconds','Help')}).Count) {throw 'wmi-probe accepts only dedicated probe options.'}
if ($Cmd -ne 'applocker-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -in @('AppLockerProbeAction','AppLockerProbeOutputPath','AppLockerProbeTimeoutSeconds')}).Count) {throw 'AppLocker probe options require applocker-probe.'}
@@ -2246,6 +2254,12 @@ switch ($Cmd.ToLower()) {
$report
if($report.ExitCode){exit $report.ExitCode}
}
+ 'dns-client-probe' {
+ if ($Help) {Write-Host 'Usage: dns-client-probe [-DnsClientProbeAction Plan|Run] -DnsClientProbeResolver approved-IPv4 [-DnsClientProbeOutputPath new-private-directory] [-DnsClientProbeTimeoutSeconds 1..30]. Fixed benign A lookup to wela-.wela.invalid. via explicit DNS TCP53 resolver; no configuration changes or Sigma credit. Plan observes prerequisites only. See docs/dns-client-probe.md.';return}
+ $report=Invoke-WelaDnsClientProbe -Action $DnsClientProbeAction -Resolver $DnsClientProbeResolver -OutputPath $DnsClientProbeOutputPath -TimeoutSeconds $DnsClientProbeTimeoutSeconds
+ $report
+ if($report.ExitCode){exit $report.ExitCode}
+ }
'wmi-probe' {
if ($Help) {Write-Host 'Usage: wmi-probe [-WmiProbeAction Plan|Run] -WmiProbeNamespace root\default [-WmiProbeOutputPath new-private-directory] [-WmiProbeTimeoutSeconds 1..30]. Fixed local read only; requires existing matching SACL and auditing. No policy changes, remote access or Sigma credit. See docs/wmi-probe.md.';return}
$report=Invoke-WelaWmiProbe -Action $WmiProbeAction -Namespace $WmiProbeNamespace -OutputPath $WmiProbeOutputPath -TimeoutSeconds $WmiProbeTimeoutSeconds
diff --git a/docs/dns-client-probe.md b/docs/dns-client-probe.md
new file mode 100644
index 00000000..1173c21f
--- /dev/null
+++ b/docs/dns-client-probe.md
@@ -0,0 +1,25 @@
+# Native DNS Client completion probe
+
+`dns-client-probe` advances #386 with a fixed benign native DNS lookup and correlation to Windows event 3008. It does **not** implement a Sigma rule test. Sysmon is excluded. Windows DNS Client Operational logging and `Dnscache` must already be enabled/running; the command never changes DNS configuration, channel settings, audit policy or service state.
+
+```powershell
+# Observe prerequisites only. Choose a resolver you are authorized to query.
+./WELA.ps1 dns-client-probe -DnsClientProbeResolver 192.0.2.53
+
+# Explicit network operation; use a NEW local output directory.
+./WELA.ps1 dns-client-probe -DnsClientProbeAction Run `
+ -DnsClientProbeResolver 192.0.2.53 `
+ -DnsClientProbeOutputPath C:\WelaEvidence\dns-client-01
+```
+
+The example address is documentation-only: replace it with an approved resolver. Plan creates no files and sends no probe lookup. Run generates exactly one application request for `wela-.wela.invalid.` type A. There is no caller-selected domain, record type or application connection to a returned address. A same-engine 64-bit worker uses synchronous `DnsQueryEx` with one explicit IPv4 DNS server, TCP port 53, recursion disabled, cache bypass, no hosts/local-name/NetBT/multicast fallback, fully qualified naming and IDN disabled. DNS retry/internal processing and normal response caching are OS behavior; this is not a promise of one wire packet, cache immutability or resolver-side enforcement. The query name, selected resolver and exact flags are retained. Only canonical unicast IPv4 literals are accepted; there is no hostname or configurable port.
+
+The bounded worker has twenty seconds to finish. The separate event wait defaults to fifteen seconds (`-DnsClientProbeTimeoutSeconds 1..30`). Native status 0 (A answers), 9003 (NXDOMAIN) and 9501 (no records) are reviewed completion outcomes. A negative response is not reported as successful name resolution. Missing events, unknown outcomes/versions/types, caps, token or configuration/source drift and incomplete reads remain `Unverified` with a nonzero exit. No setup is automatically performed to make the test pass.
+
+Evidence includes observed build/patch/role, token and same-engine context, exact provider GUID, live event/version/field types and template hashes, original pinned rule hashes, channel metadata, a pre-query record boundary, worker timestamps/status/answers and hashed original matched XML. Matching requires event 3008 version 0 on **Microsoft-Windows-DNS-Client/Operational**, source computer, unique query name/type, native completion status, requested option bits, record boundary and operation time. The emitter PID is retained in original XML; it may belong to the DNS service broker, so it is not assumed to equal the requesting worker PID. This correlation does not prove exclusive request attribution, the wire destination, DNSSEC validation or absence of simultaneous unrelated events. Artifact hashes detect byte changes; they are not signatures or historical host authentication.
+
+`PrerequisitesObserved` means only that Plan observed supported metadata. `NativeDnsLookupObserved` means that a native completion and matching local event were observed. Neither proves forwarding, downstream parsing, detection execution or retention capacity. In particular, all six pinned DNS Client rules refer to **Microsoft-Windows-DNS Client Events/Operational**, a different channel string. WELA retains that mismatch and does not rewrite it. `ReadyRuleCredit` remains **0**; there is no six-rule Sigma uplift.
+
+Native acceptance uses a separately opt-in fixture on disposable GitHub-hosted workgroup Server 2022/2025 under Windows PowerShell 5.1 and PowerShell 7. The fixture refuses an existing DNS role, installs its own standalone role, creates authoritative `wela.invalid` with a wildcard A record to `192.0.2.1`, and queries only loopback. It temporarily enables the Client channel if needed, restores its exact original settings, checks audit policies, removes its owned zone/records and removes only newly installed DNS features. Feature removal may require VM disposal rather than a live reboot; the cleanup receipt records that boundary. Fixture setup is not part of the product. Windows 11, domain-joined/DC/ADCS hosts and external resolver/network behavior still require their own acceptance evidence.
+
+Native API references: [DnsQueryEx](https://learn.microsoft.com/en-us/windows/win32/api/windns/nf-windns-dnsqueryex), [DNS_QUERY_REQUEST](https://learn.microsoft.com/en-us/windows/win32/api/windns/ns-windns-dns_query_request), [DNS query flags](https://learn.microsoft.com/en-us/windows/win32/dns/dns-constants), and the [Microsoft Windows SDK declarations](https://github.com/microsoft/win32metadata/blob/main/generation/WinSDK/RecompiledIdlHeaders/um/WinDNS.h).
diff --git a/docs/native-provider-packs.md b/docs/native-provider-packs.md
index 1061ba35..30510ef5 100644
--- a/docs/native-provider-packs.md
+++ b/docs/native-provider-packs.md
@@ -52,3 +52,5 @@ The mocked regression suite exercises missing fields/providers, unsupported type
Primary references: [Microsoft WEF Appendix C/F](https://learn.microsoft.com/en-us/windows/security/operating-system-security/device-management/use-windows-event-forwarding-to-assist-in-intrusion-detection), [DNS logging and diagnostics](https://learn.microsoft.com/en-us/windows-server/networking/dns/dns-logging-and-diagnostics), [EventMetadata](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.eventing.reader.eventmetadata?view=windowsdesktop-10.0), [EventLogLink](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.eventing.reader.eventloglink?view=windowsdesktop-10.0), [Windows 11 release families](https://learn.microsoft.com/en-us/windows/release-health/windows11-release-information), and [Windows Server release families](https://learn.microsoft.com/en-us/windows/release-health/windows-server-release-info). The WEF sample identifies event/channel candidates; it does not validate these rule definitions or this implementation on every build.
For an explicitly reviewed DNS Server analytical transition with stopped-trace archival, use the separate [DNS analytical lifecycle](dns-analytical.md). The ordinary provider-pack setter continues to refuse Analytical/Debug configuration.
+
+The separate [`dns-client-probe`](dns-client-probe.md) can collect a fixed native DNS Client lookup completion and exact Operational3008 XML. The six pinned rule channel strings remain mismatched; the probe grants no Sigma readiness credit.
diff --git a/scripts/DnsClientProbe.ps1 b/scripts/DnsClientProbe.ps1
new file mode 100644
index 00000000..b247a990
--- /dev/null
+++ b/scripts/DnsClientProbe.ps1
@@ -0,0 +1,132 @@
+# Fixed native DNS Client event3008 collection; no policy/channel/DNS configuration.
+function Initialize-WelaDnsClientProbeNative {
+ if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'DNS Client probe requires native 64-bit Windows.'}
+ $bytes=[IO.File]::ReadAllBytes((Join-Path $PSScriptRoot 'DnsClientProbeNative.cs'));$hash=Get-WelaArrivalHash $bytes
+ if(-not ('Wela.DnsClientProbe.Native' -as [type])){Add-Type -TypeDefinition ([Text.UTF8Encoding]::new($false,$true).GetString($bytes).TrimStart([char]0xfeff)) -ErrorAction Stop;[Wela.DnsClientProbe.Native]::SourceSha256=$hash}
+ if([Wela.DnsClientProbe.Native]::SourceSha256 -cne $hash){throw 'Loaded DNS helper differs from source; start a fresh PowerShell process.'}
+}
+function Assert-WelaDnsClientResolver {
+ param([string]$Resolver)
+ $ip=$null
+ if($Resolver -cnotmatch '^(0|[1-9][0-9]{0,2})(\.(0|[1-9][0-9]{0,2})){3}$' -or -not [Net.IPAddress]::TryParse($Resolver,[ref]$ip) -or $ip.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork -or $ip.ToString() -cne $Resolver -or $ip.GetAddressBytes()[0] -eq 0 -or $ip.GetAddressBytes()[0] -ge 224){throw 'Select one approved canonical unicast IPv4 DNS resolver; no hostname, port, multicast or unspecified address.'}
+}
+function Get-WelaDnsClientProbeSources {
+ $sources=[ordered]@{}
+ foreach($name in @('WELA.ps1','scripts/DnsClientProbe.ps1','scripts/DnsClientProbeWorker.ps1','scripts/DnsClientProbeNative.cs','scripts/WefArrival.ps1','scripts/AuditRecovery.ps1','scripts/ChannelRead.ps1','scripts/ChannelReadNative.cs','scripts/NativeProviderPacks.ps1','scripts/ControlApplicability.ps1','config/native_provider_packs.json','config/security_rules.json','modules/NativeProviders.psm1','modules/AuditProfiles.psm1')){$sources[$name]=(Get-FileHash -LiteralPath (Join-Path $PSScriptRoot ('../'+$name)) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()}
+ $catalog=Get-WelaProviderPackCatalog
+ foreach($rule in $catalog.ruleReviews){$sources['config/'+$rule.localPath]=$rule.sha256}
+ [pscustomobject]$sources
+}
+function Get-WelaDnsClientProbeState {
+ $service=Get-Service Dnscache -ErrorAction Stop
+ if($service.Status -ne 'Running'){throw 'DNS Client must already be running; no service is started.'}
+ $hostState=Get-WelaDefaultContext
+ if(-not(Test-WelaDefaultContextComplete $hostState) -or ($hostState.ProductType -eq 1 -and $hostState.Build -notin @(22000,22621,22631,26100,26200)) -or ($hostState.ProductType -in @(2,3) -and $hostState.Build -notin @(20348,26100))){throw 'Complete reviewed Windows 11/Server2022/2025 context required.'}
+ $catalog=Get-WelaProviderPackCatalog;$pack=@($catalog.packs|Where-Object id -ceq 'dns-client')[0]
+ $schema=Get-WelaProviderPackSchema $pack
+ $channel=Get-WelaNativeChannel $pack.channel
+ $engine=(Get-Process -Id $PID -ErrorAction Stop).Path
+ [pscustomobject][ordered]@{Computer=[Environment]::MachineName;Host=$hostState;Service=[string]$service.Status;Schema=$schema;Channel=$channel;Engine=$engine;EngineSha256=(Get-FileHash $engine -Algorithm SHA256).Hash.ToLowerInvariant();Sources=(Get-WelaDnsClientProbeSources);RuleReviews=@($catalog.ruleReviews|Where-Object {$pack.ruleIds -contains $_.id}|Select-Object id,title,sha256,ruleChannels);Reader=(Get-WelaChannelReader)}
+}
+function Get-WelaDnsClientProbeStateKey {
+ param($State)
+ $metadataErrors=if($State.Channel.MetadataErrors -is [Collections.IDictionary]){$State.Channel.MetadataErrors.Count}else{@($State.Channel.MetadataErrors.PSObject.Properties|Where-Object MemberType -eq NoteProperty).Count}
+ if($State.Service -cne 'Running' -or $State.Channel.State -cne 'Enabled' -or $State.Channel.Name -cne 'Microsoft-Windows-DNS-Client/Operational' -or -not $State.Channel.SecurityDescriptor -or $metadataErrors -or $State.Channel.Error -or $State.Channel.IsEnabled -ne $true -or $State.Channel.MaximumSizeInBytes -le 0 -or $State.Channel.LogMode -notin @('Circular','AutoBackup','Retain')){throw 'Enabled, fully observed DNS Client Operational channel is required.'}
+ if($State.Schema.State -cne 'Observed' -or $State.Schema.Provider -cne 'Microsoft-Windows-DNS-Client' -or $State.Schema.ChannelType -cne 'Operational' -or -not $State.Schema.ProviderGuid){throw 'Exact native DNS Client provider/channel manifest required.'}
+ $events=@($State.Schema.Events|Where-Object Id -eq 3008)
+ if(-not $events.Count){throw 'Native event3008 manifest is missing.'}
+ foreach($event in $events){
+ if($event.Version -ne 0 -or $event.Channel -cne $State.Channel.Name){throw 'Unreviewed native DNS3008 version/channel.'}
+ foreach($name in @('QueryName','QueryType','QueryOptions','QueryStatus','QueryResults')){
+ $field=@($event.Fields|Where-Object Name -ceq $name)
+ $types=switch($name){QueryName {@('win:UnicodeString')} QueryResults {@('win:UnicodeString')} QueryOptions {@('win:UInt64','win:HexInt64')} default {@('win:UInt32')}}
+ if($field.Count -ne 1 -or $field[0].InType -cnotin $types){throw "Native DNS3008 field/type is unreviewed: $name"}
+ }
+ }
+ Get-WelaChannelReadKey $State
+}
+function Get-WelaDnsClientProbeReaderKey {
+ param($Reader)
+ Get-WelaChannelReadKey ([pscustomobject][ordered]@{Computer=$Reader.Computer;UserSid=$Reader.UserSid;UserName=$Reader.UserName;AuthenticationId=$Reader.AuthenticationId;GroupSids=@($Reader.GroupSids);GroupCount=$Reader.GroupCount;PrivilegeCount=$Reader.PrivilegeCount;ElevatedAdministrator=$Reader.ElevatedAdministrator;TokenType=$Reader.TokenType;Impersonation=$Reader.Impersonation})
+}
+function Get-WelaDnsClientProbeWatermark {
+ $reader=$null;$record=$null
+ try{$query=[Diagnostics.Eventing.Reader.EventLogQuery]::new('Microsoft-Windows-DNS-Client/Operational',[Diagnostics.Eventing.Reader.PathType]::LogName,'*');$query.ReverseDirection=$true;$reader=[Diagnostics.Eventing.Reader.EventLogReader]::new($query);$reader.BatchSize=1;$record=$reader.ReadEvent([TimeSpan]::FromSeconds(5));Assert-WelaChannelQueryStatus -Channel 'Microsoft-Windows-DNS-Client/Operational' -LogStatus @($reader.LogStatus);if($record){if($record.RecordId -le 0){throw 'Invalid native record boundary.'};return [long]$record.RecordId};return [long]0}finally{if($record){$record.Dispose()};if($reader){$reader.Dispose()}}
+}
+function Start-WelaDnsClientProbeQuery {
+ param($State,[string]$Resolver,[string]$QueryName)
+ $fresh=Get-WelaDnsClientProbeState
+ if((Get-WelaDnsClientProbeStateKey $fresh) -cne (Get-WelaDnsClientProbeStateKey $State)){throw 'DNS prerequisites changed before query.'}
+ $boundary=Get-WelaDnsClientProbeWatermark
+ $worker=Join-Path $PSScriptRoot 'DnsClientProbeWorker.ps1'
+ $info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$State.Engine;$info.Arguments='-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "'+$worker+'" -Resolver "'+$Resolver+'" -QueryName "'+$QueryName+'"';$info.UseShellExecute=$false;$info.CreateNoWindow=$true;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true;$info.StandardOutputEncoding=[Text.UTF8Encoding]::new($false,$true);$info.StandardErrorEncoding=[Text.UTF8Encoding]::new($false,$true)
+ $process=[Diagnostics.Process]::new();$process.StartInfo=$info;$started=$false
+ try{
+ $launch=[DateTimeOffset]::UtcNow;$started=$process.Start();if(-not $started){throw 'DNS probe worker did not start.'}
+ $output=$process.StandardOutput.ReadToEndAsync();$errorText=$process.StandardError.ReadToEndAsync()
+ if(-not $process.WaitForExit(20000)){throw 'DNS query worker exceeded twenty seconds; operation completion is unverified.'}
+ if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($output,$errorText),5000)){throw 'DNS worker output did not finish.'}
+ if($output.Result.Length -gt 262144 -or $errorText.Result.Length -gt 65536){throw 'DNS worker output exceeded evidence bounds.'}
+ if($process.ExitCode -ne 0 -or $errorText.Result){throw ('DNS worker failed: '+$errorText.Result)}
+ $operation=ConvertFrom-WelaRecoveryJson $output.Result
+ if($operation.ProcessId -ne $process.Id -or $operation.Query.QueryName -cne $QueryName -or $operation.Query.Resolver -cne $Resolver -or $operation.Query.Options -ne 2103790 -or $operation.Query.Status -ne $operation.Query.ResultStatus -or $operation.Query.Status -notin @(0,9003,9501)){throw 'Unexpected DNS worker response or unsupported native outcome.'}
+ $begin=ConvertTo-WelaArrivalUtc $operation.StartedUtc;$end=ConvertTo-WelaArrivalUtc $operation.CompletedUtc
+ if($begin -lt $launch -or $end -lt $begin -or $end -gt [DateTimeOffset]::UtcNow -or ($end-$begin).TotalSeconds -gt 20){throw 'Invalid DNS operation timestamps.'}
+ if((Get-WelaChannelReadKey $operation.BeforeToken) -cne (Get-WelaChannelReadKey $operation.AfterToken) -or (Get-WelaDnsClientProbeReaderKey $operation.BeforeToken) -cne (Get-WelaDnsClientProbeReaderKey $State.Reader)){throw 'DNS worker token differs from observed caller or changed.'}
+ $operation|Add-Member NoteProperty RecordIdBefore $boundary
+ $operation
+ }finally{try{if($started -and -not $process.HasExited){$process.Kill();if(-not $process.WaitForExit(5000)){throw 'Owned DNS worker termination could not be verified.'}}}finally{$process.Dispose()}}
+}
+function Read-WelaDnsClientProbeEvents {
+ param($Operation)
+ $xpath="*[System[Provider[@Name='Microsoft-Windows-DNS-Client'] and EventID=3008 and EventRecordID>$($Operation.RecordIdBefore) and TimeCreated[@SystemTime>='$($Operation.StartedUtc)' and @SystemTime<='$($Operation.CompletedUtc)']]]"
+ $records=@();$xml=@()
+ try{try{$records=@(Get-WinEvent -LogName 'Microsoft-Windows-DNS-Client/Operational' -FilterXPath $xpath -MaxEvents 256 -ErrorAction Stop)}catch{if($_.FullyQualifiedErrorId -notlike 'NoMatchingEventsFound*'){throw}};foreach($record in $records){$text=$record.ToXml();if($text.Length -gt 131072){throw 'Native DNS XML exceeds bound.'};$xml+=$text};[pscustomobject]@{Xml=$xml;Capped=($records.Count -ge 256);Query=$xpath}}finally{foreach($record in $records){$record.Dispose()}}
+}
+function Test-WelaDnsClientProbeEvent {
+ param([string]$Xml,$Operation,$State)
+ $reader=$null
+ try{
+ $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 -or $doc.SelectNodes('/e:Event/e:UserData',$ns).Count){return $false}
+ $system=@{};foreach($name in @('Provider','EventID','Version','EventRecordID','Channel','Computer','TimeCreated','Execution')){$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 $State.Schema.Provider -or $system.Provider.GetAttribute('Guid').Trim('{}') -ine $State.Schema.ProviderGuid.Trim('{}') -or $system.EventID.InnerText -cne '3008' -or $system.Version.InnerText -cne '0' -or $system.Channel.InnerText -cne $State.Channel.Name -or $system.EventRecordID.InnerText -cnotmatch '^[1-9][0-9]*$' -or [long]$system.EventRecordID.InnerText -le $Operation.RecordIdBefore){return $false}
+ $computers=@($State.Computer);if($State.Host.DomainJoined){$computers+=$State.Computer+'.'+$State.Host.Domain};if($system.Computer.InnerText -notin $computers){return $false}
+ $time=ConvertTo-WelaArrivalUtc $system.TimeCreated.GetAttribute('SystemTime');if($time -lt (ConvertTo-WelaArrivalUtc $Operation.StartedUtc) -or $time -gt (ConvertTo-WelaArrivalUtc $Operation.CompletedUtc)){return $false}
+ # Capture the native emitter PID but do not equate service-broker PID with caller identity.
+ if($system.Execution.GetAttribute('ProcessID') -cnotmatch '^[1-9][0-9]*$'){return $false}
+ $data=@{};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 $data.ContainsKey($name)){return $false};$data[$name]=$node.InnerText}
+ if($data.Count -ne 5 -or $data.QueryName.TrimEnd('.') -cne $Operation.Query.QueryName.TrimEnd('.') -or $data.QueryType -cne '1' -or $data.QueryStatus -cne [string]$Operation.Query.Status -or -not $data.ContainsKey('QueryResults')){return $false}
+ $options=if($data.QueryOptions -match '^0x[0-9a-fA-F]+$'){[Convert]::ToUInt64($data.QueryOptions.Substring(2),16)}elseif($data.QueryOptions -match '^[0-9]+$'){[uint64]$data.QueryOptions}else{return $false}
+ if(($options -band [uint64]$Operation.Query.Options) -ne [uint64]$Operation.Query.Options){return $false}
+ return $true
+ }catch{return $false}finally{if($reader){$reader.Dispose()}}
+}
+function Invoke-WelaDnsClientProbe {
+ param([ValidateSet('Plan','Run')][string]$Action='Plan',[string]$Resolver,[string]$OutputPath,[ValidateRange(1,30)][int]$TimeoutSeconds=15)
+ $ErrorActionPreference='Stop';Assert-WelaDnsClientResolver $Resolver
+ if(($Action -eq 'Run') -ne (-not [string]::IsNullOrWhiteSpace($OutputPath))){throw 'Run requires a new output directory; Plan writes no files.'}
+ $report=[pscustomobject][ordered]@{Kind='WelaNativeDnsClientProbe';SchemaVersion=1;Action=$Action;Status='Unverified';ExitCode=1;Resolver=$Resolver;QueryPattern='wela-.wela.invalid.';QueryType='A';Transport='DNS TCP port53; recursion disabled';Before=$null;After=$null;Operation=$null;Query=$null;Candidates=0;Matches=0;Artifacts=@();OutputPath=$null;Diagnostic='';ReadyRuleCredit=0;ConfigurationChanges=0;RuleChannelMismatch='Pinned DNS rules use Microsoft-Windows-DNS Client Events/Operational; actual source is Microsoft-Windows-DNS-Client/Operational. No alias rewrite or rule credit.';Correlation='Random query name, native outcome, source/host, record boundary and operation time. Emitter PID is retained but may be a service broker. Event3008 does not independently prove resolver wire identity or exclusive request attribution.';Scope='One fixed native DNS Client lookup completion; no DNS configuration, cache flush, policy/channel/service changes, forwarding or backend execution. Sysmon excluded.'}
+ if($Action -eq 'Run'){$report.OutputPath=New-WelaArrivalOutput $OutputPath $PSScriptRoot}
+ try{
+ $before=Get-WelaDnsClientProbeState;$report.Before=$before;$key=Get-WelaDnsClientProbeStateKey $before
+ if($Action -eq 'Plan'){$report.After=$before;$report.Status='PrerequisitesObserved';$report.ExitCode=0;return $report}
+ $report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'before.json' ($before|ConvertTo-Json -Depth 24)
+ $queryName='wela-'+[guid]::NewGuid().ToString('N')+'.wela.invalid.'
+ $operation=Start-WelaDnsClientProbeQuery $before $Resolver $queryName;$report.Operation=$operation
+ $report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'operation.json' ($operation|ConvertTo-Json -Depth 15)
+ $timer=[Diagnostics.Stopwatch]::StartNew();$matches=@()
+ do{$batch=Read-WelaDnsClientProbeEvents $operation;$report.Query=$batch.Query;$report.Candidates=@($batch.Xml).Count;if($batch.Capped -isnot [bool] -or $batch.Capped){throw 'DNS event query cap reached or completeness unknown.'};$matches=@($batch.Xml|Where-Object {Test-WelaDnsClientProbeEvent $_ $operation $before});if($matches.Count){break};Start-Sleep -Milliseconds 250}while($timer.Elapsed.TotalSeconds -lt $TimeoutSeconds)
+ $report.Matches=$matches.Count;if($matches.Count -gt 16){throw 'DNS matching event set exceeds sixteen records.'}
+ $i=0;foreach($xml in $matches){$i++;$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath ('event-'+$i+'.xml') $xml}
+ if(-not $matches.Count){$i=0;foreach($xml in @($batch.Xml|Select-Object -First 4)){$i++;$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath ('candidate-'+$i+'.xml') $xml};throw 'No matching native DNS3008 completion event was observed.'}
+ if((Get-WelaDnsClientProbeWatermark) -lt $operation.RecordIdBefore){throw 'DNS log record boundary moved backwards; continuity unverified.'}
+ $after=Get-WelaDnsClientProbeState;$report.After=$after;if((Get-WelaDnsClientProbeStateKey $after) -cne $key){throw 'DNS host, token, schema, channel or source changed during collection.'}
+ $report.Status='NativeDnsLookupObserved';$report.ExitCode=0
+ }catch{$report.Diagnostic=$_.Exception.Message}
+ finally{if($report.Before -and -not $report.After){try{$report.After=Get-WelaDnsClientProbeState}catch{$report.Diagnostic+=' Final observation failed: '+$_.Exception.Message}};if($report.OutputPath -and $report.After){$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'after.json' ($report.After|ConvertTo-Json -Depth 24)}}
+ if($report.OutputPath){$null=Write-WelaArrivalArtifact $report.OutputPath 'manifest.json' ($report|ConvertTo-Json -Depth 28)}
+ $report
+}
diff --git a/scripts/DnsClientProbeNative.cs b/scripts/DnsClientProbeNative.cs
new file mode 100644
index 00000000..cd6d4f54
--- /dev/null
+++ b/scripts/DnsClientProbeNative.cs
@@ -0,0 +1,62 @@
+// One fixed DNS query, with an explicit IPv4 resolver and no configuration writes.
+using System;
+using System.Collections.Generic;
+using System.Net;
+using System.Runtime.InteropServices;
+using System.Text.RegularExpressions;
+namespace Wela.DnsClientProbe {
+ public sealed class Answer { public string Name, Address; public ushort Type; public uint Flags; }
+ public sealed class Result { public uint Status, ResultStatus; public ulong Options; public string QueryName, Resolver; public Answer[] Answers; }
+ public static class Native {
+ public static string SourceSha256;
+ // TCP, no recursion; bypass cache/local-name/hosts/NetBT/multicast/suffixes/IDN.
+ public const ulong Options=0x002019ee;
+ [StructLayout(LayoutKind.Sequential,CharSet=CharSet.Unicode)] struct Request {
+ public uint Version; [MarshalAs(UnmanagedType.LPWStr)] public string Name; public ushort Type;
+ public ulong Options; public IntPtr Servers; public uint Interface; public IntPtr Callback,Context;
+ }
+ [StructLayout(LayoutKind.Sequential)] struct QueryResult { public uint Version,Status; public ulong Options; public IntPtr Records,Reserved; }
+ [StructLayout(LayoutKind.Sequential)] struct Record { public IntPtr Next,Name; public ushort Type,Length; public uint Flags,Ttl,Reserved; }
+ [DllImport("dnsapi.dll",CharSet=CharSet.Unicode)] static extern uint DnsQueryEx(ref Request request,ref QueryResult result,IntPtr cancel);
+ [DllImport("dnsapi.dll")] static extern void DnsRecordListFree(IntPtr records,int freeType);
+ public static string ValidateResolver(string resolver) {
+ if(resolver==null||!Regex.IsMatch(resolver,@"^(0|[1-9][0-9]{0,2})(\.(0|[1-9][0-9]{0,2})){3}$"))throw new ArgumentException("One canonical dotted-decimal IPv4 resolver is required.");
+ IPAddress address;if(!IPAddress.TryParse(resolver,out address)||address.AddressFamily!=System.Net.Sockets.AddressFamily.InterNetwork||address.ToString()!=resolver)throw new ArgumentException("Invalid IPv4 resolver.");
+ byte[] bytes=address.GetAddressBytes();if(bytes[0]==0||bytes[0]>=224||resolver=="255.255.255.255")throw new ArgumentException("Unspecified, multicast and reserved/broadcast resolver addresses are refused.");
+ return resolver;
+ }
+ public static Result Query(string name,string resolver) {
+ if(IntPtr.Size!=8)throw new InvalidOperationException("Native 64-bit process required.");
+ if(name==null||!Regex.IsMatch(name,@"^wela-[a-f0-9]{32}\.wela\.invalid\.$"))throw new ArgumentException("Only the fixed random probe name is accepted.");
+ ValidateResolver(resolver);
+ // SDK DNS_ADDR_ARRAY header32 + one DNS_ADDR64; sockaddr_in in its first16 bytes.
+ byte[] server=new byte[96];BitConverter.GetBytes((uint)1).CopyTo(server,0);BitConverter.GetBytes((uint)1).CopyTo(server,4);
+ BitConverter.GetBytes((ushort)2).CopyTo(server,12);BitConverter.GetBytes((ushort)2).CopyTo(server,32);
+ server[34]=0;server[35]=53;IPAddress.Parse(resolver).GetAddressBytes().CopyTo(server,36);
+ IntPtr servers=Marshal.AllocHGlobal(server.Length);QueryResult result=new QueryResult {Version=1};
+ try {
+ Marshal.Copy(server,0,servers,server.Length);
+ Request request=new Request {Version=1,Name=name,Type=1,Options=Options,Servers=servers};
+ uint status=DnsQueryEx(ref request,ref result,IntPtr.Zero);
+ if(status==9506)throw new InvalidOperationException("Unexpected asynchronous query response.");
+ List answers=new List();HashSet seen=new HashSet();IntPtr current=result.Records;
+ while(current!=IntPtr.Zero) {
+ if(!seen.Add(current)||seen.Count>64)throw new InvalidOperationException("DNS result record bound exceeded.");
+ Record record=(Record)Marshal.PtrToStructure(current,typeof(Record));
+ string recordName=Marshal.PtrToStringUni(record.Name);if(recordName==null||recordName.Length>255)throw new InvalidOperationException("Invalid DNS result name.");
+ // Only A data is interpreted. Unexpected answer aliases/types cannot establish a fixed A result.
+ if((record.Flags&3)==1) {
+ if(record.Type!=1||!String.Equals(recordName.TrimEnd('.'),name.TrimEnd('.'),StringComparison.OrdinalIgnoreCase))throw new InvalidOperationException("Unexpected DNS answer name/type; no follow-up application connection is made.");
+ if(record.Length<4)throw new InvalidOperationException("Truncated DNS A result.");
+ byte[] address=new byte[4];Marshal.Copy(IntPtr.Add(current,Marshal.SizeOf(typeof(Record))),address,0,4);
+ answers.Add(new Answer {Name=recordName,Type=record.Type,Flags=record.Flags,Address=new IPAddress(address).ToString()});
+ if(answers.Count>16)throw new InvalidOperationException("DNS A answer bound exceeded.");
+ }
+ current=record.Next;
+ }
+ if((status==0 && answers.Count==0) || (status!=0 && answers.Count!=0))throw new InvalidOperationException("DNS status and A answers disagree.");
+ return new Result {Status=status,ResultStatus=result.Status,Options=request.Options,QueryName=name,Resolver=resolver,Answers=answers.ToArray()};
+ }finally{if(result.Records!=IntPtr.Zero)DnsRecordListFree(result.Records,1);Marshal.FreeHGlobal(servers);}
+ }
+ }
+}
diff --git a/scripts/DnsClientProbeWorker.ps1 b/scripts/DnsClientProbeWorker.ps1
new file mode 100644
index 00000000..46731ca0
--- /dev/null
+++ b/scripts/DnsClientProbeWorker.ps1
@@ -0,0 +1,15 @@
+param([Parameter(Mandatory)][string]$Resolver,[Parameter(Mandatory)][string]$QueryName)
+$ErrorActionPreference='Stop';$script:ScriptRoot=Split-Path $PSScriptRoot -Parent
+[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)
+. (Join-Path $PSScriptRoot 'WefArrival.ps1')
+. (Join-Path $PSScriptRoot 'ChannelRead.ps1')
+. (Join-Path $PSScriptRoot 'DnsClientProbe.ps1')
+Initialize-WelaDnsClientProbeNative
+if((Get-Service Dnscache -ErrorAction Stop).Status -ne 'Running'){throw 'DNS Client must already be running.'}
+$before=Get-WelaChannelReader
+$start=[DateTime]::UtcNow.ToString('o')
+$query=[Wela.DnsClientProbe.Native]::Query($QueryName,$Resolver)
+$end=[DateTime]::UtcNow.ToString('o')
+$after=Get-WelaChannelReader
+if((Get-WelaChannelReadKey $before) -cne (Get-WelaChannelReadKey $after)){throw 'Worker primary token changed during DNS query.'}
+[pscustomobject]@{Query=$query;StartedUtc=$start;CompletedUtc=$end;ProcessId=$PID;BeforeToken=$before;AfterToken=$after}|ConvertTo-Json -Depth 12 -Compress
diff --git a/tests/DnsClientProbe.Cli.Tests.ps1 b/tests/DnsClientProbe.Cli.Tests.ps1
new file mode 100644
index 00000000..af7c1c78
--- /dev/null
+++ b/tests/DnsClientProbe.Cli.Tests.ps1
@@ -0,0 +1,14 @@
+$ErrorActionPreference='Stop';$root=Split-Path $PSScriptRoot -Parent;$engine=(Get-Process -Id $PID).Path;$count=0
+$cases=@(
+ @{Args=@('dns-client-probe','-Help');Code=0;Pattern='Fixed benign A lookup'},
+ @{Args=@('configure','-DnsClientProbeAction','Run','-Auto');Code=1;Pattern='require dns-client-probe'},
+ @{Args=@('dns-analytical','-DnsClientProbeResolver','127.0.0.1');Code=1;Pattern='only dedicated'},
+ @{Args=@('dns-client-probe','-Help','-Auto');Code=1;Pattern='only dedicated'},
+ @{Args=@('dns-client-probe','-Help','-DryRun');Code=1;Pattern='only dedicated'},
+ @{Args=@('dns-client-probe','-Help','-WmiProbeAction','Run');Code=1;Pattern='only dedicated'},
+ @{Args=@('dns-client-probe','-Help','-Profile','wela-2.2.0');Code=1;Pattern='only dedicated'},
+ @{Args=@('dns-client-probe','-DnsClientProbeResolver','example.com');Code=1;Pattern='canonical unicast IPv4'},
+ @{Args=@('dns-client-probe','-DnsClientProbeResolver','127.0.0.1','-DnsClientProbeAction','Run');Code=1;Pattern='Run requires a new output'},
+ @{Args=@('dns-client-probe','-DnsClientProbeResolver','127.0.0.1','-DnsClientProbeOutputPath','unused');Code=1;Pattern='Plan writes no files'})
+foreach($case in $cases){$ErrorActionPreference='Continue';$out=& $engine -NoLogo -NoProfile -NonInteractive -File (Join-Path $root 'WELA.ps1') @($case.Args) 2>&1|Out-String;$code=$LASTEXITCODE;$ErrorActionPreference='Stop';if($code -ne $case.Code -or $out -notmatch $case.Pattern){throw "Public CLI failed: $($case.Args -join ' ') [$code] $out"};$count++}
+Write-Host "PASS: $count DNS Client public CLI checks.";$global:LASTEXITCODE=0
diff --git a/tests/DnsClientProbe.Tests.ps1 b/tests/DnsClientProbe.Tests.ps1
new file mode 100644
index 00000000..befd8814
--- /dev/null
+++ b/tests/DnsClientProbe.Tests.ps1
@@ -0,0 +1,29 @@
+$ErrorActionPreference='Stop';$script:ScriptRoot=Split-Path $PSScriptRoot -Parent
+Import-Module (Join-Path $ScriptRoot 'modules/AuditProfiles.psm1') -Force
+foreach($name in @('WefArrival','AuditRecovery','ChannelRead','DnsClientProbe')){. (Join-Path $ScriptRoot ('scripts/'+$name+'.ps1'))}
+$script:count=0
+function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
+function Throws($Code,$Pattern){$message='';try{& $Code|Out-Null}catch{$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected $Pattern, got $message"}
+Add-Type -Path (Join-Path $ScriptRoot 'scripts/DnsClientProbeNative.cs')
+foreach($resolver in @('127.0.0.1','192.0.2.53','10.0.0.53')){Assert-WelaDnsClientResolver $resolver;Assert ([Wela.DnsClientProbe.Native]::ValidateResolver($resolver) -ceq $resolver) 'Explicit canonical IPv4 accepted.'}
+foreach($resolver in @('','localhost','127.1','127.0.0.01','127.0.0.1:53','127.0.0.1"','0.0.0.0','224.0.0.1','255.255.255.255','192.0.2.999','::1')){Throws {Assert-WelaDnsClientResolver $resolver} 'IPv4';Throws {[Wela.DnsClientProbe.Native]::ValidateResolver($resolver)} 'resolver|IPv4'}
+Throws {[Wela.DnsClientProbe.Native]::Query('arbitrary.example.','127.0.0.1')} 'fixed random'
+Assert ([Wela.DnsClientProbe.Native]::Options -eq 2103790) 'Fixed documented DNS flags retained.'
+$fields=@(foreach($name in @('QueryName','QueryType','QueryOptions','QueryStatus','QueryResults')){[pscustomobject]@{Name=$name;InType=$(if($name -in @('QueryName','QueryResults')){'win:UnicodeString'}elseif($name -eq 'QueryOptions'){'win:UInt64'}else{'win:UInt32'})}})
+$state=[pscustomobject]@{Computer='host';Host=[pscustomobject]@{DomainJoined=$false;Domain='WORKGROUP'};Service='Running';Channel=[pscustomobject]@{State='Enabled';Name='Microsoft-Windows-DNS-Client/Operational';SecurityDescriptor='O:SYG:SYD:(A;;0x1;;;SY)';MetadataErrors=@{};Error=$null;IsEnabled=$true;MaximumSizeInBytes=1048576;LogMode='Circular'};Schema=[pscustomobject]@{State='Observed';Provider='Microsoft-Windows-DNS-Client';ProviderGuid='1c95126e-7eea-49a9-a3fe-a378b03ddb4d';ChannelType='Operational';Events=@([pscustomobject]@{Id=3008;Version=0;Channel='Microsoft-Windows-DNS-Client/Operational';Fields=$fields})}}
+Assert ((Get-WelaDnsClientProbeStateKey $state).Length -gt 0) 'Exact schema prerequisite accepted.'
+$state.Channel.MetadataErrors['LogMode']='denied';Throws {Get-WelaDnsClientProbeStateKey $state} 'fully observed';$state.Channel.MetadataErrors=@{}
+$state.Schema.Events[0].Fields[0].InType='win:UInt32';Throws {Get-WelaDnsClientProbeStateKey $state} 'field/type';$state.Schema.Events[0].Fields[0].InType='win:UnicodeString'
+$state.Schema.Events[0].Version=1;Throws {Get-WelaDnsClientProbeStateKey $state} 'version/channel';$state.Schema.Events[0].Version=0
+$operation=[pscustomobject]@{Query=[pscustomobject]@{QueryName='wela-0123456789abcdef0123456789abcdef.wela.invalid.';Status=0;Options=2103790};StartedUtc='2026-01-01T00:00:00.0000000Z';CompletedUtc='2026-01-01T00:00:01.0000000Z';RecordIdBefore=9}
+$xml=@'
+3008010Microsoft-Windows-DNS-Client/Operationalhostwela-0123456789abcdef0123456789abcdef.wela.invalid.10x2019ee0192.0.2.1;
+'@
+Assert (Test-WelaDnsClientProbeEvent $xml $operation $state) 'Exact synthetic native3008 shape matches.'
+$mutations=@(
+ @('>3008<','>3006<'),@('0','1'),@('>10<','>9<'),@('>host<','>other<'),@('DNS-Client/Operational','DNS Client Events/Operational'),@('1c95126e','2c95126e'),@('Microsoft-Windows-DNS-Client"','Other-Provider"'),@('00:00:00.5000000Z','00:00:01.5000000Z'),@('ProcessID="123"','ProcessID="0"'),@('Name="QueryType">1','Name="QueryType">28'),@('Name="QueryStatus">0','Name="QueryStatus">9003'),@('0x2019ee','0x2019ec'),@('0123456789abcdef0123456789abcdef','ffffffffffffffffffffffffffffffff'),@('','duplicate'),@('','extra'),@('192.0.2.1;',''),@(']>0','Name="QueryStatus">9003')) $operation $state) 'Typed NXDOMAIN completion differs from successful resolution.'
+$operation.Query.Status=0
+Write-Host "PASS: $script:count DNS Client validators and refusal assertions; synthetic XML is not native evidence."
diff --git a/tests/DnsClientProbe.Windows.Tests.ps1 b/tests/DnsClientProbe.Windows.Tests.ps1
new file mode 100644
index 00000000..8ae73242
--- /dev/null
+++ b/tests/DnsClientProbe.Windows.Tests.ps1
@@ -0,0 +1,67 @@
+param([switch]$AllowDisposableDns,[ValidateSet('powershell','pwsh')][string]$TestEngine='powershell')
+$ErrorActionPreference='Stop'
+if(-not $AllowDisposableDns -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted' -or $env:OS -ne 'Windows_NT'){throw 'Explicit DNS mutation opt-in on a disposable GitHub-hosted Windows runner is required.'}
+$script:ScriptRoot=Split-Path $PSScriptRoot -Parent
+Import-Module (Join-Path $ScriptRoot 'modules/AuditProfiles.psm1') -Force
+Import-Module (Join-Path $ScriptRoot 'modules/NativeProviders.psm1') -Force
+foreach($name in @('Configuration','ControlApplicability','NativeProviderPacks','AuditRecovery','WefArrival','ChannelRead','DnsClientProbe')){. (Join-Path $ScriptRoot ('scripts/'+$name+'.ps1'))}
+$script:count=0
+function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
+$os=Get-CimInstance Win32_OperatingSystem;$computer=Get-CimInstance Win32_ComputerSystem
+if($os.ProductType -ne 3 -or [int]$os.BuildNumber -notin @(20348,26100) -or $computer.PartOfDomain -or $computer.DomainRole -ne 2 -or (Get-WindowsFeature DNS).Installed){throw 'This fixture requires an unjoined Server2022/2025 with no existing DNS role.'}
+$engine=(Get-Command $TestEngine -ErrorAction Stop).Source
+$private=New-WelaArrivalOutput (Join-Path $env:TEMP ('wela-dns-client-native-'+[guid]::NewGuid().ToString('N'))) $PSScriptRoot
+$channel='Microsoft-Windows-DNS-Client/Operational';$zone='wela.invalid';$zoneFile='wela.invalid.dns'
+$beforeFeatures=@(Get-WindowsFeature|Where-Object Installed|ForEach-Object Name);$policies=Get-WelaEffectiveAuditPolicy;$original=Get-WelaNativeChannel $channel
+if($original.State -notin @('Enabled','Disabled') -or $original.MetadataErrors.Count -or $original.Error){throw 'Complete original DNS Client channel state is required before fixture mutation.'}
+$null=Write-WelaArrivalArtifact $private 'original-channel.json' ($original|ConvertTo-Json -Depth 10)
+$installed=$false;$zoneCreated=$false;$channelChanged=$false;$passed=$false
+function Invoke-Cli {
+ param([string[]]$Arguments,[int]$Expected=0)
+ $ErrorActionPreference='Continue'
+ try{$text=@(& $engine -NoLogo -NoProfile -NonInteractive -File (Join-Path $ScriptRoot 'WELA.ps1') @Arguments 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference='Stop'}
+ $text|ForEach-Object{Write-Host $_};$global:LASTEXITCODE=0
+ Assert ($code -eq $Expected) "Public DNS Client CLI exit $code, expected $Expected."
+}
+function Set-ChannelEnabled([bool]$Enabled){$out=& "$env:SystemRoot\System32\wevtutil.exe" sl $channel ('/e:'+([string]$Enabled).ToLowerInvariant()) 2>&1;if($LASTEXITCODE -ne 0){throw "Fixture channel update failed: $out"};$global:LASTEXITCODE=0}
+try {
+ $installed=$true;$feature=Install-WindowsFeature DNS -IncludeManagementTools -ErrorAction Stop
+ if(-not $feature.Success -or [string]$feature.RestartNeeded -ne 'No'){throw 'DNS role install failed or requires restart; no native acceptance claim.'}
+ Start-Service DNS -ErrorAction Stop
+ if(Get-DnsServerZone -Name $zone -ErrorAction SilentlyContinue){throw 'Fixture zone already exists; no replacement is permitted.'}
+ if(Test-Path -LiteralPath (Join-Path $env:SystemRoot ('System32\dns\'+$zoneFile))){throw 'Fixture zone file already exists.'}
+ $zoneCreated=$true;Add-DnsServerPrimaryZone -Name $zone -ZoneFile $zoneFile -DynamicUpdate None -ErrorAction Stop
+ Add-DnsServerResourceRecordA -ZoneName $zone -Name '*' -IPv4Address '192.0.2.1' -TimeToLive ([TimeSpan]::Zero) -ErrorAction Stop|Out-Null
+ # The zone is authoritative and the native request has recursion disabled. No external resolver or answer connection is used.
+ if(-not $original.IsEnabled){$channelChanged=$true;Set-ChannelEnabled $true}
+ $configured=Get-WelaNativeChannel $channel
+ $catalog=Get-WelaProviderPackCatalog;$pack=@($catalog.packs|Where-Object id -ceq 'dns-client')[0]
+ Write-Host ((Get-WelaProviderPackSchema $pack)|ConvertTo-Json -Depth 12)
+ Invoke-Cli @('dns-client-probe','-DnsClientProbeResolver','127.0.0.1')
+ $output=Join-Path $private 'evidence'
+ Invoke-Cli @('dns-client-probe','-DnsClientProbeAction','Run','-DnsClientProbeResolver','127.0.0.1','-DnsClientProbeOutputPath',$output)
+ $report=ConvertFrom-WelaRecoveryJson ([IO.File]::ReadAllText((Join-Path $output 'manifest.json')))
+ Assert ($report.Status -ceq 'NativeDnsLookupObserved' -and $report.ExitCode -eq 0 -and $report.Matches -ge 1 -and $report.ReadyRuleCredit -eq 0 -and $report.ConfigurationChanges -eq 0) 'Actual native3008 correlation is observed without configuration/Sigma credit.'
+ Assert ($report.Operation.Query.QueryName -cmatch '^wela-[a-f0-9]{32}\.wela\.invalid\.$' -and $report.Operation.Query.Status -eq 0 -and $report.Operation.Query.ResultStatus -eq 0 -and @($report.Operation.Query.Answers).Count -eq 1 -and $report.Operation.Query.Answers[0].Address -ceq '192.0.2.1') 'Owned authoritative loopback resolver returns the exact fixed A answer.'
+ foreach($artifact in $report.Artifacts){Assert ((Get-FileHash -LiteralPath (Join-Path $output $artifact.Name) -Algorithm SHA256).Hash.ToLowerInvariant() -ceq $artifact.Sha256) 'Evidence bytes match recorded SHA256.'}
+ foreach($file in Get-ChildItem -LiteralPath $output -Filter 'event-*.xml'){$xml=[IO.File]::ReadAllText($file.FullName);Assert (Test-WelaDnsClientProbeEvent $xml $report.Operation $report.Before) 'Actual persisted3008 XML matches the production validator.';Write-Host $xml}
+ Assert ((Get-WelaChannelReadKey (Get-WelaNativeChannel $channel)) -ceq (Get-WelaChannelReadKey $configured)) 'Product preserves the exact configured channel metadata.'
+ Assert ($report.RuleChannelMismatch -match 'DNS Client Events/Operational') 'Original rule-channel mismatch remains explicit.'
+ $passed=$true;Write-Host "PASS: $script:count native DNS Client checks through $TestEngine."
+}catch{
+ Write-Host ('Native DNS Client failure: '+($_|Out-String));Write-Host $_.ScriptStackTrace
+ # Small owned diagnostics only; avoid dumping unrelated channel payloads.
+ if(Test-Path (Join-Path $private 'evidence')){Get-ChildItem (Join-Path $private 'evidence') -File|Where-Object {$_.Name -in @('manifest.json','operation.json') -or $_.Name -like 'candidate-*.xml'}|ForEach-Object{Write-Host $_.Name;Write-Host ([IO.File]::ReadAllText($_.FullName))}}
+ throw
+}finally{
+ $errors=@()
+ try{if($channelChanged){Set-ChannelEnabled ([bool]$original.IsEnabled)};if((Get-WelaChannelReadKey (Get-WelaNativeChannel $channel)) -cne (Get-WelaChannelReadKey $original)){throw 'DNS Client channel configuration restoration differs.'}}catch{$errors+=$_.Exception.Message}
+ if($zoneCreated){try{$owned=Get-DnsServerZone -Name $zone -ErrorAction SilentlyContinue;if($owned){if($owned.IsDsIntegrated -or $owned.ZoneType -ne 'Primary'){throw 'Owned DNS zone identity changed; cleanup refused.'};Remove-DnsServerZone -Name $zone -Force -ErrorAction Stop};if(Get-DnsServerZone -Name $zone -ErrorAction SilentlyContinue){throw 'Owned zone remains.'};$file=Join-Path $env:SystemRoot ('System32\dns\'+$zoneFile);if(Test-Path -LiteralPath $file){Remove-Item -LiteralPath $file -ErrorAction Stop}}catch{$errors+=$_.Exception.Message}}
+ try{$afterPolicies=Get-WelaEffectiveAuditPolicy;foreach($guid in $policies.Keys){if($afterPolicies[$guid] -ne $policies[$guid]){throw 'Native audit policy changed.'}}}catch{$errors+=$_.Exception.Message}
+ $removal=[pscustomobject]@{ChannelAndZoneRestored=($errors.Count -eq 0);Attempted=$false;Features=@();Success=$null;RestartNeeded=$null;Boundary='Owned feature removal can require disposal of this GitHub-hosted VM; no restart or complete live feature-restoration claim.'}
+ if($installed -and -not $errors.Count){try{$added=@(Get-WindowsFeature|Where-Object {$_.Installed -and $_.Name -notin $beforeFeatures -and $_.Name -in @('DNS','RSAT-DNS-Server')}|ForEach-Object Name);if($added.Count){$removal.Attempted=$true;$removal.Features=$added;$removed=Uninstall-WindowsFeature -Name $added -ErrorAction Stop;$removal.Success=[bool]$removed.Success;$removal.RestartNeeded=[string]$removed.RestartNeeded;if(-not $removed.Success -or $removal.RestartNeeded -notin @('No','Yes')){throw 'DNS feature removal failed or restart state is unknown.'}}}catch{$errors+=$_.Exception.Message}}
+ $null=Write-WelaArrivalArtifact $private 'cleanup.json' ($removal|ConvertTo-Json -Depth 6);$removal|ConvertTo-Json -Depth 6|Write-Host
+ if($errors.Count){throw "Disposable DNS cleanup failed; evidence retained at $private : $($errors -join '; ')"}
+ if($passed){Remove-Item -LiteralPath $private -Recurse -Force}
+}
+$global:LASTEXITCODE=0
diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md
index b91d1c7d..a306b6d1 100644
--- a/website/docs/resources/changelog.ja.md
+++ b/website/docs/resources/changelog.ja.md
@@ -7,6 +7,8 @@
**改善:**
+- 明示した IPv4 リゾルバーに固定の無害な A クエリを送信し、Windows 標準イベント 3008 と照合する任意実行の `dns-client-probe` を追加しました。既定では前提条件の計画だけを行い、実行時は時間・件数を制限した証拠をハッシュ付きで保護します。Windows 設定は変更せず、元の DNS ルールのチャネル不一致と相関の限界を保持し、Sigma の評価には加算しません。Server 2022/2025 と PowerShell 5.1/7 向けに使い捨ての権威 DNS・ループバック検証を追加しました。 (@Shirofune-Security)
+
- 読み取り専用の `channel-read` を追加し、実際の実行トークンで選択したWindows標準ローカルログを確認します。メタデータやACLとは独立して拒否・不在・空・イベント読取を区別し、トークンと環境の変化を検出して保護された限定レポートに記録します。Windows設定の変更やSigma利用可能数への加算は行いません。使い捨て標準ユーザーでServer 2022/2025・PowerShell 5.1/7の拒否と読取を検証します。 (#432) (@Shirofune-Security)
- 専用 CA 監査設定の再起動待ちを確認し、明示的に再開する `adcs-resume` を追加。元の記録・結果、出典、現在の CA と実行者、永続的な意図記録、変更直前・最終確認により古い計画の再実行を拒否し、観測した設定を保持します。ドライランと失敗を区別し、イベントや Sigma の証明は加算しません。使い捨て CA テストでは最初の再起動拒否のみを注入し、公開 CLI による実際の再起動と要求イベントを検証します。既存の専用 CA Configure のドライラン引数制限も修正しました。 (#431) (@Shirofune-Security)
diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md
index ccaa1794..a9806f76 100644
--- a/website/docs/resources/changelog.md
+++ b/website/docs/resources/changelog.md
@@ -7,6 +7,8 @@
**Improvements:**
+- Added opt-in `dns-client-probe` for one fixed benign A lookup to an explicitly selected IPv4 resolver, with default prerequisite planning, bounded native DNS execution and exact local event 3008 correlation. Private hashed evidence preserves native status, context and correlation limits without changing Windows settings. The original DNS rule/channel mismatch remains explicit and Sigma credit stays zero. Added disposable authoritative-loopback DNS acceptance tests for Server 2022/2025 and PowerShell 5.1/7. (@Shirofune-Security)
+
- 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)
- Added `adcs-resume` to review and explicitly resume a dedicated pending CA auditing restart. Original journal/results, source and current CA/operator fingerprints, durable intent receipts and fresh/final checks prevent stale-plan replay and preserve observed settings. Dry-run and failed attempts remain explicit, with no event/Sigma credit. Disposable CA tests inject the initial refusal then verify an actual public-CLI restart and request events. Also fixed the existing dedicated CA Configure dry-run CLI guard. (#431) (@Shirofune-Security)
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 04/18] 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 05/18] 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:**
From 7f9c53329d441df05bbf470a906f5b7202f1bc6c Mon Sep 17 00:00:00 2001
From: Shirofune-Security
<43838376+Shirofune-Security@users.noreply.github.com>
Date: Mon, 21 Sep 2026 09:39:02 +0900
Subject: [PATCH 06/18] Bind native DNS evidence to bounded query status and
token intervals
---
CHANGELOG-Japanese.md | 2 +-
CHANGELOG.md | 2 +-
docs/dns-client-probe.md | 2 +-
scripts/DnsClientProbe.ps1 | 43 +++++++++++++++++++-------
scripts/DnsClientProbeNative.cs | 2 +-
tests/DnsClientProbe.Tests.ps1 | 30 +++++++++++++++++-
website/docs/resources/changelog.ja.md | 2 +-
website/docs/resources/changelog.md | 2 +-
8 files changed, 67 insertions(+), 18 deletions(-)
diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md
index b40f1d81..57ae6659 100644
--- a/CHANGELOG-Japanese.md
+++ b/CHANGELOG-Japanese.md
@@ -4,7 +4,7 @@
**改善:**
-- 明示した IPv4 リゾルバーに固定の無害な A クエリを送信し、Windows 標準イベント 3008 と照合する任意実行の `dns-client-probe` を追加しました。既定では前提条件の計画だけを行い、実行時は時間・件数を制限した証拠をハッシュ付きで保護します。Windows 設定は変更せず、元の DNS ルールのチャネル不一致と相関の限界を保持し、Sigma の評価には加算しません。Server 2022/2025 と PowerShell 5.1/7 向けに使い捨ての権威 DNS・ループバック検証を追加しました。 (@Shirofune-Security)
+- 明示した IPv4 リゾルバーに固定の無害な A クエリを送信し、Windows 標準イベント 3008 と照合する任意実行の `dns-client-probe` を追加しました。既定では前提条件の計画だけを行い、実行時は時間・件数を制限した証拠をハッシュ付きで保護します。Windows 設定は変更せず、元の DNS ルールのチャネル不一致と相関の限界を保持し、Sigma の評価には加算しません。Server 2022/2025 と PowerShell 5.1/7 向けに使い捨ての権威 DNS・ループバック検証を追加しました。 (#434) (@Shirofune-Security)
- 読み取り専用の `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 880f0ca6..54a4f532 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,7 +4,7 @@
**Improvements:**
-- Added opt-in `dns-client-probe` for one fixed benign A lookup to an explicitly selected IPv4 resolver, with default prerequisite planning, bounded native DNS execution and exact local event 3008 correlation. Private hashed evidence preserves native status, context and correlation limits without changing Windows settings. The original DNS rule/channel mismatch remains explicit and Sigma credit stays zero. Added disposable authoritative-loopback DNS acceptance tests for Server 2022/2025 and PowerShell 5.1/7. (@Shirofune-Security)
+- Added opt-in `dns-client-probe` for one fixed benign A lookup to an explicitly selected IPv4 resolver, with default prerequisite planning, bounded native DNS execution and exact local event 3008 correlation. Private hashed evidence preserves native status, context and correlation limits without changing Windows settings. The original DNS rule/channel mismatch remains explicit and Sigma credit stays zero. Added disposable authoritative-loopback DNS acceptance tests for Server 2022/2025 and PowerShell 5.1/7. (#434) (@Shirofune-Security)
- 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/dns-client-probe.md b/docs/dns-client-probe.md
index 1173c21f..6479eecd 100644
--- a/docs/dns-client-probe.md
+++ b/docs/dns-client-probe.md
@@ -16,7 +16,7 @@ The example address is documentation-only: replace it with an approved resolver.
The bounded worker has twenty seconds to finish. The separate event wait defaults to fifteen seconds (`-DnsClientProbeTimeoutSeconds 1..30`). Native status 0 (A answers), 9003 (NXDOMAIN) and 9501 (no records) are reviewed completion outcomes. A negative response is not reported as successful name resolution. Missing events, unknown outcomes/versions/types, caps, token or configuration/source drift and incomplete reads remain `Unverified` with a nonzero exit. No setup is automatically performed to make the test pass.
-Evidence includes observed build/patch/role, token and same-engine context, exact provider GUID, live event/version/field types and template hashes, original pinned rule hashes, channel metadata, a pre-query record boundary, worker timestamps/status/answers and hashed original matched XML. Matching requires event 3008 version 0 on **Microsoft-Windows-DNS-Client/Operational**, source computer, unique query name/type, native completion status, requested option bits, record boundary and operation time. The emitter PID is retained in original XML; it may belong to the DNS service broker, so it is not assumed to equal the requesting worker PID. This correlation does not prove exclusive request attribution, the wire destination, DNSSEC validation or absence of simultaneous unrelated events. Artifact hashes detect byte changes; they are not signatures or historical host authentication.
+Evidence includes observed build/patch/role, token and same-engine context, exact provider GUID, live event/version/field types and template hashes, original pinned rule hashes, channel metadata, a pre-query record boundary, worker timestamps/status/answers and hashed original matched XML. Matching requires event 3008 version 0 on **Microsoft-Windows-DNS-Client/Operational**, source computer, unique query name/type, native completion status, requested option bits, record boundary and operation time. The emitter PID is retained in original XML; it may belong to the DNS service broker, so it is not assumed to equal the requesting worker PID. This correlation does not prove exclusive request attribution, the wire destination, DNSSEC validation or absence of simultaneous unrelated events. Full caller token snapshots bracket actual query/event I/O and are compared before final metadata inventory; the worker has its own exact before/after token checks. Metadata inventories are outside this interval because DISM and channel inspection may temporarily adjust privileges. Native event-query status is retained separately from its records. Artifact hashes detect byte changes; they are not signatures or historical host authentication.
`PrerequisitesObserved` means only that Plan observed supported metadata. `NativeDnsLookupObserved` means that a native completion and matching local event were observed. Neither proves forwarding, downstream parsing, detection execution or retention capacity. In particular, all six pinned DNS Client rules refer to **Microsoft-Windows-DNS Client Events/Operational**, a different channel string. WELA retains that mismatch and does not rewrite it. `ReadyRuleCredit` remains **0**; there is no six-rule Sigma uplift.
diff --git a/scripts/DnsClientProbe.ps1 b/scripts/DnsClientProbe.ps1
index b247a990..027b6733 100644
--- a/scripts/DnsClientProbe.ps1
+++ b/scripts/DnsClientProbe.ps1
@@ -2,7 +2,7 @@
function Initialize-WelaDnsClientProbeNative {
if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'DNS Client probe requires native 64-bit Windows.'}
$bytes=[IO.File]::ReadAllBytes((Join-Path $PSScriptRoot 'DnsClientProbeNative.cs'));$hash=Get-WelaArrivalHash $bytes
- if(-not ('Wela.DnsClientProbe.Native' -as [type])){Add-Type -TypeDefinition ([Text.UTF8Encoding]::new($false,$true).GetString($bytes).TrimStart([char]0xfeff)) -ErrorAction Stop;[Wela.DnsClientProbe.Native]::SourceSha256=$hash}
+ if(-not ('Wela.DnsClientProbe.Native' -as [type])){$source=[Text.UTF8Encoding]::new($false,$true).GetString($bytes).TrimStart([char]0xfeff);Add-Type -TypeDefinition $source.Replace('__WELA_DNS_CLIENT_SOURCE_SHA256__',$hash) -ErrorAction Stop}
if([Wela.DnsClientProbe.Native]::SourceSha256 -cne $hash){throw 'Loaded DNS helper differs from source; start a fresh PowerShell process.'}
}
function Assert-WelaDnsClientResolver {
@@ -34,16 +34,20 @@ function Get-WelaDnsClientProbeStateKey {
if($State.Service -cne 'Running' -or $State.Channel.State -cne 'Enabled' -or $State.Channel.Name -cne 'Microsoft-Windows-DNS-Client/Operational' -or -not $State.Channel.SecurityDescriptor -or $metadataErrors -or $State.Channel.Error -or $State.Channel.IsEnabled -ne $true -or $State.Channel.MaximumSizeInBytes -le 0 -or $State.Channel.LogMode -notin @('Circular','AutoBackup','Retain')){throw 'Enabled, fully observed DNS Client Operational channel is required.'}
if($State.Schema.State -cne 'Observed' -or $State.Schema.Provider -cne 'Microsoft-Windows-DNS-Client' -or $State.Schema.ChannelType -cne 'Operational' -or -not $State.Schema.ProviderGuid){throw 'Exact native DNS Client provider/channel manifest required.'}
$events=@($State.Schema.Events|Where-Object Id -eq 3008)
- if(-not $events.Count){throw 'Native event3008 manifest is missing.'}
+ if($events.Count -ne 1){throw 'Exactly one reviewed native event3008 template is required.'}
foreach($event in $events){
- if($event.Version -ne 0 -or $event.Channel -cne $State.Channel.Name){throw 'Unreviewed native DNS3008 version/channel.'}
+ if($event.Version -ne 0 -or $event.Channel -cne $State.Channel.Name -or @($event.Fields).Count -ne 5){throw 'Unreviewed native DNS3008 version/channel.'}
foreach($name in @('QueryName','QueryType','QueryOptions','QueryStatus','QueryResults')){
$field=@($event.Fields|Where-Object Name -ceq $name)
$types=switch($name){QueryName {@('win:UnicodeString')} QueryResults {@('win:UnicodeString')} QueryOptions {@('win:UInt64','win:HexInt64')} default {@('win:UInt32')}}
if($field.Count -ne 1 -or $field[0].InType -cnotin $types){throw "Native DNS3008 field/type is unreviewed: $name"}
}
}
- Get-WelaChannelReadKey $State
+ # Metadata inventories may adjust and restore token privileges. Full token stability
+ # is verified around query/event I/O, outside those inventories.
+ $key=[ordered]@{};foreach($property in $State.PSObject.Properties){if($property.Name -cne 'Reader'){$key[$property.Name]=$property.Value}}
+ $key.ReaderContext=Get-WelaDnsClientProbeReaderKey $State.Reader
+ Get-WelaChannelReadKey ([pscustomobject]$key)
}
function Get-WelaDnsClientProbeReaderKey {
param($Reader)
@@ -58,6 +62,7 @@ function Start-WelaDnsClientProbeQuery {
$fresh=Get-WelaDnsClientProbeState
if((Get-WelaDnsClientProbeStateKey $fresh) -cne (Get-WelaDnsClientProbeStateKey $State)){throw 'DNS prerequisites changed before query.'}
$boundary=Get-WelaDnsClientProbeWatermark
+ $callerBefore=Get-WelaChannelReader
$worker=Join-Path $PSScriptRoot 'DnsClientProbeWorker.ps1'
$info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$State.Engine;$info.Arguments='-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "'+$worker+'" -Resolver "'+$Resolver+'" -QueryName "'+$QueryName+'"';$info.UseShellExecute=$false;$info.CreateNoWindow=$true;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true;$info.StandardOutputEncoding=[Text.UTF8Encoding]::new($false,$true);$info.StandardErrorEncoding=[Text.UTF8Encoding]::new($false,$true)
$process=[Diagnostics.Process]::new();$process.StartInfo=$info;$started=$false
@@ -71,17 +76,32 @@ function Start-WelaDnsClientProbeQuery {
$operation=ConvertFrom-WelaRecoveryJson $output.Result
if($operation.ProcessId -ne $process.Id -or $operation.Query.QueryName -cne $QueryName -or $operation.Query.Resolver -cne $Resolver -or $operation.Query.Options -ne 2103790 -or $operation.Query.Status -ne $operation.Query.ResultStatus -or $operation.Query.Status -notin @(0,9003,9501)){throw 'Unexpected DNS worker response or unsupported native outcome.'}
$begin=ConvertTo-WelaArrivalUtc $operation.StartedUtc;$end=ConvertTo-WelaArrivalUtc $operation.CompletedUtc
+ $operation.StartedUtc=$begin.UtcDateTime.ToString('o');$operation.CompletedUtc=$end.UtcDateTime.ToString('o')
if($begin -lt $launch -or $end -lt $begin -or $end -gt [DateTimeOffset]::UtcNow -or ($end-$begin).TotalSeconds -gt 20){throw 'Invalid DNS operation timestamps.'}
- if((Get-WelaChannelReadKey $operation.BeforeToken) -cne (Get-WelaChannelReadKey $operation.AfterToken) -or (Get-WelaDnsClientProbeReaderKey $operation.BeforeToken) -cne (Get-WelaDnsClientProbeReaderKey $State.Reader)){throw 'DNS worker token differs from observed caller or changed.'}
+ if((Get-WelaChannelReadKey $operation.BeforeToken) -cne (Get-WelaChannelReadKey $operation.AfterToken) -or (Get-WelaDnsClientProbeReaderKey $operation.BeforeToken) -cne (Get-WelaDnsClientProbeReaderKey $callerBefore)){throw 'DNS worker token differs from observed caller or changed.'}
$operation|Add-Member NoteProperty RecordIdBefore $boundary
+ $operation|Add-Member NoteProperty CallerBefore $callerBefore
$operation
}finally{try{if($started -and -not $process.HasExited){$process.Kill();if(-not $process.WaitForExit(5000)){throw 'Owned DNS worker termination could not be verified.'}}}finally{$process.Dispose()}}
}
function Read-WelaDnsClientProbeEvents {
param($Operation)
+ $channel='Microsoft-Windows-DNS-Client/Operational'
$xpath="*[System[Provider[@Name='Microsoft-Windows-DNS-Client'] and EventID=3008 and EventRecordID>$($Operation.RecordIdBefore) and TimeCreated[@SystemTime>='$($Operation.StartedUtc)' and @SystemTime<='$($Operation.CompletedUtc)']]]"
- $records=@();$xml=@()
- try{try{$records=@(Get-WinEvent -LogName 'Microsoft-Windows-DNS-Client/Operational' -FilterXPath $xpath -MaxEvents 256 -ErrorAction Stop)}catch{if($_.FullyQualifiedErrorId -notlike 'NoMatchingEventsFound*'){throw}};foreach($record in $records){$text=$record.ToXml();if($text.Length -gt 131072){throw 'Native DNS XML exceeds bound.'};$xml+=$text};[pscustomobject]@{Xml=$xml;Capped=($records.Count -ge 256);Query=$xpath}}finally{foreach($record in $records){$record.Dispose()}}
+ $reader=$null;$record=$null;$xml=@();$timer=[Diagnostics.Stopwatch]::StartNew()
+ try{
+ $query=[Diagnostics.Eventing.Reader.EventLogQuery]::new($channel,[Diagnostics.Eventing.Reader.PathType]::LogName,$xpath);$query.TolerateQueryErrors=$false
+ $reader=[Diagnostics.Eventing.Reader.EventLogReader]::new($query);$reader.BatchSize=16
+ while($xml.Count -lt 256){
+ if($timer.Elapsed.TotalSeconds -ge 5){throw 'DNS event read exceeded five-second bound.'}
+ $record=$reader.ReadEvent([TimeSpan]::FromSeconds(5-$timer.Elapsed.TotalSeconds))
+ if($null -eq $record){break}
+ try{$text=$record.ToXml();if($text.Length -gt 131072){throw 'Native DNS XML exceeds bound.'};$xml+=$text}finally{$record.Dispose();$record=$null}
+ }
+ $status=@($reader.LogStatus|ForEach-Object{[pscustomobject]@{LogName=$_.LogName;StatusCode=$_.StatusCode}})
+ Assert-WelaChannelQueryStatus -Channel $channel -LogStatus $status
+ [pscustomobject]@{Xml=$xml;Capped=($xml.Count -ge 256);Query=$xpath;LogStatus=$status}
+ }finally{if($record){$record.Dispose()};if($reader){$reader.Dispose()}}
}
function Test-WelaDnsClientProbeEvent {
param([string]$Xml,$Operation,$State)
@@ -96,7 +116,7 @@ function Test-WelaDnsClientProbeEvent {
$computers=@($State.Computer);if($State.Host.DomainJoined){$computers+=$State.Computer+'.'+$State.Host.Domain};if($system.Computer.InnerText -notin $computers){return $false}
$time=ConvertTo-WelaArrivalUtc $system.TimeCreated.GetAttribute('SystemTime');if($time -lt (ConvertTo-WelaArrivalUtc $Operation.StartedUtc) -or $time -gt (ConvertTo-WelaArrivalUtc $Operation.CompletedUtc)){return $false}
# Capture the native emitter PID but do not equate service-broker PID with caller identity.
- if($system.Execution.GetAttribute('ProcessID') -cnotmatch '^[1-9][0-9]*$'){return $false}
+ if($system.Execution.GetAttribute('ProcessID') -cnotmatch '^[1-9][0-9]*$' -or [uint32]$system.Execution.GetAttribute('ProcessID') -eq 0){return $false}
$data=@{};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 $data.ContainsKey($name)){return $false};$data[$name]=$node.InnerText}
if($data.Count -ne 5 -or $data.QueryName.TrimEnd('.') -cne $Operation.Query.QueryName.TrimEnd('.') -or $data.QueryType -cne '1' -or $data.QueryStatus -cne [string]$Operation.Query.Status -or -not $data.ContainsKey('QueryResults')){return $false}
$options=if($data.QueryOptions -match '^0x[0-9a-fA-F]+$'){[Convert]::ToUInt64($data.QueryOptions.Substring(2),16)}elseif($data.QueryOptions -match '^[0-9]+$'){[uint64]$data.QueryOptions}else{return $false}
@@ -108,21 +128,22 @@ function Invoke-WelaDnsClientProbe {
param([ValidateSet('Plan','Run')][string]$Action='Plan',[string]$Resolver,[string]$OutputPath,[ValidateRange(1,30)][int]$TimeoutSeconds=15)
$ErrorActionPreference='Stop';Assert-WelaDnsClientResolver $Resolver
if(($Action -eq 'Run') -ne (-not [string]::IsNullOrWhiteSpace($OutputPath))){throw 'Run requires a new output directory; Plan writes no files.'}
- $report=[pscustomobject][ordered]@{Kind='WelaNativeDnsClientProbe';SchemaVersion=1;Action=$Action;Status='Unverified';ExitCode=1;Resolver=$Resolver;QueryPattern='wela-.wela.invalid.';QueryType='A';Transport='DNS TCP port53; recursion disabled';Before=$null;After=$null;Operation=$null;Query=$null;Candidates=0;Matches=0;Artifacts=@();OutputPath=$null;Diagnostic='';ReadyRuleCredit=0;ConfigurationChanges=0;RuleChannelMismatch='Pinned DNS rules use Microsoft-Windows-DNS Client Events/Operational; actual source is Microsoft-Windows-DNS-Client/Operational. No alias rewrite or rule credit.';Correlation='Random query name, native outcome, source/host, record boundary and operation time. Emitter PID is retained but may be a service broker. Event3008 does not independently prove resolver wire identity or exclusive request attribution.';Scope='One fixed native DNS Client lookup completion; no DNS configuration, cache flush, policy/channel/service changes, forwarding or backend execution. Sysmon excluded.'}
+ $report=[pscustomobject][ordered]@{Kind='WelaNativeDnsClientProbe';SchemaVersion=1;Action=$Action;Status='Unverified';ExitCode=1;Resolver=$Resolver;QueryPattern='wela-.wela.invalid.';QueryType='A';Transport='DNS TCP port53; recursion disabled';Before=$null;After=$null;Operation=$null;Query=$null;QueryLogStatus=@();ReaderBefore=$null;ReaderAfter=$null;ReaderInterval='After all initial metadata/output preparation and record boundary, through worker/event I/O and continuity read; before final metadata inventory.';Candidates=0;Matches=0;Artifacts=@();OutputPath=$null;Diagnostic='';ReadyRuleCredit=0;ConfigurationChanges=0;RuleChannelMismatch='Pinned DNS rules use Microsoft-Windows-DNS Client Events/Operational; actual source is Microsoft-Windows-DNS-Client/Operational. No alias rewrite or rule credit.';Correlation='Random query name, native outcome, source/host, record boundary and operation time. Emitter PID is retained but may be a service broker. Event3008 does not independently prove resolver wire identity or exclusive request attribution.';Scope='One fixed native DNS Client lookup completion; no DNS configuration, cache flush, policy/channel/service changes, forwarding or backend execution. Sysmon excluded.'}
if($Action -eq 'Run'){$report.OutputPath=New-WelaArrivalOutput $OutputPath $PSScriptRoot}
try{
$before=Get-WelaDnsClientProbeState;$report.Before=$before;$key=Get-WelaDnsClientProbeStateKey $before
if($Action -eq 'Plan'){$report.After=$before;$report.Status='PrerequisitesObserved';$report.ExitCode=0;return $report}
$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'before.json' ($before|ConvertTo-Json -Depth 24)
$queryName='wela-'+[guid]::NewGuid().ToString('N')+'.wela.invalid.'
- $operation=Start-WelaDnsClientProbeQuery $before $Resolver $queryName;$report.Operation=$operation
+ $operation=Start-WelaDnsClientProbeQuery $before $Resolver $queryName;$report.Operation=$operation;$report.ReaderBefore=$operation.CallerBefore
$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'operation.json' ($operation|ConvertTo-Json -Depth 15)
$timer=[Diagnostics.Stopwatch]::StartNew();$matches=@()
- do{$batch=Read-WelaDnsClientProbeEvents $operation;$report.Query=$batch.Query;$report.Candidates=@($batch.Xml).Count;if($batch.Capped -isnot [bool] -or $batch.Capped){throw 'DNS event query cap reached or completeness unknown.'};$matches=@($batch.Xml|Where-Object {Test-WelaDnsClientProbeEvent $_ $operation $before});if($matches.Count){break};Start-Sleep -Milliseconds 250}while($timer.Elapsed.TotalSeconds -lt $TimeoutSeconds)
+ do{$batch=Read-WelaDnsClientProbeEvents $operation;$report.Query=$batch.Query;$report.QueryLogStatus=@($batch.LogStatus);Assert-WelaChannelQueryStatus -Channel 'Microsoft-Windows-DNS-Client/Operational' -LogStatus $report.QueryLogStatus;$report.Candidates=@($batch.Xml).Count;if($batch.Capped -isnot [bool] -or $batch.Capped){throw 'DNS event query cap reached or completeness unknown.'};$matches=@($batch.Xml|Where-Object {Test-WelaDnsClientProbeEvent $_ $operation $before});if($matches.Count){break};Start-Sleep -Milliseconds 250}while($timer.Elapsed.TotalSeconds -lt $TimeoutSeconds)
$report.Matches=$matches.Count;if($matches.Count -gt 16){throw 'DNS matching event set exceeds sixteen records.'}
$i=0;foreach($xml in $matches){$i++;$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath ('event-'+$i+'.xml') $xml}
if(-not $matches.Count){$i=0;foreach($xml in @($batch.Xml|Select-Object -First 4)){$i++;$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath ('candidate-'+$i+'.xml') $xml};throw 'No matching native DNS3008 completion event was observed.'}
if((Get-WelaDnsClientProbeWatermark) -lt $operation.RecordIdBefore){throw 'DNS log record boundary moved backwards; continuity unverified.'}
+ $report.ReaderAfter=Get-WelaChannelReader;if((Get-WelaChannelReadKey $report.ReaderAfter) -cne (Get-WelaChannelReadKey $report.ReaderBefore)){throw 'Reader primary token changed during query/event collection.'}
$after=Get-WelaDnsClientProbeState;$report.After=$after;if((Get-WelaDnsClientProbeStateKey $after) -cne $key){throw 'DNS host, token, schema, channel or source changed during collection.'}
$report.Status='NativeDnsLookupObserved';$report.ExitCode=0
}catch{$report.Diagnostic=$_.Exception.Message}
diff --git a/scripts/DnsClientProbeNative.cs b/scripts/DnsClientProbeNative.cs
index cd6d4f54..d0041093 100644
--- a/scripts/DnsClientProbeNative.cs
+++ b/scripts/DnsClientProbeNative.cs
@@ -8,7 +8,7 @@ namespace Wela.DnsClientProbe {
public sealed class Answer { public string Name, Address; public ushort Type; public uint Flags; }
public sealed class Result { public uint Status, ResultStatus; public ulong Options; public string QueryName, Resolver; public Answer[] Answers; }
public static class Native {
- public static string SourceSha256;
+ public const string SourceSha256="__WELA_DNS_CLIENT_SOURCE_SHA256__";
// TCP, no recursion; bypass cache/local-name/hosts/NetBT/multicast/suffixes/IDN.
public const ulong Options=0x002019ee;
[StructLayout(LayoutKind.Sequential,CharSet=CharSet.Unicode)] struct Request {
diff --git a/tests/DnsClientProbe.Tests.ps1 b/tests/DnsClientProbe.Tests.ps1
index befd8814..a3ab2503 100644
--- a/tests/DnsClientProbe.Tests.ps1
+++ b/tests/DnsClientProbe.Tests.ps1
@@ -8,6 +8,7 @@ Add-Type -Path (Join-Path $ScriptRoot 'scripts/DnsClientProbeNative.cs')
foreach($resolver in @('127.0.0.1','192.0.2.53','10.0.0.53')){Assert-WelaDnsClientResolver $resolver;Assert ([Wela.DnsClientProbe.Native]::ValidateResolver($resolver) -ceq $resolver) 'Explicit canonical IPv4 accepted.'}
foreach($resolver in @('','localhost','127.1','127.0.0.01','127.0.0.1:53','127.0.0.1"','0.0.0.0','224.0.0.1','255.255.255.255','192.0.2.999','::1')){Throws {Assert-WelaDnsClientResolver $resolver} 'IPv4';Throws {[Wela.DnsClientProbe.Native]::ValidateResolver($resolver)} 'resolver|IPv4'}
Throws {[Wela.DnsClientProbe.Native]::Query('arbitrary.example.','127.0.0.1')} 'fixed random'
+Assert ([Wela.DnsClientProbe.Native].GetField('SourceSha256').IsLiteral) 'Compiled source fingerprint cannot be reassigned.'
Assert ([Wela.DnsClientProbe.Native]::Options -eq 2103790) 'Fixed documented DNS flags retained.'
$fields=@(foreach($name in @('QueryName','QueryType','QueryOptions','QueryStatus','QueryResults')){[pscustomobject]@{Name=$name;InType=$(if($name -in @('QueryName','QueryResults')){'win:UnicodeString'}elseif($name -eq 'QueryOptions'){'win:UInt64'}else{'win:UInt32'})}})
$state=[pscustomobject]@{Computer='host';Host=[pscustomobject]@{DomainJoined=$false;Domain='WORKGROUP'};Service='Running';Channel=[pscustomobject]@{State='Enabled';Name='Microsoft-Windows-DNS-Client/Operational';SecurityDescriptor='O:SYG:SYD:(A;;0x1;;;SY)';MetadataErrors=@{};Error=$null;IsEnabled=$true;MaximumSizeInBytes=1048576;LogMode='Circular'};Schema=[pscustomobject]@{State='Observed';Provider='Microsoft-Windows-DNS-Client';ProviderGuid='1c95126e-7eea-49a9-a3fe-a378b03ddb4d';ChannelType='Operational';Events=@([pscustomobject]@{Id=3008;Version=0;Channel='Microsoft-Windows-DNS-Client/Operational';Fields=$fields})}}
@@ -26,4 +27,31 @@ foreach($mutation in $mutations){Assert (-not(Test-WelaDnsClientProbeEvent ($xml
Assert (Test-WelaDnsClientProbeEvent ($xml.Replace('ProcessID="123"','ProcessID="456"')) $operation $state) 'Emitter broker PID remains recorded without invented caller attribution.'
$operation.Query.Status=9003;Assert (Test-WelaDnsClientProbeEvent ($xml.Replace('Name="QueryStatus">0','Name="QueryStatus">9003')) $operation $state) 'Typed NXDOMAIN completion differs from successful resolution.'
$operation.Query.Status=0
-Write-Host "PASS: $script:count DNS Client validators and refusal assertions; synthetic XML is not native evidence."
+# Exercise report/cap/drift behavior; only native boundaries are mocked.
+function Clone($Value){ConvertFrom-WelaRecoveryJson ($Value|ConvertTo-Json -Depth 20 -Compress)}
+$token=[pscustomobject]@{Computer='host';ProcessId=123;UserSid='S-1-5-21-1-2-3-1001';UserName='HOST\Reader';TokenId='100';AuthenticationId='99';ModifiedId='200';GroupSids=@('S-1-1-0');GroupCount=1;PrivilegeCount=1;ElevatedAdministrator=$false;TokenType='Primary';Impersonation='Absent'}
+$state|Add-Member NoteProperty Reader (Clone $token);$operation|Add-Member NoteProperty CallerBefore (Clone $token)
+$script:mode='Success';$script:reads=0;$script:workerCalls=0
+function Get-WelaDnsClientProbeState {$script:reads++;$copy=Clone $state;$copy.Reader.ModifiedId=[string](200+$script:reads);if($script:mode -eq 'Drift' -and $script:reads -gt 1){$copy.Computer='changed'};if($script:mode -eq 'Blocked'){$copy.Service='Stopped'};$copy}
+function Start-WelaDnsClientProbeQuery {param($State,$Resolver,$QueryName);$script:workerCalls++;$operation}
+function Read-WelaDnsClientProbeEvents {param($Operation);if($script:mode -eq 'ReadError'){throw 'native query denied'};[pscustomobject]@{Xml=$(if($script:mode -eq 'Missing'){@()}else{@($xml)});Capped=($script:mode -eq 'Cap');Query='synthetic bounded query';LogStatus=@([pscustomobject]@{LogName='Microsoft-Windows-DNS-Client/Operational';StatusCode=$(if($script:mode -eq 'DeniedStatus'){[int]5}else{[int]0})})}}
+function Get-WelaChannelReader {$copy=Clone $token;if($script:mode -eq 'TokenDrift'){$copy.ModifiedId='changed'};$copy}
+function Get-WelaDnsClientProbeWatermark {if($script:mode -eq 'Clear'){8}else{10}}
+$temp=Join-Path ([IO.Path]::GetTempPath()) ('wela-dns-fixtures-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $temp
+try{
+ $plan=Invoke-WelaDnsClientProbe -Resolver '127.0.0.1'
+ Assert ($plan.Status -ceq 'PrerequisitesObserved' -and $script:workerCalls -eq 0 -and -not $plan.OutputPath) 'Default Plan never runs a DNS query or writes evidence.'
+ foreach($mode in @('Success','Blocked','Cap','ReadError','Drift','Clear','Missing','DeniedStatus','TokenDrift')){
+ $script:mode=$mode;$script:reads=0;$script:workerCalls=0;$directory=Join-Path $temp $mode
+ $result=Invoke-WelaDnsClientProbe -Action Run -Resolver '127.0.0.1' -OutputPath $directory -TimeoutSeconds 1
+ $manifest=ConvertFrom-WelaRecoveryJson ([IO.File]::ReadAllText((Join-Path $directory 'manifest.json')))
+ Assert ($manifest.ReadyRuleCredit -eq 0 -and $manifest.ConfigurationChanges -eq 0 -and $manifest.RuleChannelMismatch -match 'DNS Client Events/Operational') 'Success and failure retain original channel mismatch and zero configuration/Sigma credit.'
+ Assert ($null -ne $manifest.After) 'Final observations survive failures.'
+ foreach($artifact in $manifest.Artifacts){Assert ($artifact.Sha256 -ceq (Get-FileHash -LiteralPath (Join-Path $directory $artifact.Name)).Hash.ToLowerInvariant()) 'Manifest hashes match written bytes.'}
+ if($mode -eq 'Success'){Assert ($result.Status -ceq 'NativeDnsLookupObserved' -and $result.Matches -eq 1 -and $result.ExitCode -eq 0) 'Exact synthetic event yields the bounded observation.';Assert ([IO.File]::ReadAllText((Join-Path $directory 'event-1.xml')) -ceq $xml) 'Original XML retained unchanged.'}
+ else{Assert ($result.Status -ceq 'Unverified' -and $result.ExitCode -eq 1 -and $result.Diagnostic) "Failure $mode remains unverified."}
+ if($mode -eq 'Blocked'){Assert ($script:workerCalls -eq 0) 'Missing prerequisites prevent the native operation.'}
+ }
+ Throws {Invoke-WelaDnsClientProbe -Action Run -Resolver '127.0.0.1' -OutputPath (Join-Path $temp 'Success')} 'new directory'
+}finally{Remove-Item -LiteralPath $temp -Recurse -Force}
+Write-Host "PASS: $script:count DNS Client validator/report/refusal assertions; native boundaries were mocked."
diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md
index a306b6d1..c1b5070a 100644
--- a/website/docs/resources/changelog.ja.md
+++ b/website/docs/resources/changelog.ja.md
@@ -7,7 +7,7 @@
**改善:**
-- 明示した IPv4 リゾルバーに固定の無害な A クエリを送信し、Windows 標準イベント 3008 と照合する任意実行の `dns-client-probe` を追加しました。既定では前提条件の計画だけを行い、実行時は時間・件数を制限した証拠をハッシュ付きで保護します。Windows 設定は変更せず、元の DNS ルールのチャネル不一致と相関の限界を保持し、Sigma の評価には加算しません。Server 2022/2025 と PowerShell 5.1/7 向けに使い捨ての権威 DNS・ループバック検証を追加しました。 (@Shirofune-Security)
+- 明示した IPv4 リゾルバーに固定の無害な A クエリを送信し、Windows 標準イベント 3008 と照合する任意実行の `dns-client-probe` を追加しました。既定では前提条件の計画だけを行い、実行時は時間・件数を制限した証拠をハッシュ付きで保護します。Windows 設定は変更せず、元の DNS ルールのチャネル不一致と相関の限界を保持し、Sigma の評価には加算しません。Server 2022/2025 と PowerShell 5.1/7 向けに使い捨ての権威 DNS・ループバック検証を追加しました。 (#434) (@Shirofune-Security)
- 読み取り専用の `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 a9806f76..d9de819b 100644
--- a/website/docs/resources/changelog.md
+++ b/website/docs/resources/changelog.md
@@ -7,7 +7,7 @@
**Improvements:**
-- Added opt-in `dns-client-probe` for one fixed benign A lookup to an explicitly selected IPv4 resolver, with default prerequisite planning, bounded native DNS execution and exact local event 3008 correlation. Private hashed evidence preserves native status, context and correlation limits without changing Windows settings. The original DNS rule/channel mismatch remains explicit and Sigma credit stays zero. Added disposable authoritative-loopback DNS acceptance tests for Server 2022/2025 and PowerShell 5.1/7. (@Shirofune-Security)
+- Added opt-in `dns-client-probe` for one fixed benign A lookup to an explicitly selected IPv4 resolver, with default prerequisite planning, bounded native DNS execution and exact local event 3008 correlation. Private hashed evidence preserves native status, context and correlation limits without changing Windows settings. The original DNS rule/channel mismatch remains explicit and Sigma credit stays zero. Added disposable authoritative-loopback DNS acceptance tests for Server 2022/2025 and PowerShell 5.1/7. (#434) (@Shirofune-Security)
- 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 1df3e401ff88677cae7ff9c4c2df014c4b26fcaa Mon Sep 17 00:00:00 2001
From: Shirofune-Security
<43838376+Shirofune-Security@users.noreply.github.com>
Date: Mon, 21 Sep 2026 09:52:31 +0900
Subject: [PATCH 07/18] Prepare explicit owned DNS zone data for native
acceptance
---
.github/workflows/release.yml | 2 +-
scripts/DnsClientProbeNative.cs | 2 +-
tests/DnsClientProbe.Windows.Tests.ps1 | 26 +++++++++++++++++++++-----
3 files changed, 23 insertions(+), 7 deletions(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 8e912d6c..edefa9f6 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -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 -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/dns-client-probe.md -Destination release-binaries/docs/
- name: Set Artifact Name
if: contains(matrix.info.os, 'windows') == true
diff --git a/scripts/DnsClientProbeNative.cs b/scripts/DnsClientProbeNative.cs
index d0041093..b629e2b4 100644
--- a/scripts/DnsClientProbeNative.cs
+++ b/scripts/DnsClientProbeNative.cs
@@ -27,7 +27,7 @@ namespace Wela.DnsClientProbe {
}
public static Result Query(string name,string resolver) {
if(IntPtr.Size!=8)throw new InvalidOperationException("Native 64-bit process required.");
- if(name==null||!Regex.IsMatch(name,@"^wela-[a-f0-9]{32}\.wela\.invalid\.$"))throw new ArgumentException("Only the fixed random probe name is accepted.");
+ if(name==null||!Regex.IsMatch(name,@"^wela-[a-f0-9]{32}\.wela\.invalid\.\z"))throw new ArgumentException("Only the fixed random probe name is accepted.");
ValidateResolver(resolver);
// SDK DNS_ADDR_ARRAY header32 + one DNS_ADDR64; sockaddr_in in its first16 bytes.
byte[] server=new byte[96];BitConverter.GetBytes((uint)1).CopyTo(server,0);BitConverter.GetBytes((uint)1).CopyTo(server,4);
diff --git a/tests/DnsClientProbe.Windows.Tests.ps1 b/tests/DnsClientProbe.Windows.Tests.ps1
index 8ae73242..8334a1f1 100644
--- a/tests/DnsClientProbe.Windows.Tests.ps1
+++ b/tests/DnsClientProbe.Windows.Tests.ps1
@@ -11,7 +11,7 @@ $os=Get-CimInstance Win32_OperatingSystem;$computer=Get-CimInstance Win32_Comput
if($os.ProductType -ne 3 -or [int]$os.BuildNumber -notin @(20348,26100) -or $computer.PartOfDomain -or $computer.DomainRole -ne 2 -or (Get-WindowsFeature DNS).Installed){throw 'This fixture requires an unjoined Server2022/2025 with no existing DNS role.'}
$engine=(Get-Command $TestEngine -ErrorAction Stop).Source
$private=New-WelaArrivalOutput (Join-Path $env:TEMP ('wela-dns-client-native-'+[guid]::NewGuid().ToString('N'))) $PSScriptRoot
-$channel='Microsoft-Windows-DNS-Client/Operational';$zone='wela.invalid';$zoneFile='wela.invalid.dns'
+$channel='Microsoft-Windows-DNS-Client/Operational';$zone='wela.invalid';$zoneFile='wela-native-'+[guid]::NewGuid().ToString('N')+'.dns';$zoneFilePath=$null;$zoneFileCreated=$false
$beforeFeatures=@(Get-WindowsFeature|Where-Object Installed|ForEach-Object Name);$policies=Get-WelaEffectiveAuditPolicy;$original=Get-WelaNativeChannel $channel
if($original.State -notin @('Enabled','Disabled') -or $original.MetadataErrors.Count -or $original.Error){throw 'Complete original DNS Client channel state is required before fixture mutation.'}
$null=Write-WelaArrivalArtifact $private 'original-channel.json' ($original|ConvertTo-Json -Depth 10)
@@ -28,10 +28,25 @@ try {
$installed=$true;$feature=Install-WindowsFeature DNS -IncludeManagementTools -ErrorAction Stop
if(-not $feature.Success -or [string]$feature.RestartNeeded -ne 'No'){throw 'DNS role install failed or requires restart; no native acceptance claim.'}
Start-Service DNS -ErrorAction Stop
+ $ready=[Diagnostics.Stopwatch]::StartNew();do{try{$null=Get-DnsServerZone -ErrorAction Stop;break}catch{if($ready.Elapsed.TotalSeconds -gt 30){throw};Start-Sleep -Milliseconds 500}}while($true)
if(Get-DnsServerZone -Name $zone -ErrorAction SilentlyContinue){throw 'Fixture zone already exists; no replacement is permitted.'}
- if(Test-Path -LiteralPath (Join-Path $env:SystemRoot ('System32\dns\'+$zoneFile))){throw 'Fixture zone file already exists.'}
- $zoneCreated=$true;Add-DnsServerPrimaryZone -Name $zone -ZoneFile $zoneFile -DynamicUpdate None -ErrorAction Stop
- Add-DnsServerResourceRecordA -ZoneName $zone -Name '*' -IPv4Address '192.0.2.1' -TimeToLive ([TimeSpan]::Zero) -ErrorAction Stop|Out-Null
+ $zoneFilePath=Join-Path $env:SystemRoot ('System32\dns\'+$zoneFile)
+ if(Test-Path -LiteralPath $zoneFilePath){throw 'Fixture zone file already exists.'}
+ # Avoid relying on generated SOA/NS names on an unjoined, suffix-free runner.
+ $zoneText=@'
+$ORIGIN wela.invalid.
+$TTL 0
+@ IN SOA ns.wela.invalid. hostmaster.wela.invalid. ( 1 3600 600 86400 0 )
+@ IN NS ns.wela.invalid.
+ns IN A 127.0.0.1
+* IN A 192.0.2.1
+'@
+ $stream=[IO.File]::Open($zoneFilePath,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None)
+ try{$zoneFileCreated=$true;$bytes=[Text.Encoding]::ASCII.GetBytes($zoneText.Replace("`n","`r`n")+"`r`n");$stream.Write($bytes,0,$bytes.Length);$stream.Flush()}finally{$stream.Dispose()}
+ Write-Host "Creating owned authoritative zone $zone from new file $zoneFile"
+ Add-DnsServerPrimaryZone -Name $zone -ZoneFile $zoneFile -DynamicUpdate None -LoadExisting -ErrorAction Stop;$zoneCreated=$true
+ $record=Get-DnsServerResourceRecord -ZoneName $zone -Name '*' -RRType A -ErrorAction Stop
+ Assert (@($record).Count -eq 1 -and $record.RecordData.IPv4Address.IPAddressToString -ceq '192.0.2.1') 'Loaded owned wildcard A record is exact.'
# The zone is authoritative and the native request has recursion disabled. No external resolver or answer connection is used.
if(-not $original.IsEnabled){$channelChanged=$true;Set-ChannelEnabled $true}
$configured=Get-WelaNativeChannel $channel
@@ -56,7 +71,8 @@ try {
}finally{
$errors=@()
try{if($channelChanged){Set-ChannelEnabled ([bool]$original.IsEnabled)};if((Get-WelaChannelReadKey (Get-WelaNativeChannel $channel)) -cne (Get-WelaChannelReadKey $original)){throw 'DNS Client channel configuration restoration differs.'}}catch{$errors+=$_.Exception.Message}
- if($zoneCreated){try{$owned=Get-DnsServerZone -Name $zone -ErrorAction SilentlyContinue;if($owned){if($owned.IsDsIntegrated -or $owned.ZoneType -ne 'Primary'){throw 'Owned DNS zone identity changed; cleanup refused.'};Remove-DnsServerZone -Name $zone -Force -ErrorAction Stop};if(Get-DnsServerZone -Name $zone -ErrorAction SilentlyContinue){throw 'Owned zone remains.'};$file=Join-Path $env:SystemRoot ('System32\dns\'+$zoneFile);if(Test-Path -LiteralPath $file){Remove-Item -LiteralPath $file -ErrorAction Stop}}catch{$errors+=$_.Exception.Message}}
+ if($zoneCreated){try{$owned=Get-DnsServerZone -Name $zone -ErrorAction SilentlyContinue;if($owned){if($owned.IsDsIntegrated -or $owned.ZoneType -ne 'Primary'){throw 'Owned DNS zone identity changed; cleanup refused.'};Remove-DnsServerZone -Name $zone -Force -ErrorAction Stop};if(Get-DnsServerZone -Name $zone -ErrorAction SilentlyContinue){throw 'Owned zone remains.'}}catch{$errors+=$_.Exception.Message}}
+ if($zoneFileCreated){try{if(Get-DnsServerZone -Name $zone -ErrorAction SilentlyContinue){throw 'Refuse deleting a zone file still loaded by DNS.'};if(Test-Path -LiteralPath $zoneFilePath){Remove-Item -LiteralPath $zoneFilePath -ErrorAction Stop}}catch{$errors+=$_.Exception.Message}}
try{$afterPolicies=Get-WelaEffectiveAuditPolicy;foreach($guid in $policies.Keys){if($afterPolicies[$guid] -ne $policies[$guid]){throw 'Native audit policy changed.'}}}catch{$errors+=$_.Exception.Message}
$removal=[pscustomobject]@{ChannelAndZoneRestored=($errors.Count -eq 0);Attempted=$false;Features=@();Success=$null;RestartNeeded=$null;Boundary='Owned feature removal can require disposal of this GitHub-hosted VM; no restart or complete live feature-restoration claim.'}
if($installed -and -not $errors.Count){try{$added=@(Get-WindowsFeature|Where-Object {$_.Installed -and $_.Name -notin $beforeFeatures -and $_.Name -in @('DNS','RSAT-DNS-Server')}|ForEach-Object Name);if($added.Count){$removal.Attempted=$true;$removal.Features=$added;$removed=Uninstall-WindowsFeature -Name $added -ErrorAction Stop;$removal.Success=[bool]$removed.Success;$removal.RestartNeeded=[string]$removed.RestartNeeded;if(-not $removed.Success -or $removal.RestartNeeded -notin @('No','Yes')){throw 'DNS feature removal failed or restart state is unknown.'}}}catch{$errors+=$_.Exception.Message}}
From 1759f5a19621095efab44af479cf06f594ef0362 Mon Sep 17 00:00:00 2001
From: Shirofune-Security
<43838376+Shirofune-Security@users.noreply.github.com>
Date: Mon, 21 Sep 2026 09:56:40 +0900
Subject: [PATCH 08/18] Use reserved test namespace for native DNS completion
probe
---
CHANGELOG-Japanese.md | 2 +-
CHANGELOG.md | 2 +-
WELA.ps1 | 2 +-
docs/dns-client-probe.md | 4 ++--
scripts/DnsClientProbe.ps1 | 4 ++--
scripts/DnsClientProbeNative.cs | 2 +-
tests/DnsClientProbe.Tests.ps1 | 4 ++--
tests/DnsClientProbe.Windows.Tests.ps1 | 14 +++++++-------
website/docs/resources/changelog.ja.md | 2 +-
website/docs/resources/changelog.md | 2 +-
10 files changed, 19 insertions(+), 19 deletions(-)
diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md
index 57ae6659..f7f8cca4 100644
--- a/CHANGELOG-Japanese.md
+++ b/CHANGELOG-Japanese.md
@@ -4,7 +4,7 @@
**改善:**
-- 明示した IPv4 リゾルバーに固定の無害な A クエリを送信し、Windows 標準イベント 3008 と照合する任意実行の `dns-client-probe` を追加しました。既定では前提条件の計画だけを行い、実行時は時間・件数を制限した証拠をハッシュ付きで保護します。Windows 設定は変更せず、元の DNS ルールのチャネル不一致と相関の限界を保持し、Sigma の評価には加算しません。Server 2022/2025 と PowerShell 5.1/7 向けに使い捨ての権威 DNS・ループバック検証を追加しました。 (#434) (@Shirofune-Security)
+- 明示した IPv4 リゾルバーに固定の無害な `wela-.wela.test.` A クエリを送信し、Windows 標準イベント 3008 と照合する任意実行の `dns-client-probe` を追加しました。既定では前提条件の計画だけを行い、実行時は時間・件数を制限した証拠をハッシュ付きで保護します。Windows 設定は変更せず、元の DNS ルールのチャネル不一致と相関の限界を保持し、Sigma の評価には加算しません。Server 2022/2025 と PowerShell 5.1/7 向けに使い捨ての権威 DNS・ループバック検証を追加しました。 (#434) (@Shirofune-Security)
- 読み取り専用の `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 54a4f532..7bf7e327 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,7 +4,7 @@
**Improvements:**
-- Added opt-in `dns-client-probe` for one fixed benign A lookup to an explicitly selected IPv4 resolver, with default prerequisite planning, bounded native DNS execution and exact local event 3008 correlation. Private hashed evidence preserves native status, context and correlation limits without changing Windows settings. The original DNS rule/channel mismatch remains explicit and Sigma credit stays zero. Added disposable authoritative-loopback DNS acceptance tests for Server 2022/2025 and PowerShell 5.1/7. (#434) (@Shirofune-Security)
+- Added opt-in `dns-client-probe` for one fixed benign `wela-.wela.test.` A lookup to an explicitly selected IPv4 resolver, with default prerequisite planning, bounded native DNS execution and exact local event 3008 correlation. Private hashed evidence preserves native status, context and correlation limits without changing Windows settings. The original DNS rule/channel mismatch remains explicit and Sigma credit stays zero. Added disposable authoritative-loopback DNS acceptance tests for Server 2022/2025 and PowerShell 5.1/7. (#434) (@Shirofune-Security)
- 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/WELA.ps1 b/WELA.ps1
index b5a203d3..0baec2ce 100644
--- a/WELA.ps1
+++ b/WELA.ps1
@@ -2255,7 +2255,7 @@ switch ($Cmd.ToLower()) {
if($report.ExitCode){exit $report.ExitCode}
}
'dns-client-probe' {
- if ($Help) {Write-Host 'Usage: dns-client-probe [-DnsClientProbeAction Plan|Run] -DnsClientProbeResolver approved-IPv4 [-DnsClientProbeOutputPath new-private-directory] [-DnsClientProbeTimeoutSeconds 1..30]. Fixed benign A lookup to wela-.wela.invalid. via explicit DNS TCP53 resolver; no configuration changes or Sigma credit. Plan observes prerequisites only. See docs/dns-client-probe.md.';return}
+ if ($Help) {Write-Host 'Usage: dns-client-probe [-DnsClientProbeAction Plan|Run] -DnsClientProbeResolver approved-IPv4 [-DnsClientProbeOutputPath new-private-directory] [-DnsClientProbeTimeoutSeconds 1..30]. Fixed benign A lookup to wela-.wela.test. via explicit DNS TCP53 resolver; no configuration changes or Sigma credit. Plan observes prerequisites only. See docs/dns-client-probe.md.';return}
$report=Invoke-WelaDnsClientProbe -Action $DnsClientProbeAction -Resolver $DnsClientProbeResolver -OutputPath $DnsClientProbeOutputPath -TimeoutSeconds $DnsClientProbeTimeoutSeconds
$report
if($report.ExitCode){exit $report.ExitCode}
diff --git a/docs/dns-client-probe.md b/docs/dns-client-probe.md
index 6479eecd..e52e6002 100644
--- a/docs/dns-client-probe.md
+++ b/docs/dns-client-probe.md
@@ -12,7 +12,7 @@
-DnsClientProbeOutputPath C:\WelaEvidence\dns-client-01
```
-The example address is documentation-only: replace it with an approved resolver. Plan creates no files and sends no probe lookup. Run generates exactly one application request for `wela-.wela.invalid.` type A. There is no caller-selected domain, record type or application connection to a returned address. A same-engine 64-bit worker uses synchronous `DnsQueryEx` with one explicit IPv4 DNS server, TCP port 53, recursion disabled, cache bypass, no hosts/local-name/NetBT/multicast fallback, fully qualified naming and IDN disabled. DNS retry/internal processing and normal response caching are OS behavior; this is not a promise of one wire packet, cache immutability or resolver-side enforcement. The query name, selected resolver and exact flags are retained. Only canonical unicast IPv4 literals are accepted; there is no hostname or configurable port.
+The example address is documentation-only: replace it with an approved resolver. Plan creates no files and sends no probe lookup. Run generates exactly one application request for `wela-.wela.test.` type A; `.test` is reserved for DNS testing by [RFC 2606](https://www.rfc-editor.org/rfc/rfc2606.html). There is no caller-selected domain, record type or application connection to a returned address. A same-engine 64-bit worker uses synchronous `DnsQueryEx` with one explicit IPv4 DNS server, TCP port 53, recursion disabled, cache bypass, no hosts/local-name/NetBT/multicast fallback, fully qualified naming and IDN disabled. DNS retry/internal processing and normal response caching are OS behavior; this is not a promise of one wire packet, cache immutability or resolver-side enforcement. The query name, selected resolver and exact flags are retained. Only canonical unicast IPv4 literals are accepted; there is no hostname or configurable port.
The bounded worker has twenty seconds to finish. The separate event wait defaults to fifteen seconds (`-DnsClientProbeTimeoutSeconds 1..30`). Native status 0 (A answers), 9003 (NXDOMAIN) and 9501 (no records) are reviewed completion outcomes. A negative response is not reported as successful name resolution. Missing events, unknown outcomes/versions/types, caps, token or configuration/source drift and incomplete reads remain `Unverified` with a nonzero exit. No setup is automatically performed to make the test pass.
@@ -20,6 +20,6 @@ Evidence includes observed build/patch/role, token and same-engine context, exac
`PrerequisitesObserved` means only that Plan observed supported metadata. `NativeDnsLookupObserved` means that a native completion and matching local event were observed. Neither proves forwarding, downstream parsing, detection execution or retention capacity. In particular, all six pinned DNS Client rules refer to **Microsoft-Windows-DNS Client Events/Operational**, a different channel string. WELA retains that mismatch and does not rewrite it. `ReadyRuleCredit` remains **0**; there is no six-rule Sigma uplift.
-Native acceptance uses a separately opt-in fixture on disposable GitHub-hosted workgroup Server 2022/2025 under Windows PowerShell 5.1 and PowerShell 7. The fixture refuses an existing DNS role, installs its own standalone role, creates authoritative `wela.invalid` with a wildcard A record to `192.0.2.1`, and queries only loopback. It temporarily enables the Client channel if needed, restores its exact original settings, checks audit policies, removes its owned zone/records and removes only newly installed DNS features. Feature removal may require VM disposal rather than a live reboot; the cleanup receipt records that boundary. Fixture setup is not part of the product. Windows 11, domain-joined/DC/ADCS hosts and external resolver/network behavior still require their own acceptance evidence.
+Native acceptance uses a separately opt-in fixture on disposable GitHub-hosted workgroup Server 2022/2025 under Windows PowerShell 5.1 and PowerShell 7. The fixture refuses an existing DNS role, installs its own standalone role, creates authoritative `wela.test` with a wildcard A record to `192.0.2.1`, and queries only loopback. It temporarily enables the Client channel if needed, restores its exact original settings, checks audit policies, removes its owned zone/records and removes only newly installed DNS features. Feature removal may require VM disposal rather than a live reboot; the cleanup receipt records that boundary. Fixture setup is not part of the product. Windows 11, domain-joined/DC/ADCS hosts and external resolver/network behavior still require their own acceptance evidence.
Native API references: [DnsQueryEx](https://learn.microsoft.com/en-us/windows/win32/api/windns/nf-windns-dnsqueryex), [DNS_QUERY_REQUEST](https://learn.microsoft.com/en-us/windows/win32/api/windns/ns-windns-dns_query_request), [DNS query flags](https://learn.microsoft.com/en-us/windows/win32/dns/dns-constants), and the [Microsoft Windows SDK declarations](https://github.com/microsoft/win32metadata/blob/main/generation/WinSDK/RecompiledIdlHeaders/um/WinDNS.h).
diff --git a/scripts/DnsClientProbe.ps1 b/scripts/DnsClientProbe.ps1
index 027b6733..c7bc4f2c 100644
--- a/scripts/DnsClientProbe.ps1
+++ b/scripts/DnsClientProbe.ps1
@@ -128,13 +128,13 @@ function Invoke-WelaDnsClientProbe {
param([ValidateSet('Plan','Run')][string]$Action='Plan',[string]$Resolver,[string]$OutputPath,[ValidateRange(1,30)][int]$TimeoutSeconds=15)
$ErrorActionPreference='Stop';Assert-WelaDnsClientResolver $Resolver
if(($Action -eq 'Run') -ne (-not [string]::IsNullOrWhiteSpace($OutputPath))){throw 'Run requires a new output directory; Plan writes no files.'}
- $report=[pscustomobject][ordered]@{Kind='WelaNativeDnsClientProbe';SchemaVersion=1;Action=$Action;Status='Unverified';ExitCode=1;Resolver=$Resolver;QueryPattern='wela-.wela.invalid.';QueryType='A';Transport='DNS TCP port53; recursion disabled';Before=$null;After=$null;Operation=$null;Query=$null;QueryLogStatus=@();ReaderBefore=$null;ReaderAfter=$null;ReaderInterval='After all initial metadata/output preparation and record boundary, through worker/event I/O and continuity read; before final metadata inventory.';Candidates=0;Matches=0;Artifacts=@();OutputPath=$null;Diagnostic='';ReadyRuleCredit=0;ConfigurationChanges=0;RuleChannelMismatch='Pinned DNS rules use Microsoft-Windows-DNS Client Events/Operational; actual source is Microsoft-Windows-DNS-Client/Operational. No alias rewrite or rule credit.';Correlation='Random query name, native outcome, source/host, record boundary and operation time. Emitter PID is retained but may be a service broker. Event3008 does not independently prove resolver wire identity or exclusive request attribution.';Scope='One fixed native DNS Client lookup completion; no DNS configuration, cache flush, policy/channel/service changes, forwarding or backend execution. Sysmon excluded.'}
+ $report=[pscustomobject][ordered]@{Kind='WelaNativeDnsClientProbe';SchemaVersion=1;Action=$Action;Status='Unverified';ExitCode=1;Resolver=$Resolver;QueryPattern='wela-.wela.test.';QueryType='A';Transport='DNS TCP port53; recursion disabled';Before=$null;After=$null;Operation=$null;Query=$null;QueryLogStatus=@();ReaderBefore=$null;ReaderAfter=$null;ReaderInterval='After all initial metadata/output preparation and record boundary, through worker/event I/O and continuity read; before final metadata inventory.';Candidates=0;Matches=0;Artifacts=@();OutputPath=$null;Diagnostic='';ReadyRuleCredit=0;ConfigurationChanges=0;RuleChannelMismatch='Pinned DNS rules use Microsoft-Windows-DNS Client Events/Operational; actual source is Microsoft-Windows-DNS-Client/Operational. No alias rewrite or rule credit.';Correlation='Random query name, native outcome, source/host, record boundary and operation time. Emitter PID is retained but may be a service broker. Event3008 does not independently prove resolver wire identity or exclusive request attribution.';Scope='One fixed native DNS Client lookup completion; no DNS configuration, cache flush, policy/channel/service changes, forwarding or backend execution. Sysmon excluded.'}
if($Action -eq 'Run'){$report.OutputPath=New-WelaArrivalOutput $OutputPath $PSScriptRoot}
try{
$before=Get-WelaDnsClientProbeState;$report.Before=$before;$key=Get-WelaDnsClientProbeStateKey $before
if($Action -eq 'Plan'){$report.After=$before;$report.Status='PrerequisitesObserved';$report.ExitCode=0;return $report}
$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'before.json' ($before|ConvertTo-Json -Depth 24)
- $queryName='wela-'+[guid]::NewGuid().ToString('N')+'.wela.invalid.'
+ $queryName='wela-'+[guid]::NewGuid().ToString('N')+'.wela.test.'
$operation=Start-WelaDnsClientProbeQuery $before $Resolver $queryName;$report.Operation=$operation;$report.ReaderBefore=$operation.CallerBefore
$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'operation.json' ($operation|ConvertTo-Json -Depth 15)
$timer=[Diagnostics.Stopwatch]::StartNew();$matches=@()
diff --git a/scripts/DnsClientProbeNative.cs b/scripts/DnsClientProbeNative.cs
index b629e2b4..8712158a 100644
--- a/scripts/DnsClientProbeNative.cs
+++ b/scripts/DnsClientProbeNative.cs
@@ -27,7 +27,7 @@ namespace Wela.DnsClientProbe {
}
public static Result Query(string name,string resolver) {
if(IntPtr.Size!=8)throw new InvalidOperationException("Native 64-bit process required.");
- if(name==null||!Regex.IsMatch(name,@"^wela-[a-f0-9]{32}\.wela\.invalid\.\z"))throw new ArgumentException("Only the fixed random probe name is accepted.");
+ if(name==null||!Regex.IsMatch(name,@"^wela-[a-f0-9]{32}\.wela\.test\.\z"))throw new ArgumentException("Only the fixed random probe name is accepted.");
ValidateResolver(resolver);
// SDK DNS_ADDR_ARRAY header32 + one DNS_ADDR64; sockaddr_in in its first16 bytes.
byte[] server=new byte[96];BitConverter.GetBytes((uint)1).CopyTo(server,0);BitConverter.GetBytes((uint)1).CopyTo(server,4);
diff --git a/tests/DnsClientProbe.Tests.ps1 b/tests/DnsClientProbe.Tests.ps1
index a3ab2503..71aef0db 100644
--- a/tests/DnsClientProbe.Tests.ps1
+++ b/tests/DnsClientProbe.Tests.ps1
@@ -16,9 +16,9 @@ Assert ((Get-WelaDnsClientProbeStateKey $state).Length -gt 0) 'Exact schema prer
$state.Channel.MetadataErrors['LogMode']='denied';Throws {Get-WelaDnsClientProbeStateKey $state} 'fully observed';$state.Channel.MetadataErrors=@{}
$state.Schema.Events[0].Fields[0].InType='win:UInt32';Throws {Get-WelaDnsClientProbeStateKey $state} 'field/type';$state.Schema.Events[0].Fields[0].InType='win:UnicodeString'
$state.Schema.Events[0].Version=1;Throws {Get-WelaDnsClientProbeStateKey $state} 'version/channel';$state.Schema.Events[0].Version=0
-$operation=[pscustomobject]@{Query=[pscustomobject]@{QueryName='wela-0123456789abcdef0123456789abcdef.wela.invalid.';Status=0;Options=2103790};StartedUtc='2026-01-01T00:00:00.0000000Z';CompletedUtc='2026-01-01T00:00:01.0000000Z';RecordIdBefore=9}
+$operation=[pscustomobject]@{Query=[pscustomobject]@{QueryName='wela-0123456789abcdef0123456789abcdef.wela.test.';Status=0;Options=2103790};StartedUtc='2026-01-01T00:00:00.0000000Z';CompletedUtc='2026-01-01T00:00:01.0000000Z';RecordIdBefore=9}
$xml=@'
-3008010Microsoft-Windows-DNS-Client/Operationalhostwela-0123456789abcdef0123456789abcdef.wela.invalid.10x2019ee0192.0.2.1;
+3008010Microsoft-Windows-DNS-Client/Operationalhostwela-0123456789abcdef0123456789abcdef.wela.test.10x2019ee0192.0.2.1;
'@
Assert (Test-WelaDnsClientProbeEvent $xml $operation $state) 'Exact synthetic native3008 shape matches.'
$mutations=@(
diff --git a/tests/DnsClientProbe.Windows.Tests.ps1 b/tests/DnsClientProbe.Windows.Tests.ps1
index 8334a1f1..4353e8c8 100644
--- a/tests/DnsClientProbe.Windows.Tests.ps1
+++ b/tests/DnsClientProbe.Windows.Tests.ps1
@@ -11,7 +11,7 @@ $os=Get-CimInstance Win32_OperatingSystem;$computer=Get-CimInstance Win32_Comput
if($os.ProductType -ne 3 -or [int]$os.BuildNumber -notin @(20348,26100) -or $computer.PartOfDomain -or $computer.DomainRole -ne 2 -or (Get-WindowsFeature DNS).Installed){throw 'This fixture requires an unjoined Server2022/2025 with no existing DNS role.'}
$engine=(Get-Command $TestEngine -ErrorAction Stop).Source
$private=New-WelaArrivalOutput (Join-Path $env:TEMP ('wela-dns-client-native-'+[guid]::NewGuid().ToString('N'))) $PSScriptRoot
-$channel='Microsoft-Windows-DNS-Client/Operational';$zone='wela.invalid';$zoneFile='wela-native-'+[guid]::NewGuid().ToString('N')+'.dns';$zoneFilePath=$null;$zoneFileCreated=$false
+$channel='Microsoft-Windows-DNS-Client/Operational';$zone='wela.test';$zoneFile='wela-native-'+[guid]::NewGuid().ToString('N')+'.dns';$zoneFilePath=$null;$zoneFileCreated=$false
$beforeFeatures=@(Get-WindowsFeature|Where-Object Installed|ForEach-Object Name);$policies=Get-WelaEffectiveAuditPolicy;$original=Get-WelaNativeChannel $channel
if($original.State -notin @('Enabled','Disabled') -or $original.MetadataErrors.Count -or $original.Error){throw 'Complete original DNS Client channel state is required before fixture mutation.'}
$null=Write-WelaArrivalArtifact $private 'original-channel.json' ($original|ConvertTo-Json -Depth 10)
@@ -34,18 +34,18 @@ try {
if(Test-Path -LiteralPath $zoneFilePath){throw 'Fixture zone file already exists.'}
# Avoid relying on generated SOA/NS names on an unjoined, suffix-free runner.
$zoneText=@'
-$ORIGIN wela.invalid.
+$ORIGIN wela.test.
$TTL 0
-@ IN SOA ns.wela.invalid. hostmaster.wela.invalid. ( 1 3600 600 86400 0 )
-@ IN NS ns.wela.invalid.
+@ IN SOA ns.wela.test. hostmaster.wela.test. ( 1 3600 600 86400 0 )
+@ IN NS ns.wela.test.
ns IN A 127.0.0.1
* IN A 192.0.2.1
'@
$stream=[IO.File]::Open($zoneFilePath,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None)
- try{$zoneFileCreated=$true;$bytes=[Text.Encoding]::ASCII.GetBytes($zoneText.Replace("`n","`r`n")+"`r`n");$stream.Write($bytes,0,$bytes.Length);$stream.Flush()}finally{$stream.Dispose()}
+ try{$zoneFileCreated=$true;$bytes=[Text.Encoding]::ASCII.GetBytes(($zoneText -replace "\r?\n","`r`n")+"`r`n");$stream.Write($bytes,0,$bytes.Length);$stream.Flush()}finally{$stream.Dispose()}
Write-Host "Creating owned authoritative zone $zone from new file $zoneFile"
Add-DnsServerPrimaryZone -Name $zone -ZoneFile $zoneFile -DynamicUpdate None -LoadExisting -ErrorAction Stop;$zoneCreated=$true
- $record=Get-DnsServerResourceRecord -ZoneName $zone -Name '*' -RRType A -ErrorAction Stop
+ $record=@(Get-DnsServerResourceRecord -ZoneName $zone -RRType A -ErrorAction Stop|Where-Object HostName -ceq '*')
Assert (@($record).Count -eq 1 -and $record.RecordData.IPv4Address.IPAddressToString -ceq '192.0.2.1') 'Loaded owned wildcard A record is exact.'
# The zone is authoritative and the native request has recursion disabled. No external resolver or answer connection is used.
if(-not $original.IsEnabled){$channelChanged=$true;Set-ChannelEnabled $true}
@@ -57,7 +57,7 @@ ns IN A 127.0.0.1
Invoke-Cli @('dns-client-probe','-DnsClientProbeAction','Run','-DnsClientProbeResolver','127.0.0.1','-DnsClientProbeOutputPath',$output)
$report=ConvertFrom-WelaRecoveryJson ([IO.File]::ReadAllText((Join-Path $output 'manifest.json')))
Assert ($report.Status -ceq 'NativeDnsLookupObserved' -and $report.ExitCode -eq 0 -and $report.Matches -ge 1 -and $report.ReadyRuleCredit -eq 0 -and $report.ConfigurationChanges -eq 0) 'Actual native3008 correlation is observed without configuration/Sigma credit.'
- Assert ($report.Operation.Query.QueryName -cmatch '^wela-[a-f0-9]{32}\.wela\.invalid\.$' -and $report.Operation.Query.Status -eq 0 -and $report.Operation.Query.ResultStatus -eq 0 -and @($report.Operation.Query.Answers).Count -eq 1 -and $report.Operation.Query.Answers[0].Address -ceq '192.0.2.1') 'Owned authoritative loopback resolver returns the exact fixed A answer.'
+ Assert ($report.Operation.Query.QueryName -cmatch '^wela-[a-f0-9]{32}\.wela\.test\.$' -and $report.Operation.Query.Status -eq 0 -and $report.Operation.Query.ResultStatus -eq 0 -and @($report.Operation.Query.Answers).Count -eq 1 -and $report.Operation.Query.Answers[0].Address -ceq '192.0.2.1') 'Owned authoritative loopback resolver returns the exact fixed A answer.'
foreach($artifact in $report.Artifacts){Assert ((Get-FileHash -LiteralPath (Join-Path $output $artifact.Name) -Algorithm SHA256).Hash.ToLowerInvariant() -ceq $artifact.Sha256) 'Evidence bytes match recorded SHA256.'}
foreach($file in Get-ChildItem -LiteralPath $output -Filter 'event-*.xml'){$xml=[IO.File]::ReadAllText($file.FullName);Assert (Test-WelaDnsClientProbeEvent $xml $report.Operation $report.Before) 'Actual persisted3008 XML matches the production validator.';Write-Host $xml}
Assert ((Get-WelaChannelReadKey (Get-WelaNativeChannel $channel)) -ceq (Get-WelaChannelReadKey $configured)) 'Product preserves the exact configured channel metadata.'
diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md
index c1b5070a..3c9299b7 100644
--- a/website/docs/resources/changelog.ja.md
+++ b/website/docs/resources/changelog.ja.md
@@ -7,7 +7,7 @@
**改善:**
-- 明示した IPv4 リゾルバーに固定の無害な A クエリを送信し、Windows 標準イベント 3008 と照合する任意実行の `dns-client-probe` を追加しました。既定では前提条件の計画だけを行い、実行時は時間・件数を制限した証拠をハッシュ付きで保護します。Windows 設定は変更せず、元の DNS ルールのチャネル不一致と相関の限界を保持し、Sigma の評価には加算しません。Server 2022/2025 と PowerShell 5.1/7 向けに使い捨ての権威 DNS・ループバック検証を追加しました。 (#434) (@Shirofune-Security)
+- 明示した IPv4 リゾルバーに固定の無害な `wela-.wela.test.` A クエリを送信し、Windows 標準イベント 3008 と照合する任意実行の `dns-client-probe` を追加しました。既定では前提条件の計画だけを行い、実行時は時間・件数を制限した証拠をハッシュ付きで保護します。Windows 設定は変更せず、元の DNS ルールのチャネル不一致と相関の限界を保持し、Sigma の評価には加算しません。Server 2022/2025 と PowerShell 5.1/7 向けに使い捨ての権威 DNS・ループバック検証を追加しました。 (#434) (@Shirofune-Security)
- 読み取り専用の `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 d9de819b..a1f92c6e 100644
--- a/website/docs/resources/changelog.md
+++ b/website/docs/resources/changelog.md
@@ -7,7 +7,7 @@
**Improvements:**
-- Added opt-in `dns-client-probe` for one fixed benign A lookup to an explicitly selected IPv4 resolver, with default prerequisite planning, bounded native DNS execution and exact local event 3008 correlation. Private hashed evidence preserves native status, context and correlation limits without changing Windows settings. The original DNS rule/channel mismatch remains explicit and Sigma credit stays zero. Added disposable authoritative-loopback DNS acceptance tests for Server 2022/2025 and PowerShell 5.1/7. (#434) (@Shirofune-Security)
+- Added opt-in `dns-client-probe` for one fixed benign `wela-.wela.test.` A lookup to an explicitly selected IPv4 resolver, with default prerequisite planning, bounded native DNS execution and exact local event 3008 correlation. Private hashed evidence preserves native status, context and correlation limits without changing Windows settings. The original DNS rule/channel mismatch remains explicit and Sigma credit stays zero. Added disposable authoritative-loopback DNS acceptance tests for Server 2022/2025 and PowerShell 5.1/7. (#434) (@Shirofune-Security)
- 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 2b1a3bce820c3d45dbf3e68e04819de3fae19aa0 Mon Sep 17 00:00:00 2001
From: Shirofune-Security
<43838376+Shirofune-Security@users.noreply.github.com>
Date: Mon, 21 Sep 2026 10:03:00 +0900
Subject: [PATCH 09/18] Add guarded recovery for one selected leaf-file audit
ACE
---
.gitattributes | 3 +
.github/workflows/file-sacl-recovery.yml | 40 +++++
CHANGELOG-Japanese.md | 1 +
CHANGELOG.md | 1 +
WELA.ps1 | 19 +-
docs/file-sacl-recovery.md | 65 +++++++
scripts/FileSaclRecovery.ps1 | 183 ++++++++++++++++++++
scripts/FileSaclRecoveryNative.cs | 110 ++++++++++++
tests/FileSaclRecovery.Cli.Tests.ps1 | 24 +++
tests/FileSaclRecovery.Descriptor.Tests.ps1 | 98 +++++++++++
tests/FileSaclRecovery.Tests.ps1 | 37 ++++
tests/FileSaclRecovery.Windows.Tests.ps1 | 103 +++++++++++
website/docs/resources/changelog.ja.md | 1 +
website/docs/resources/changelog.md | 1 +
14 files changed, 685 insertions(+), 1 deletion(-)
create mode 100644 .github/workflows/file-sacl-recovery.yml
create mode 100644 docs/file-sacl-recovery.md
create mode 100644 scripts/FileSaclRecovery.ps1
create mode 100644 scripts/FileSaclRecoveryNative.cs
create mode 100644 tests/FileSaclRecovery.Cli.Tests.ps1
create mode 100644 tests/FileSaclRecovery.Descriptor.Tests.ps1
create mode 100644 tests/FileSaclRecovery.Tests.ps1
create mode 100644 tests/FileSaclRecovery.Windows.Tests.ps1
diff --git a/.gitattributes b/.gitattributes
index 7f0dff24..44fd81e8 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -58,3 +58,6 @@ tests/SelectedSaclFixtureProtection.cs text eol=lf
/scripts/WmiNamespaceAuditing.ps1 text eol=lf
/scripts/WefArrival.ps1 text eol=lf
/tests/WmiProbe*.ps1 text eol=lf
+# Leaf-file recovery review binds these exact helper bytes.
+/scripts/FileSaclRecovery* text eol=lf
+/tests/FileSaclRecovery* text eol=lf
diff --git a/.github/workflows/file-sacl-recovery.yml b/.github/workflows/file-sacl-recovery.yml
new file mode 100644
index 00000000..0ca53259
--- /dev/null
+++ b/.github/workflows/file-sacl-recovery.yml
@@ -0,0 +1,40 @@
+name: Native leaf-file SACL recovery
+on:
+ push:
+ branches: ['**']
+ pull_request:
+ workflow_dispatch:
+permissions:
+ contents: read
+jobs:
+ file-sacl-recovery:
+ timeout-minutes: 35
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [windows-2022, windows-2025]
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - name: Strict inputs and CLI on Windows PowerShell 5.1
+ shell: powershell
+ run: |
+ ./tests/FileSaclRecovery.Tests.ps1
+ ./tests/FileSaclRecovery.Cli.Tests.ps1
+ - name: Native descriptor guards on Windows PowerShell 5.1
+ shell: powershell
+ run: ./tests/FileSaclRecovery.Descriptor.Tests.ps1
+ - name: Public owned-file addition and recovery on Windows PowerShell 5.1
+ shell: powershell
+ run: ./tests/FileSaclRecovery.Windows.Tests.ps1 -AllowDisposableSaclWrite
+ - name: Strict inputs and CLI on PowerShell 7
+ shell: pwsh
+ run: |
+ ./tests/FileSaclRecovery.Tests.ps1
+ ./tests/FileSaclRecovery.Cli.Tests.ps1
+ - name: Native descriptor guards on PowerShell 7
+ shell: pwsh
+ run: ./tests/FileSaclRecovery.Descriptor.Tests.ps1
+ - name: Public owned-file addition and recovery on PowerShell 7
+ shell: pwsh
+ run: ./tests/FileSaclRecovery.Windows.Tests.ps1 -AllowDisposableSaclWrite
diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md
index fbb5f432..a2fd2aec 100644
--- a/CHANGELOG-Japanese.md
+++ b/CHANGELOG-Japanese.md
@@ -4,6 +4,7 @@
**改善:**
+- `file-sacl-recovery` を追加しました。元の計画、変更前後のレシート、成功結果、ソースとホストのハッシュ、実際のファイル識別情報を照合し、変更されていない選択済み単一ファイルに追加した監査 ACE だけを明示的に削除できます。書き込み前の永続記録と SACL 限定の変更・再読込により、無関係な ACE のバイト列と個数、観測した他の記述子要素を保持します。空の SACL が残る場合を区別し、元の記述子との完全一致、ポリシー・ディレクトリ・レジストリの復旧、Sigma の利用可能性は保証しません。(@Shirofune-Security)
- 読み取り専用の `channel-read` を追加し、実際の実行トークンで選択したWindows標準ローカルログを確認します。メタデータやACLとは独立して拒否・不在・空・イベント読取を区別し、トークンと環境の変化を検出して保護された限定レポートに記録します。Windows設定の変更やSigma利用可能数への加算は行いません。使い捨て標準ユーザーでServer 2022/2025・PowerShell 5.1/7の拒否と読取を検証します。 (#432) (@Shirofune-Security)
- 専用 CA 監査設定の再起動待ちを確認し、明示的に再開する `adcs-resume` を追加。元の記録・結果、出典、現在の CA と実行者、永続的な意図記録、変更直前・最終確認により古い計画の再実行を拒否し、観測した設定を保持します。ドライランと失敗を区別し、イベントや Sigma の証明は加算しません。使い捨て CA テストでは最初の再起動拒否のみを注入し、公開 CLI による実際の再起動と要求イベントを検証します。既存の専用 CA Configure のドライラン引数制限も修正しました。 (#431) (@Shirofune-Security)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5f47af61..70adc3b1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
**Improvements:**
+- Added opt-in `file-sacl-recovery` to review and remove one proven explicit audit ACE from an unchanged selected leaf file. Original plans, paired receipts, successful results, source/host hashes and held file identity bind the operation; durable pre-write evidence and SACL-only readback preserve unrelated ACE bytes/counts and observed descriptor components. Empty present SACLs are reported without claiming exact historical descriptor restoration; policy, directory/registry recovery and Sigma readiness remain outside this scope. (@Shirofune-Security)
- 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)
- Added `adcs-resume` to review and explicitly resume a dedicated pending CA auditing restart. Original journal/results, source and current CA/operator fingerprints, durable intent receipts and fresh/final checks prevent stale-plan replay and preserve observed settings. Dry-run and failed attempts remain explicit, with no event/Sigma credit. Disposable CA tests inject the initial refusal then verify an actual public-CLI restart and request events. Also fixed the existing dedicated CA Configure dry-run CLI guard. (#431) (@Shirofune-Security)
diff --git a/WELA.ps1 b/WELA.ps1
index fb027456..1587ac50 100644
--- a/WELA.ps1
+++ b/WELA.ps1
@@ -104,6 +104,14 @@
[string]$EvtxProbePath,
[string]$EvtxArchivePath,
[string]$EvtxOutputPath,
+ [ValidateSet('Plan','Restore')][string]$FileSaclRecoveryAction = 'Plan',
+ [string]$FileSaclRecoveryOriginalPlanPath,
+ [string]$FileSaclRecoveryPendingPath,
+ [string]$FileSaclRecoveryConfirmedPath,
+ [string]$FileSaclRecoveryResultsPath,
+ [string]$FileSaclRecoveryPlanPath,
+ [string]$FileSaclRecoveryPlanHash,
+ [string]$FileSaclRecoveryOutputPath,
[ValidateSet('Plan','Restore')][string]$RecoveryAction = 'Plan',
[string]$RecoveryJournalPath,
[string]$RecoveryOriginalResultsPath,
@@ -192,6 +200,7 @@ Import-Module (Join-Path $ScriptRoot "modules/WefSubscriptions.psm1") -ErrorActi
. (Join-Path $ScriptRoot "scripts/EventMeasurement.ps1")
. (Join-Path $ScriptRoot "scripts/GpoCreation.ps1")
. (Join-Path $ScriptRoot "scripts/AuditRecovery.ps1")
+. (Join-Path $ScriptRoot "scripts/FileSaclRecovery.ps1")
# 64bit の PowerShell と GPO が読むのは Wow6432Node の無いパス。32bit 用に両方を扱う。
$PowerShellPolicyRoots = @(
@@ -2026,6 +2035,8 @@ if ($Cmd -eq 'intune-export' -and @($PSBoundParameters.Keys | Where-Object { $_
if ($Cmd -ne 'evtx-recovery' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'Evtx*'}).Count) {throw 'EVTX options require evtx-recovery. No command was run.'}
if ($Cmd -eq 'evtx-recovery' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','EvtxAction','EvtxProbePath','EvtxArchivePath','EvtxOutputPath','Help')}).Count) {throw 'evtx-recovery accepts only its dedicated options. No command was run.'}
+if ($Cmd -ne 'file-sacl-recovery' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'FileSaclRecovery*'}).Count) {throw 'FileSaclRecovery options require file-sacl-recovery. No command was run.'}
+if ($Cmd -eq 'file-sacl-recovery' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','FileSaclRecoveryAction','FileSaclRecoveryOriginalPlanPath','FileSaclRecoveryPendingPath','FileSaclRecoveryConfirmedPath','FileSaclRecoveryResultsPath','FileSaclRecoveryPlanPath','FileSaclRecoveryPlanHash','FileSaclRecoveryOutputPath','Auto','DryRun','Help')}).Count) {throw 'file-sacl-recovery accepts only dedicated recovery options, Auto and DryRun. No command was run.'}
if ($Cmd -ne 'audit-recovery' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'Recovery*'}).Count) {throw 'Recovery options require audit-recovery. No command was run.'}
if ($Cmd -eq 'audit-recovery' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','RecoveryAction','RecoveryJournalPath','RecoveryOriginalResultsPath','RecoveryControlId','RecoveryPlanPath','RecoveryOutputPath','Auto','DryRun','Help')}).Count) {throw 'audit-recovery accepts only dedicated recovery options, Auto and DryRun. No command was run.'}
@@ -2115,7 +2126,7 @@ if ($Cmd -ne 'ad-object-sacl' -and @($PSBoundParameters.Keys | Where-Object {
}).Count) {
throw 'AD object SACL options require the dedicated ad-object-sacl command. No command was run.'
}
-if ($DryRun -and -not ($Cmd -eq 'adcs-auditing' -and $AdcsAction -eq 'Configure') -and -not ($Cmd -eq 'adcs-resume' -and $AdcsResumeAction -eq 'Resume') -and -not ($Cmd -eq 'gpo-create' -and $GpoCreateAction -eq 'Create') -and -not ($Cmd -eq 'dns-analytical' -and $DnsAction -eq 'Configure') -and -not ($Cmd -eq 'targeted-sacl' -and $TargetSaclAction -eq 'Configure') -and -not ($Cmd -eq 'audit-recovery' -and $RecoveryAction -eq 'Restore') -and -not ($Cmd -eq 'gpo-package' -and $GpoAction -eq 'Export') -and -not ($Cmd -eq 'audit-integrity' -and $IntegrityAction -eq 'Configure') -and -not ($Cmd -eq 'audit-notifications' -and $NotificationAction -eq 'Configure') -and -not ($Cmd -eq 'ldap-diagnostics' -and $LdapAction -eq 'Configure') -and -not ($Cmd -eq 'applocker-readiness' -and $AppLockerAction -eq 'Import') -and $Cmd -notin @('configure', 'configure-eventlogs') -and
+if ($DryRun -and -not ($Cmd -eq 'file-sacl-recovery' -and $FileSaclRecoveryAction -eq 'Restore') -and -not ($Cmd -eq 'adcs-auditing' -and $AdcsAction -eq 'Configure') -and -not ($Cmd -eq 'adcs-resume' -and $AdcsResumeAction -eq 'Resume') -and -not ($Cmd -eq 'gpo-create' -and $GpoCreateAction -eq 'Create') -and -not ($Cmd -eq 'dns-analytical' -and $DnsAction -eq 'Configure') -and -not ($Cmd -eq 'targeted-sacl' -and $TargetSaclAction -eq 'Configure') -and -not ($Cmd -eq 'audit-recovery' -and $RecoveryAction -eq 'Restore') -and -not ($Cmd -eq 'gpo-package' -and $GpoAction -eq 'Export') -and -not ($Cmd -eq 'audit-integrity' -and $IntegrityAction -eq 'Configure') -and -not ($Cmd -eq 'audit-notifications' -and $NotificationAction -eq 'Configure') -and -not ($Cmd -eq 'ldap-diagnostics' -and $LdapAction -eq 'Configure') -and -not ($Cmd -eq 'applocker-readiness' -and $AppLockerAction -eq 'Import') -and $Cmd -notin @('configure', 'configure-eventlogs') -and
-not ($Cmd -eq 'provider-packs' -and $ProviderAction -eq 'Configure') -and
-not ($Cmd -eq 'firewall-logging' -and $FirewallAction -eq 'Configure') -and
-not ($Cmd -eq 'smb-auditing' -and $SmbAction -eq 'Configure') -and
@@ -2231,6 +2242,12 @@ switch ($Cmd.ToLower()) {
$report
if ($report.ExitCode) {exit $report.ExitCode}
}
+ 'file-sacl-recovery' {
+ if ($Help) {Write-Host 'Usage: file-sacl-recovery [-FileSaclRecoveryAction Plan] -FileSaclRecoveryOriginalPlanPath original-plan.json -FileSaclRecoveryPendingPath target.pending.json -FileSaclRecoveryConfirmedPath target.confirmed.json -FileSaclRecoveryResultsPath original-results.json -FileSaclRecoveryOutputPath new-directory; then -FileSaclRecoveryAction Restore -FileSaclRecoveryPlanPath reviewed-plan.json -FileSaclRecoveryPlanHash SHA256 with -DryRun, or -Auto -FileSaclRecoveryOutputPath new-directory. Removes only one proven explicit leaf-file audit ACE. See docs/file-sacl-recovery.md.';return}
+ $report=Invoke-WelaFileSaclRecovery -Action $FileSaclRecoveryAction -OriginalPlanPath $FileSaclRecoveryOriginalPlanPath -PendingPath $FileSaclRecoveryPendingPath -ConfirmedPath $FileSaclRecoveryConfirmedPath -ResultsPath $FileSaclRecoveryResultsPath -PlanPath $FileSaclRecoveryPlanPath -PlanHash $FileSaclRecoveryPlanHash -OutputPath $FileSaclRecoveryOutputPath -Auto:$Auto -DryRun:$DryRun
+ $report | ConvertTo-Json -Depth 30 | Write-Output
+ if ($report.ExitCode) {exit $report.ExitCode};return
+ }
'audit-recovery' {
if ($Help) {Write-Host 'Usage: audit-recovery [-RecoveryAction Plan] -RecoveryJournalPath before.jsonl -RecoveryOriginalResultsPath results.json -RecoveryControlId IDs -RecoveryOutputPath new-directory; then -RecoveryAction Restore -RecoveryPlanPath reviewed-plan.json -RecoveryOutputPath new-directory [-Auto], or -DryRun without output. See docs/audit-recovery.md.';return}
$report=Invoke-WelaAuditRecovery -Action $RecoveryAction -JournalPath $RecoveryJournalPath -OriginalResultsPath $RecoveryOriginalResultsPath -ControlId $RecoveryControlId -PlanPath $RecoveryPlanPath -OutputPath $RecoveryOutputPath -Auto:$Auto -DryRun:$DryRun
diff --git a/docs/file-sacl-recovery.md b/docs/file-sacl-recovery.md
new file mode 100644
index 00000000..c971ea72
--- /dev/null
+++ b/docs/file-sacl-recovery.md
@@ -0,0 +1,65 @@
+# Recover one selected leaf-file audit ACE
+
+`file-sacl-recovery` removes one explicit ordinary audit ACE proven to have been added by a completed `targeted-sacl` operation. It supports one existing local leaf file, selected from the installed catalog with `Inheritance=None` and without child consent. Use elevated native 64-bit PowerShell on the original host. Registry keys, directories, descendants, inherited/object/callback ACE additions, arbitrary supplied ACEs and older or source-mismatched receipts require manual review.
+
+This command changes only that file's SACL. It does not restore audit policy, rewrite its DACL, stop services, alter inheritance settings, or make an event-generation/Sigma readiness claim. Sysmon is out of scope. Preserve trusted original evidence; hashes detect changes and bind the reviewed selection but do not authenticate an untrusted receipt author.
+
+## Required evidence and review
+
+Retain all four files from the original public selected-target operation:
+
+- Its original one-target `Plan` JSON, recorded while the row was `ChangeRequired`.
+- The matching `.pending.json` and `.confirmed.json` under the original backup directory.
+- The successful `Configure` results JSON, with its one row marked `Applied`.
+
+The original before/after snapshots must prove exactly one new explicit ordinary audit ACE for the selected principal, rights and outcomes. Every previous ACE's bytes and count must remain; owner/group, DACL bytes, control flags, resource-manager control byte and SACL revision must agree, except that the original addition may have introduced the SACL-present flag. Neither an already-covered ACE nor any additional unexplained delta grants removal authority. Original snapshots are reconstructed from their binary descriptors and checked against their reported metadata.
+
+The host/context, installed catalog and original selected-operation source hashes must still match. Recovery additionally records current helper/source hashes, actual elevated operator SID/groups and machine GUID, original input hashes, full current descriptor bytes and volume/file-index/creation identity. Current state must exactly match the confirmed addition. Input JSON is strict UTF-8, rejects duplicate properties and is limited to four MiB per file.
+
+```powershell
+.\WELA.ps1 file-sacl-recovery `
+ -FileSaclRecoveryOriginalPlanPath C:\Evidence\selected-plan.json `
+ -FileSaclRecoveryPendingPath C:\Evidence\receipts\sacl-.pending.json `
+ -FileSaclRecoveryConfirmedPath C:\Evidence\receipts\sacl-.confirmed.json `
+ -FileSaclRecoveryResultsPath C:\Evidence\selected-results.json `
+ -FileSaclRecoveryOutputPath C:\Evidence\recovery-review
+```
+
+Review the new `plan.json`, especially `OriginalFiles`, `Operator`, `Expected`, `AddedAce` and `BeforeAddition`. Record its SHA-256 from the command result or `Get-FileHash`. The review directory must be new, outside the WELA installation, with an existing parent.
+
+```powershell
+$plan = 'C:\Evidence\recovery-review\plan.json'
+$hash = (Get-FileHash $plan -Algorithm SHA256).Hash.ToLowerInvariant()
+.\WELA.ps1 file-sacl-recovery -FileSaclRecoveryAction Restore `
+ -FileSaclRecoveryPlanPath $plan -FileSaclRecoveryPlanHash $hash -DryRun
+
+.\WELA.ps1 file-sacl-recovery -FileSaclRecoveryAction Restore `
+ -FileSaclRecoveryPlanPath $plan -FileSaclRecoveryPlanHash $hash `
+ -Auto -FileSaclRecoveryOutputPath C:\Evidence\recovery-result
+```
+
+`DryRun` rebuilds and compares the review from the original evidence and current host/file, then reports `WouldRemoveAddedAce`; it writes nothing. Actual restore requires `Auto` and a new private output directory outside the review directory. Both actions reject a modified or stale plan; rerunning an already completed plan is refused.
+
+## Mutation and outcomes
+
+Before mutation, `reviewed-plan.json` and `pending.json` are created exclusively, flushed to disk, reopened and hashed. Implementation, operator, host, original input files and reviewed plan are rechecked. The native helper holds a file handle without delete sharing, rejects directories and reparse files, verifies its final path and actual identity, and rereads the exact descriptor. It submits only `SACL_SECURITY_INFORMATION` to remove the unique proven ACE. Temporary `SeSecurityPrivilege` state is restored.
+
+Afterwards WELA reads the held file and reopens the path, checks identity, unrelated ACE bytes/counts, SACL revision/presence, owner/group, DACL, control flags and resource-manager control, then rechecks sources/evidence and reopens once more. Descriptor observations cover WinSDK-defined sections `0x1ff`; future sections are unobserved. Windows security-descriptor operations are not an atomic compare-and-swap against another administrator. Quiesce concurrent ACL writers; the guards detect observed drift, not an arbitrarily timed competing write.
+
+`result.json` reports:
+
+| Status | Meaning |
+| --- | --- |
+| `AddedAceRemoved` | One proven addition was removed and the bounded readback/preservation checks passed. |
+| `Refused` | The operation failed before any native write attempt. |
+| `WriteAttemptedUnverified` | A native write was attempted but complete final verification failed. Retain evidence and inspect manually. |
+
+Removing the final audit ACE may leave an **empty present SACL** even if the historical descriptor had no SACL. This is an ACE-removal result, not a byte-for-byte restoration of the historical descriptor. `OriginalDescriptorBytesMatch` is only an observation; exact historical descriptor equality and original ACE ordering are not promised. Unrelated ACE bytes and counts are preserved. WELA does not automatically re-add the ACE after partial failure. No outcome grants rule-readiness credit.
+
+## Validation and limits
+
+`tests/FileSaclRecovery.Tests.ps1` covers strict input, source binding, durable exclusive output and action guards; separate CLI tests run real public process dispatch. Native descriptor tests exercise exact deltas and unsafe ACE/header/control changes. The explicitly gated Windows fixture runs on disposable Server 2022/2025 with Windows PowerShell 5.1 and PowerShell 7: it installs an owned one-file catalog only in a disposable checkout copy, obtains genuine public `Plan`/`Configure` receipts, then exercises public review/dry-run/removal/replay refusal, altered evidence/source and replacement file identity. Empty and unrelated-ACE cases retain their observed outside descriptor components. The fixture restores all 59 audit-policy masks and the exact typed precedence value/absence and deletes only its owned files.
+
+This is not Windows 11, DC, ADCS, inherited directory recovery, distributed policy refresh or event/backend acceptance evidence. The original selected-target implementation files remain unchanged so the recovery feature itself does not invalidate their existing source hashes.
+
+API contracts: [GetSecurityInfo](https://learn.microsoft.com/en-us/windows/win32/api/aclapi/nf-aclapi-getsecurityinfo), [SetSecurityInfo](https://learn.microsoft.com/en-us/windows/win32/api/aclapi/nf-aclapi-setsecurityinfo), [RawSecurityDescriptor](https://learn.microsoft.com/en-us/dotnet/api/system.security.accesscontrol.rawsecuritydescriptor).
diff --git a/scripts/FileSaclRecovery.ps1 b/scripts/FileSaclRecovery.ps1
new file mode 100644
index 00000000..603c7eb4
--- /dev/null
+++ b/scripts/FileSaclRecovery.ps1
@@ -0,0 +1,183 @@
+# Recovery is limited to a single proven explicit addition on an existing leaf file.
+function Get-WelaFileSaclRecoveryKey {param($Value) ConvertTo-Json -InputObject $Value -Depth 30 -Compress}
+function Initialize-WelaFileSaclRecoveryNative {
+ $path=Join-Path $PSScriptRoot 'FileSaclRecoveryNative.cs';$bytes=[IO.File]::ReadAllBytes($path);$hash=Get-WelaArrivalHash $bytes
+ if (-not ('Wela.FileSaclRecovery.Descriptor' -as [type])) {
+ $source=[Text.UTF8Encoding]::new($false,$true).GetString($bytes).TrimStart([char]0xfeff)
+ $marker='__WELA_FILE_SACL_RECOVERY_SOURCE_SHA256__'
+ if (($source.Split(@($marker),[StringSplitOptions]::None)).Count -ne 2) {throw 'Unexpected native recovery source binding.'}
+ Add-Type -TypeDefinition $source.Replace($marker,$hash) -ErrorAction Stop
+ }
+ if ([Wela.FileSaclRecovery.Descriptor]::SourceSha256 -cne $hash) {throw 'Loaded file recovery helper differs from current source; start a fresh PowerShell process.'}
+}
+function Get-WelaFileSaclRecoverySources {
+ $sources=[ordered]@{}
+ foreach ($path in @('WELA.ps1','scripts/FileSaclRecovery.ps1','scripts/FileSaclRecoveryNative.cs','scripts/SelectedSaclConfiguration.ps1','scripts/SelectedSaclNative.cs','scripts/SelectedSaclDescendants.ps1','scripts/TargetedSaclPlanning.ps1','scripts/ControlApplicability.ps1','scripts/Configuration.ps1','config/control_applicability.json','modules/NativeProviders.psm1','scripts/EvtxRecovery.ps1','scripts/WefArrival.ps1','modules/AuditProfiles.psm1','modules/AuditCatalog.psm1','config/audit_profiles.json','config/audit_sacl_targets.json')) {
+ $sources[$path]=(Get-FileHash -LiteralPath (Join-Path (Split-Path $PSScriptRoot -Parent) $path) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()
+ }
+ [pscustomobject]$sources
+}
+function Get-WelaFileSaclRecoveryOperator {
+ if ($env:OS -ne 'Windows_NT' -or -not [Environment]::Is64BitProcess) {throw 'File SACL recovery requires native 64-bit Windows.'}
+ $thread=[Security.Principal.WindowsIdentity]::GetCurrent($true)
+ if ($thread) {$thread.Dispose();throw 'Impersonated recovery is unsupported.'}
+ $identity=[Security.Principal.WindowsIdentity]::GetCurrent()
+ try {
+ if (-not ([Security.Principal.WindowsPrincipal]::new($identity)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {throw 'File SACL recovery requires the actual elevated operator.'}
+ $key=[Microsoft.Win32.Registry]::LocalMachine.OpenSubKey('SOFTWARE\Microsoft\Cryptography',$false)
+ if (-not $key) {throw 'Machine identity is unavailable.'}
+ try {$machine=$key.GetValue('MachineGuid');if ($key.GetValueKind('MachineGuid') -ne 'String' -or $machine -isnot [string]) {throw 'Machine identity is mistyped.'}} finally {$key.Dispose()}
+ [guid]$parsed=[guid]::Empty;if (-not [guid]::TryParse($machine,[ref]$parsed) -or $parsed -eq [guid]::Empty) {throw 'Machine identity is invalid.'}
+ [pscustomobject][ordered]@{Computer=[Environment]::MachineName;MachineGuid=$parsed.ToString();UserSid=$identity.User.Value;Groups=@($identity.Groups|ForEach-Object Value|Sort-Object);ElevatedAdministrator=$true;Impersonation='Absent'}
+ } finally {$identity.Dispose()}
+}
+function Read-WelaFileSaclRecoveryInput {
+ param([string]$Path)
+ $full=Resolve-WelaArrivalPath $Path
+ $stream=[IO.File]::Open($full,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read)
+ try {
+ if ($stream.Length -lt 1 -or $stream.Length -gt 4194304) {throw 'Recovery JSON must contain 1 byte through four MiB.'}
+ $bytes=New-Object byte[] ([int]$stream.Length);$offset=0
+ while ($offset -lt $bytes.Length) {$count=$stream.Read($bytes,$offset,$bytes.Length-$offset);if ($count -eq 0) {throw 'Recovery input changed during reading.'};$offset+=$count}
+ if ($stream.Length -ne $bytes.Length) {throw 'Recovery input length changed.'}
+ } finally {$stream.Dispose()}
+ $text=[Text.UTF8Encoding]::new($false,$true).GetString($bytes).TrimStart([char]0xfeff)
+ [pscustomobject]@{Path=$full;Sha256=(Get-WelaArrivalHash $bytes);Bytes=$bytes.Length;Data=(ConvertFrom-WelaEvtxJson $text)}
+}
+function Assert-WelaFileSaclRecoverySnapshot {
+ param($Snapshot,$Definition)
+ Assert-WelaEvtxObject $Snapshot @('Path','Kind','Identity','IsDirectory','DescriptorBase64','Owner','Group','DaclBase64','ControlFlags','SecurityInformation','DescriptorScope','Aces')
+ if ($Snapshot.Kind -cne 'FileSystem' -or $Snapshot.IsDirectory -isnot [bool] -or $Snapshot.IsDirectory -or $Snapshot.Path -cne $Definition.Path -or $Snapshot.Identity -cnotmatch '^[0-9]+:[0-9]+:[0-9]+:[0-9]+$' -or $Snapshot.Aces -isnot [array]) {throw 'Only exact historical leaf-file snapshots are supported.'}
+ $null=Get-WelaSelectedSaclSnapshotKey $Snapshot
+ foreach ($ace in $Snapshot.Aces) {
+ Assert-WelaEvtxObject $ace @('Binary','Type','Flags','Mask','Sid','Ordinary')
+ if ($ace.Ordinary -isnot [bool]) {throw 'Mistyped ACE metadata.'}
+ foreach ($name in @('Type','Flags','Mask')) {if ($ace.$name -isnot [int] -and $ace.$name -isnot [long]) {throw 'Mistyped ACE metadata.'}}
+ }
+ Initialize-WelaFileSaclRecoveryNative
+ $parsed=[Wela.FileSaclRecovery.Descriptor]::Observe($Snapshot.Path,$Snapshot.Identity,[Convert]::FromBase64String($Snapshot.DescriptorBase64))
+ if ((Get-WelaSelectedSaclSnapshotKey $parsed) -cne (Get-WelaSelectedSaclSnapshotKey $Snapshot)) {throw 'Historical snapshot metadata differs from its actual descriptor bytes.'}
+}
+function Get-WelaFileSaclRecoverySnapshot {
+ param($Definition)
+ if ($Definition.Kind -cne 'FileSystem') {throw 'Only leaf FileSystem targets are supported.'}
+ $path=Resolve-WelaSelectedSaclNativePath $Definition;Initialize-WelaFileSaclRecoveryNative
+ $target=[Wela.FileSaclRecovery.Target]::new($path)
+ try {$target.Read()} finally {$target.Dispose()}
+}
+function Get-WelaFileSaclRecoveryAddition {
+ param($Before,$After,$Ace)
+ Initialize-WelaFileSaclRecoveryNative
+ [Wela.FileSaclRecovery.Descriptor]::AddedAce($Before.DescriptorBase64,$After.DescriptorBase64,$Ace.Sid,$Ace.Mask,$Ace.Flags)
+}
+function New-WelaFileSaclRecoveryPlan {
+ param([string]$OriginalPlanPath,[string]$PendingPath,[string]$ConfirmedPath,[string]$ResultsPath)
+ $operator=Get-WelaFileSaclRecoveryOperator;$context=Get-WelaSelectedSaclContext;$sources=Get-WelaFileSaclRecoverySources
+ $files=[ordered]@{};foreach ($entry in @(@('OriginalPlan',$OriginalPlanPath),@('Pending',$PendingPath),@('Confirmed',$ConfirmedPath),@('Results',$ResultsPath))) {$files[$entry[0]]=Read-WelaFileSaclRecoveryInput $entry[1]}
+ if (@($files.Values.Path|Sort-Object -Unique).Count -ne 4) {throw 'Four distinct original evidence files are required.'}
+ $plan=$files.OriginalPlan.Data;$pending=$files.Pending.Data;$confirmed=$files.Confirmed.Data;$result=$files.Results.Data
+ Assert-WelaEvtxObject $plan @('SchemaVersion','Kind','CapturedUtc','Profile','IncludeOptional','IncludeChildren','Context','Sources','Rows','GenerationReadiness','UsableRuleCredit','Catalog','UserInventory')
+ foreach ($value in @($plan,$pending,$confirmed,$result)) {if (($value.SchemaVersion -isnot [int] -and $value.SchemaVersion -isnot [long]) -or $value.SchemaVersion -ne 1) {throw 'Unsupported original evidence schema.'}}
+ if ($plan.Kind -cne 'WelaSelectedSaclPlan' -or $plan.IncludeChildren -isnot [bool] -or $plan.IncludeChildren -or $plan.IncludeOptional -isnot [bool] -or $plan.Rows -isnot [array] -or $plan.Rows.Count -ne 1) {throw 'Require one original selected target, without child consent.'}
+ $row=$plan.Rows[0]
+ if ($row.Status -cne 'ChangeRequired' -or $row.After -or $row.DescendantsBefore -or $row.DescendantsAfter -or $row.DescendantVerification -or $row.Id -cnotmatch '^sacl-[a-f0-9]{24}$' -or $row.Definition.Kind -cne 'FileSystem' -or $row.Definition.Inheritance -cne 'None' -or $row.Definition.Propagation -cne 'None') {throw 'Original plan must describe one explicit leaf-file addition without inheritance.'}
+ Assert-WelaSelectedSaclSources $plan.Sources
+ if ($plan.Context.Key -cne $context.Key -or $plan.Context.Computer -cne $operator.Computer) {throw 'Original host context differs from the actual recovery host.'}
+ $catalog=Get-WelaSelectedSaclCatalog -Profile $plan.Profile -IncludeOptional:$plan.IncludeOptional -Context $context
+ $selected=@($catalog.Rows|Where-Object Id -CEQ $row.Id)
+ if ($selected.Count -ne 1 -or $selected[0].DefinitionKey -cne $row.DefinitionKey -or (Get-WelaSelectedSaclDefinitionKey $row.Definition) -cne $row.DefinitionKey -or (Get-WelaFileSaclRecoveryKey $selected[0].Definition) -cne (Get-WelaFileSaclRecoveryKey $row.Definition)) {throw 'Original target is not the exact currently source-bound catalog selection.'}
+ Assert-WelaFileSaclRecoverySnapshot $row.Before $row.Definition
+ $ace=Get-WelaSelectedSaclAce $row.Definition $row.Before
+ if ((Get-WelaFileSaclRecoveryKey $ace) -cne (Get-WelaFileSaclRecoveryKey $row.Ace) -or $ace.Flags -notin @(64,128,192) -or (Test-WelaSelectedSaclAce $row.Before $ace)) {throw 'Original selected audit ACE is mistyped, inherited or already covered.'}
+ $receiptFields=@('SchemaVersion','Kind','State','RecordedUtc','Computer','ContextKey','Id','Sources','Definition','Before','Ace','After','DescendantsBefore','DescendantsAfter','DescendantVerification','Ownership')
+ foreach ($receipt in @($pending,$confirmed)) {
+ Assert-WelaEvtxObject $receipt $receiptFields
+ if ($receipt.Kind -cne 'WelaSelectedSaclReceipt' -or $receipt.Computer -cne $operator.Computer -or $receipt.ContextKey -cne $context.Key -or $receipt.Id -cne $row.Id -or $receipt.DescendantsBefore -or $receipt.DescendantsAfter -or $receipt.DescendantVerification -or $receipt.Ownership -cne 'Only the verified explicit selected-root addition; never descendant ACE ownership or bulk rollback authority.') {throw 'Original receipt scope or ownership is unsupported.'}
+ Assert-WelaSelectedSaclSources $receipt.Sources
+ foreach ($name in @('Definition','Ace')) {if ((Get-WelaFileSaclRecoveryKey $receipt.$name) -cne (Get-WelaFileSaclRecoveryKey $row.$name)) {throw 'Original receipt differs from the selected plan.'}}
+ Assert-WelaFileSaclRecoverySnapshot $receipt.Before $row.Definition
+ if ((Get-WelaSelectedSaclSnapshotKey $receipt.Before) -cne (Get-WelaSelectedSaclSnapshotKey $row.Before)) {throw 'Original before-state differs across records.'}
+ }
+ if ($pending.State -cne 'Pending' -or $pending.After -or $confirmed.State -cne 'Confirmed' -or -not $confirmed.After -or $pending.RecordedUtc -cne $confirmed.RecordedUtc) {throw 'A matching pending and confirmed receipt pair is required.'}
+ Assert-WelaFileSaclRecoverySnapshot $confirmed.After $row.Definition
+ if ($confirmed.After.Identity -cne $row.Before.Identity) {throw 'The original operation changed file identity.'}
+ $added=Get-WelaFileSaclRecoveryAddition $row.Before $confirmed.After $ace
+ Assert-WelaEvtxObject $result @('SchemaVersion','Kind','ExitCode','DryRun','BackupPath','Plan','Results','GenerationReadiness','UsableRuleCredit')
+ if ($result.Kind -cne 'WelaSelectedSaclResult' -or ($result.ExitCode -isnot [int] -and $result.ExitCode -isnot [long]) -or $result.ExitCode -ne 0 -or $result.DryRun -isnot [bool] -or $result.DryRun -or $result.Results -isnot [array] -or $result.Results.Count -ne 1 -or $result.Results[0].Status -cne 'Applied') {throw 'Require a completed successful, non-dry-run selected operation.'}
+ $applied=$result.Results[0]
+ if ($result.Plan.Kind -cne 'WelaSelectedSaclPlan' -or $result.Plan.Rows -isnot [array] -or $result.Plan.Rows.Count -ne 1 -or (Get-WelaFileSaclRecoveryKey $applied) -cne (Get-WelaFileSaclRecoveryKey $result.Plan.Rows[0]) -or $applied.Id -cne $row.Id -or $applied.DefinitionKey -cne $row.DefinitionKey -or $applied.DescendantsBefore -or $applied.DescendantsAfter -or $applied.DescendantVerification) {throw 'Completed result rows or scope disagree.'}
+ foreach ($name in @('Definition','Ace')) {if ((Get-WelaFileSaclRecoveryKey $applied.$name) -cne (Get-WelaFileSaclRecoveryKey $row.$name)) {throw 'Completed selection differs from original plan.'}}
+ if ((Get-WelaSelectedSaclSnapshotKey $applied.Before) -cne (Get-WelaSelectedSaclSnapshotKey $row.Before) -or (Get-WelaSelectedSaclSnapshotKey $applied.After) -cne (Get-WelaSelectedSaclSnapshotKey $confirmed.After)) {throw 'Completed descriptor evidence disagrees.'}
+ foreach ($name in @('Profile','IncludeOptional','IncludeChildren','Context','Sources')) {if ((Get-WelaFileSaclRecoveryKey $result.Plan.$name) -cne (Get-WelaFileSaclRecoveryKey $plan.$name)) {throw 'Completed plan context differs from the original selection.'}}
+ $backup=Resolve-WelaArrivalPath $result.BackupPath
+ if ($files.Pending.Path -ine (Join-Path $backup ($row.Id+'.pending.json')) -or $files.Confirmed.Path -ine (Join-Path $backup ($row.Id+'.confirmed.json'))) {throw 'Receipt paths do not match the original recorded backup directory.'}
+ $originalTime=ConvertTo-WelaEvtxUtc $plan.CapturedUtc;$configuredTime=ConvertTo-WelaEvtxUtc $result.Plan.CapturedUtc;$receiptTime=ConvertTo-WelaEvtxUtc $pending.RecordedUtc
+ if ($originalTime -gt $configuredTime -or $configuredTime -gt $receiptTime -or $receiptTime -gt [DateTimeOffset]::UtcNow) {throw 'Original evidence timestamps are out of order or in the future.'}
+ $current=Get-WelaFileSaclRecoverySnapshot $row.Definition
+ if ((Get-WelaSelectedSaclSnapshotKey $current) -cne (Get-WelaSelectedSaclSnapshotKey $confirmed.After)) {throw 'Current file identity or descriptor differs from the completed operation; manual review required.'}
+ if ((Get-WelaSelectedSaclSnapshotKey (Get-WelaFileSaclRecoverySnapshot $row.Definition)) -cne (Get-WelaSelectedSaclSnapshotKey $current)) {throw 'File changed during recovery planning.'}
+ $inputFiles=[ordered]@{};foreach ($name in $files.Keys) {$file=$files[$name];$inputFiles[$name]=[pscustomobject]@{Path=$file.Path;Sha256=$file.Sha256;Bytes=$file.Bytes}}
+ $recovery=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaFileSaclRecoveryPlan';Id=$row.Id;Profile=$plan.Profile;Operator=$operator;ContextKey=$context.Key;Sources=$sources;OriginalFiles=[pscustomobject]$inputFiles;Definition=$row.Definition;BeforeAddition=$row.Before;Expected=$current;AddedAce=$added;Outcome='Remove one proven explicit ordinary audit ACE; an empty present SACL can remain.';ReadyRuleCredit=0}
+ Assert-WelaFileSaclRecoveryFresh $recovery
+ $recovery
+}
+function Assert-WelaFileSaclRecoveryFresh {
+ param($Plan)
+ if ((Get-WelaFileSaclRecoveryKey (Get-WelaFileSaclRecoverySources)) -cne (Get-WelaFileSaclRecoveryKey $Plan.Sources) -or (Get-WelaFileSaclRecoveryKey (Get-WelaFileSaclRecoveryOperator)) -cne (Get-WelaFileSaclRecoveryKey $Plan.Operator) -or (Get-WelaSelectedSaclContext).Key -cne $Plan.ContextKey) {throw 'Recovery implementation, operator or host context changed.'}
+ foreach ($entry in $Plan.OriginalFiles.PSObject.Properties) {$file=Read-WelaFileSaclRecoveryInput $entry.Value.Path;if ($file.Sha256 -cne $entry.Value.Sha256 -or $file.Bytes -ne $entry.Value.Bytes) {throw 'Original recovery evidence changed.'}}
+ if ((Get-WelaSelectedSaclSnapshotKey (Get-WelaFileSaclRecoverySnapshot $Plan.Definition)) -cne (Get-WelaSelectedSaclSnapshotKey $Plan.Expected)) {throw 'Reviewed file changed before removal.'}
+}
+function Invoke-WelaFileSaclRecovery {
+ param([ValidateSet('Plan','Restore')][string]$Action='Plan',[string]$OriginalPlanPath,[string]$PendingPath,[string]$ConfirmedPath,[string]$ResultsPath,[string]$PlanPath,[string]$PlanHash,[string]$OutputPath,[switch]$Auto,[switch]$DryRun)
+ if ($Action -eq 'Plan') {
+ if ($PlanPath -or $PlanHash -or $Auto -or $DryRun -or -not $OriginalPlanPath -or -not $PendingPath -or -not $ConfirmedPath -or -not $ResultsPath -or -not $OutputPath) {throw 'Plan requires four original evidence paths and a new output directory only.'}
+ $plan=New-WelaFileSaclRecoveryPlan $OriginalPlanPath $PendingPath $ConfirmedPath $ResultsPath
+ $output=New-WelaArrivalOutput -Path $OutputPath -SourcePath (Split-Path $PSScriptRoot -Parent)
+ $artifact=Write-WelaFileSaclRecoveryArtifact $output 'plan.json' (Get-WelaFileSaclRecoveryKey $plan)
+ return [pscustomobject]@{Status='Planned';ExitCode=0;PlanPath=(Join-Path $output 'plan.json');PlanHash=$artifact.Sha256;ReadyRuleCredit=0}
+ }
+ if ($OriginalPlanPath -or $PendingPath -or $ConfirmedPath -or $ResultsPath -or -not $PlanPath -or $PlanHash -cnotmatch '^[a-f0-9]{64}$' -or ($DryRun -and ($OutputPath -or $Auto)) -or (-not $DryRun -and (-not $Auto -or -not $OutputPath))) {throw 'Restore requires PlanPath/PlanHash and either DryRun or Auto with a new output directory.'}
+ $reviewed=Read-WelaFileSaclRecoveryInput $PlanPath
+ if ($reviewed.Sha256 -cne $PlanHash -or $reviewed.Data.Kind -cne 'WelaFileSaclRecoveryPlan') {throw 'Reviewed recovery plan hash or kind differs.'}
+ $plan=$reviewed.Data;$inputs=$plan.OriginalFiles
+ $rebuilt=New-WelaFileSaclRecoveryPlan $inputs.OriginalPlan.Path $inputs.Pending.Path $inputs.Confirmed.Path $inputs.Results.Path
+ if ((Get-WelaFileSaclRecoveryKey $plan) -cne (Get-WelaFileSaclRecoveryKey $rebuilt)) {throw 'Reviewed recovery plan is stale or modified.'}
+ if ($DryRun) {return [pscustomobject]@{Status='WouldRemoveAddedAce';ExitCode=0;Target=$plan.Definition.Path;ReadyRuleCredit=0}}
+ $output=New-WelaArrivalOutput -Path $OutputPath -SourcePath (Split-Path $reviewed.Path -Parent)
+ $report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaFileSaclRecoveryResult';Status='Refused';ExitCode=1;StartedUtc=[DateTime]::UtcNow.ToString('o');CompletedUtc=$null;PlanHash=$PlanHash;Before=$plan.Expected;After=$null;WriteAttempted=$false;Artifacts=@();OriginalDescriptorBytesMatch=$false;Diagnostic='';OutputPath=$output;ReadyRuleCredit=0;PolicyChanges=0;Scope='Remove only one proven explicit leaf-file audit ACE; preserve other ACE bytes/counts and observed descriptor components. No descendant, exact historical descriptor, event or Sigma claim.'}
+ $target=$null
+ try {
+ $report.Artifacts+=Write-WelaFileSaclRecoveryArtifact $output 'reviewed-plan.json' (Get-WelaFileSaclRecoveryKey $plan)
+ $report.Artifacts+=Write-WelaFileSaclRecoveryArtifact $output 'pending.json' (Get-WelaFileSaclRecoveryKey ([pscustomobject]@{Kind='WelaFileSaclRecoveryIntent';PlanHash=$PlanHash;Before=$plan.Expected;RemoveAce=$plan.AddedAce;RecordedUtc=[DateTime]::UtcNow.ToString('o')}))
+ Assert-WelaFileSaclRecoveryFresh $plan
+ if ((Read-WelaFileSaclRecoveryInput $reviewed.Path).Sha256 -cne $PlanHash) {throw 'Reviewed recovery plan changed before write.'}
+ Initialize-WelaFileSaclRecoveryNative
+ $target=[Wela.FileSaclRecovery.Target]::new((Resolve-WelaSelectedSaclNativePath $plan.Definition))
+ try {$report.After=$target.Remove($plan.Expected.Identity,$plan.Expected.DescriptorBase64,$plan.AddedAce)} finally {$report.WriteAttempted=$target.WriteAttempted}
+ $target.Dispose();$target=$null
+ $fresh=Get-WelaFileSaclRecoverySnapshot $plan.Definition
+ if ((Get-WelaSelectedSaclSnapshotKey $fresh) -cne (Get-WelaSelectedSaclSnapshotKey $report.After)) {throw 'File identity or descriptor changed after removal.'}
+ if ((Get-WelaFileSaclRecoveryKey (Get-WelaFileSaclRecoverySources)) -cne (Get-WelaFileSaclRecoveryKey $plan.Sources) -or (Get-WelaFileSaclRecoveryKey (Get-WelaFileSaclRecoveryOperator)) -cne (Get-WelaFileSaclRecoveryKey $plan.Operator) -or (Get-WelaSelectedSaclContext).Key -cne $plan.ContextKey) {throw 'Recovery context changed after removal.'}
+ foreach ($entry in $plan.OriginalFiles.PSObject.Properties) {if ((Read-WelaFileSaclRecoveryInput $entry.Value.Path).Sha256 -cne $entry.Value.Sha256) {throw 'Original recovery evidence changed after removal.'}}
+ if ((Read-WelaFileSaclRecoveryInput $reviewed.Path).Sha256 -cne $PlanHash) {throw 'Reviewed plan changed after removal.'}
+ foreach ($artifact in $report.Artifacts) {if ((Get-FileHash -LiteralPath (Join-Path $output $artifact.Name) -Algorithm SHA256).Hash.ToLowerInvariant() -cne $artifact.Sha256) {throw 'Recovery artifact changed after writing.'}}
+ if ((Get-WelaSelectedSaclSnapshotKey (Get-WelaFileSaclRecoverySnapshot $plan.Definition)) -cne (Get-WelaSelectedSaclSnapshotKey $report.After)) {throw 'Final reopened file differs after recovery.'}
+ $report.OriginalDescriptorBytesMatch=$report.After.DescriptorBase64 -ceq $plan.BeforeAddition.DescriptorBase64
+ $report.Status='AddedAceRemoved';$report.ExitCode=0
+ } catch {$report.Diagnostic=$_.Exception.Message;if ($report.WriteAttempted) {$report.Status='WriteAttemptedUnverified'}}
+ finally {if ($target) {try {$target.Dispose()} catch {$report.Status='WriteAttemptedUnverified';$report.ExitCode=1;$report.Diagnostic+=' Native cleanup failed: '+$_.Exception.Message}}}
+ $report.CompletedUtc=[DateTime]::UtcNow.ToString('o')
+ $null=Write-WelaFileSaclRecoveryArtifact $output 'result.json' (Get-WelaFileSaclRecoveryKey $report)
+ $report
+}
+
+function Write-WelaFileSaclRecoveryArtifact {
+ param([string]$Root,[string]$Name,[string]$Text)
+ $null=Resolve-WelaArrivalPath $Root
+ $bytes=[Text.UTF8Encoding]::new($false).GetBytes($Text);$path=Join-Path $Root $Name
+ $stream=[IO.File]::Open($path,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None)
+ try {$stream.Write($bytes,0,$bytes.Length);$stream.Flush($true)} finally {$stream.Dispose()}
+ $hash=Get-WelaArrivalHash $bytes
+ if ((Get-FileHash -LiteralPath $path -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() -cne $hash) {throw 'Recovery artifact readback differs.'}
+ [pscustomobject]@{Name=$Name;Sha256=$hash;Bytes=$bytes.Length}
+}
diff --git a/scripts/FileSaclRecoveryNative.cs b/scripts/FileSaclRecoveryNative.cs
new file mode 100644
index 00000000..ffb781c7
--- /dev/null
+++ b/scripts/FileSaclRecoveryNative.cs
@@ -0,0 +1,110 @@
+// Narrow leaf-file recovery: remove one proven explicit ordinary audit ACE.
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Runtime.InteropServices;
+using System.Security.AccessControl;
+using System.Security.Principal;
+using System.Text;
+namespace Wela.FileSaclRecovery {
+ public sealed class Ace { public string Binary; public int Type,Flags,Mask; public string Sid; public bool Ordinary; }
+ public sealed class Snapshot {
+ public string Path,Kind,Identity; public bool IsDirectory;
+ public string DescriptorBase64,Owner,Group,DaclBase64; public int ControlFlags,SecurityInformation;
+ public string DescriptorScope; public Ace[] Aces;
+ }
+ public static class Descriptor {
+ public const string SourceSha256="__WELA_FILE_SACL_RECOVERY_SOURCE_SHA256__";
+ public static string Bytes(GenericAcl value) { if(value==null)return null;byte[] b=new byte[value.BinaryLength];value.GetBinaryForm(b,0);return Convert.ToBase64String(b); }
+ public static string Bytes(GenericAce value) { byte[] b=new byte[value.BinaryLength];value.GetBinaryForm(b,0);return Convert.ToBase64String(b); }
+ static string Sid(SecurityIdentifier value) {return value==null?null:value.Value;}
+ public static RawSecurityDescriptor Parse(string value) {
+ byte[] b=Convert.FromBase64String(value);
+ if(b.Length<20||b.Length>1048576||Convert.ToBase64String(b)!=value)throw new InvalidOperationException("Invalid or noncanonical descriptor bytes.");
+ RawSecurityDescriptor sd=new RawSecurityDescriptor(b,0);
+ return sd;
+ }
+ static Dictionary Counts(RawAcl acl) {
+ Dictionary counts=new Dictionary(StringComparer.Ordinal);
+ if(acl!=null)foreach(GenericAce ace in acl){string b=Bytes(ace);if(!counts.ContainsKey(b))counts[b]=0;counts[b]++;}
+ return counts;
+ }
+ static void Outside(RawSecurityDescriptor before,RawSecurityDescriptor after,bool allowPresence) {
+ int mask=allowPresence?~16:~0;
+ if(Sid(before.Owner)!=Sid(after.Owner)||Sid(before.Group)!=Sid(after.Group)||Bytes(before.DiscretionaryAcl)!=Bytes(after.DiscretionaryAcl)||before.ResourceManagerControl!=after.ResourceManagerControl||(((int)before.ControlFlags)&mask)!=(((int)after.ControlFlags)&mask))throw new InvalidOperationException("Owner, group, DACL or preserved control/header fields differ.");
+ }
+ public static string AddedAce(string beforeBytes,string afterBytes,string sid,int mask,int flags) {
+ if((sid!="S-1-1-0"&&sid!="S-1-5-11")||mask<=0||(flags!=64&&flags!=128&&flags!=192))throw new InvalidOperationException("Only an explicit ordinary non-inherited selected audit ACE is supported.");
+ RawSecurityDescriptor before=Parse(beforeBytes),after=Parse(afterBytes);Outside(before,after,true);
+ if(after.SystemAcl==null||after.SystemAcl.Revision!=(before.SystemAcl==null?2:before.SystemAcl.Revision))throw new InvalidOperationException("SACL revision changed during the claimed addition.");
+ if(before.SystemAcl!=null)foreach(GenericAce entry in before.SystemAcl){CommonAce common=entry as CommonAce;if(common!=null&&!common.IsCallback&&common.AceType==AceType.SystemAudit&&common.SecurityIdentifier.Value==sid&&(int)common.AceFlags==flags&&(common.AccessMask&mask)==mask)throw new InvalidOperationException("Original descriptor already covered the requested audit ACE.");}
+ string added=Bytes(new CommonAce((AceFlags)flags,AceQualifier.SystemAudit,mask,new SecurityIdentifier(sid),false,null));
+ Dictionary remaining=Counts(after.SystemAcl);
+ if(!remaining.ContainsKey(added)||remaining[added]!=1)throw new InvalidOperationException("Expected exactly one new matching audit ACE.");
+ remaining[added]--;
+ if(before.SystemAcl!=null)foreach(GenericAce entry in before.SystemAcl){string b=Bytes(entry);if(!remaining.ContainsKey(b)||remaining[b]<1)throw new InvalidOperationException("An original ACE was changed or removed.");remaining[b]--;}
+ foreach(int count in remaining.Values)if(count!=0)throw new InvalidOperationException("The completed operation changed more than one audit ACE.");
+ return added;
+ }
+ public static void Removed(string beforeBytes,string afterBytes,string added) {
+ RawSecurityDescriptor before=Parse(beforeBytes),after=Parse(afterBytes);Outside(before,after,false);
+ if(before.SystemAcl==null||after.SystemAcl==null||before.SystemAcl.Revision!=after.SystemAcl.Revision)throw new InvalidOperationException("SACL revision or presence changed during removal.");
+ Dictionary expected=Counts(before.SystemAcl),actual=Counts(after.SystemAcl);
+ if(!expected.ContainsKey(added)||expected[added]!=1)throw new InvalidOperationException("The selected audit ACE is no longer unique.");
+ expected[added]--;
+ foreach(KeyValuePair entry in expected){int count=actual.ContainsKey(entry.Key)?actual[entry.Key]:0;if(count!=entry.Value)throw new InvalidOperationException("Unrelated audit ACEs changed during removal.");actual.Remove(entry.Key);}
+ if(actual.Count!=0)throw new InvalidOperationException("Unexpected ACE appeared during removal.");
+ }
+ public static Snapshot Observe(string path,string identity,byte[] bytes) {
+ string encoded=Convert.ToBase64String(bytes);RawSecurityDescriptor sd=Parse(encoded);List entries=new List();
+ if(sd.SystemAcl!=null)foreach(GenericAce ace in sd.SystemAcl){CommonAce common=ace as CommonAce;bool ordinary=common!=null&&!common.IsCallback&&common.AceType==AceType.SystemAudit;entries.Add(new Ace {Binary=Bytes(ace),Type=(int)ace.AceType,Flags=(int)ace.AceFlags,Mask=ordinary?common.AccessMask:0,Sid=ordinary?common.SecurityIdentifier.Value:null,Ordinary=ordinary});}
+ return new Snapshot {Path=path,Kind="FileSystem",Identity=identity,IsDirectory=false,DescriptorBase64=encoded,Owner=Sid(sd.Owner),Group=Sid(sd.Group),DaclBase64=Bytes(sd.DiscretionaryAcl),ControlFlags=(int)sd.ControlFlags,SecurityInformation=511,DescriptorScope="WinSDK-defined sections 0x1ff; future sections unobserved",Aces=entries.ToArray()};
+ }
+ }
+ sealed class Privilege : IDisposable {
+ [StructLayout(LayoutKind.Sequential)] struct Luid {public uint Low;public int High;}
+ [StructLayout(LayoutKind.Sequential)] struct TokenPrivileges {public uint Count;public Luid Luid;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 value);
+ [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 disable,ref TokenPrivileges value,uint size,out TokenPrivileges previous,out uint required);
+ IntPtr token;TokenPrivileges previous;
+ public Privilege(){IntPtr thread;
+ if(OpenThreadToken(GetCurrentThread(),8,true,out thread)){CloseHandle(thread);throw new InvalidOperationException("Impersonated recovery is unsupported.");}
+ int error=Marshal.GetLastWin32Error();if(error!=1008)throw new Win32Exception(error);
+ if(!OpenProcessToken(GetCurrentProcess(),0x28,out token))throw new Win32Exception(Marshal.GetLastWin32Error());
+ try{Luid luid;if(!LookupPrivilegeValue(null,"SeSecurityPrivilege",out luid))throw new Win32Exception(Marshal.GetLastWin32Error());TokenPrivileges request=new TokenPrivileges {Count=1,Luid=luid,Attributes=2};uint required;bool ok=AdjustTokenPrivileges(token,false,ref request,(uint)Marshal.SizeOf(typeof(TokenPrivileges)),out previous,out required);error=Marshal.GetLastWin32Error();if(!ok||error!=0)throw new Win32Exception(error,"SeSecurityPrivilege is unavailable.");}
+ catch{CloseHandle(token);token=IntPtr.Zero;throw;}
+ }
+ public void Dispose(){if(token==IntPtr.Zero)return;try{TokenPrivileges ignored;uint required;bool ok=AdjustTokenPrivileges(token,false,ref previous,(uint)Marshal.SizeOf(typeof(TokenPrivileges)),out ignored,out required);int error=Marshal.GetLastWin32Error();if(!ok||error!=0)throw new Win32Exception(error,"SeSecurityPrivilege restoration failed.");}finally{CloseHandle(token);token=IntPtr.Zero;}}
+ }
+ public sealed class Target : IDisposable {
+ [StructLayout(LayoutKind.Sequential,Pack=4)] struct FileInfo {public uint Attributes;public long Created,Accessed,Written;public uint Volume,SizeHigh,SizeLow,Links,IndexHigh,IndexLow;}
+ [DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern IntPtr CreateFile(string name,uint access,uint share,IntPtr security,uint disposition,uint flags,IntPtr template);
+ [DllImport("kernel32.dll",SetLastError=true)] static extern bool CloseHandle(IntPtr handle);
+ [DllImport("kernel32.dll",SetLastError=true)] static extern bool GetFileInformationByHandle(IntPtr handle,out FileInfo info);
+ [DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern uint GetFinalPathNameByHandle(IntPtr handle,StringBuilder path,uint size,uint flags);
+ [DllImport("kernel32.dll")] static extern IntPtr LocalFree(IntPtr value);
+ [DllImport("advapi32.dll")] static extern uint GetSecurityInfo(IntPtr handle,uint kind,uint flags,out IntPtr owner,out IntPtr group,out IntPtr dacl,out IntPtr sacl,out IntPtr descriptor);
+ [DllImport("advapi32.dll")] static extern uint GetSecurityDescriptorLength(IntPtr descriptor);
+ [DllImport("advapi32.dll")] static extern uint SetSecurityInfo(IntPtr handle,uint kind,uint flags,IntPtr owner,IntPtr group,IntPtr dacl,IntPtr sacl);
+ readonly string path;IntPtr handle;Privilege privilege;public bool WriteAttempted {get;private set;}
+ public Target(string path){this.path=path;try{privilege=new Privilege();handle=CreateFile(path,0x01020000,3,IntPtr.Zero,3,0x02200000,IntPtr.Zero);if(handle==new IntPtr(-1)){int error=Marshal.GetLastWin32Error();handle=IntPtr.Zero;throw new Win32Exception(error);}Check();}catch{Dispose();throw;}}
+ string Check(){if(handle==IntPtr.Zero)throw new ObjectDisposedException("Target");FileInfo info;if(!GetFileInformationByHandle(handle,out info))throw new Win32Exception(Marshal.GetLastWin32Error());if((info.Attributes&0x410)!=0)throw new InvalidOperationException("Directories and reparse files are unsupported.");StringBuilder final=new StringBuilder(32768);uint length=GetFinalPathNameByHandle(handle,final,(uint)final.Capacity,0);if(length==0||length>=final.Capacity||!String.Equals(final.ToString(),"\\\\?\\"+path,StringComparison.OrdinalIgnoreCase))throw new InvalidOperationException("Final held file path differs from the reviewed path.");return info.Volume+":"+info.IndexHigh+":"+info.IndexLow+":"+info.Created;}
+ public Snapshot Read(){string identity=Check();IntPtr owner,group,dacl,sacl,descriptor;uint error=GetSecurityInfo(handle,1,511,out owner,out group,out dacl,out sacl,out descriptor);if(error!=0)throw new Win32Exception((int)error,"Full SDK-defined file descriptor read failed.");byte[] bytes;try{uint size=GetSecurityDescriptorLength(descriptor);if(size<20||size>1048576)throw new InvalidOperationException("Invalid descriptor size.");bytes=new byte[size];Marshal.Copy(descriptor,bytes,0,(int)size);}finally{LocalFree(descriptor);}if(Check()!=identity)throw new InvalidOperationException("Held file identity changed.");return Descriptor.Observe(path,identity,bytes);}
+ public Snapshot Remove(string expectedIdentity,string expectedDescriptor,string added){
+ Snapshot before=Read();if(before.Identity!=expectedIdentity||before.DescriptorBase64!=expectedDescriptor)throw new InvalidOperationException("Reviewed file identity or descriptor changed before removal.");
+ RawSecurityDescriptor sd=Descriptor.Parse(before.DescriptorBase64);int index=-1;
+ if(sd.SystemAcl!=null)for(int i=0;i&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference='Stop'}
+ if($code -ne $case.Exit -or ($text -join "`n") -notmatch $case.Pattern){throw "Unexpected CLI result for $($case.Args -join ' '): $code / $text"}
+}
+Write-Host "File SACL recovery public CLI: $($cases.Count) checks passed."
+$global:LASTEXITCODE=0
diff --git a/tests/FileSaclRecovery.Descriptor.Tests.ps1 b/tests/FileSaclRecovery.Descriptor.Tests.ps1
new file mode 100644
index 00000000..e822fd07
--- /dev/null
+++ b/tests/FileSaclRecovery.Descriptor.Tests.ps1
@@ -0,0 +1,98 @@
+# Actual Windows security-descriptor parsing, without file or policy mutation.
+$ErrorActionPreference='Stop'
+if ($env:OS -ne 'Windows_NT') {Write-Host 'Skipped: Windows security descriptor runtime required.';exit 0}
+$repo=Split-Path $PSScriptRoot -Parent
+. (Join-Path $repo 'scripts/WefArrival.ps1')
+. (Join-Path $repo 'scripts/FileSaclRecovery.ps1')
+Initialize-WelaFileSaclRecoveryNative
+$script:n=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 Encode($Descriptor) {$bytes=New-Object byte[] $Descriptor.BinaryLength;$Descriptor.GetBinaryForm($bytes,0);[Convert]::ToBase64String($bytes)}
+function Clone($Descriptor) {[Security.AccessControl.RawSecurityDescriptor]::new([Convert]::FromBase64String((Encode $Descriptor)),0)}
+function New-AuditAce([int]$Mask=1,[int]$Flags=64,[string]$Sid='S-1-1-0') {
+ [Security.AccessControl.CommonAce]::new([Security.AccessControl.AceFlags]$Flags,[Security.AccessControl.AceQualifier]::SystemAudit,$Mask,[Security.Principal.SecurityIdentifier]::new($Sid),$false,$null)
+}
+function Add-AuditAce($Descriptor,$Ace) {
+ $copy=Clone $Descriptor
+ if ($null -eq $copy.SystemAcl) {$copy.SystemAcl=[Security.AccessControl.RawAcl]::new(2,1);$copy.SetFlags($copy.ControlFlags -bor [Security.AccessControl.ControlFlags]::SystemAclPresent)}
+ $copy.SystemAcl.InsertAce($copy.SystemAcl.Count,$Ace)
+ $copy
+}
+$base=[Security.AccessControl.RawSecurityDescriptor]::new('O:SYG:SYD:(A;;FA;;;SY)')
+$before=Encode $base
+foreach ($flags in @(64,128,192)) {
+ foreach ($sid in @('S-1-1-0','S-1-5-11')) {
+ $after=Add-AuditAce $base (New-AuditAce 1 $flags $sid)
+ $added=[Wela.FileSaclRecovery.Descriptor]::AddedAce($before,(Encode $after),$sid,1,$flags)
+ Assert ($added -ceq [Wela.FileSaclRecovery.Descriptor]::Bytes($after.SystemAcl[0])) 'Exactly the ordinary selected ACE is identified.'
+ $empty=Clone $after;$empty.SystemAcl.RemoveAce(0)
+ [Wela.FileSaclRecovery.Descriptor]::Removed((Encode $after),(Encode $empty),$added)
+ Assert ($empty.SystemAcl.Count -eq 0 -and ($empty.ControlFlags -band 16) -ne 0 -and (Encode $empty) -cne $before) 'ACE removal preserves an empty present SACL without claiming historical representation equality.'
+ Throws {[Wela.FileSaclRecovery.Descriptor]::Removed((Encode $after),$before,$added)} 'control'
+ $duplicate=Add-AuditAce $after (New-AuditAce 1 $flags $sid)
+ Throws {[Wela.FileSaclRecovery.Descriptor]::AddedAce($before,(Encode $duplicate),$sid,1,$flags)} 'exactly one'
+ Throws {[Wela.FileSaclRecovery.Descriptor]::Removed((Encode $duplicate),(Encode $after),$added)} 'no longer unique'
+ $unrelated=Add-AuditAce $after (New-AuditAce 2 128 'S-1-5-11')
+ Throws {[Wela.FileSaclRecovery.Descriptor]::AddedAce($before,(Encode $unrelated),$sid,1,$flags)} 'more than one|exactly one'
+ Throws {[Wela.FileSaclRecovery.Descriptor]::Removed((Encode $after),(Encode $unrelated),$added)} 'Unrelated|Unexpected'
+ }
+}
+$old=Add-AuditAce $base (New-AuditAce 2 128 'S-1-5-11')
+$after=Add-AuditAce $old (New-AuditAce)
+$added=[Wela.FileSaclRecovery.Descriptor]::AddedAce((Encode $old),(Encode $after),'S-1-1-0',1,64)
+[Wela.FileSaclRecovery.Descriptor]::Removed((Encode $after),(Encode $old),$added)
+Assert ($old.SystemAcl.Count -eq 1) 'The original unrelated audit ACE remains after a valid removal.'
+$lost=Add-AuditAce $base (New-AuditAce)
+Throws {[Wela.FileSaclRecovery.Descriptor]::AddedAce((Encode $old),(Encode $lost),'S-1-1-0',1,64)} 'original ACE'
+$missing=Clone $after;$missing.SystemAcl.RemoveAce(0);$missing.SystemAcl.RemoveAce(0)
+Throws {[Wela.FileSaclRecovery.Descriptor]::Removed((Encode $after),(Encode $missing),$added)} 'Unrelated audit ACEs'
+$covering=Add-AuditAce $base (New-AuditAce 3 64)
+$redundant=Add-AuditAce $covering (New-AuditAce)
+Throws {[Wela.FileSaclRecovery.Descriptor]::AddedAce((Encode $covering),(Encode $redundant),'S-1-1-0',1,64)} 'already covered'
+foreach ($flags in @(0,16,65,80,129,208)) {Throws {[Wela.FileSaclRecovery.Descriptor]::AddedAce($before,(Encode $after),'S-1-1-0',1,$flags)} 'explicit ordinary'}
+Throws {[Wela.FileSaclRecovery.Descriptor]::AddedAce($before,(Encode $after),'S-1-5-18',1,64)} 'explicit ordinary'
+Throws {[Wela.FileSaclRecovery.Descriptor]::AddedAce($before,(Encode $after),'S-1-1-0',0,64)} 'explicit ordinary'
+# Both historical addition and removal must preserve non-audit descriptor fields.
+foreach ($mutation in @('Owner','Group','Dacl','ControlFlags')) {
+ $changed=Clone $old
+ switch ($mutation) {
+ Owner {$changed.Owner=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')}
+ Group {$changed.Group=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')}
+ Dacl {$changed.DiscretionaryAcl.RemoveAce(0)}
+ ControlFlags {$changed.SetFlags($changed.ControlFlags -bor [Security.AccessControl.ControlFlags]::DiscretionaryAclProtected)}
+ }
+ Throws {[Wela.FileSaclRecovery.Descriptor]::Removed((Encode $after),(Encode $changed),$added)} 'Owner|control|header|manager'
+ $withAddition=Add-AuditAce $changed (New-AuditAce)
+ Throws {[Wela.FileSaclRecovery.Descriptor]::AddedAce((Encode $old),(Encode $withAddition),'S-1-1-0',1,64)} 'Owner|control|header|manager'
+}
+# Resource-manager control is serialized only when its valid flag is present.
+$rmBefore=Clone $old;$rmBefore.SetFlags($rmBefore.ControlFlags -bor [Security.AccessControl.ControlFlags]::RMControlValid);$rmBefore.ResourceManagerControl=1
+$rmAfter=Add-AuditAce $rmBefore (New-AuditAce)
+$rmChanged=Clone $rmBefore;$rmChanged.ResourceManagerControl=2
+Assert ((Encode $rmChanged) -cne (Encode $rmBefore)) 'RMControl fixture changes actual serialized bytes with flags unchanged.'
+Throws {[Wela.FileSaclRecovery.Descriptor]::Removed((Encode $rmAfter),(Encode $rmChanged),$added)} 'control|header'
+$rmChangedAddition=Add-AuditAce $rmChanged (New-AuditAce)
+Throws {[Wela.FileSaclRecovery.Descriptor]::AddedAce((Encode $rmBefore),(Encode $rmChangedAddition),'S-1-1-0',1,64)} 'control|header'
+# ACL revision changes cannot hide behind unchanged ACE bytes.
+$revised=Clone $old;$acl4=[Security.AccessControl.RawAcl]::new(4,$revised.SystemAcl.Count)
+foreach ($entry in $revised.SystemAcl) {$acl4.InsertAce($acl4.Count,$entry)}
+$revised.SystemAcl=$acl4;$revisedAddition=Add-AuditAce $revised (New-AuditAce)
+Throws {[Wela.FileSaclRecovery.Descriptor]::AddedAce((Encode $old),(Encode $revisedAddition),'S-1-1-0',1,64)} 'revision'
+Throws {[Wela.FileSaclRecovery.Descriptor]::Removed((Encode $after),(Encode $revised),$added)} 'revision'
+# Duplicate unrelated entries retain their exact counts.
+$duplicateOld=Add-AuditAce $old (New-AuditAce 2 128 'S-1-5-11')
+$duplicateAfter=Add-AuditAce $duplicateOld (New-AuditAce)
+$duplicateAdded=[Wela.FileSaclRecovery.Descriptor]::AddedAce((Encode $duplicateOld),(Encode $duplicateAfter),'S-1-1-0',1,64)
+[Wela.FileSaclRecovery.Descriptor]::Removed((Encode $duplicateAfter),(Encode $duplicateOld),$duplicateAdded)
+Assert ($duplicateOld.SystemAcl.Count -eq 2) 'Duplicate unrelated ACEs are preserved.'
+Throws {[Wela.FileSaclRecovery.Descriptor]::Removed((Encode $duplicateAfter),(Encode $old),$duplicateAdded)} 'Unrelated audit ACEs'
+# Native object audit ACEs never qualify as the ordinary selected addition.
+$objectBase=Clone $base;$objectBase.SystemAcl=[Security.AccessControl.RawAcl]::new(4,0);$objectBase.SetFlags($objectBase.ControlFlags -bor [Security.AccessControl.ControlFlags]::SystemAclPresent)
+$objectAfter=Clone $objectBase
+$objectAce=[Security.AccessControl.ObjectAce]::new([Security.AccessControl.AceFlags]64,[Security.AccessControl.AceQualifier]::SystemAudit,1,[Security.Principal.SecurityIdentifier]::new('S-1-1-0'),[Security.AccessControl.ObjectAceFlags]::ObjectAceTypePresent,[guid]::NewGuid(),[guid]::Empty,$false,$null)
+$objectAfter.SystemAcl.InsertAce(0,$objectAce)
+Throws {[Wela.FileSaclRecovery.Descriptor]::AddedAce((Encode $objectBase),(Encode $objectAfter),'S-1-1-0',1,64)} 'exactly one'
+Throws {[Wela.FileSaclRecovery.Descriptor]::Parse('not base64')} '.'
+$global:LASTEXITCODE=0
+Write-Host "File SACL recovery native descriptor guards: $script:n assertions passed."
diff --git a/tests/FileSaclRecovery.Tests.ps1 b/tests/FileSaclRecovery.Tests.ps1
new file mode 100644
index 00000000..7a75e5af
--- /dev/null
+++ b/tests/FileSaclRecovery.Tests.ps1
@@ -0,0 +1,37 @@
+$ErrorActionPreference='Stop'
+$root=Split-Path $PSScriptRoot -Parent
+Import-Module (Join-Path $root 'modules/AuditProfiles.psm1') -Force
+. (Join-Path $root 'scripts/WefArrival.ps1')
+. (Join-Path $root 'scripts/EvtxRecovery.ps1')
+. (Join-Path $root 'scripts/FileSaclRecovery.ps1')
+$script:count=0
+function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
+function Throws($Action,$Pattern){$message='';try{& $Action|Out-Null}catch{$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected $Pattern, got $message"}
+Initialize-WelaFileSaclRecoveryNative
+Assert ([Wela.FileSaclRecovery.Descriptor]::SourceSha256 -ceq (Get-FileHash (Join-Path $root 'scripts/FileSaclRecoveryNative.cs')).Hash.ToLowerInvariant()) 'Compiled helper is bound to actual source bytes.'
+$temp=Join-Path ([IO.Path]::GetTempPath()) ('wela-file-recovery-json-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $temp
+try {
+ $path=Join-Path $temp 'input.json'
+ foreach($value in @('{"ExitCode":0}','{"text":"東京","SchemaVersion":1}')){
+ [IO.File]::WriteAllText($path,$value,[Text.UTF8Encoding]::new($false))
+ $input=Read-WelaFileSaclRecoveryInput $path
+ Assert ($input.Sha256 -ceq (Get-FileHash $path).Hash.ToLowerInvariant() -and $input.Bytes -eq ([IO.File]::ReadAllBytes($path)).Length) 'Strict evidence reader hashes actual UTF-8 bytes.'
+ }
+ $zero=ConvertFrom-WelaEvtxJson '{"ExitCode":0}'
+ Assert (($zero.ExitCode -is [int] -or $zero.ExitCode -is [long]) -and $zero.ExitCode -eq 0) 'Real JSON integer zero is accepted across engines.'
+ foreach($invalid in @('{"a":1,"a":2}','{"x":NaN}','{"x":1,}','{"x":true} trailing','')){
+ [IO.File]::WriteAllText($path,$invalid)
+ Throws {Read-WelaFileSaclRecoveryInput $path} 'JSON|json|byte|Unexpected|Invalid|Duplicate|custom-profile'
+ }
+ [IO.File]::WriteAllBytes($path,[byte[]]@(0xc3,0x28));Throws {Read-WelaFileSaclRecoveryInput $path} 'translate|valid|Unable'
+ $oversize=New-Object byte[] 4194305;[IO.File]::WriteAllBytes($path,$oversize);Throws {Read-WelaFileSaclRecoveryInput $path} 'four MiB'
+ $artifact=Write-WelaFileSaclRecoveryArtifact $temp 'pending.json' '{"state":"Pending"}'
+ Assert ($artifact.Bytes -gt 0 -and $artifact.Sha256 -ceq (Get-FileHash (Join-Path $temp 'pending.json')).Hash.ToLowerInvariant()) 'Durably flushed pending artifact is reopened and hashed.'
+ Throws {Write-WelaFileSaclRecoveryArtifact $temp 'pending.json' '{}'} 'exists'
+ foreach($arguments in @(@{},@{Action='Plan';PlanPath='x'},@{Action='Plan';Auto=$true},@{Action='Plan';DryRun=$true},@{Action='Restore'},@{Action='Restore';PlanPath='x';PlanHash=('a'*64);DryRun=$true;Auto=$true},@{Action='Restore';PlanPath='x';PlanHash=('a'*64);DryRun=$true;OutputPath='out'},@{Action='Restore';PlanPath='x';PlanHash=('a'*64);OutputPath='out'})) {
+ Throws {Invoke-WelaFileSaclRecovery @arguments} 'requires'
+ }
+ if($env:OS -ne 'Windows_NT'){Throws {Get-WelaFileSaclRecoveryOperator} 'Windows'}
+ Write-Host "PASS: $script:count file recovery source, strict input, durable output and argument assertions. Native descriptor semantics run separately on Windows."
+} finally {Remove-Item -LiteralPath $temp -Recurse -Force}
+$global:LASTEXITCODE=0
diff --git a/tests/FileSaclRecovery.Windows.Tests.ps1 b/tests/FileSaclRecovery.Windows.Tests.ps1
new file mode 100644
index 00000000..6063be16
--- /dev/null
+++ b/tests/FileSaclRecovery.Windows.Tests.ps1
@@ -0,0 +1,103 @@
+param([switch]$AllowDisposableSaclWrite)
+$ErrorActionPreference='Stop'
+if(-not $AllowDisposableSaclWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit opt-in on a disposable GitHub-hosted Windows runner is required.'}
+$root=Split-Path $PSScriptRoot -Parent
+Import-Module (Join-Path $root 'modules/AuditProfiles.psm1') -Force
+. (Join-Path $root 'scripts/Configuration.ps1')
+$script:count=0
+function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
+function Fingerprint($Map){(@($Map.Keys|Sort-Object|ForEach-Object{"$_=$($Map[$_])"}) -join ';')}
+function Json($Path){Get-Content -LiteralPath $Path -Raw|ConvertFrom-Json}
+function Save($Path,$Value){[IO.File]::WriteAllText($Path,($Value|ConvertTo-Json -Depth 30),[Text.UTF8Encoding]::new($false))}
+$policyBefore=Get-WelaEffectiveAuditPolicy
+$precedencePath='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa';$precedenceBefore=Get-WelaRegistryState $precedencePath SCENoApplyLegacyAuditPolicy
+$nonce=[guid]::NewGuid().ToString('N');$temp=Join-Path ([IO.Path]::GetTempPath()) ('wela-file-recovery-'+$nonce);$copy=Join-Path $temp 'checkout'
+$engine=(Get-Process -Id $PID).Path
+$script:call=0
+function Run-Wela {
+ param([string[]]$Arguments,[int]$Expected=0,[string]$Pattern='')
+ $script:call++;$log=Join-Path $temp ('call-'+$script:call+'.log')
+ $start=[Diagnostics.ProcessStartInfo]::new();$start.FileName=$engine;$start.UseShellExecute=$false;$start.RedirectStandardOutput=$true;$start.RedirectStandardError=$true
+ $all=@('-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-File',(Join-Path $copy 'WELA.ps1'))+$Arguments
+ $start.Arguments=(@($all|ForEach-Object {'"'+$_.Replace('"','\"')+'"'}) -join ' ')
+ $process=[Diagnostics.Process]::new();$process.StartInfo=$start
+ try {$null=$process.Start();$out=$process.StandardOutput.ReadToEndAsync();$err=$process.StandardError.ReadToEndAsync();if(-not $process.WaitForExit(180000)){$process.Kill();throw 'Public recovery fixture command timed out.'};if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($out,$err),10000)){throw 'Public fixture output capture timed out.'};$text=$out.Result+$err.Result;[IO.File]::WriteAllText($log,$text);Assert ($process.ExitCode -eq $Expected) "Public command failed with $($process.ExitCode), expected $Expected. $text";if($Pattern){Assert ($text -match $Pattern) "Expected diagnostic $Pattern. $text"}}finally{if($process.Id -and -not $process.HasExited){$process.Kill();$null=$process.WaitForExit(10000)};$process.Dispose()}
+}
+$completed=$false
+try {
+ $null=New-Item -ItemType Directory $copy -Force
+ foreach($name in @('WELA.ps1','config','scripts','modules')){Copy-Item -LiteralPath (Join-Path $root $name) -Destination $copy -Recurse}
+ # Only the owned disposable checkout gets this installed one-file catalog.
+ # Production command and receipt validation expose no arbitrary-target override.
+ $file=Join-Path $temp 'owned.txt';[IO.File]::WriteAllText($file,'owned recovery fixture')
+ $catalog=[pscustomobject]@{description='Owned disposable installed catalog';registry=@();files=@([pscustomobject]@{path=$file;inherit=$false;rights=@('ReadData');note='Owned leaf'});user_registry=@();user_files=@()}
+ Save (Join-Path $copy 'config/audit_sacl_targets.json') $catalog
+ Import-Module (Join-Path $copy 'modules/AuditProfiles.psm1') -Force
+ . (Join-Path $copy 'scripts/ControlApplicability.ps1')
+ . (Join-Path $copy 'scripts/TargetedSaclPlanning.ps1')
+ . (Join-Path $copy 'scripts/SelectedSaclConfiguration.ps1')
+ . (Join-Path $copy 'scripts/WefArrival.ps1')
+ . (Join-Path $copy 'scripts/EvtxRecovery.ps1')
+ . (Join-Path $copy 'scripts/FileSaclRecovery.ps1')
+ Initialize-WelaFileSaclRecoveryNative
+ Set-ItemProperty -LiteralPath $precedencePath -Name SCENoApplyLegacyAuditPolicy -Value 1 -Type DWord
+ Set-WelaEffectiveAuditPolicy -Guid '0CCE921D-69AE-11D9-BED3-505054503030' -Mask 3 -Mode minimum
+ $context=Get-WelaSelectedSaclContext
+ $target=@((Get-WelaSelectedSaclCatalog -Profile wela-2.2.0 -IncludeOptional -Context $context).Rows)
+ Assert ($target.Count -eq 1 -and $target[0].Definition.Path -ceq $file) 'Installed fixture catalog selects only the owned leaf.'
+ $id=$target[0].Id;$definition=$target[0].Definition
+ foreach($case in @('empty','unrelated')){
+ $caseDir=Join-Path $temp $case;$null=New-Item -ItemType Directory $caseDir
+ if($case -eq 'unrelated'){
+ $beforeUnrelated=Get-WelaSelectedSaclSnapshot $definition
+ $other=[pscustomobject]@{Sid='S-1-5-11';Mask=2;Flags=64;RequiredPolicyMask=1}
+ $null=Write-WelaSelectedSaclNative $definition $beforeUnrelated $other
+ }
+ $before=Get-WelaSelectedSaclSnapshot $definition
+ $original=Join-Path $caseDir 'original.json';$backup=Join-Path $caseDir 'receipts';$configured=Join-Path $caseDir 'configured.json'
+ Run-Wela @('targeted-sacl','-TargetSaclAction','Plan','-TargetSaclProfile','wela-2.2.0','-TargetSaclId',$id,'-IncludeOptional','-ResultsPath',$original)
+ Run-Wela @('targeted-sacl','-TargetSaclAction','Configure','-TargetSaclPlanPath',$original,'-TargetSaclId',$id,'-IncludeOptional','-Auto','-BackupPath',$backup,'-ResultsPath',$configured)
+ $completedAddition=Json $configured
+ Assert ($completedAddition.Results[0].Status -ceq 'Applied' -and $completedAddition.ExitCode -eq 0) 'Original public Configure supplied genuine Applied result and receipt pair.'
+ $pending=Join-Path $backup ($id+'.pending.json');$confirmed=Join-Path $backup ($id+'.confirmed.json')
+ $afterAddition=Get-WelaSelectedSaclSnapshot $definition
+ $planDir=Join-Path $caseDir 'recovery-plan'
+ $planArgs=@('file-sacl-recovery','-FileSaclRecoveryOriginalPlanPath',$original,'-FileSaclRecoveryPendingPath',$pending,'-FileSaclRecoveryConfirmedPath',$confirmed,'-FileSaclRecoveryResultsPath',$configured)
+ Run-Wela ($planArgs+@('-FileSaclRecoveryOutputPath',$planDir))
+ $planPath=Join-Path $planDir 'plan.json';$hash=(Get-FileHash $planPath).Hash.ToLowerInvariant();$plan=Json $planPath
+ Assert ($plan.Kind -ceq 'WelaFileSaclRecoveryPlan' -and $plan.Expected.Identity -ceq $before.Identity -and $plan.ReadyRuleCredit -eq 0) 'Recovery plan binds the original actual file identity without telemetry credit.'
+ $restore=@('file-sacl-recovery','-FileSaclRecoveryAction','Restore','-FileSaclRecoveryPlanPath',$planPath,'-FileSaclRecoveryPlanHash',$hash)
+ Run-Wela ($restore+@('-DryRun'))
+ Assert ((Get-WelaSelectedSaclSnapshotKey (Get-WelaSelectedSaclSnapshot $definition)) -ceq (Get-WelaSelectedSaclSnapshotKey $afterAddition)) 'Public dry run preserves the exact current full descriptor.'
+ if($case -eq 'empty'){
+ $saved=[IO.File]::ReadAllBytes($confirmed);$broken=Json $confirmed;$broken.State='Pending';Save $confirmed $broken
+ Run-Wela ($restore+@('-DryRun')) 1 'pending and confirmed'
+ [IO.File]::WriteAllBytes($confirmed,$saved)
+ $nativePath=Join-Path $copy 'scripts/FileSaclRecoveryNative.cs';$nativeBytes=[IO.File]::ReadAllBytes($nativePath);[IO.File]::AppendAllText($nativePath,"`n// owned source mismatch fixture`n")
+ Run-Wela ($restore+@('-DryRun')) 1 'stale or modified'
+ [IO.File]::WriteAllBytes($nativePath,$nativeBytes)
+ # A different file at the identical path must not inherit recovery authority.
+ $held=Join-Path $caseDir 'original-held.txt';Move-Item -LiteralPath $file -Destination $held;[IO.File]::WriteAllText($file,'replacement')
+ Run-Wela ($restore+@('-DryRun')) 1 'identity or descriptor differs'
+ Remove-Item -LiteralPath $file;Move-Item -LiteralPath $held -Destination $file
+ Assert ((Get-WelaSelectedSaclSnapshotKey (Get-WelaSelectedSaclSnapshot $definition)) -ceq (Get-WelaSelectedSaclSnapshotKey $afterAddition)) 'Refused receipt/source/replacement cases did not alter the original descriptor.'
+ }
+ $out=Join-Path $caseDir 'restored'
+ Run-Wela ($restore+@('-Auto','-FileSaclRecoveryOutputPath',$out))
+ $result=Json (Join-Path $out 'result.json');$after=Get-WelaSelectedSaclSnapshot $definition
+ Assert ($result.Status -ceq 'AddedAceRemoved' -and $result.WriteAttempted -and $result.ExitCode -eq 0 -and $result.PolicyChanges -eq 0) 'Public recovery performs and verifies only the proven added ACE removal.'
+ [Wela.FileSaclRecovery.Descriptor]::Removed($afterAddition.DescriptorBase64,$after.DescriptorBase64,$plan.AddedAce)
+ Assert ($before.Identity -ceq $after.Identity -and $before.Owner -ceq $after.Owner -and $before.Group -ceq $after.Group -and $before.DaclBase64 -ceq $after.DaclBase64 -and $before.Aces.Count -eq $after.Aces.Count) 'Actual reopened leaf preserves identity, owner/group/DACL and unrelated ACE counts.'
+ foreach($artifact in $result.Artifacts){Assert ($artifact.Sha256 -ceq (Get-FileHash (Join-Path $out $artifact.Name)).Hash.ToLowerInvariant()) 'Durable review and pre-write intent artifacts retain their recorded hashes.'}
+ Assert ((Json (Join-Path $out 'pending.json')).Before.DescriptorBase64 -ceq $afterAddition.DescriptorBase64) 'Pending receipt records the exact descriptor reviewed before removal.'
+ Run-Wela ($restore+@('-DryRun')) 1 'identity or descriptor differs'
+ Write-Host "PASS: actual public leaf recovery $case, original identity $($before.Identity), $($before.Aces.Count) unrelated ACEs preserved."
+ }
+ $completed=$true
+} finally {
+ Set-WelaEffectiveAuditPolicy -Guid '0CCE921D-69AE-11D9-BED3-505054503030' -Mask $policyBefore['0CCE921D-69AE-11D9-BED3-505054503030'] -Mode exact
+ if($precedenceBefore.ValueExists){Set-ItemProperty -LiteralPath $precedencePath -Name SCENoApplyLegacyAuditPolicy -Type $precedenceBefore.Type -Value $precedenceBefore.Value}else{Remove-ItemProperty -LiteralPath $precedencePath -Name SCENoApplyLegacyAuditPolicy -ErrorAction SilentlyContinue}
+ Assert ((Fingerprint (Get-WelaEffectiveAuditPolicy)) -ceq (Fingerprint $policyBefore) -and ((Get-WelaRegistryState $precedencePath SCENoApplyLegacyAuditPolicy)|ConvertTo-Json -Compress) -ceq ($precedenceBefore|ConvertTo-Json -Compress)) 'All 59 original policy masks and typed precedence restored.'
+ if($completed){Remove-Item -LiteralPath $temp -Recurse -Force;Write-Host "PASS: $script:count actual public file recovery assertions; only owned files and checkout removed."}else{Write-Host "Failed fixture evidence retained at $temp"}
+}
+$global:LASTEXITCODE=0
diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md
index b91d1c7d..53152428 100644
--- a/website/docs/resources/changelog.ja.md
+++ b/website/docs/resources/changelog.ja.md
@@ -7,6 +7,7 @@
**改善:**
+- `file-sacl-recovery` を追加しました。元の計画、変更前後のレシート、成功結果、ソースとホストのハッシュ、実際のファイル識別情報を照合し、変更されていない選択済み単一ファイルに追加した監査 ACE だけを明示的に削除できます。書き込み前の永続記録と SACL 限定の変更・再読込により、無関係な ACE のバイト列と個数、観測した他の記述子要素を保持します。空の SACL が残る場合を区別し、元の記述子との完全一致、ポリシー・ディレクトリ・レジストリの復旧、Sigma の利用可能性は保証しません。(@Shirofune-Security)
- 読み取り専用の `channel-read` を追加し、実際の実行トークンで選択したWindows標準ローカルログを確認します。メタデータやACLとは独立して拒否・不在・空・イベント読取を区別し、トークンと環境の変化を検出して保護された限定レポートに記録します。Windows設定の変更やSigma利用可能数への加算は行いません。使い捨て標準ユーザーでServer 2022/2025・PowerShell 5.1/7の拒否と読取を検証します。 (#432) (@Shirofune-Security)
- 専用 CA 監査設定の再起動待ちを確認し、明示的に再開する `adcs-resume` を追加。元の記録・結果、出典、現在の CA と実行者、永続的な意図記録、変更直前・最終確認により古い計画の再実行を拒否し、観測した設定を保持します。ドライランと失敗を区別し、イベントや Sigma の証明は加算しません。使い捨て CA テストでは最初の再起動拒否のみを注入し、公開 CLI による実際の再起動と要求イベントを検証します。既存の専用 CA Configure のドライラン引数制限も修正しました。 (#431) (@Shirofune-Security)
diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md
index ccaa1794..db609ae2 100644
--- a/website/docs/resources/changelog.md
+++ b/website/docs/resources/changelog.md
@@ -7,6 +7,7 @@
**Improvements:**
+- Added opt-in `file-sacl-recovery` to review and remove one proven explicit audit ACE from an unchanged selected leaf file. Original plans, paired receipts, successful results, source/host hashes and held file identity bind the operation; durable pre-write evidence and SACL-only readback preserve unrelated ACE bytes/counts and observed descriptor components. Empty present SACLs are reported without claiming exact historical descriptor restoration; policy, directory/registry recovery and Sigma readiness remain outside this scope. (@Shirofune-Security)
- 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)
- Added `adcs-resume` to review and explicitly resume a dedicated pending CA auditing restart. Original journal/results, source and current CA/operator fingerprints, durable intent receipts and fresh/final checks prevent stale-plan replay and preserve observed settings. Dry-run and failed attempts remain explicit, with no event/Sigma credit. Disposable CA tests inject the initial refusal then verify an actual public-CLI restart and request events. Also fixed the existing dedicated CA Configure dry-run CLI guard. (#431) (@Shirofune-Security)
From d36203bbe4180be0727c2ccea28712a59023bd7d Mon Sep 17 00:00:00 2001
From: Shirofune-Security
<43838376+Shirofune-Security@users.noreply.github.com>
Date: Mon, 21 Sep 2026 10:03:37 +0900
Subject: [PATCH 10/18] Use native zone and wildcard creation for DNS test
namespace
---
tests/DnsClientProbe.Windows.Tests.ps1 | 30 ++++++++------------------
1 file changed, 9 insertions(+), 21 deletions(-)
diff --git a/tests/DnsClientProbe.Windows.Tests.ps1 b/tests/DnsClientProbe.Windows.Tests.ps1
index 4353e8c8..fd05e570 100644
--- a/tests/DnsClientProbe.Windows.Tests.ps1
+++ b/tests/DnsClientProbe.Windows.Tests.ps1
@@ -11,7 +11,7 @@ $os=Get-CimInstance Win32_OperatingSystem;$computer=Get-CimInstance Win32_Comput
if($os.ProductType -ne 3 -or [int]$os.BuildNumber -notin @(20348,26100) -or $computer.PartOfDomain -or $computer.DomainRole -ne 2 -or (Get-WindowsFeature DNS).Installed){throw 'This fixture requires an unjoined Server2022/2025 with no existing DNS role.'}
$engine=(Get-Command $TestEngine -ErrorAction Stop).Source
$private=New-WelaArrivalOutput (Join-Path $env:TEMP ('wela-dns-client-native-'+[guid]::NewGuid().ToString('N'))) $PSScriptRoot
-$channel='Microsoft-Windows-DNS-Client/Operational';$zone='wela.test';$zoneFile='wela-native-'+[guid]::NewGuid().ToString('N')+'.dns';$zoneFilePath=$null;$zoneFileCreated=$false
+$channel='Microsoft-Windows-DNS-Client/Operational';$zone='wela.test';$zoneFile='wela-native-'+[guid]::NewGuid().ToString('N')+'.dns'
$beforeFeatures=@(Get-WindowsFeature|Where-Object Installed|ForEach-Object Name);$policies=Get-WelaEffectiveAuditPolicy;$original=Get-WelaNativeChannel $channel
if($original.State -notin @('Enabled','Disabled') -or $original.MetadataErrors.Count -or $original.Error){throw 'Complete original DNS Client channel state is required before fixture mutation.'}
$null=Write-WelaArrivalArtifact $private 'original-channel.json' ($original|ConvertTo-Json -Depth 10)
@@ -24,34 +24,22 @@ function Invoke-Cli {
Assert ($code -eq $Expected) "Public DNS Client CLI exit $code, expected $Expected."
}
function Set-ChannelEnabled([bool]$Enabled){$out=& "$env:SystemRoot\System32\wevtutil.exe" sl $channel ('/e:'+([string]$Enabled).ToLowerInvariant()) 2>&1;if($LASTEXITCODE -ne 0){throw "Fixture channel update failed: $out"};$global:LASTEXITCODE=0}
+$catalog=Get-WelaProviderPackCatalog;$pack=@($catalog.packs|Where-Object id -ceq 'dns-client')[0]
+Write-Host ((Get-WelaProviderPackSchema $pack)|ConvertTo-Json -Depth 12)
try {
$installed=$true;$feature=Install-WindowsFeature DNS -IncludeManagementTools -ErrorAction Stop
if(-not $feature.Success -or [string]$feature.RestartNeeded -ne 'No'){throw 'DNS role install failed or requires restart; no native acceptance claim.'}
Start-Service DNS -ErrorAction Stop
$ready=[Diagnostics.Stopwatch]::StartNew();do{try{$null=Get-DnsServerZone -ErrorAction Stop;break}catch{if($ready.Elapsed.TotalSeconds -gt 30){throw};Start-Sleep -Milliseconds 500}}while($true)
if(Get-DnsServerZone -Name $zone -ErrorAction SilentlyContinue){throw 'Fixture zone already exists; no replacement is permitted.'}
- $zoneFilePath=Join-Path $env:SystemRoot ('System32\dns\'+$zoneFile)
- if(Test-Path -LiteralPath $zoneFilePath){throw 'Fixture zone file already exists.'}
- # Avoid relying on generated SOA/NS names on an unjoined, suffix-free runner.
- $zoneText=@'
-$ORIGIN wela.test.
-$TTL 0
-@ IN SOA ns.wela.test. hostmaster.wela.test. ( 1 3600 600 86400 0 )
-@ IN NS ns.wela.test.
-ns IN A 127.0.0.1
-* IN A 192.0.2.1
-'@
- $stream=[IO.File]::Open($zoneFilePath,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None)
- try{$zoneFileCreated=$true;$bytes=[Text.Encoding]::ASCII.GetBytes(($zoneText -replace "\r?\n","`r`n")+"`r`n");$stream.Write($bytes,0,$bytes.Length);$stream.Flush()}finally{$stream.Dispose()}
- Write-Host "Creating owned authoritative zone $zone from new file $zoneFile"
- Add-DnsServerPrimaryZone -Name $zone -ZoneFile $zoneFile -DynamicUpdate None -LoadExisting -ErrorAction Stop;$zoneCreated=$true
+ if(Test-Path -LiteralPath (Join-Path $env:SystemRoot ('System32\dns\'+$zoneFile))){throw 'Fixture zone file already exists.'}
+ Add-DnsServerPrimaryZone -Name $zone -ZoneFile $zoneFile -DynamicUpdate None -ErrorAction Stop;$zoneCreated=$true
+ Add-DnsServerResourceRecordA -ZoneName $zone -Name '*' -IPv4Address '192.0.2.1' -TimeToLive ([TimeSpan]::FromSeconds(1)) -ErrorAction Stop|Out-Null
$record=@(Get-DnsServerResourceRecord -ZoneName $zone -RRType A -ErrorAction Stop|Where-Object HostName -ceq '*')
- Assert (@($record).Count -eq 1 -and $record.RecordData.IPv4Address.IPAddressToString -ceq '192.0.2.1') 'Loaded owned wildcard A record is exact.'
+ Assert ($record.Count -eq 1 -and $record[0].RecordData.IPv4Address.IPAddressToString -ceq '192.0.2.1') 'Owned wildcard A record is exact.'
# The zone is authoritative and the native request has recursion disabled. No external resolver or answer connection is used.
if(-not $original.IsEnabled){$channelChanged=$true;Set-ChannelEnabled $true}
$configured=Get-WelaNativeChannel $channel
- $catalog=Get-WelaProviderPackCatalog;$pack=@($catalog.packs|Where-Object id -ceq 'dns-client')[0]
- Write-Host ((Get-WelaProviderPackSchema $pack)|ConvertTo-Json -Depth 12)
Invoke-Cli @('dns-client-probe','-DnsClientProbeResolver','127.0.0.1')
$output=Join-Path $private 'evidence'
Invoke-Cli @('dns-client-probe','-DnsClientProbeAction','Run','-DnsClientProbeResolver','127.0.0.1','-DnsClientProbeOutputPath',$output)
@@ -71,8 +59,8 @@ ns IN A 127.0.0.1
}finally{
$errors=@()
try{if($channelChanged){Set-ChannelEnabled ([bool]$original.IsEnabled)};if((Get-WelaChannelReadKey (Get-WelaNativeChannel $channel)) -cne (Get-WelaChannelReadKey $original)){throw 'DNS Client channel configuration restoration differs.'}}catch{$errors+=$_.Exception.Message}
- if($zoneCreated){try{$owned=Get-DnsServerZone -Name $zone -ErrorAction SilentlyContinue;if($owned){if($owned.IsDsIntegrated -or $owned.ZoneType -ne 'Primary'){throw 'Owned DNS zone identity changed; cleanup refused.'};Remove-DnsServerZone -Name $zone -Force -ErrorAction Stop};if(Get-DnsServerZone -Name $zone -ErrorAction SilentlyContinue){throw 'Owned zone remains.'}}catch{$errors+=$_.Exception.Message}}
- if($zoneFileCreated){try{if(Get-DnsServerZone -Name $zone -ErrorAction SilentlyContinue){throw 'Refuse deleting a zone file still loaded by DNS.'};if(Test-Path -LiteralPath $zoneFilePath){Remove-Item -LiteralPath $zoneFilePath -ErrorAction Stop}}catch{$errors+=$_.Exception.Message}}
+ if($zoneCreated){try{$owned=Get-DnsServerZone -Name $zone -ErrorAction SilentlyContinue;if($owned){if($owned.IsDsIntegrated -or $owned.ZoneType -ne 'Primary'){throw 'Owned DNS zone identity changed; cleanup refused.'};Remove-DnsServerZone -Name $zone -Force -ErrorAction Stop};if(Get-DnsServerZone -Name $zone -ErrorAction SilentlyContinue){throw 'Owned zone remains.'};$file=Join-Path $env:SystemRoot ('System32\dns\'+$zoneFile);if(Test-Path -LiteralPath $file){Remove-Item -LiteralPath $file -ErrorAction Stop}}catch{$errors+=$_.Exception.Message}}
+
try{$afterPolicies=Get-WelaEffectiveAuditPolicy;foreach($guid in $policies.Keys){if($afterPolicies[$guid] -ne $policies[$guid]){throw 'Native audit policy changed.'}}}catch{$errors+=$_.Exception.Message}
$removal=[pscustomobject]@{ChannelAndZoneRestored=($errors.Count -eq 0);Attempted=$false;Features=@();Success=$null;RestartNeeded=$null;Boundary='Owned feature removal can require disposal of this GitHub-hosted VM; no restart or complete live feature-restoration claim.'}
if($installed -and -not $errors.Count){try{$added=@(Get-WindowsFeature|Where-Object {$_.Installed -and $_.Name -notin $beforeFeatures -and $_.Name -in @('DNS','RSAT-DNS-Server')}|ForEach-Object Name);if($added.Count){$removal.Attempted=$true;$removal.Features=$added;$removed=Uninstall-WindowsFeature -Name $added -ErrorAction Stop;$removal.Success=[bool]$removed.Success;$removal.RestartNeeded=[string]$removed.RestartNeeded;if(-not $removed.Success -or $removal.RestartNeeded -notin @('No','Yes')){throw 'DNS feature removal failed or restart state is unknown.'}}}catch{$errors+=$_.Exception.Message}}
From 9e5351d7e463972980fb6c7c111f0020a459a759 Mon Sep 17 00:00:00 2001
From: Shirofune-Security
<43838376+Shirofune-Security@users.noreply.github.com>
Date: Mon, 21 Sep 2026 10:03:55 +0900
Subject: [PATCH 11/18] Link leaf-file recovery changelog to PR437
---
CHANGELOG-Japanese.md | 2 +-
CHANGELOG.md | 2 +-
website/docs/resources/changelog.ja.md | 2 +-
website/docs/resources/changelog.md | 2 +-
4 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md
index a2fd2aec..ef86bd62 100644
--- a/CHANGELOG-Japanese.md
+++ b/CHANGELOG-Japanese.md
@@ -4,7 +4,7 @@
**改善:**
-- `file-sacl-recovery` を追加しました。元の計画、変更前後のレシート、成功結果、ソースとホストのハッシュ、実際のファイル識別情報を照合し、変更されていない選択済み単一ファイルに追加した監査 ACE だけを明示的に削除できます。書き込み前の永続記録と SACL 限定の変更・再読込により、無関係な ACE のバイト列と個数、観測した他の記述子要素を保持します。空の SACL が残る場合を区別し、元の記述子との完全一致、ポリシー・ディレクトリ・レジストリの復旧、Sigma の利用可能性は保証しません。(@Shirofune-Security)
+- `file-sacl-recovery` を追加しました。元の計画、変更前後のレシート、成功結果、ソースとホストのハッシュ、実際のファイル識別情報を照合し、変更されていない選択済み単一ファイルに追加した監査 ACE だけを明示的に削除できます。書き込み前の永続記録と SACL 限定の変更・再読込により、無関係な ACE のバイト列と個数、観測した他の記述子要素を保持します。空の SACL が残る場合を区別し、元の記述子との完全一致、ポリシー・ディレクトリ・レジストリの復旧、Sigma の利用可能性は保証しません。(#437) (@Shirofune-Security)
- 読み取り専用の `channel-read` を追加し、実際の実行トークンで選択したWindows標準ローカルログを確認します。メタデータやACLとは独立して拒否・不在・空・イベント読取を区別し、トークンと環境の変化を検出して保護された限定レポートに記録します。Windows設定の変更やSigma利用可能数への加算は行いません。使い捨て標準ユーザーでServer 2022/2025・PowerShell 5.1/7の拒否と読取を検証します。 (#432) (@Shirofune-Security)
- 専用 CA 監査設定の再起動待ちを確認し、明示的に再開する `adcs-resume` を追加。元の記録・結果、出典、現在の CA と実行者、永続的な意図記録、変更直前・最終確認により古い計画の再実行を拒否し、観測した設定を保持します。ドライランと失敗を区別し、イベントや Sigma の証明は加算しません。使い捨て CA テストでは最初の再起動拒否のみを注入し、公開 CLI による実際の再起動と要求イベントを検証します。既存の専用 CA Configure のドライラン引数制限も修正しました。 (#431) (@Shirofune-Security)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 70adc3b1..997bf1ac 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,7 +4,7 @@
**Improvements:**
-- Added opt-in `file-sacl-recovery` to review and remove one proven explicit audit ACE from an unchanged selected leaf file. Original plans, paired receipts, successful results, source/host hashes and held file identity bind the operation; durable pre-write evidence and SACL-only readback preserve unrelated ACE bytes/counts and observed descriptor components. Empty present SACLs are reported without claiming exact historical descriptor restoration; policy, directory/registry recovery and Sigma readiness remain outside this scope. (@Shirofune-Security)
+- Added opt-in `file-sacl-recovery` to review and remove one proven explicit audit ACE from an unchanged selected leaf file. Original plans, paired receipts, successful results, source/host hashes and held file identity bind the operation; durable pre-write evidence and SACL-only readback preserve unrelated ACE bytes/counts and observed descriptor components. Empty present SACLs are reported without claiming exact historical descriptor restoration; policy, directory/registry recovery and Sigma readiness remain outside this scope. (#437) (@Shirofune-Security)
- 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)
- Added `adcs-resume` to review and explicitly resume a dedicated pending CA auditing restart. Original journal/results, source and current CA/operator fingerprints, durable intent receipts and fresh/final checks prevent stale-plan replay and preserve observed settings. Dry-run and failed attempts remain explicit, with no event/Sigma credit. Disposable CA tests inject the initial refusal then verify an actual public-CLI restart and request events. Also fixed the existing dedicated CA Configure dry-run CLI guard. (#431) (@Shirofune-Security)
diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md
index 53152428..1dae6372 100644
--- a/website/docs/resources/changelog.ja.md
+++ b/website/docs/resources/changelog.ja.md
@@ -7,7 +7,7 @@
**改善:**
-- `file-sacl-recovery` を追加しました。元の計画、変更前後のレシート、成功結果、ソースとホストのハッシュ、実際のファイル識別情報を照合し、変更されていない選択済み単一ファイルに追加した監査 ACE だけを明示的に削除できます。書き込み前の永続記録と SACL 限定の変更・再読込により、無関係な ACE のバイト列と個数、観測した他の記述子要素を保持します。空の SACL が残る場合を区別し、元の記述子との完全一致、ポリシー・ディレクトリ・レジストリの復旧、Sigma の利用可能性は保証しません。(@Shirofune-Security)
+- `file-sacl-recovery` を追加しました。元の計画、変更前後のレシート、成功結果、ソースとホストのハッシュ、実際のファイル識別情報を照合し、変更されていない選択済み単一ファイルに追加した監査 ACE だけを明示的に削除できます。書き込み前の永続記録と SACL 限定の変更・再読込により、無関係な ACE のバイト列と個数、観測した他の記述子要素を保持します。空の SACL が残る場合を区別し、元の記述子との完全一致、ポリシー・ディレクトリ・レジストリの復旧、Sigma の利用可能性は保証しません。(#437) (@Shirofune-Security)
- 読み取り専用の `channel-read` を追加し、実際の実行トークンで選択したWindows標準ローカルログを確認します。メタデータやACLとは独立して拒否・不在・空・イベント読取を区別し、トークンと環境の変化を検出して保護された限定レポートに記録します。Windows設定の変更やSigma利用可能数への加算は行いません。使い捨て標準ユーザーでServer 2022/2025・PowerShell 5.1/7の拒否と読取を検証します。 (#432) (@Shirofune-Security)
- 専用 CA 監査設定の再起動待ちを確認し、明示的に再開する `adcs-resume` を追加。元の記録・結果、出典、現在の CA と実行者、永続的な意図記録、変更直前・最終確認により古い計画の再実行を拒否し、観測した設定を保持します。ドライランと失敗を区別し、イベントや Sigma の証明は加算しません。使い捨て CA テストでは最初の再起動拒否のみを注入し、公開 CLI による実際の再起動と要求イベントを検証します。既存の専用 CA Configure のドライラン引数制限も修正しました。 (#431) (@Shirofune-Security)
diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md
index db609ae2..8d872f13 100644
--- a/website/docs/resources/changelog.md
+++ b/website/docs/resources/changelog.md
@@ -7,7 +7,7 @@
**Improvements:**
-- Added opt-in `file-sacl-recovery` to review and remove one proven explicit audit ACE from an unchanged selected leaf file. Original plans, paired receipts, successful results, source/host hashes and held file identity bind the operation; durable pre-write evidence and SACL-only readback preserve unrelated ACE bytes/counts and observed descriptor components. Empty present SACLs are reported without claiming exact historical descriptor restoration; policy, directory/registry recovery and Sigma readiness remain outside this scope. (@Shirofune-Security)
+- Added opt-in `file-sacl-recovery` to review and remove one proven explicit audit ACE from an unchanged selected leaf file. Original plans, paired receipts, successful results, source/host hashes and held file identity bind the operation; durable pre-write evidence and SACL-only readback preserve unrelated ACE bytes/counts and observed descriptor components. Empty present SACLs are reported without claiming exact historical descriptor restoration; policy, directory/registry recovery and Sigma readiness remain outside this scope. (#437) (@Shirofune-Security)
- 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)
- Added `adcs-resume` to review and explicitly resume a dedicated pending CA auditing restart. Original journal/results, source and current CA/operator fingerprints, durable intent receipts and fresh/final checks prevent stale-plan replay and preserve observed settings. Dry-run and failed attempts remain explicit, with no event/Sigma credit. Disposable CA tests inject the initial refusal then verify an actual public-CLI restart and request events. Also fixed the existing dedicated CA Configure dry-run CLI guard. (#431) (@Shirofune-Security)
From a4a0a100f3b9d0f002e6ce027a81d600761f890a Mon Sep 17 00:00:00 2001
From: Shirofune-Security
<43838376+Shirofune-Security@users.noreply.github.com>
Date: Mon, 21 Sep 2026 10:07:33 +0900
Subject: [PATCH 12/18] Use independent ASD file selection in owned recovery
fixture
---
tests/FileSaclRecovery.Windows.Tests.ps1 | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/tests/FileSaclRecovery.Windows.Tests.ps1 b/tests/FileSaclRecovery.Windows.Tests.ps1
index 6063be16..00a7cfee 100644
--- a/tests/FileSaclRecovery.Windows.Tests.ps1
+++ b/tests/FileSaclRecovery.Windows.Tests.ps1
@@ -42,8 +42,10 @@ try {
Initialize-WelaFileSaclRecoveryNative
Set-ItemProperty -LiteralPath $precedencePath -Name SCENoApplyLegacyAuditPolicy -Value 1 -Type DWord
Set-WelaEffectiveAuditPolicy -Guid '0CCE921D-69AE-11D9-BED3-505054503030' -Mask 3 -Mode minimum
+ # ASD has the same explicit opt-in file prerequisite without WEF screenshot
+ # companion registry rows, so this isolated catalog can contain one file only.
$context=Get-WelaSelectedSaclContext
- $target=@((Get-WelaSelectedSaclCatalog -Profile wela-2.2.0 -IncludeOptional -Context $context).Rows)
+ $target=@((Get-WelaSelectedSaclCatalog -Profile asd-native-2021-10 -IncludeOptional -Context $context).Rows)
Assert ($target.Count -eq 1 -and $target[0].Definition.Path -ceq $file) 'Installed fixture catalog selects only the owned leaf.'
$id=$target[0].Id;$definition=$target[0].Definition
foreach($case in @('empty','unrelated')){
@@ -55,7 +57,7 @@ try {
}
$before=Get-WelaSelectedSaclSnapshot $definition
$original=Join-Path $caseDir 'original.json';$backup=Join-Path $caseDir 'receipts';$configured=Join-Path $caseDir 'configured.json'
- Run-Wela @('targeted-sacl','-TargetSaclAction','Plan','-TargetSaclProfile','wela-2.2.0','-TargetSaclId',$id,'-IncludeOptional','-ResultsPath',$original)
+ Run-Wela @('targeted-sacl','-TargetSaclAction','Plan','-TargetSaclProfile','asd-native-2021-10','-TargetSaclId',$id,'-IncludeOptional','-ResultsPath',$original)
Run-Wela @('targeted-sacl','-TargetSaclAction','Configure','-TargetSaclPlanPath',$original,'-TargetSaclId',$id,'-IncludeOptional','-Auto','-BackupPath',$backup,'-ResultsPath',$configured)
$completedAddition=Json $configured
Assert ($completedAddition.Results[0].Status -ceq 'Applied' -and $completedAddition.ExitCode -eq 0) 'Original public Configure supplied genuine Applied result and receipt pair.'
From f75bb3019bdf9b9b76d676df58ca346cae201f65 Mon Sep 17 00:00:00 2001
From: Shirofune-Security
<43838376+Shirofune-Security@users.noreply.github.com>
Date: Mon, 21 Sep 2026 10:07:57 +0900
Subject: [PATCH 13/18] Retain bounded DNS worker evidence when native
validation fails
---
docs/dns-client-probe.md | 4 ++--
scripts/DnsClientProbe.ps1 | 8 +++++---
tests/DnsClientProbe.Windows.Tests.ps1 | 2 +-
3 files changed, 8 insertions(+), 6 deletions(-)
diff --git a/docs/dns-client-probe.md b/docs/dns-client-probe.md
index e52e6002..13941da5 100644
--- a/docs/dns-client-probe.md
+++ b/docs/dns-client-probe.md
@@ -14,9 +14,9 @@
The example address is documentation-only: replace it with an approved resolver. Plan creates no files and sends no probe lookup. Run generates exactly one application request for `wela-.wela.test.` type A; `.test` is reserved for DNS testing by [RFC 2606](https://www.rfc-editor.org/rfc/rfc2606.html). There is no caller-selected domain, record type or application connection to a returned address. A same-engine 64-bit worker uses synchronous `DnsQueryEx` with one explicit IPv4 DNS server, TCP port 53, recursion disabled, cache bypass, no hosts/local-name/NetBT/multicast fallback, fully qualified naming and IDN disabled. DNS retry/internal processing and normal response caching are OS behavior; this is not a promise of one wire packet, cache immutability or resolver-side enforcement. The query name, selected resolver and exact flags are retained. Only canonical unicast IPv4 literals are accepted; there is no hostname or configurable port.
-The bounded worker has twenty seconds to finish. The separate event wait defaults to fifteen seconds (`-DnsClientProbeTimeoutSeconds 1..30`). Native status 0 (A answers), 9003 (NXDOMAIN) and 9501 (no records) are reviewed completion outcomes. A negative response is not reported as successful name resolution. Missing events, unknown outcomes/versions/types, caps, token or configuration/source drift and incomplete reads remain `Unverified` with a nonzero exit. No setup is automatically performed to make the test pass.
+The bounded worker has twenty seconds to finish. Terminating it does not prove cancellation of DNS service or network work; timed-out completion remains unverified. The separate event wait defaults to fifteen seconds (`-DnsClientProbeTimeoutSeconds 1..30`). Native status 0 (A answers), 9003 (NXDOMAIN) and 9501 (no records) are reviewed completion outcomes. A negative response is not reported as successful name resolution. Missing events, unknown outcomes/versions/types, caps, token or configuration/source drift and incomplete reads remain `Unverified` with a nonzero exit. No setup is automatically performed to make the test pass.
-Evidence includes observed build/patch/role, token and same-engine context, exact provider GUID, live event/version/field types and template hashes, original pinned rule hashes, channel metadata, a pre-query record boundary, worker timestamps/status/answers and hashed original matched XML. Matching requires event 3008 version 0 on **Microsoft-Windows-DNS-Client/Operational**, source computer, unique query name/type, native completion status, requested option bits, record boundary and operation time. The emitter PID is retained in original XML; it may belong to the DNS service broker, so it is not assumed to equal the requesting worker PID. This correlation does not prove exclusive request attribution, the wire destination, DNSSEC validation or absence of simultaneous unrelated events. Full caller token snapshots bracket actual query/event I/O and are compared before final metadata inventory; the worker has its own exact before/after token checks. Metadata inventories are outside this interval because DISM and channel inspection may temporarily adjust privileges. Native event-query status is retained separately from its records. Artifact hashes detect byte changes; they are not signatures or historical host authentication.
+Evidence includes observed build/patch/role, token and same-engine context, exact provider GUID, live event/version/field types and template hashes, original pinned rule hashes, channel metadata, a pre-query record boundary, bounded original worker JSON (also retained if its validation fails), worker timestamps/status/answers and hashed original matched XML. Matching requires event 3008 version 0 on **Microsoft-Windows-DNS-Client/Operational**, source computer, unique query name/type, native completion status, requested option bits, record boundary and operation time. The emitter PID is retained in original XML; it may belong to the DNS service broker, so it is not assumed to equal the requesting worker PID. This correlation does not prove exclusive request attribution, the wire destination, DNSSEC validation or absence of simultaneous unrelated events. Full caller token snapshots bracket actual query/event I/O and are compared before final metadata inventory; the worker has its own exact before/after token checks. Metadata inventories are outside this interval because DISM and channel inspection may temporarily adjust privileges. Native event-query status is retained separately from its records. Artifact hashes detect byte changes; they are not signatures or historical host authentication.
`PrerequisitesObserved` means only that Plan observed supported metadata. `NativeDnsLookupObserved` means that a native completion and matching local event were observed. Neither proves forwarding, downstream parsing, detection execution or retention capacity. In particular, all six pinned DNS Client rules refer to **Microsoft-Windows-DNS Client Events/Operational**, a different channel string. WELA retains that mismatch and does not rewrite it. `ReadyRuleCredit` remains **0**; there is no six-rule Sigma uplift.
diff --git a/scripts/DnsClientProbe.ps1 b/scripts/DnsClientProbe.ps1
index c7bc4f2c..4f64ca48 100644
--- a/scripts/DnsClientProbe.ps1
+++ b/scripts/DnsClientProbe.ps1
@@ -58,7 +58,7 @@ function Get-WelaDnsClientProbeWatermark {
try{$query=[Diagnostics.Eventing.Reader.EventLogQuery]::new('Microsoft-Windows-DNS-Client/Operational',[Diagnostics.Eventing.Reader.PathType]::LogName,'*');$query.ReverseDirection=$true;$reader=[Diagnostics.Eventing.Reader.EventLogReader]::new($query);$reader.BatchSize=1;$record=$reader.ReadEvent([TimeSpan]::FromSeconds(5));Assert-WelaChannelQueryStatus -Channel 'Microsoft-Windows-DNS-Client/Operational' -LogStatus @($reader.LogStatus);if($record){if($record.RecordId -le 0){throw 'Invalid native record boundary.'};return [long]$record.RecordId};return [long]0}finally{if($record){$record.Dispose()};if($reader){$reader.Dispose()}}
}
function Start-WelaDnsClientProbeQuery {
- param($State,[string]$Resolver,[string]$QueryName)
+ param($State,[string]$Resolver,[string]$QueryName,$Report)
$fresh=Get-WelaDnsClientProbeState
if((Get-WelaDnsClientProbeStateKey $fresh) -cne (Get-WelaDnsClientProbeStateKey $State)){throw 'DNS prerequisites changed before query.'}
$boundary=Get-WelaDnsClientProbeWatermark
@@ -73,8 +73,10 @@ function Start-WelaDnsClientProbeQuery {
if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($output,$errorText),5000)){throw 'DNS worker output did not finish.'}
if($output.Result.Length -gt 262144 -or $errorText.Result.Length -gt 65536){throw 'DNS worker output exceeded evidence bounds.'}
if($process.ExitCode -ne 0 -or $errorText.Result){throw ('DNS worker failed: '+$errorText.Result)}
+ # Retain bounded owned-worker output even when schema/status validation refuses it.
+ $Report.Artifacts+=Write-WelaArrivalArtifact $Report.OutputPath 'worker.json' $output.Result
$operation=ConvertFrom-WelaRecoveryJson $output.Result
- if($operation.ProcessId -ne $process.Id -or $operation.Query.QueryName -cne $QueryName -or $operation.Query.Resolver -cne $Resolver -or $operation.Query.Options -ne 2103790 -or $operation.Query.Status -ne $operation.Query.ResultStatus -or $operation.Query.Status -notin @(0,9003,9501)){throw 'Unexpected DNS worker response or unsupported native outcome.'}
+ if($operation.ProcessId -ne $process.Id -or $operation.Query.QueryName -cne $QueryName -or $operation.Query.Resolver -cne $Resolver -or $operation.Query.Options -ne 2103790 -or $operation.Query.Status -ne $operation.Query.ResultStatus -or $operation.Query.Status -notin @(0,9003,9501)){throw ('Unexpected DNS worker response or unsupported native outcome: PID='+$operation.ProcessId+' expectedPID='+$process.Id+' options='+$operation.Query.Options+' APIstatus='+$operation.Query.Status+' resultStatus='+$operation.Query.ResultStatus)}
$begin=ConvertTo-WelaArrivalUtc $operation.StartedUtc;$end=ConvertTo-WelaArrivalUtc $operation.CompletedUtc
$operation.StartedUtc=$begin.UtcDateTime.ToString('o');$operation.CompletedUtc=$end.UtcDateTime.ToString('o')
if($begin -lt $launch -or $end -lt $begin -or $end -gt [DateTimeOffset]::UtcNow -or ($end-$begin).TotalSeconds -gt 20){throw 'Invalid DNS operation timestamps.'}
@@ -135,7 +137,7 @@ function Invoke-WelaDnsClientProbe {
if($Action -eq 'Plan'){$report.After=$before;$report.Status='PrerequisitesObserved';$report.ExitCode=0;return $report}
$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'before.json' ($before|ConvertTo-Json -Depth 24)
$queryName='wela-'+[guid]::NewGuid().ToString('N')+'.wela.test.'
- $operation=Start-WelaDnsClientProbeQuery $before $Resolver $queryName;$report.Operation=$operation;$report.ReaderBefore=$operation.CallerBefore
+ $operation=Start-WelaDnsClientProbeQuery $before $Resolver $queryName $report;$report.Operation=$operation;$report.ReaderBefore=$operation.CallerBefore
$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'operation.json' ($operation|ConvertTo-Json -Depth 15)
$timer=[Diagnostics.Stopwatch]::StartNew();$matches=@()
do{$batch=Read-WelaDnsClientProbeEvents $operation;$report.Query=$batch.Query;$report.QueryLogStatus=@($batch.LogStatus);Assert-WelaChannelQueryStatus -Channel 'Microsoft-Windows-DNS-Client/Operational' -LogStatus $report.QueryLogStatus;$report.Candidates=@($batch.Xml).Count;if($batch.Capped -isnot [bool] -or $batch.Capped){throw 'DNS event query cap reached or completeness unknown.'};$matches=@($batch.Xml|Where-Object {Test-WelaDnsClientProbeEvent $_ $operation $before});if($matches.Count){break};Start-Sleep -Milliseconds 250}while($timer.Elapsed.TotalSeconds -lt $TimeoutSeconds)
diff --git a/tests/DnsClientProbe.Windows.Tests.ps1 b/tests/DnsClientProbe.Windows.Tests.ps1
index fd05e570..a3c8a6f2 100644
--- a/tests/DnsClientProbe.Windows.Tests.ps1
+++ b/tests/DnsClientProbe.Windows.Tests.ps1
@@ -54,7 +54,7 @@ try {
}catch{
Write-Host ('Native DNS Client failure: '+($_|Out-String));Write-Host $_.ScriptStackTrace
# Small owned diagnostics only; avoid dumping unrelated channel payloads.
- if(Test-Path (Join-Path $private 'evidence')){Get-ChildItem (Join-Path $private 'evidence') -File|Where-Object {$_.Name -in @('manifest.json','operation.json') -or $_.Name -like 'candidate-*.xml'}|ForEach-Object{Write-Host $_.Name;Write-Host ([IO.File]::ReadAllText($_.FullName))}}
+ if(Test-Path (Join-Path $private 'evidence')){Get-ChildItem (Join-Path $private 'evidence') -File|Where-Object {$_.Name -in @('manifest.json','operation.json','worker.json') -or $_.Name -like 'candidate-*.xml'}|ForEach-Object{Write-Host $_.Name;Write-Host ([IO.File]::ReadAllText($_.FullName))}}
throw
}finally{
$errors=@()
From d43352cbd6ec85a63aeb21005ebe0452b603638a Mon Sep 17 00:00:00 2001
From: Shirofune-Security
<43838376+Shirofune-Security@users.noreply.github.com>
Date: Mon, 21 Sep 2026 10:26:31 +0900
Subject: [PATCH 14/18] Set documented DNS server-array byte size for native
requests
---
docs/dns-client-probe.md | 2 +-
scripts/DnsClientProbeNative.cs | 3 ++-
tests/DnsClientProbe.Tests.ps1 | 1 +
3 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/docs/dns-client-probe.md b/docs/dns-client-probe.md
index 13941da5..d45abb43 100644
--- a/docs/dns-client-probe.md
+++ b/docs/dns-client-probe.md
@@ -22,4 +22,4 @@ Evidence includes observed build/patch/role, token and same-engine context, exac
Native acceptance uses a separately opt-in fixture on disposable GitHub-hosted workgroup Server 2022/2025 under Windows PowerShell 5.1 and PowerShell 7. The fixture refuses an existing DNS role, installs its own standalone role, creates authoritative `wela.test` with a wildcard A record to `192.0.2.1`, and queries only loopback. It temporarily enables the Client channel if needed, restores its exact original settings, checks audit policies, removes its owned zone/records and removes only newly installed DNS features. Feature removal may require VM disposal rather than a live reboot; the cleanup receipt records that boundary. Fixture setup is not part of the product. Windows 11, domain-joined/DC/ADCS hosts and external resolver/network behavior still require their own acceptance evidence.
-Native API references: [DnsQueryEx](https://learn.microsoft.com/en-us/windows/win32/api/windns/nf-windns-dnsqueryex), [DNS_QUERY_REQUEST](https://learn.microsoft.com/en-us/windows/win32/api/windns/ns-windns-dns_query_request), [DNS query flags](https://learn.microsoft.com/en-us/windows/win32/dns/dns-constants), and the [Microsoft Windows SDK declarations](https://github.com/microsoft/win32metadata/blob/main/generation/WinSDK/RecompiledIdlHeaders/um/WinDNS.h).
+Native API references: [DnsQueryEx](https://learn.microsoft.com/en-us/windows/win32/api/windns/nf-windns-dnsqueryex), [DNS_QUERY_REQUEST](https://learn.microsoft.com/en-us/windows/win32/api/windns/ns-windns-dns_query_request), [DNS_ADDR_ARRAY](https://learn.microsoft.com/en-us/windows/win32/api/windnsdef/ns-windnsdef-dns_addr_array), [DNS query flags](https://learn.microsoft.com/en-us/windows/win32/dns/dns-constants), and the [Microsoft Windows SDK declarations](https://github.com/microsoft/win32metadata/blob/main/generation/WinSDK/RecompiledIdlHeaders/um/WinDNS.h).
diff --git a/scripts/DnsClientProbeNative.cs b/scripts/DnsClientProbeNative.cs
index 8712158a..6323d0c9 100644
--- a/scripts/DnsClientProbeNative.cs
+++ b/scripts/DnsClientProbeNative.cs
@@ -30,7 +30,8 @@ namespace Wela.DnsClientProbe {
if(name==null||!Regex.IsMatch(name,@"^wela-[a-f0-9]{32}\.wela\.test\.\z"))throw new ArgumentException("Only the fixed random probe name is accepted.");
ValidateResolver(resolver);
// SDK DNS_ADDR_ARRAY header32 + one DNS_ADDR64; sockaddr_in in its first16 bytes.
- byte[] server=new byte[96];BitConverter.GetBytes((uint)1).CopyTo(server,0);BitConverter.GetBytes((uint)1).CopyTo(server,4);
+ // DNS_ADDR_ARRAY.MaxCount is the structure size in bytes; AddrCount is the element count.
+ byte[] server=new byte[96];BitConverter.GetBytes((uint)server.Length).CopyTo(server,0);BitConverter.GetBytes((uint)1).CopyTo(server,4);
BitConverter.GetBytes((ushort)2).CopyTo(server,12);BitConverter.GetBytes((ushort)2).CopyTo(server,32);
server[34]=0;server[35]=53;IPAddress.Parse(resolver).GetAddressBytes().CopyTo(server,36);
IntPtr servers=Marshal.AllocHGlobal(server.Length);QueryResult result=new QueryResult {Version=1};
diff --git a/tests/DnsClientProbe.Tests.ps1 b/tests/DnsClientProbe.Tests.ps1
index 71aef0db..12b45614 100644
--- a/tests/DnsClientProbe.Tests.ps1
+++ b/tests/DnsClientProbe.Tests.ps1
@@ -8,6 +8,7 @@ Add-Type -Path (Join-Path $ScriptRoot 'scripts/DnsClientProbeNative.cs')
foreach($resolver in @('127.0.0.1','192.0.2.53','10.0.0.53')){Assert-WelaDnsClientResolver $resolver;Assert ([Wela.DnsClientProbe.Native]::ValidateResolver($resolver) -ceq $resolver) 'Explicit canonical IPv4 accepted.'}
foreach($resolver in @('','localhost','127.1','127.0.0.01','127.0.0.1:53','127.0.0.1"','0.0.0.0','224.0.0.1','255.255.255.255','192.0.2.999','::1')){Throws {Assert-WelaDnsClientResolver $resolver} 'IPv4';Throws {[Wela.DnsClientProbe.Native]::ValidateResolver($resolver)} 'resolver|IPv4'}
Throws {[Wela.DnsClientProbe.Native]::Query('arbitrary.example.','127.0.0.1')} 'fixed random'
+foreach($entry in @(@('Request',64),@('QueryResult',32),@('Record',32))){$nativeType=[Wela.DnsClientProbe.Native].GetNestedType($entry[0],[Reflection.BindingFlags]::NonPublic);Assert ([Runtime.InteropServices.Marshal]::SizeOf($nativeType) -eq $entry[1]) ('Native64 SDK layout: '+$entry[0])}
Assert ([Wela.DnsClientProbe.Native].GetField('SourceSha256').IsLiteral) 'Compiled source fingerprint cannot be reassigned.'
Assert ([Wela.DnsClientProbe.Native]::Options -eq 2103790) 'Fixed documented DNS flags retained.'
$fields=@(foreach($name in @('QueryName','QueryType','QueryOptions','QueryStatus','QueryResults')){[pscustomobject]@{Name=$name;InType=$(if($name -in @('QueryName','QueryResults')){'win:UnicodeString'}elseif($name -eq 'QueryOptions'){'win:UInt64'}else{'win:UInt32'})}})
From 30ba5bdf07853f5c3198cef40ba2c138e96fd68e Mon Sep 17 00:00:00 2001
From: Shirofune-Security
<43838376+Shirofune-Security@users.noreply.github.com>
Date: Mon, 21 Sep 2026 10:26:57 +0900
Subject: [PATCH 15/18] Verify the observed present-null SACL after sole audit
ACE removal
---
.github/workflows/release.yml | 2 +-
CHANGELOG-Japanese.md | 2 +-
CHANGELOG.md | 2 +-
docs/file-sacl-recovery.md | 4 ++--
scripts/FileSaclRecovery.ps1 | 6 +++---
scripts/FileSaclRecoveryNative.cs | 14 +++++++++++---
tests/FileSaclRecovery.Descriptor.Tests.ps1 | 8 ++++++++
tests/FileSaclRecovery.Windows.Tests.ps1 | 2 ++
website/docs/resources/changelog.ja.md | 2 +-
website/docs/resources/changelog.md | 2 +-
10 files changed, 31 insertions(+), 13 deletions(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 8e912d6c..116adde6 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -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 -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/file-sacl-recovery.md -Destination release-binaries/docs/
- name: Set Artifact Name
if: contains(matrix.info.os, 'windows') == true
diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md
index ef86bd62..35e38791 100644
--- a/CHANGELOG-Japanese.md
+++ b/CHANGELOG-Japanese.md
@@ -4,7 +4,7 @@
**改善:**
-- `file-sacl-recovery` を追加しました。元の計画、変更前後のレシート、成功結果、ソースとホストのハッシュ、実際のファイル識別情報を照合し、変更されていない選択済み単一ファイルに追加した監査 ACE だけを明示的に削除できます。書き込み前の永続記録と SACL 限定の変更・再読込により、無関係な ACE のバイト列と個数、観測した他の記述子要素を保持します。空の SACL が残る場合を区別し、元の記述子との完全一致、ポリシー・ディレクトリ・レジストリの復旧、Sigma の利用可能性は保証しません。(#437) (@Shirofune-Security)
+- `file-sacl-recovery` を追加しました。元の計画、変更前後のレシート、成功結果、ソースとホストのハッシュ、実際のファイル識別情報を照合し、変更されていない選択済み単一ファイルに追加した監査 ACE だけを明示的に削除できます。書き込み前の永続記録と SACL 限定の変更・再読込により、無関係な ACE のバイト列と個数、観測した他の記述子要素を保持します。空または null の SACL が存在フラグ付きで残る場合を区別し、元の記述子との完全一致、ポリシー・ディレクトリ・レジストリの復旧、Sigma の利用可能性は保証しません。(#437) (@Shirofune-Security)
- 読み取り専用の `channel-read` を追加し、実際の実行トークンで選択したWindows標準ローカルログを確認します。メタデータやACLとは独立して拒否・不在・空・イベント読取を区別し、トークンと環境の変化を検出して保護された限定レポートに記録します。Windows設定の変更やSigma利用可能数への加算は行いません。使い捨て標準ユーザーでServer 2022/2025・PowerShell 5.1/7の拒否と読取を検証します。 (#432) (@Shirofune-Security)
- 専用 CA 監査設定の再起動待ちを確認し、明示的に再開する `adcs-resume` を追加。元の記録・結果、出典、現在の CA と実行者、永続的な意図記録、変更直前・最終確認により古い計画の再実行を拒否し、観測した設定を保持します。ドライランと失敗を区別し、イベントや Sigma の証明は加算しません。使い捨て CA テストでは最初の再起動拒否のみを注入し、公開 CLI による実際の再起動と要求イベントを検証します。既存の専用 CA Configure のドライラン引数制限も修正しました。 (#431) (@Shirofune-Security)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 997bf1ac..46dc2c34 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,7 +4,7 @@
**Improvements:**
-- Added opt-in `file-sacl-recovery` to review and remove one proven explicit audit ACE from an unchanged selected leaf file. Original plans, paired receipts, successful results, source/host hashes and held file identity bind the operation; durable pre-write evidence and SACL-only readback preserve unrelated ACE bytes/counts and observed descriptor components. Empty present SACLs are reported without claiming exact historical descriptor restoration; policy, directory/registry recovery and Sigma readiness remain outside this scope. (#437) (@Shirofune-Security)
+- Added opt-in `file-sacl-recovery` to review and remove one proven explicit audit ACE from an unchanged selected leaf file. Original plans, paired receipts, successful results, source/host hashes and held file identity bind the operation; durable pre-write evidence and SACL-only readback preserve unrelated ACE bytes/counts and observed descriptor components. Empty or null present SACLs are reported without claiming exact historical descriptor restoration; policy, directory/registry recovery and Sigma readiness remain outside this scope. (#437) (@Shirofune-Security)
- 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)
- Added `adcs-resume` to review and explicitly resume a dedicated pending CA auditing restart. Original journal/results, source and current CA/operator fingerprints, durable intent receipts and fresh/final checks prevent stale-plan replay and preserve observed settings. Dry-run and failed attempts remain explicit, with no event/Sigma credit. Disposable CA tests inject the initial refusal then verify an actual public-CLI restart and request events. Also fixed the existing dedicated CA Configure dry-run CLI guard. (#431) (@Shirofune-Security)
diff --git a/docs/file-sacl-recovery.md b/docs/file-sacl-recovery.md
index c971ea72..2b921e18 100644
--- a/docs/file-sacl-recovery.md
+++ b/docs/file-sacl-recovery.md
@@ -44,7 +44,7 @@ $hash = (Get-FileHash $plan -Algorithm SHA256).Hash.ToLowerInvariant()
Before mutation, `reviewed-plan.json` and `pending.json` are created exclusively, flushed to disk, reopened and hashed. Implementation, operator, host, original input files and reviewed plan are rechecked. The native helper holds a file handle without delete sharing, rejects directories and reparse files, verifies its final path and actual identity, and rereads the exact descriptor. It submits only `SACL_SECURITY_INFORMATION` to remove the unique proven ACE. Temporary `SeSecurityPrivilege` state is restored.
-Afterwards WELA reads the held file and reopens the path, checks identity, unrelated ACE bytes/counts, SACL revision/presence, owner/group, DACL, control flags and resource-manager control, then rechecks sources/evidence and reopens once more. Descriptor observations cover WinSDK-defined sections `0x1ff`; future sections are unobserved. Windows security-descriptor operations are not an atomic compare-and-swap against another administrator. Quiesce concurrent ACL writers; the guards detect observed drift, not an arbitrarily timed competing write.
+Afterwards WELA reads the held file and reopens the path, checks identity, unrelated ACE bytes/counts, SACL presence, revision when an ACL remains, owner/group, DACL, control flags and resource-manager control, then rechecks sources/evidence and reopens once more. Descriptor observations cover WinSDK-defined sections `0x1ff`; future sections are unobserved. Windows security-descriptor operations are not an atomic compare-and-swap against another administrator. Quiesce concurrent ACL writers; the guards detect observed drift, not an arbitrarily timed competing write.
`result.json` reports:
@@ -54,7 +54,7 @@ Afterwards WELA reads the held file and reopens the path, checks identity, unrel
| `Refused` | The operation failed before any native write attempt. |
| `WriteAttemptedUnverified` | A native write was attempted but complete final verification failed. Retain evidence and inspect manually. |
-Removing the final audit ACE may leave an **empty present SACL** even if the historical descriptor had no SACL. This is an ACE-removal result, not a byte-for-byte restoration of the historical descriptor. `OriginalDescriptorBytesMatch` is only an observation; exact historical descriptor equality and original ACE ordering are not promised. Unrelated ACE bytes and counts are preserved. WELA does not automatically re-add the ACE after partial failure. No outcome grants rule-readiness credit.
+Removing the final audit ACE may leave an **empty or null present SACL** even if the historical descriptor had no SACL. Windows may retain `SACL_PRESENT` while returning no ACL pointer (`PresentNull`); this is accepted only when the removed ACE was the sole original ACE and all outside control/header fields still match. `SaclBefore` and `SaclAfter` record the observed representation and available ACL revision. This is an ACE-removal result, not a byte-for-byte restoration of the historical descriptor. `OriginalDescriptorBytesMatch` is only an observation; exact historical descriptor equality and original ACE ordering are not promised. Unrelated ACE bytes and counts are preserved. WELA does not automatically re-add the ACE after partial failure. No outcome grants rule-readiness credit.
## Validation and limits
diff --git a/scripts/FileSaclRecovery.ps1 b/scripts/FileSaclRecovery.ps1
index 603c7eb4..cb601620 100644
--- a/scripts/FileSaclRecovery.ps1
+++ b/scripts/FileSaclRecovery.ps1
@@ -117,7 +117,7 @@ function New-WelaFileSaclRecoveryPlan {
if ((Get-WelaSelectedSaclSnapshotKey $current) -cne (Get-WelaSelectedSaclSnapshotKey $confirmed.After)) {throw 'Current file identity or descriptor differs from the completed operation; manual review required.'}
if ((Get-WelaSelectedSaclSnapshotKey (Get-WelaFileSaclRecoverySnapshot $row.Definition)) -cne (Get-WelaSelectedSaclSnapshotKey $current)) {throw 'File changed during recovery planning.'}
$inputFiles=[ordered]@{};foreach ($name in $files.Keys) {$file=$files[$name];$inputFiles[$name]=[pscustomobject]@{Path=$file.Path;Sha256=$file.Sha256;Bytes=$file.Bytes}}
- $recovery=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaFileSaclRecoveryPlan';Id=$row.Id;Profile=$plan.Profile;Operator=$operator;ContextKey=$context.Key;Sources=$sources;OriginalFiles=[pscustomobject]$inputFiles;Definition=$row.Definition;BeforeAddition=$row.Before;Expected=$current;AddedAce=$added;Outcome='Remove one proven explicit ordinary audit ACE; an empty present SACL can remain.';ReadyRuleCredit=0}
+ $recovery=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaFileSaclRecoveryPlan';Id=$row.Id;Profile=$plan.Profile;Operator=$operator;ContextKey=$context.Key;Sources=$sources;OriginalFiles=[pscustomobject]$inputFiles;Definition=$row.Definition;BeforeAddition=$row.Before;Expected=$current;AddedAce=$added;Outcome='Remove one proven explicit ordinary audit ACE; an empty or null present SACL can remain.';ReadyRuleCredit=0}
Assert-WelaFileSaclRecoveryFresh $recovery
$recovery
}
@@ -144,7 +144,7 @@ function Invoke-WelaFileSaclRecovery {
if ((Get-WelaFileSaclRecoveryKey $plan) -cne (Get-WelaFileSaclRecoveryKey $rebuilt)) {throw 'Reviewed recovery plan is stale or modified.'}
if ($DryRun) {return [pscustomobject]@{Status='WouldRemoveAddedAce';ExitCode=0;Target=$plan.Definition.Path;ReadyRuleCredit=0}}
$output=New-WelaArrivalOutput -Path $OutputPath -SourcePath (Split-Path $reviewed.Path -Parent)
- $report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaFileSaclRecoveryResult';Status='Refused';ExitCode=1;StartedUtc=[DateTime]::UtcNow.ToString('o');CompletedUtc=$null;PlanHash=$PlanHash;Before=$plan.Expected;After=$null;WriteAttempted=$false;Artifacts=@();OriginalDescriptorBytesMatch=$false;Diagnostic='';OutputPath=$output;ReadyRuleCredit=0;PolicyChanges=0;Scope='Remove only one proven explicit leaf-file audit ACE; preserve other ACE bytes/counts and observed descriptor components. No descendant, exact historical descriptor, event or Sigma claim.'}
+ $report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaFileSaclRecoveryResult';Status='Refused';ExitCode=1;StartedUtc=[DateTime]::UtcNow.ToString('o');CompletedUtc=$null;PlanHash=$PlanHash;Before=$plan.Expected;After=$null;SaclBefore=[Wela.FileSaclRecovery.Descriptor]::SaclRepresentation($plan.Expected.DescriptorBase64);SaclAfter=$null;WriteAttempted=$false;Artifacts=@();OriginalDescriptorBytesMatch=$false;Diagnostic='';OutputPath=$output;ReadyRuleCredit=0;PolicyChanges=0;Scope='Remove only one proven explicit leaf-file audit ACE; preserve other ACE bytes/counts and observed descriptor components. No descendant, exact historical descriptor, event or Sigma claim.'}
$target=$null
try {
$report.Artifacts+=Write-WelaFileSaclRecoveryArtifact $output 'reviewed-plan.json' (Get-WelaFileSaclRecoveryKey $plan)
@@ -153,7 +153,7 @@ function Invoke-WelaFileSaclRecovery {
if ((Read-WelaFileSaclRecoveryInput $reviewed.Path).Sha256 -cne $PlanHash) {throw 'Reviewed recovery plan changed before write.'}
Initialize-WelaFileSaclRecoveryNative
$target=[Wela.FileSaclRecovery.Target]::new((Resolve-WelaSelectedSaclNativePath $plan.Definition))
- try {$report.After=$target.Remove($plan.Expected.Identity,$plan.Expected.DescriptorBase64,$plan.AddedAce)} finally {$report.WriteAttempted=$target.WriteAttempted}
+ try {$report.After=$target.Remove($plan.Expected.Identity,$plan.Expected.DescriptorBase64,$plan.AddedAce)} finally {$report.WriteAttempted=$target.WriteAttempted;if ($target.AfterObservation) {$report.After=$target.AfterObservation;$report.SaclAfter=[Wela.FileSaclRecovery.Descriptor]::SaclRepresentation($report.After.DescriptorBase64)}}
$target.Dispose();$target=$null
$fresh=Get-WelaFileSaclRecoverySnapshot $plan.Definition
if ((Get-WelaSelectedSaclSnapshotKey $fresh) -cne (Get-WelaSelectedSaclSnapshotKey $report.After)) {throw 'File identity or descriptor changed after removal.'}
diff --git a/scripts/FileSaclRecoveryNative.cs b/scripts/FileSaclRecoveryNative.cs
index ffb781c7..ed4e09c2 100644
--- a/scripts/FileSaclRecoveryNative.cs
+++ b/scripts/FileSaclRecoveryNative.cs
@@ -48,13 +48,21 @@ namespace Wela.FileSaclRecovery {
}
public static void Removed(string beforeBytes,string afterBytes,string added) {
RawSecurityDescriptor before=Parse(beforeBytes),after=Parse(afterBytes);Outside(before,after,false);
- if(before.SystemAcl==null||after.SystemAcl==null||before.SystemAcl.Revision!=after.SystemAcl.Revision)throw new InvalidOperationException("SACL revision or presence changed during removal.");
+ if(before.SystemAcl==null)throw new InvalidOperationException("Original SACL is absent.");
+ if(after.SystemAcl==null){if(before.SystemAcl.Count!=1)throw new InvalidOperationException("A null SACL would lose unrelated audit ACEs.");}
+ else if(before.SystemAcl.Revision!=after.SystemAcl.Revision)throw new InvalidOperationException("SACL revision changed during removal: "+before.SystemAcl.Revision+" to "+after.SystemAcl.Revision+" (after count "+after.SystemAcl.Count+").");
Dictionary expected=Counts(before.SystemAcl),actual=Counts(after.SystemAcl);
if(!expected.ContainsKey(added)||expected[added]!=1)throw new InvalidOperationException("The selected audit ACE is no longer unique.");
expected[added]--;
foreach(KeyValuePair entry in expected){int count=actual.ContainsKey(entry.Key)?actual[entry.Key]:0;if(count!=entry.Value)throw new InvalidOperationException("Unrelated audit ACEs changed during removal.");actual.Remove(entry.Key);}
if(actual.Count!=0)throw new InvalidOperationException("Unexpected ACE appeared during removal.");
}
+ public static string SaclRepresentation(string value) {
+ RawSecurityDescriptor sd=Parse(value);bool present=(sd.ControlFlags&ControlFlags.SystemAclPresent)!=0;
+ if(!present)return "Absent";
+ if(sd.SystemAcl==null)return "PresentNull";
+ return (sd.SystemAcl.Count==0?"PresentEmpty":"PresentWithAces")+";Revision="+sd.SystemAcl.Revision;
+ }
public static Snapshot Observe(string path,string identity,byte[] bytes) {
string encoded=Convert.ToBase64String(bytes);RawSecurityDescriptor sd=Parse(encoded);List entries=new List();
if(sd.SystemAcl!=null)foreach(GenericAce ace in sd.SystemAcl){CommonAce common=ace as CommonAce;bool ordinary=common!=null&&!common.IsCallback&&common.AceType==AceType.SystemAudit;entries.Add(new Ace {Binary=Bytes(ace),Type=(int)ace.AceType,Flags=(int)ace.AceFlags,Mask=ordinary?common.AccessMask:0,Sid=ordinary?common.SecurityIdentifier.Value:null,Ordinary=ordinary});}
@@ -91,7 +99,7 @@ namespace Wela.FileSaclRecovery {
[DllImport("advapi32.dll")] static extern uint GetSecurityInfo(IntPtr handle,uint kind,uint flags,out IntPtr owner,out IntPtr group,out IntPtr dacl,out IntPtr sacl,out IntPtr descriptor);
[DllImport("advapi32.dll")] static extern uint GetSecurityDescriptorLength(IntPtr descriptor);
[DllImport("advapi32.dll")] static extern uint SetSecurityInfo(IntPtr handle,uint kind,uint flags,IntPtr owner,IntPtr group,IntPtr dacl,IntPtr sacl);
- readonly string path;IntPtr handle;Privilege privilege;public bool WriteAttempted {get;private set;}
+ readonly string path;IntPtr handle;Privilege privilege;public bool WriteAttempted {get;private set;}public Snapshot AfterObservation {get;private set;}
public Target(string path){this.path=path;try{privilege=new Privilege();handle=CreateFile(path,0x01020000,3,IntPtr.Zero,3,0x02200000,IntPtr.Zero);if(handle==new IntPtr(-1)){int error=Marshal.GetLastWin32Error();handle=IntPtr.Zero;throw new Win32Exception(error);}Check();}catch{Dispose();throw;}}
string Check(){if(handle==IntPtr.Zero)throw new ObjectDisposedException("Target");FileInfo info;if(!GetFileInformationByHandle(handle,out info))throw new Win32Exception(Marshal.GetLastWin32Error());if((info.Attributes&0x410)!=0)throw new InvalidOperationException("Directories and reparse files are unsupported.");StringBuilder final=new StringBuilder(32768);uint length=GetFinalPathNameByHandle(handle,final,(uint)final.Capacity,0);if(length==0||length>=final.Capacity||!String.Equals(final.ToString(),"\\\\?\\"+path,StringComparison.OrdinalIgnoreCase))throw new InvalidOperationException("Final held file path differs from the reviewed path.");return info.Volume+":"+info.IndexHigh+":"+info.IndexLow+":"+info.Created;}
public Snapshot Read(){string identity=Check();IntPtr owner,group,dacl,sacl,descriptor;uint error=GetSecurityInfo(handle,1,511,out owner,out group,out dacl,out sacl,out descriptor);if(error!=0)throw new Win32Exception((int)error,"Full SDK-defined file descriptor read failed.");byte[] bytes;try{uint size=GetSecurityDescriptorLength(descriptor);if(size<20||size>1048576)throw new InvalidOperationException("Invalid descriptor size.");bytes=new byte[size];Marshal.Copy(descriptor,bytes,0,(int)size);}finally{LocalFree(descriptor);}if(Check()!=identity)throw new InvalidOperationException("Held file identity changed.");return Descriptor.Observe(path,identity,bytes);}
@@ -103,7 +111,7 @@ namespace Wela.FileSaclRecovery {
if(ace==null||ace.IsCallback||ace.AceType!=AceType.SystemAudit||((int)ace.AceFlags!=64&&(int)ace.AceFlags!=128&&(int)ace.AceFlags!=192))throw new InvalidOperationException("Only an explicit ordinary audit ACE can be removed.");
sd.SystemAcl.RemoveAce(index);byte[] bytes=new byte[sd.SystemAcl.BinaryLength];sd.SystemAcl.GetBinaryForm(bytes,0);IntPtr buffer=Marshal.AllocHGlobal(bytes.Length);
try{Marshal.Copy(bytes,0,buffer,bytes.Length);WriteAttempted=true;uint error=SetSecurityInfo(handle,1,8,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,buffer);if(error!=0)throw new Win32Exception((int)error,"SACL-only removal failed.");}finally{Marshal.FreeHGlobal(buffer);}
- Snapshot after=Read();if(after.Identity!=before.Identity)throw new InvalidOperationException("File identity changed during removal.");Descriptor.Removed(before.DescriptorBase64,after.DescriptorBase64,added);return after;
+ Snapshot after=Read();AfterObservation=after;if(after.Identity!=before.Identity)throw new InvalidOperationException("File identity changed during removal.");Descriptor.Removed(before.DescriptorBase64,after.DescriptorBase64,added);return after;
}
public void Dispose(){try{if(handle!=IntPtr.Zero){CloseHandle(handle);handle=IntPtr.Zero;}}finally{if(privilege!=null){privilege.Dispose();privilege=null;}}}
}
diff --git a/tests/FileSaclRecovery.Descriptor.Tests.ps1 b/tests/FileSaclRecovery.Descriptor.Tests.ps1
index e822fd07..7f86dbd5 100644
--- a/tests/FileSaclRecovery.Descriptor.Tests.ps1
+++ b/tests/FileSaclRecovery.Descriptor.Tests.ps1
@@ -87,6 +87,14 @@ $duplicateAdded=[Wela.FileSaclRecovery.Descriptor]::AddedAce((Encode $duplicateO
[Wela.FileSaclRecovery.Descriptor]::Removed((Encode $duplicateAfter),(Encode $duplicateOld),$duplicateAdded)
Assert ($duplicateOld.SystemAcl.Count -eq 2) 'Duplicate unrelated ACEs are preserved.'
Throws {[Wela.FileSaclRecovery.Descriptor]::Removed((Encode $duplicateAfter),(Encode $old),$duplicateAdded)} 'Unrelated audit ACEs'
+# Windows can retain SACL_PRESENT with a null ACL after removing the sole ACE.
+$sole=Add-AuditAce $base (New-AuditAce)
+$soleAdded=[Wela.FileSaclRecovery.Descriptor]::AddedAce($before,(Encode $sole),'S-1-1-0',1,64)
+$presentNull=Clone $sole;$presentNull.SystemAcl=$null
+[Wela.FileSaclRecovery.Descriptor]::Removed((Encode $sole),(Encode $presentNull),$soleAdded)
+Assert ([Wela.FileSaclRecovery.Descriptor]::SaclRepresentation((Encode $presentNull)) -ceq 'PresentNull') 'Sole-ACE removal can retain present-null SACL with exact control fields.'
+Throws {[Wela.FileSaclRecovery.Descriptor]::Removed((Encode $after),(Encode $presentNull),$added)} 'lose unrelated'
+Throws {[Wela.FileSaclRecovery.Descriptor]::Removed((Encode $sole),(Encode $presentNull),'different-ACE')} 'no longer unique'
# Native object audit ACEs never qualify as the ordinary selected addition.
$objectBase=Clone $base;$objectBase.SystemAcl=[Security.AccessControl.RawAcl]::new(4,0);$objectBase.SetFlags($objectBase.ControlFlags -bor [Security.AccessControl.ControlFlags]::SystemAclPresent)
$objectAfter=Clone $objectBase
diff --git a/tests/FileSaclRecovery.Windows.Tests.ps1 b/tests/FileSaclRecovery.Windows.Tests.ps1
index 00a7cfee..feb89a0d 100644
--- a/tests/FileSaclRecovery.Windows.Tests.ps1
+++ b/tests/FileSaclRecovery.Windows.Tests.ps1
@@ -87,6 +87,8 @@ try {
$out=Join-Path $caseDir 'restored'
Run-Wela ($restore+@('-Auto','-FileSaclRecoveryOutputPath',$out))
$result=Json (Join-Path $out 'result.json');$after=Get-WelaSelectedSaclSnapshot $definition
+ Assert ($result.SaclAfter -ceq [Wela.FileSaclRecovery.Descriptor]::SaclRepresentation($after.DescriptorBase64)) 'Reported final SACL representation matches actual reopened native bytes.'
+ Write-Host ("Native SACL representation: "+$result.SaclBefore+' -> '+$result.SaclAfter)
Assert ($result.Status -ceq 'AddedAceRemoved' -and $result.WriteAttempted -and $result.ExitCode -eq 0 -and $result.PolicyChanges -eq 0) 'Public recovery performs and verifies only the proven added ACE removal.'
[Wela.FileSaclRecovery.Descriptor]::Removed($afterAddition.DescriptorBase64,$after.DescriptorBase64,$plan.AddedAce)
Assert ($before.Identity -ceq $after.Identity -and $before.Owner -ceq $after.Owner -and $before.Group -ceq $after.Group -and $before.DaclBase64 -ceq $after.DaclBase64 -and $before.Aces.Count -eq $after.Aces.Count) 'Actual reopened leaf preserves identity, owner/group/DACL and unrelated ACE counts.'
diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md
index 1dae6372..23e85947 100644
--- a/website/docs/resources/changelog.ja.md
+++ b/website/docs/resources/changelog.ja.md
@@ -7,7 +7,7 @@
**改善:**
-- `file-sacl-recovery` を追加しました。元の計画、変更前後のレシート、成功結果、ソースとホストのハッシュ、実際のファイル識別情報を照合し、変更されていない選択済み単一ファイルに追加した監査 ACE だけを明示的に削除できます。書き込み前の永続記録と SACL 限定の変更・再読込により、無関係な ACE のバイト列と個数、観測した他の記述子要素を保持します。空の SACL が残る場合を区別し、元の記述子との完全一致、ポリシー・ディレクトリ・レジストリの復旧、Sigma の利用可能性は保証しません。(#437) (@Shirofune-Security)
+- `file-sacl-recovery` を追加しました。元の計画、変更前後のレシート、成功結果、ソースとホストのハッシュ、実際のファイル識別情報を照合し、変更されていない選択済み単一ファイルに追加した監査 ACE だけを明示的に削除できます。書き込み前の永続記録と SACL 限定の変更・再読込により、無関係な ACE のバイト列と個数、観測した他の記述子要素を保持します。空または null の SACL が存在フラグ付きで残る場合を区別し、元の記述子との完全一致、ポリシー・ディレクトリ・レジストリの復旧、Sigma の利用可能性は保証しません。(#437) (@Shirofune-Security)
- 読み取り専用の `channel-read` を追加し、実際の実行トークンで選択したWindows標準ローカルログを確認します。メタデータやACLとは独立して拒否・不在・空・イベント読取を区別し、トークンと環境の変化を検出して保護された限定レポートに記録します。Windows設定の変更やSigma利用可能数への加算は行いません。使い捨て標準ユーザーでServer 2022/2025・PowerShell 5.1/7の拒否と読取を検証します。 (#432) (@Shirofune-Security)
- 専用 CA 監査設定の再起動待ちを確認し、明示的に再開する `adcs-resume` を追加。元の記録・結果、出典、現在の CA と実行者、永続的な意図記録、変更直前・最終確認により古い計画の再実行を拒否し、観測した設定を保持します。ドライランと失敗を区別し、イベントや Sigma の証明は加算しません。使い捨て CA テストでは最初の再起動拒否のみを注入し、公開 CLI による実際の再起動と要求イベントを検証します。既存の専用 CA Configure のドライラン引数制限も修正しました。 (#431) (@Shirofune-Security)
diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md
index 8d872f13..21670da1 100644
--- a/website/docs/resources/changelog.md
+++ b/website/docs/resources/changelog.md
@@ -7,7 +7,7 @@
**Improvements:**
-- Added opt-in `file-sacl-recovery` to review and remove one proven explicit audit ACE from an unchanged selected leaf file. Original plans, paired receipts, successful results, source/host hashes and held file identity bind the operation; durable pre-write evidence and SACL-only readback preserve unrelated ACE bytes/counts and observed descriptor components. Empty present SACLs are reported without claiming exact historical descriptor restoration; policy, directory/registry recovery and Sigma readiness remain outside this scope. (#437) (@Shirofune-Security)
+- Added opt-in `file-sacl-recovery` to review and remove one proven explicit audit ACE from an unchanged selected leaf file. Original plans, paired receipts, successful results, source/host hashes and held file identity bind the operation; durable pre-write evidence and SACL-only readback preserve unrelated ACE bytes/counts and observed descriptor components. Empty or null present SACLs are reported without claiming exact historical descriptor restoration; policy, directory/registry recovery and Sigma readiness remain outside this scope. (#437) (@Shirofune-Security)
- 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)
- Added `adcs-resume` to review and explicitly resume a dedicated pending CA auditing restart. Original journal/results, source and current CA/operator fingerprints, durable intent receipts and fresh/final checks prevent stale-plan replay and preserve observed settings. Dry-run and failed attempts remain explicit, with no event/Sigma credit. Disposable CA tests inject the initial refusal then verify an actual public-CLI restart and request events. Also fixed the existing dedicated CA Configure dry-run CLI guard. (#431) (@Shirofune-Security)
From 58231e61f0978ec74b26e59c91132029332d60e4 Mon Sep 17 00:00:00 2001
From: Shirofune-Security
<43838376+Shirofune-Security@users.noreply.github.com>
Date: Mon, 21 Sep 2026 10:27:12 +0900
Subject: [PATCH 16/18] Select the native-layout type overload in ABI fixtures
---
tests/DnsClientProbe.Tests.ps1 | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests/DnsClientProbe.Tests.ps1 b/tests/DnsClientProbe.Tests.ps1
index 12b45614..3bb3c4bd 100644
--- a/tests/DnsClientProbe.Tests.ps1
+++ b/tests/DnsClientProbe.Tests.ps1
@@ -8,7 +8,7 @@ Add-Type -Path (Join-Path $ScriptRoot 'scripts/DnsClientProbeNative.cs')
foreach($resolver in @('127.0.0.1','192.0.2.53','10.0.0.53')){Assert-WelaDnsClientResolver $resolver;Assert ([Wela.DnsClientProbe.Native]::ValidateResolver($resolver) -ceq $resolver) 'Explicit canonical IPv4 accepted.'}
foreach($resolver in @('','localhost','127.1','127.0.0.01','127.0.0.1:53','127.0.0.1"','0.0.0.0','224.0.0.1','255.255.255.255','192.0.2.999','::1')){Throws {Assert-WelaDnsClientResolver $resolver} 'IPv4';Throws {[Wela.DnsClientProbe.Native]::ValidateResolver($resolver)} 'resolver|IPv4'}
Throws {[Wela.DnsClientProbe.Native]::Query('arbitrary.example.','127.0.0.1')} 'fixed random'
-foreach($entry in @(@('Request',64),@('QueryResult',32),@('Record',32))){$nativeType=[Wela.DnsClientProbe.Native].GetNestedType($entry[0],[Reflection.BindingFlags]::NonPublic);Assert ([Runtime.InteropServices.Marshal]::SizeOf($nativeType) -eq $entry[1]) ('Native64 SDK layout: '+$entry[0])}
+foreach($entry in @(@('Request',64),@('QueryResult',32),@('Record',32))){$nativeType=[Wela.DnsClientProbe.Native].GetNestedType($entry[0],[Reflection.BindingFlags]::NonPublic);Assert ([Runtime.InteropServices.Marshal].GetMethod('SizeOf',[type[]]@([type])).Invoke($null,@($nativeType)) -eq $entry[1]) ('Native64 SDK layout: '+$entry[0])}
Assert ([Wela.DnsClientProbe.Native].GetField('SourceSha256').IsLiteral) 'Compiled source fingerprint cannot be reassigned.'
Assert ([Wela.DnsClientProbe.Native]::Options -eq 2103790) 'Fixed documented DNS flags retained.'
$fields=@(foreach($name in @('QueryName','QueryType','QueryOptions','QueryStatus','QueryResults')){[pscustomobject]@{Name=$name;InType=$(if($name -in @('QueryName','QueryResults')){'win:UnicodeString'}elseif($name -eq 'QueryOptions'){'win:UInt64'}else{'win:UInt32'})}})
From cddafd04e3bd7692c9773d2f4ef3630e0c81e84d Mon Sep 17 00:00:00 2001
From: Shirofune-Security
<43838376+Shirofune-Security@users.noreply.github.com>
Date: Mon, 21 Sep 2026 10:41:30 +0900
Subject: [PATCH 17/18] Bind documented DNS query export exactly and match
Microsoft server buffer
---
scripts/DnsClientProbeNative.cs | 12 +++++++-----
tests/DnsClientProbe.Diagnostics.ps1 | 11 +++++++++++
tests/DnsClientProbe.Tests.ps1 | 2 ++
tests/DnsClientProbe.Windows.Tests.ps1 | 4 ++++
4 files changed, 24 insertions(+), 5 deletions(-)
create mode 100644 tests/DnsClientProbe.Diagnostics.ps1
diff --git a/scripts/DnsClientProbeNative.cs b/scripts/DnsClientProbeNative.cs
index 6323d0c9..369da352 100644
--- a/scripts/DnsClientProbeNative.cs
+++ b/scripts/DnsClientProbeNative.cs
@@ -17,7 +17,8 @@ namespace Wela.DnsClientProbe {
}
[StructLayout(LayoutKind.Sequential)] struct QueryResult { public uint Version,Status; public ulong Options; public IntPtr Records,Reserved; }
[StructLayout(LayoutKind.Sequential)] struct Record { public IntPtr Next,Name; public ushort Type,Length; public uint Flags,Ttl,Reserved; }
- [DllImport("dnsapi.dll",CharSet=CharSet.Unicode)] static extern uint DnsQueryEx(ref Request request,ref QueryResult result,IntPtr cancel);
+ // DnsQueryEx is the documented exact export; do not allow a W-suffixed name probe.
+ [DllImport("dnsapi.dll",EntryPoint="DnsQueryEx",ExactSpelling=true)] static extern uint DnsQueryEx(ref Request request,ref QueryResult result,IntPtr cancel);
[DllImport("dnsapi.dll")] static extern void DnsRecordListFree(IntPtr records,int freeType);
public static string ValidateResolver(string resolver) {
if(resolver==null||!Regex.IsMatch(resolver,@"^(0|[1-9][0-9]{0,2})(\.(0|[1-9][0-9]{0,2})){3}$"))throw new ArgumentException("One canonical dotted-decimal IPv4 resolver is required.");
@@ -30,10 +31,11 @@ namespace Wela.DnsClientProbe {
if(name==null||!Regex.IsMatch(name,@"^wela-[a-f0-9]{32}\.wela\.test\.\z"))throw new ArgumentException("Only the fixed random probe name is accepted.");
ValidateResolver(resolver);
// SDK DNS_ADDR_ARRAY header32 + one DNS_ADDR64; sockaddr_in in its first16 bytes.
- // DNS_ADDR_ARRAY.MaxCount is the structure size in bytes; AddrCount is the element count.
- byte[] server=new byte[96];BitConverter.GetBytes((uint)server.Length).CopyTo(server,0);BitConverter.GetBytes((uint)1).CopyTo(server,4);
- BitConverter.GetBytes((ushort)2).CopyTo(server,12);BitConverter.GetBytes((ushort)2).CopyTo(server,32);
- server[34]=0;server[35]=53;IPAddress.Parse(resolver).GetAddressBytes().CopyTo(server,36);
+ // Match Microsoft Windows-classic-samples/DNSAsyncQuery CreateDnsServerList:
+ // one address, unspecified aggregate family, sockaddr IPv4 with default DNS port.
+ byte[] server=new byte[96];BitConverter.GetBytes((uint)1).CopyTo(server,0);BitConverter.GetBytes((uint)1).CopyTo(server,4);
+ BitConverter.GetBytes((ushort)2).CopyTo(server,32);
+ IPAddress.Parse(resolver).GetAddressBytes().CopyTo(server,36);
IntPtr servers=Marshal.AllocHGlobal(server.Length);QueryResult result=new QueryResult {Version=1};
try {
Marshal.Copy(server,0,servers,server.Length);
diff --git a/tests/DnsClientProbe.Diagnostics.ps1 b/tests/DnsClientProbe.Diagnostics.ps1
new file mode 100644
index 00000000..8fc49ebb
--- /dev/null
+++ b/tests/DnsClientProbe.Diagnostics.ps1
@@ -0,0 +1,11 @@
+# Temporary native ABI diagnostic: called only inside the explicitly gated owned DNS fixture.
+param([ValidateRange(0,4)][int]$Variant)
+$ErrorActionPreference='Stop'
+if($env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted' -or $env:OS -ne 'Windows_NT'){throw 'Disposable native fixture only.'}
+$source=[IO.File]::ReadAllText((Join-Path $PSScriptRoot '../scripts/DnsClientProbeNative.cs'))
+# Keep the fixed product query name/options and explicit loopback resolver. Vary only server buffer ABI.
+$variants=@(@(1,0,0),@(96,0,0),@(1,2,0),@(1,0,53),@(96,2,53))
+$v=$variants[$Variant]
+$source=$source.Replace('BitConverter.GetBytes((uint)1).CopyTo(server,0);',('BitConverter.GetBytes((uint)'+$v[0]+').CopyTo(server,0);BitConverter.GetBytes((ushort)'+$v[1]+').CopyTo(server,12);server[35]='+$v[2]+';'))
+Add-Type -TypeDefinition $source
+[pscustomobject]@{Variant=$Variant;MaxCount=$v[0];Family=$v[1];Port=$v[2];Result=[Wela.DnsClientProbe.Native]::Query(('wela-'+[guid]::NewGuid().ToString('N')+'.wela.test.'),'127.0.0.1')}|ConvertTo-Json -Depth 8 -Compress
diff --git a/tests/DnsClientProbe.Tests.ps1 b/tests/DnsClientProbe.Tests.ps1
index 3bb3c4bd..ed8737d8 100644
--- a/tests/DnsClientProbe.Tests.ps1
+++ b/tests/DnsClientProbe.Tests.ps1
@@ -10,6 +10,8 @@ foreach($resolver in @('','localhost','127.1','127.0.0.01','127.0.0.1:53','127.0
Throws {[Wela.DnsClientProbe.Native]::Query('arbitrary.example.','127.0.0.1')} 'fixed random'
foreach($entry in @(@('Request',64),@('QueryResult',32),@('Record',32))){$nativeType=[Wela.DnsClientProbe.Native].GetNestedType($entry[0],[Reflection.BindingFlags]::NonPublic);Assert ([Runtime.InteropServices.Marshal].GetMethod('SizeOf',[type[]]@([type])).Invoke($null,@($nativeType)) -eq $entry[1]) ('Native64 SDK layout: '+$entry[0])}
Assert ([Wela.DnsClientProbe.Native].GetField('SourceSha256').IsLiteral) 'Compiled source fingerprint cannot be reassigned.'
+$queryImport=[Wela.DnsClientProbe.Native].GetMethod('DnsQueryEx',[Reflection.BindingFlags]'NonPublic,Static').GetCustomAttributes([Runtime.InteropServices.DllImportAttribute],$false)[0]
+Assert ($queryImport.ExactSpelling -and $queryImport.EntryPoint -ceq 'DnsQueryEx') 'Bind the documented DnsQueryEx export exactly; no W suffix with a different ABI.'
Assert ([Wela.DnsClientProbe.Native]::Options -eq 2103790) 'Fixed documented DNS flags retained.'
$fields=@(foreach($name in @('QueryName','QueryType','QueryOptions','QueryStatus','QueryResults')){[pscustomobject]@{Name=$name;InType=$(if($name -in @('QueryName','QueryResults')){'win:UnicodeString'}elseif($name -eq 'QueryOptions'){'win:UInt64'}else{'win:UInt32'})}})
$state=[pscustomobject]@{Computer='host';Host=[pscustomobject]@{DomainJoined=$false;Domain='WORKGROUP'};Service='Running';Channel=[pscustomobject]@{State='Enabled';Name='Microsoft-Windows-DNS-Client/Operational';SecurityDescriptor='O:SYG:SYD:(A;;0x1;;;SY)';MetadataErrors=@{};Error=$null;IsEnabled=$true;MaximumSizeInBytes=1048576;LogMode='Circular'};Schema=[pscustomobject]@{State='Observed';Provider='Microsoft-Windows-DNS-Client';ProviderGuid='1c95126e-7eea-49a9-a3fe-a378b03ddb4d';ChannelType='Operational';Events=@([pscustomobject]@{Id=3008;Version=0;Channel='Microsoft-Windows-DNS-Client/Operational';Fields=$fields})}}
diff --git a/tests/DnsClientProbe.Windows.Tests.ps1 b/tests/DnsClientProbe.Windows.Tests.ps1
index a3c8a6f2..4102c78c 100644
--- a/tests/DnsClientProbe.Windows.Tests.ps1
+++ b/tests/DnsClientProbe.Windows.Tests.ps1
@@ -53,6 +53,10 @@ try {
$passed=$true;Write-Host "PASS: $script:count native DNS Client checks through $TestEngine."
}catch{
Write-Host ('Native DNS Client failure: '+($_|Out-String));Write-Host $_.ScriptStackTrace
+ if($zoneCreated){foreach($variant in 0..4){
+ $diagnostic=[Diagnostics.Process]::new();$info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$engine;$info.Arguments='-NoLogo -NoProfile -NonInteractive -File "'+(Join-Path $PSScriptRoot 'DnsClientProbe.Diagnostics.ps1')+'" -Variant '+$variant;$info.UseShellExecute=$false;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true;$diagnostic.StartInfo=$info
+ try{$null=$diagnostic.Start();$stdout=$diagnostic.StandardOutput.ReadToEndAsync();$stderr=$diagnostic.StandardError.ReadToEndAsync();if(-not $diagnostic.WaitForExit(20000)){throw 'Owned diagnostic worker timeout.'};if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),5000)){throw 'Diagnostic output timeout.'};Write-Host ('Owned ABI variant '+$variant+' exit '+$diagnostic.ExitCode);Write-Host $stdout.Result;Write-Host $stderr.Result}catch{Write-Host $_}finally{if(-not $diagnostic.HasExited){$diagnostic.Kill();$null=$diagnostic.WaitForExit(5000)};$diagnostic.Dispose()}
+ }}
# Small owned diagnostics only; avoid dumping unrelated channel payloads.
if(Test-Path (Join-Path $private 'evidence')){Get-ChildItem (Join-Path $private 'evidence') -File|Where-Object {$_.Name -in @('manifest.json','operation.json','worker.json') -or $_.Name -like 'candidate-*.xml'}|ForEach-Object{Write-Host $_.Name;Write-Host ([IO.File]::ReadAllText($_.FullName))}}
throw
From 2973eafb98095a5beba0eeb8ca8d67243c6bbae8 Mon Sep 17 00:00:00 2001
From: Shirofune-Security
<43838376+Shirofune-Security@users.noreply.github.com>
Date: Mon, 21 Sep 2026 10:47:06 +0900
Subject: [PATCH 18/18] Use precise native DNS operation timestamps and
nonce-only event reads
---
docs/dns-client-probe.md | 6 ++++--
scripts/DnsClientProbe.ps1 | 11 ++++++++---
scripts/DnsClientProbeNative.cs | 12 +++++++++---
scripts/DnsClientProbeWorker.ps1 | 6 +++---
tests/DnsClientProbe.Tests.ps1 | 6 ++++++
5 files changed, 30 insertions(+), 11 deletions(-)
diff --git a/docs/dns-client-probe.md b/docs/dns-client-probe.md
index d45abb43..2034a96d 100644
--- a/docs/dns-client-probe.md
+++ b/docs/dns-client-probe.md
@@ -14,12 +14,14 @@
The example address is documentation-only: replace it with an approved resolver. Plan creates no files and sends no probe lookup. Run generates exactly one application request for `wela-.wela.test.` type A; `.test` is reserved for DNS testing by [RFC 2606](https://www.rfc-editor.org/rfc/rfc2606.html). There is no caller-selected domain, record type or application connection to a returned address. A same-engine 64-bit worker uses synchronous `DnsQueryEx` with one explicit IPv4 DNS server, TCP port 53, recursion disabled, cache bypass, no hosts/local-name/NetBT/multicast fallback, fully qualified naming and IDN disabled. DNS retry/internal processing and normal response caching are OS behavior; this is not a promise of one wire packet, cache immutability or resolver-side enforcement. The query name, selected resolver and exact flags are retained. Only canonical unicast IPv4 literals are accepted; there is no hostname or configurable port.
-The bounded worker has twenty seconds to finish. Terminating it does not prove cancellation of DNS service or network work; timed-out completion remains unverified. The separate event wait defaults to fifteen seconds (`-DnsClientProbeTimeoutSeconds 1..30`). Native status 0 (A answers), 9003 (NXDOMAIN) and 9501 (no records) are reviewed completion outcomes. A negative response is not reported as successful name resolution. Missing events, unknown outcomes/versions/types, caps, token or configuration/source drift and incomplete reads remain `Unverified` with a nonzero exit. No setup is automatically performed to make the test pass.
+The bounded worker has twenty seconds to finish. Parent/worker timestamps use [GetSystemTimePreciseAsFileTime](https://learn.microsoft.com/en-us/windows/win32/api/sysinfoapi/nf-sysinfoapi-getsystemtimepreciseasfiletime), with no coarse-clock fallback or positive-match time padding. Terminating the worker does not prove cancellation of DNS service or network work; timed-out completion remains unverified. The separate event wait defaults to fifteen seconds (`-DnsClientProbeTimeoutSeconds 1..30`). Native status 0 (A answers), 9003 (NXDOMAIN) and 9501 (no records) are reviewed completion outcomes. A negative response is not reported as successful name resolution. Missing events, unknown outcomes/versions/types, caps, token or configuration/source drift and incomplete reads remain `Unverified` with a nonzero exit. No setup is automatically performed to make the test pass.
-Evidence includes observed build/patch/role, token and same-engine context, exact provider GUID, live event/version/field types and template hashes, original pinned rule hashes, channel metadata, a pre-query record boundary, bounded original worker JSON (also retained if its validation fails), worker timestamps/status/answers and hashed original matched XML. Matching requires event 3008 version 0 on **Microsoft-Windows-DNS-Client/Operational**, source computer, unique query name/type, native completion status, requested option bits, record boundary and operation time. The emitter PID is retained in original XML; it may belong to the DNS service broker, so it is not assumed to equal the requesting worker PID. This correlation does not prove exclusive request attribution, the wire destination, DNSSEC validation or absence of simultaneous unrelated events. Full caller token snapshots bracket actual query/event I/O and are compared before final metadata inventory; the worker has its own exact before/after token checks. Metadata inventories are outside this interval because DISM and channel inspection may temporarily adjust privileges. Native event-query status is retained separately from its records. Artifact hashes detect byte changes; they are not signatures or historical host authentication.
+Evidence includes observed build/patch/role, token and same-engine context, exact provider GUID, live event/version/field types and template hashes, original pinned rule hashes, channel metadata, a pre-query record boundary, bounded original worker JSON (also retained if its validation fails), worker timestamps/status/answers and hashed original matched XML. Matching requires event 3008 version 0 on **Microsoft-Windows-DNS-Client/Operational**, source computer, unique query name/type, native completion status, requested option bits, record boundary and operation time. The emitter PID is retained in original XML; it may belong to the DNS service broker, so it is not assumed to equal the requesting worker PID. This correlation does not prove exclusive request attribution, the wire destination, DNSSEC validation or absence of simultaneous unrelated events. Full caller token snapshots bracket actual query/event I/O and are compared before final metadata inventory; the worker has its own exact before/after token checks. Metadata inventories are outside this interval because DISM and channel inspection may temporarily adjust privileges. The native read is limited to this random query name, record boundary and last sixty seconds; any retained candidate outside the exact operation interval is diagnostic only. Native event-query status is retained separately from its records. Artifact hashes detect byte changes; they are not signatures or historical host authentication.
`PrerequisitesObserved` means only that Plan observed supported metadata. `NativeDnsLookupObserved` means that a native completion and matching local event were observed. Neither proves forwarding, downstream parsing, detection execution or retention capacity. In particular, all six pinned DNS Client rules refer to **Microsoft-Windows-DNS Client Events/Operational**, a different channel string. WELA retains that mismatch and does not rewrite it. `ReadyRuleCredit` remains **0**; there is no six-rule Sigma uplift.
Native acceptance uses a separately opt-in fixture on disposable GitHub-hosted workgroup Server 2022/2025 under Windows PowerShell 5.1 and PowerShell 7. The fixture refuses an existing DNS role, installs its own standalone role, creates authoritative `wela.test` with a wildcard A record to `192.0.2.1`, and queries only loopback. It temporarily enables the Client channel if needed, restores its exact original settings, checks audit policies, removes its owned zone/records and removes only newly installed DNS features. Feature removal may require VM disposal rather than a live reboot; the cleanup receipt records that boundary. Fixture setup is not part of the product. Windows 11, domain-joined/DC/ADCS hosts and external resolver/network behavior still require their own acceptance evidence.
+The P/Invoke entry point is exactly `DnsQueryEx`, preventing [Unicode suffix probing](https://learn.microsoft.com/en-us/dotnet/standard/native-interop/specifying-a-character-set). The server-address buffer follows [Microsoft’s DNSAsyncQuery sample](https://github.com/microsoft/Windows-classic-samples/blob/main/Samples/DNSAsyncQuery/cpp/DnsQueryEx.cpp): one element, zero aggregate family, and the sockaddr default DNS port.
+
Native API references: [DnsQueryEx](https://learn.microsoft.com/en-us/windows/win32/api/windns/nf-windns-dnsqueryex), [DNS_QUERY_REQUEST](https://learn.microsoft.com/en-us/windows/win32/api/windns/ns-windns-dns_query_request), [DNS_ADDR_ARRAY](https://learn.microsoft.com/en-us/windows/win32/api/windnsdef/ns-windnsdef-dns_addr_array), [DNS query flags](https://learn.microsoft.com/en-us/windows/win32/dns/dns-constants), and the [Microsoft Windows SDK declarations](https://github.com/microsoft/win32metadata/blob/main/generation/WinSDK/RecompiledIdlHeaders/um/WinDNS.h).
diff --git a/scripts/DnsClientProbe.ps1 b/scripts/DnsClientProbe.ps1
index 4f64ca48..fc4f76ab 100644
--- a/scripts/DnsClientProbe.ps1
+++ b/scripts/DnsClientProbe.ps1
@@ -59,6 +59,7 @@ function Get-WelaDnsClientProbeWatermark {
}
function Start-WelaDnsClientProbeQuery {
param($State,[string]$Resolver,[string]$QueryName,$Report)
+ Initialize-WelaDnsClientProbeNative
$fresh=Get-WelaDnsClientProbeState
if((Get-WelaDnsClientProbeStateKey $fresh) -cne (Get-WelaDnsClientProbeStateKey $State)){throw 'DNS prerequisites changed before query.'}
$boundary=Get-WelaDnsClientProbeWatermark
@@ -67,7 +68,7 @@ function Start-WelaDnsClientProbeQuery {
$info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$State.Engine;$info.Arguments='-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "'+$worker+'" -Resolver "'+$Resolver+'" -QueryName "'+$QueryName+'"';$info.UseShellExecute=$false;$info.CreateNoWindow=$true;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true;$info.StandardOutputEncoding=[Text.UTF8Encoding]::new($false,$true);$info.StandardErrorEncoding=[Text.UTF8Encoding]::new($false,$true)
$process=[Diagnostics.Process]::new();$process.StartInfo=$info;$started=$false
try{
- $launch=[DateTimeOffset]::UtcNow;$started=$process.Start();if(-not $started){throw 'DNS probe worker did not start.'}
+ $launch=[DateTimeOffset][Wela.DnsClientProbe.Native]::UtcNow();$started=$process.Start();if(-not $started){throw 'DNS probe worker did not start.'}
$output=$process.StandardOutput.ReadToEndAsync();$errorText=$process.StandardError.ReadToEndAsync()
if(-not $process.WaitForExit(20000)){throw 'DNS query worker exceeded twenty seconds; operation completion is unverified.'}
if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($output,$errorText),5000)){throw 'DNS worker output did not finish.'}
@@ -79,7 +80,7 @@ function Start-WelaDnsClientProbeQuery {
if($operation.ProcessId -ne $process.Id -or $operation.Query.QueryName -cne $QueryName -or $operation.Query.Resolver -cne $Resolver -or $operation.Query.Options -ne 2103790 -or $operation.Query.Status -ne $operation.Query.ResultStatus -or $operation.Query.Status -notin @(0,9003,9501)){throw ('Unexpected DNS worker response or unsupported native outcome: PID='+$operation.ProcessId+' expectedPID='+$process.Id+' options='+$operation.Query.Options+' APIstatus='+$operation.Query.Status+' resultStatus='+$operation.Query.ResultStatus)}
$begin=ConvertTo-WelaArrivalUtc $operation.StartedUtc;$end=ConvertTo-WelaArrivalUtc $operation.CompletedUtc
$operation.StartedUtc=$begin.UtcDateTime.ToString('o');$operation.CompletedUtc=$end.UtcDateTime.ToString('o')
- if($begin -lt $launch -or $end -lt $begin -or $end -gt [DateTimeOffset]::UtcNow -or ($end-$begin).TotalSeconds -gt 20){throw 'Invalid DNS operation timestamps.'}
+ if($operation.Clock -cne 'GetSystemTimePreciseAsFileTime' -or $begin -lt $launch -or $end -lt $begin -or $end -gt [DateTimeOffset][Wela.DnsClientProbe.Native]::UtcNow() -or ($end-$begin).TotalSeconds -gt 20){throw 'Invalid DNS operation timestamps.'}
if((Get-WelaChannelReadKey $operation.BeforeToken) -cne (Get-WelaChannelReadKey $operation.AfterToken) -or (Get-WelaDnsClientProbeReaderKey $operation.BeforeToken) -cne (Get-WelaDnsClientProbeReaderKey $callerBefore)){throw 'DNS worker token differs from observed caller or changed.'}
$operation|Add-Member NoteProperty RecordIdBefore $boundary
$operation|Add-Member NoteProperty CallerBefore $callerBefore
@@ -89,7 +90,11 @@ function Start-WelaDnsClientProbeQuery {
function Read-WelaDnsClientProbeEvents {
param($Operation)
$channel='Microsoft-Windows-DNS-Client/Operational'
- $xpath="*[System[Provider[@Name='Microsoft-Windows-DNS-Client'] and EventID=3008 and EventRecordID>$($Operation.RecordIdBefore) and TimeCreated[@SystemTime>='$($Operation.StartedUtc)' and @SystemTime<='$($Operation.CompletedUtc)']]]"
+ # Read only this nonce in a bounded recent interval. The validator still requires
+ # exact operation timestamps; outside-interval XML is useful failure evidence only.
+ $name=$Operation.Query.QueryName
+ if($name -cnotmatch '^wela-[a-f0-9]{32}\.wela\.test\.$'){throw 'Unexpected DNS event query name.'}
+ $xpath="*[System[Provider[@Name='Microsoft-Windows-DNS-Client'] and EventID=3008 and EventRecordID>$($Operation.RecordIdBefore) and TimeCreated[timediff(@SystemTime)<=60000]] and EventData[Data[@Name='QueryName']='$name' or Data[@Name='QueryName']='$($name.TrimEnd('.'))']]"
$reader=$null;$record=$null;$xml=@();$timer=[Diagnostics.Stopwatch]::StartNew()
try{
$query=[Diagnostics.Eventing.Reader.EventLogQuery]::new($channel,[Diagnostics.Eventing.Reader.PathType]::LogName,$xpath);$query.TolerateQueryErrors=$false
diff --git a/scripts/DnsClientProbeNative.cs b/scripts/DnsClientProbeNative.cs
index 369da352..a43a0638 100644
--- a/scripts/DnsClientProbeNative.cs
+++ b/scripts/DnsClientProbeNative.cs
@@ -20,15 +20,15 @@ namespace Wela.DnsClientProbe {
// DnsQueryEx is the documented exact export; do not allow a W-suffixed name probe.
[DllImport("dnsapi.dll",EntryPoint="DnsQueryEx",ExactSpelling=true)] static extern uint DnsQueryEx(ref Request request,ref QueryResult result,IntPtr cancel);
[DllImport("dnsapi.dll")] static extern void DnsRecordListFree(IntPtr records,int freeType);
+ [DllImport("kernel32.dll",ExactSpelling=true)] static extern void GetSystemTimePreciseAsFileTime(out long value);
+ public static DateTime UtcNow() { long value;GetSystemTimePreciseAsFileTime(out value);return DateTime.FromFileTimeUtc(value); }
public static string ValidateResolver(string resolver) {
if(resolver==null||!Regex.IsMatch(resolver,@"^(0|[1-9][0-9]{0,2})(\.(0|[1-9][0-9]{0,2})){3}$"))throw new ArgumentException("One canonical dotted-decimal IPv4 resolver is required.");
IPAddress address;if(!IPAddress.TryParse(resolver,out address)||address.AddressFamily!=System.Net.Sockets.AddressFamily.InterNetwork||address.ToString()!=resolver)throw new ArgumentException("Invalid IPv4 resolver.");
byte[] bytes=address.GetAddressBytes();if(bytes[0]==0||bytes[0]>=224||resolver=="255.255.255.255")throw new ArgumentException("Unspecified, multicast and reserved/broadcast resolver addresses are refused.");
return resolver;
}
- public static Result Query(string name,string resolver) {
- if(IntPtr.Size!=8)throw new InvalidOperationException("Native 64-bit process required.");
- if(name==null||!Regex.IsMatch(name,@"^wela-[a-f0-9]{32}\.wela\.test\.\z"))throw new ArgumentException("Only the fixed random probe name is accepted.");
+ static byte[] BuildServerArray(string resolver) {
ValidateResolver(resolver);
// SDK DNS_ADDR_ARRAY header32 + one DNS_ADDR64; sockaddr_in in its first16 bytes.
// Match Microsoft Windows-classic-samples/DNSAsyncQuery CreateDnsServerList:
@@ -36,6 +36,12 @@ namespace Wela.DnsClientProbe {
byte[] server=new byte[96];BitConverter.GetBytes((uint)1).CopyTo(server,0);BitConverter.GetBytes((uint)1).CopyTo(server,4);
BitConverter.GetBytes((ushort)2).CopyTo(server,32);
IPAddress.Parse(resolver).GetAddressBytes().CopyTo(server,36);
+ return server;
+ }
+ public static Result Query(string name,string resolver) {
+ if(IntPtr.Size!=8)throw new InvalidOperationException("Native 64-bit process required.");
+ if(name==null||!Regex.IsMatch(name,@"^wela-[a-f0-9]{32}\.wela\.test\.\z"))throw new ArgumentException("Only the fixed random probe name is accepted.");
+ byte[] server=BuildServerArray(resolver);
IntPtr servers=Marshal.AllocHGlobal(server.Length);QueryResult result=new QueryResult {Version=1};
try {
Marshal.Copy(server,0,servers,server.Length);
diff --git a/scripts/DnsClientProbeWorker.ps1 b/scripts/DnsClientProbeWorker.ps1
index 46731ca0..c73fee82 100644
--- a/scripts/DnsClientProbeWorker.ps1
+++ b/scripts/DnsClientProbeWorker.ps1
@@ -7,9 +7,9 @@ $ErrorActionPreference='Stop';$script:ScriptRoot=Split-Path $PSScriptRoot -Paren
Initialize-WelaDnsClientProbeNative
if((Get-Service Dnscache -ErrorAction Stop).Status -ne 'Running'){throw 'DNS Client must already be running.'}
$before=Get-WelaChannelReader
-$start=[DateTime]::UtcNow.ToString('o')
+$start=[Wela.DnsClientProbe.Native]::UtcNow().ToString('o')
$query=[Wela.DnsClientProbe.Native]::Query($QueryName,$Resolver)
-$end=[DateTime]::UtcNow.ToString('o')
+$end=[Wela.DnsClientProbe.Native]::UtcNow().ToString('o')
$after=Get-WelaChannelReader
if((Get-WelaChannelReadKey $before) -cne (Get-WelaChannelReadKey $after)){throw 'Worker primary token changed during DNS query.'}
-[pscustomobject]@{Query=$query;StartedUtc=$start;CompletedUtc=$end;ProcessId=$PID;BeforeToken=$before;AfterToken=$after}|ConvertTo-Json -Depth 12 -Compress
+[pscustomobject]@{Query=$query;StartedUtc=$start;CompletedUtc=$end;Clock='GetSystemTimePreciseAsFileTime';ProcessId=$PID;BeforeToken=$before;AfterToken=$after}|ConvertTo-Json -Depth 12 -Compress
diff --git a/tests/DnsClientProbe.Tests.ps1 b/tests/DnsClientProbe.Tests.ps1
index ed8737d8..ffcf3186 100644
--- a/tests/DnsClientProbe.Tests.ps1
+++ b/tests/DnsClientProbe.Tests.ps1
@@ -12,6 +12,12 @@ foreach($entry in @(@('Request',64),@('QueryResult',32),@('Record',32))){$native
Assert ([Wela.DnsClientProbe.Native].GetField('SourceSha256').IsLiteral) 'Compiled source fingerprint cannot be reassigned.'
$queryImport=[Wela.DnsClientProbe.Native].GetMethod('DnsQueryEx',[Reflection.BindingFlags]'NonPublic,Static').GetCustomAttributes([Runtime.InteropServices.DllImportAttribute],$false)[0]
Assert ($queryImport.ExactSpelling -and $queryImport.EntryPoint -ceq 'DnsQueryEx') 'Bind the documented DnsQueryEx export exactly; no W suffix with a different ABI.'
+$clockImport=[Wela.DnsClientProbe.Native].GetMethod('GetSystemTimePreciseAsFileTime',[Reflection.BindingFlags]'NonPublic,Static').GetCustomAttributes([Runtime.InteropServices.DllImportAttribute],$false)[0]
+Assert ($clockImport.ExactSpelling -and $clockImport.EntryPoint -ceq 'GetSystemTimePreciseAsFileTime') 'Precise native UTC has an exact entry point and no coarse fallback.'
+$server=[Wela.DnsClientProbe.Native].GetMethod('BuildServerArray',[Reflection.BindingFlags]'NonPublic,Static').Invoke($null,@('192.0.2.53'))
+Assert ($server.Length -eq 96 -and [BitConverter]::ToUInt32($server,0) -eq 1 -and [BitConverter]::ToUInt32($server,4) -eq 1 -and [BitConverter]::ToUInt16($server,32) -eq 2) 'SDK header/address storage and sample element counts are exact.'
+Assert (([Net.IPAddress]::new([byte[]]$server[36..39])).ToString() -ceq '192.0.2.53') 'Explicit resolver address is encoded in network order.'
+Assert (@(8..31 + 34..35 + 40..95|Where-Object {$server[$_] -ne 0}).Count -eq 0) 'Aggregate family/default DNS port and all reserved address bytes remain zero.'
Assert ([Wela.DnsClientProbe.Native]::Options -eq 2103790) 'Fixed documented DNS flags retained.'
$fields=@(foreach($name in @('QueryName','QueryType','QueryOptions','QueryStatus','QueryResults')){[pscustomobject]@{Name=$name;InType=$(if($name -in @('QueryName','QueryResults')){'win:UnicodeString'}elseif($name -eq 'QueryOptions'){'win:UInt64'}else{'win:UInt32'})}})
$state=[pscustomobject]@{Computer='host';Host=[pscustomobject]@{DomainJoined=$false;Domain='WORKGROUP'};Service='Running';Channel=[pscustomobject]@{State='Enabled';Name='Microsoft-Windows-DNS-Client/Operational';SecurityDescriptor='O:SYG:SYD:(A;;0x1;;;SY)';MetadataErrors=@{};Error=$null;IsEnabled=$true;MaximumSizeInBytes=1048576;LogMode='Circular'};Schema=[pscustomobject]@{State='Observed';Provider='Microsoft-Windows-DNS-Client';ProviderGuid='1c95126e-7eea-49a9-a3fe-a378b03ddb4d';ChannelType='Operational';Events=@([pscustomobject]@{Id=3008;Version=0;Channel='Microsoft-Windows-DNS-Client/Operational';Fields=$fields})}}