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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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 480ddea0b40b991211a29332cfd64f1188571f70 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:43:39 +0900 Subject: [PATCH 07/24] Add current-account automatic transcription probe --- .gitattributes | 5 + .github/workflows/transcript-probe.yml | 46 +++++ CHANGELOG-Japanese.md | 2 + CHANGELOG.md | 2 + WELA.ps1 | 14 ++ docs/powershell-transcription.md | 2 + docs/transcript-probe.md | 55 ++++++ scripts/TranscriptProbe.ps1 | 212 ++++++++++++++++++++++++ scripts/TranscriptProbeNative.cs | 66 ++++++++ scripts/TranscriptProbeWorker.ps1 | 40 +++++ tests/TranscriptProbe.Tests.ps1 | 71 ++++++++ tests/TranscriptProbe.Windows.Tests.ps1 | 99 +++++++++++ website/docs/resources/changelog.ja.md | 2 + website/docs/resources/changelog.md | 2 + 14 files changed, 618 insertions(+) create mode 100644 .github/workflows/transcript-probe.yml create mode 100644 docs/transcript-probe.md create mode 100644 scripts/TranscriptProbe.ps1 create mode 100644 scripts/TranscriptProbeNative.cs create mode 100644 scripts/TranscriptProbeWorker.ps1 create mode 100644 tests/TranscriptProbe.Tests.ps1 create mode 100644 tests/TranscriptProbe.Windows.Tests.ps1 diff --git a/.gitattributes b/.gitattributes index 7f0dff24..acfb0e43 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 + +/scripts/TranscriptProbe* text eol=lf +/scripts/PowerShellTranscription.ps1 text eol=lf +/scripts/WefArrival.ps1 text eol=lf +/tests/TranscriptProbe*.ps1 text eol=lf diff --git a/.github/workflows/transcript-probe.yml b/.github/workflows/transcript-probe.yml new file mode 100644 index 00000000..39e93f4c --- /dev/null +++ b/.github/workflows/transcript-probe.yml @@ -0,0 +1,46 @@ +name: Native automatic transcription probe +on: + push: + paths: ['WELA.ps1', 'scripts/TranscriptProbe*', 'scripts/PowerShellTranscription.ps1', 'scripts/WmiProbe*', 'scripts/ChannelRead.ps1', 'scripts/WefArrival.ps1', 'tests/TranscriptProbe*', '.github/workflows/transcript-probe.yml'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + actual-writer: + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + engine: [powershell, pwsh] + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - name: Correlation and refusal fixtures (Windows PowerShell5.1) + if: matrix.engine == 'powershell' + shell: powershell + run: ./tests/TranscriptProbe.Tests.ps1 + - name: Actual standard writer, denial and restoration (Windows PowerShell5.1 host) + if: matrix.engine == 'powershell' + shell: powershell + run: ./tests/TranscriptProbe.Windows.Tests.ps1 -AllowDisposableWriter -TestEngine powershell + - name: Correlation and refusal fixtures (PowerShell7) + if: matrix.engine == 'pwsh' + shell: pwsh + run: ./tests/TranscriptProbe.Tests.ps1 + - name: Actual standard writer, denial and restoration (PowerShell7 host) + if: matrix.engine == 'pwsh' + shell: pwsh + run: ./tests/TranscriptProbe.Windows.Tests.ps1 -AllowDisposableWriter -TestEngine pwsh + - name: Retain native probe and cleanup evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: transcript-probe-${{ matrix.os }}-${{ matrix.engine }} + path: | + ${{ runner.temp }}/wela-transcript-probe-*/acceptance.json + ${{ runner.temp }}/wela-transcript-probe-*/policy-before.json + ${{ runner.temp }}/wela-transcript-probe-*/writer/ + if-no-files-found: warn + retention-days: 7 diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index fbb5f432..6cda55db 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,6 +4,8 @@ **改善:** +- 既に有効なローカル保存先で、現在の実行ユーザーによる固定 Windows PowerShell 5.1 子プロセスの完了した自動トランスクリプトを検証する `transcript-probe` を追加しました。実トークン・ログオン、標準ヘッダー/フッター、nonce・PID・時刻、件数を制限したファイル識別と出典確認により不明な結果を保持し、ポリシーやACLの変更・明示的トランスクリプト開始・Sigma/EVTX加算は行いません。Server 2022/2025と両WELA実行エンジン向けに、使い捨て標準ユーザーの成功・拒否テストを追加しました。 (@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..944951a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ **Improvements:** +- Added opt-in `transcript-probe` to verify a fixed current-account Windows PowerShell 5.1 child produces its own completed automatic transcript in an already enabled local destination. Actual token/logon, native header/footer, nonce/PID/time, bounded file identity and source checks preserve unverified outcomes without changing policy or ACLs, invoking explicit transcription or granting Sigma/EVTX credit. Added disposable standard-writer success/denial tests for Server 2022/2025 under both WELA host engines. (@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..66e36a21 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -53,6 +53,9 @@ [string]$RuleManifestPath, [ValidateSet('Audit', 'Plan', 'Configure')][string]$TranscriptionAction = 'Audit', [string]$TranscriptDirectory, + [ValidateSet('Plan','Run')][string]$TranscriptProbeAction = 'Plan', + [string]$TranscriptProbeDirectory, + [string]$TranscriptProbeOutputPath, [ValidateSet('Audit','Plan','Configure')][string]$LdapAction = 'Audit', [ValidateSet('Preserve','Diagnostic','MdiCleanup')][string]$LdapMode = 'Preserve', [ValidateRange(1,2147483647)][int]$LdapSearchTimeMs, @@ -168,6 +171,7 @@ $SaclTargetsPath = Join-Path $ScriptRoot "config/audit_sacl_targets.json" . (Join-Path $ScriptRoot "scripts/WmiNamespaceAuditing.ps1") . (Join-Path $ScriptRoot "scripts/WmiProbe.ps1") . (Join-Path $ScriptRoot "scripts/PowerShellTranscription.ps1") +. (Join-Path $ScriptRoot "scripts/TranscriptProbe.ps1") Import-Module (Join-Path $ScriptRoot "modules/AuditProfiles.psm1") -ErrorAction Stop Import-Module (Join-Path $ScriptRoot "modules/RuleEligibility.psm1") -ErrorAction Stop Import-Module (Join-Path $ScriptRoot "modules/AuditCatalog.psm1") -ErrorAction Stop @@ -1928,6 +1932,7 @@ Usage: ./WELA.ps1 event-measurement -MeasurementChannel Security -MeasurementAction Run -MeasurementOutputPath C:\Evidence\new-sample -MeasurementExportEvtx ./WELA.ps1 rule-eligibility -RuleEvidencePath reviewed-lab-evidence.json -ResultsPath evidence-review.json ./WELA.ps1 smb-auditing -SmbAction Configure -DryRun + ./WELA.ps1 transcript-probe -Help # Verify one automatic native5.1 transcript under the actual identity ./WELA.ps1 powershell-transcription -TranscriptionAction Plan -TranscriptDirectory C:\Transcripts -ResultsPath transcription-plan.json ./WELA.ps1 applocker-readiness -ResultsPath applocker.json ./WELA.ps1 applocker-readiness -AppLockerAction Plan -AppLockerPolicyPath operator-audit.xml @@ -1977,6 +1982,9 @@ Write-Host "" Write-Host "WELA v$WELAVersion - $WELAReleaseName" Write-Host "" +if ($Cmd -ne 'transcript-probe' -and @($PSBoundParameters.Keys | Where-Object { $_ -like 'TranscriptProbe*' }).Count) { throw 'TranscriptProbe options require transcript-probe. No command was run.' } +if ($Cmd -eq 'transcript-probe' -and @($PSBoundParameters.Keys | Where-Object { $_ -notin @('Cmd','TranscriptProbeAction','TranscriptProbeDirectory','TranscriptProbeOutputPath','Help') }).Count) { throw 'transcript-probe accepts only dedicated action/directory/output options. No command was run.' } + if ($Cmd -ne 'channel-read' -and @($PSBoundParameters.Keys | Where-Object { $_ -like 'ChannelRead*' }).Count) { throw 'ChannelRead options require channel-read. No command was run.' } if ($Cmd -eq 'channel-read' -and @($PSBoundParameters.Keys | Where-Object { $_ -notin @('Cmd','ChannelReadName','ChannelReadOutputPath','Help') }).Count) { throw 'channel-read accepts only dedicated channel/output options. No command was run.' } @@ -2430,6 +2438,12 @@ switch ($Cmd.ToLower()) { if ($report.ExitCode) { exit $report.ExitCode } } catch { Write-Host "[Failed] SMB auditing: $_" -ForegroundColor Red; exit 1 } } + 'transcript-probe' { + if ($Help) { Write-Host 'Usage: ./WELA.ps1 transcript-probe [-TranscriptProbeAction Plan|Run] -TranscriptProbeDirectory existing-local-policy-directory [-TranscriptProbeOutputPath new-private-directory]. Run starts one fixed native5.1 child using existing automatic transcription policy; no policy or destination changes. See docs/transcript-probe.md.'; return } + $report=Invoke-WelaTranscriptProbe -Action $TranscriptProbeAction -Directory $TranscriptProbeDirectory -OutputPath $TranscriptProbeOutputPath + $report | Select-Object Action,Status,WriterAuthorization,Diagnostic,OutputPath | Format-List | Out-Host + if ($report.ExitCode) { exit $report.ExitCode } + } 'powershell-transcription' { if ($Help) { Write-Host 'Usage: ./WELA.ps1 powershell-transcription [-TranscriptionAction Audit|Plan|Configure] [-TranscriptDirectory absolute-existing-directory] [-Auto] [-DryRun] [-BackupPath new-directory] [-ResultsPath file.json]' diff --git a/docs/powershell-transcription.md b/docs/powershell-transcription.md index 3905d869..558a2219 100644 --- a/docs/powershell-transcription.md +++ b/docs/powershell-transcription.md @@ -71,3 +71,5 @@ The mock suite covers typed values, shared views, idempotence, ordering, destina Native CI passed on both Server 2022 and Server 2025 under Windows PowerShell 5.1 and PowerShell 7 in [run 35439090461](https://github.com/Yamato-Security/WELA/actions/runs/35439090461): 14 native assertions per OS/host combination, including fresh x64/x86 Windows PowerShell 5.1 transcript markers and verified restoration (56 native assertions total). Production/central validation still requires actual client, server, DC and service identities: test a benign new session, record the transcript and effective policy, verify unauthorized read/modify attempts fail, check collection and quotas/retention, and verify recovery. CI's local private folder does not satisfy the central authorization/ingestion acceptance criterion. Sysmon and external telemetry are out of scope. Reviewed CIS references: [Windows 11 Enterprise v4.0.0, PDF pages 1286–1287](https://rayasec.com/wp-content/uploads/CIS-Benchmark/Microsoft-Windows-Desktop/CIS_Microsoft_Windows_11_Enterprise_Benchmark_v4.0.0.pdf#page=1286), [Windows Server 2022 v4.0.0, PDF pages 1029–1030](https://rayasec.com/wp-content/uploads/CIS-Benchmark/Microsoft-Windows-Server/CIS_Microsoft_Windows_Server_2022_Benchmark_v4.0.0.pdf#page=1029). + +For a fixed child under the actual current account, see the optional [automatic transcription probe](transcript-probe.md). It verifies completed local automatic output without changing policy or granting EVTX/Sigma credit. diff --git a/docs/transcript-probe.md b/docs/transcript-probe.md new file mode 100644 index 00000000..32d2c8fb --- /dev/null +++ b/docs/transcript-probe.md @@ -0,0 +1,55 @@ +# Automatic Windows PowerShell transcription probe + +`transcript-probe` checks whether one fixed Windows PowerShell 5.1 child, launched as the current WELA account, produces its own completed **automatic** transcript in an already configured local destination. It does not change policy, ACLs, services or shares. It never calls `Start-Transcript` as a fallback. Transcripts are not EVTX, and the report always grants zero Sigma/EVTX credit. + +This is the current-account local-writer acceptance portion of #376. The existing [transcription audit/configure command](powershell-transcription.md) remains separate. UNC/share acceptance, remote collection, retention and testing other writer accounts remain separate work. + +## Commands + +Run from native 64-bit Windows PowerShell 5.1 or PowerShell 7 on a supported Windows 11, Server 2022 or Server 2025 host. The child being tested is always native Windows PowerShell 5.1; running WELA in PowerShell 7 does not test PowerShell 7 transcription. + +```powershell +# Default Plan: inspect the selected destination and prerequisites. +.\WELA.ps1 transcript-probe -TranscriptProbeDirectory C:\Transcripts + +# Explicit Run: one fixed child, then verify its completed automatic transcript. +.\WELA.ps1 transcript-probe -TranscriptProbeAction Run ` + -TranscriptProbeDirectory C:\Transcripts ` + -TranscriptProbeOutputPath C:\Evidence\transcript-unique-run +``` + +The output directory must be new, have an existing parent, and be outside the transcript destination. WELA creates it with access for the current account, SYSTEM and Administrators. `Plan` launches no probe child and creates no explicit evidence directory. An already enabled transcription policy can naturally transcribe the WELA invocation itself, including a Plan invocation. + +An enabled machine `EnableTranscripting` DWORD policy and an explicit literal `OutputDirectory` string must already exist and match the selected local fixed-drive directory. Both shared registry views must agree. Current-user policy and invocation-header settings are also recorded and checked for known types. This initial command does not infer default destinations or accept a current-user-only policy. Winmgmt must already be running for read-only host observations. + +The account needs directory/date-folder metadata and listing access, plus read access to the new transcript. A write-only drop-box destination may accept automatic transcripts but cannot be proven by this verifier. Permission failures remain unverified; WELA does not broaden access to obtain proof. + +## What a successful result means + +`Status: CompletedAutomaticTranscript` and `WriterAuthorization: ObservedForThisChild` mean the local verifier observed exactly one fresh matching completed transcript from the fixed child during this run. The evidence binds the following: + +- The actual child PID, executable, PowerShell 5.1 Desktop version, command arguments and loaded engine assembly hash. +- Actual current-account SID, logon authentication ID and group attributes in the parent and child. Full before/after token observations are retained within each process; cross-process matching uses the authorization identity and groups because process startup can change privilege flags. +- Unique standalone begin/end output lines, the native engine's localized header/footer resource templates, header identity/PID/host command, and header/footer timestamps within the observed launch/exit window. +- Existing policy, executable/source hashes, host and time-zone observations, plus handle-based destination/date-folder identity and owner/group/DACL observations before and after the operation. +- Bounded raw matching transcript bytes, SHA-256 hashes and a matching file handle retained through final checks. Reparse points, multi-link files and identity/descriptor drift are refused. + +The fixed child uses `-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File` with WELA's bundled worker and a generated nonce. The execution-policy option is local to that process; it does not change stored policy or override enforced Group Policy. No arbitrary command, credential or alternative executable can be supplied to this command. + +Output preparation and initial observations precede the parent token interval. The measured interval covers the worker and transcript verification; the report retains both parent token snapshots. The child records its own before/after interval. Token differences, ambiguous transcripts, incomplete output, unexpected formats or context drift fail verification. A completed transcript proves this observed operation, not continuing authorization, other users' access, remote share acceptance, reliable collection or application of every baseline recommendation. + +This is local consistency evidence, not tamper-proof attestation against another process controlled by the same account or an administrator. The fixed nonce, PID, command and time checks provide correlation; they do not establish exclusive writer attribution against a malicious local actor. + +## Bounds and artifacts + +The inventory covers only the previous, current and next local calendar-date folders, with at most 256 entries in total. Existing transcript contents are never read. The verifier considers at most 32 new file identities, reads at most 1 MiB per candidate and 4 MiB in total, and accepts exactly one matching transcript. Unexpected directories, names, encodings or candidate times remain unverified. Busy destinations can exceed these conservative bounds. + +The worker has a 30-second deadline. Each redirected output stream retains at most 64 KiB, with bounded pipe-drain and termination waits. The report includes explicit diagnostics for refusal and incomplete evidence; no fallback obtains a positive result. + +A completed Run writes `result.json`, `worker.json` and the exact matching `transcript.txt` bytes into the protected output. Failed runs that reached output preparation keep diagnostic artifacts and exit nonzero. Prerequisite failures can occur before an output directory exists. Source transcripts are retained in their configured destination; WELA never removes them. + +## Validation + +Portable fixtures exercise the actual native-format matcher, identity/time/nonce/version refusals, localization templates, encoding limits, policy types, drift and dedicated CLI guards. The opt-in hosted Windows fixture provisions only its own standard account and destination, enables a temporary machine transcription policy, and invokes the public command in fresh processes. It tests an allowed writer and then a denied writer, preserves both original typed policy views, restores the original destination ACL and removes only the owned account. This fixture is gated to disposable standalone GitHub-hosted Server 2022/2025 machines and both WELA host engines. It never substitutes an explicit transcript for automatic policy output. + +Microsoft documents automatic policy transcription and machine-policy precedence in [Turn on PowerShell Transcription](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_group_policy_settings?view=powershell-5.1#turn-on-powershell-transcription). The verifier reads the actual installed engine's transcript resource templates rather than assuming an English header. diff --git a/scripts/TranscriptProbe.ps1 b/scripts/TranscriptProbe.ps1 new file mode 100644 index 00000000..9a0ebdd7 --- /dev/null +++ b/scripts/TranscriptProbe.ps1 @@ -0,0 +1,212 @@ +# Fixed native automatic-transcription evidence; never provisions a destination or changes policy. +function Initialize-WelaTranscriptProbe { + if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'transcript-probe requires native 64-bit Windows.'} + $bytes=[IO.File]::ReadAllBytes((Join-Path $PSScriptRoot 'TranscriptProbeNative.cs'));$hash=Get-WelaArrivalHash $bytes + if(-not ('Wela.TranscriptProbe.Item' -as [type])){Add-Type -TypeDefinition ([Text.UTF8Encoding]::new($false,$true).GetString($bytes).Replace('__WELA_TRANSCRIPT_SOURCE_SHA256__',$hash)) -ErrorAction Stop} + if([Wela.TranscriptProbe.Item]::SourceSha256 -cne $hash){throw 'Loaded transcript helper differs from source; start a fresh PowerShell process.'} + Initialize-WelaWmiProbeNative +} +function Get-WelaTranscriptProbeKey {param($Value) ConvertTo-Json -InputObject $Value -Depth 20 -Compress} +function Get-WelaTranscriptProbeSources { + $result=[ordered]@{} + foreach($name in @('WELA.ps1','scripts/TranscriptProbe.ps1','scripts/TranscriptProbeWorker.ps1','scripts/TranscriptProbeNative.cs','scripts/PowerShellTranscription.ps1','scripts/WmiProbe.ps1','scripts/WmiProbeNative.cs','scripts/ChannelRead.ps1','scripts/WefArrival.ps1','modules/AuditProfiles.psm1')){$result[$name]=(Get-FileHash -LiteralPath (Join-Path $script:ScriptRoot $name) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()} + [pscustomobject]$result +} +function Get-WelaTranscriptProbeObjectKey { + param($Observation,[switch]$Directory) + $value=[ordered]@{Path=$Observation.Path;Identity=$Observation.Identity;CreatedUtc=$Observation.CreatedUtc;Attributes=$Observation.Attributes;Descriptor=$Observation.Descriptor} + if(-not $Directory){$value.Length=$Observation.Length;$value.WrittenUtc=$Observation.WrittenUtc;$value.Links=$Observation.Links} + Get-WelaTranscriptProbeKey ([pscustomobject]$value) +} +function Assert-WelaTranscriptProbePolicy { + param([array]$Policy,[string]$Directory) + Test-WelaTranscriptSharedPolicy $Policy + if($Policy.Count -ne 2 -or $Policy[0].View -cne 'Registry64' -or $Policy[1].View -cne 'Registry32'){throw 'Both canonical shared policy views are required.'} + foreach($view in $Policy){ + $machine=$view.Machine + if(-not $machine.EnableTranscripting.ValueExists -or $machine.EnableTranscripting.Type -cne 'DWord' -or $machine.EnableTranscripting.Value -ne 1 -or -not $machine.OutputDirectory.ValueExists -or $machine.OutputDirectory.Type -cne 'String' -or $machine.OutputDirectory.Value -isnot [string]){throw 'An already enabled machine transcription policy with explicit literal output is required.'} + $path=Resolve-WelaArrivalPath $machine.OutputDirectory.Value + if(-not $path.Equals($Directory,[StringComparison]::OrdinalIgnoreCase)){throw 'Selected destination does not match the current machine transcription policy.'} + foreach($hive in @('Machine','CurrentUser')){ + foreach($name in @('EnableTranscripting','EnableInvocationHeader')){$value=$view.$hive.$name;if($value.ValueExists -and ($value.Type -cne 'DWord' -or $value.Value -notin @(0,1))){throw 'Unknown typed transcription policy value.'}} + $value=$view.$hive.OutputDirectory;if($value.ValueExists -and ($value.Type -cne 'String' -or $value.Value -isnot [string])){throw 'Unknown transcription destination value type.'} + } + } +} +function Get-WelaTranscriptProbeState { + param([string]$Directory,$Handle) + if((Get-Service Winmgmt -ErrorAction Stop).Status -ne 'Running'){throw 'Winmgmt must already be running for host observations; no service is started.'} + $capability=Get-WelaTranscriptCapability;if($capability.Status -cne 'Supported'){throw $capability.Diagnostic} + $engine=Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::Windows)) 'System32\WindowsPowerShell\v1.0\powershell.exe' + $engine=Resolve-WelaArrivalPath $engine + $policy=@(Get-WelaTranscriptPolicy @('Registry64','Registry32'));Assert-WelaTranscriptProbePolicy $policy $Directory + [pscustomobject][ordered]@{Host=(Get-WelaChannelReadHost);Engine=$engine;EngineHash=(Get-FileHash -LiteralPath $engine -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant();InstalledVersion=$capability.EngineVersion;Policy=$policy;Directory=$Handle.Snapshot();TimeZone=[TimeZoneInfo]::Local.Id;OffsetMinutes=[DateTimeOffset]::Now.Offset.TotalMinutes;Sources=(Get-WelaTranscriptProbeSources)} +} +function Get-WelaTranscriptProbeStateKey { + param($State) + Get-WelaTranscriptProbeKey ([pscustomobject][ordered]@{Host=$State.Host;Engine=$State.Engine;EngineHash=$State.EngineHash;InstalledVersion=$State.InstalledVersion;Policy=$State.Policy;Directory=(Get-WelaTranscriptProbeObjectKey $State.Directory -Directory);TimeZone=$State.TimeZone;OffsetMinutes=$State.OffsetMinutes;Sources=$State.Sources}) +} +function Get-WelaTranscriptProbeInventory { + param([string]$Directory,[string[]]$Dates) + $folders=@();$files=@() + foreach($date in $Dates){ + if($date -cnotmatch '^\d{8}$'){throw 'Invalid bounded transcript date scope.'} + $path=Join-Path $Directory $date + if(-not [IO.Directory]::Exists($path)){if(Test-Path -LiteralPath $path){throw 'Expected date folder is not a directory.'};$folders+=[pscustomobject]@{Date=$date;Exists=$false;Observation=$null};continue} + $null=Resolve-WelaArrivalPath $path;$handle=[Wela.TranscriptProbe.Item]::Directory($path) + try{ + $observation=$handle.Snapshot();$folders+=[pscustomobject]@{Date=$date;Exists=$true;Observation=$observation} + foreach($entry in [IO.Directory]::EnumerateFileSystemEntries($path)){ + if($files.Count -ge 256){throw 'Current-date inventory reached its 256-entry limit.'} + $item=Get-Item -LiteralPath $entry -Force -ErrorAction Stop + if($item -isnot [IO.FileInfo] -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)){throw 'Unexpected directory or reparse entry in the current-date scope.'} + $file=[Wela.TranscriptProbe.Item]::Metadata($entry) + try{$files+=$file.Snapshot()}finally{$file.Dispose()} + } + if((Get-WelaTranscriptProbeObjectKey $handle.Snapshot() -Directory) -cne (Get-WelaTranscriptProbeObjectKey $observation -Directory)){throw 'Date-directory identity or descriptor changed during enumeration.'} + }finally{$handle.Dispose()} + } + [pscustomobject]@{Dates=$Dates;Folders=$folders;Files=@($files|Sort-Object Path)} +} +function Assert-WelaTranscriptProbeInventory { + param($Before,$After) + foreach($folder in $Before.Folders|Where-Object Exists){ + $match=@($After.Folders|Where-Object Date -eq $folder.Date) + if($match.Count -ne 1 -or -not $match[0].Exists -or (Get-WelaTranscriptProbeObjectKey $folder.Observation -Directory) -cne (Get-WelaTranscriptProbeObjectKey $match[0].Observation -Directory)){throw 'An existing date directory changed or disappeared.'} + } + foreach($file in $Before.Files){ + $match=@($After.Files|Where-Object Path -eq $file.Path) + # Existing sessions can append to their transcripts. Their bytes are never read. + if($match.Count -ne 1 -or $match[0].Identity -cne $file.Identity -or $match[0].CreatedUtc -cne $file.CreatedUtc -or $match[0].Descriptor -cne $file.Descriptor){throw 'An existing transcript was replaced, removed or had its descriptor changed.'} + } +} +function Start-WelaTranscriptProbeWorker { + param($State,[string]$Nonce,$ParentToken) + $worker=Join-Path $PSScriptRoot 'TranscriptProbeWorker.ps1' + $arguments=@('-NoLogo','-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-File',$worker,'-Nonce',$Nonce) + $info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$State.Engine + $info.Arguments='-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "'+$worker+'" -Nonce '+$Nonce + $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;$launched=[DateTime]::UtcNow + try{ + if(-not $process.Start()){throw 'Fixed transcript worker did not start.'};$started=$true + $stdout=[Wela.TranscriptProbe.Item]::Drain($process.StandardOutput,65536);$stderr=[Wela.TranscriptProbe.Item]::Drain($process.StandardError,65536) + if(-not $process.WaitForExit(30000)){throw 'Fixed transcript worker exceeded thirty seconds.'} + $exited=[DateTime]::UtcNow + if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),3000)){throw 'Worker output pipes did not close within their bound.'} + if($stdout.Result.Exceeded -or $stderr.Result.Exceeded -or $stdout.Result.Error -or $stderr.Result.Error){throw 'Worker output is oversized or incomplete.'} + if($process.ExitCode -ne 0 -or $stderr.Result.Text){throw ('Fixed native5.1 worker failed; exit '+$process.ExitCode+'. No transcript fallback was attempted.')} + $lines=@(($stdout.Result.Text -replace "`r`n","`n").TrimEnd("`r","`n") -split "`n") + $json=@($lines|Where-Object{$_ -clike 'WELA-WORKER-JSON:*'}) + if($lines.Count -ne 3 -or $json.Count -ne 1){throw 'Unexpected worker output framing.'} + $operation=ConvertFrom-WelaArrivalJson $json[0].Substring('WELA-WORKER-JSON:'.Length) + if($operation.Nonce -cne $Nonce -or $operation.ProcessId -ne $process.Id -or $operation.Engine -ine $State.Engine -or $operation.Edition -cne 'Desktop' -or $operation.EngineVersion -cnotmatch '^5\.1\.\d+\.\d+$'){throw 'Fixed worker engine/identity response differs.'} + $actualArgs=@($operation.Arguments|Select-Object -Skip 1) + if((Get-WelaTranscriptProbeKey $actualArgs) -cne (Get-WelaTranscriptProbeKey $arguments) -or $operation.Arguments[0] -ine $State.Engine){throw 'Worker command arguments differ from the fixed launch.'} + if(@($lines|Where-Object{$_ -ceq ('WELA-TRANSCRIPT-BEGIN:'+${Nonce}+':'+$process.Id)}).Count -ne 1 -or @($lines|Where-Object{$_ -ceq ('WELA-TRANSCRIPT-END:'+${Nonce}+':'+$process.Id)}).Count -ne 1){throw 'Fixed worker output markers are missing or ambiguous.'} + if((Get-WelaWmiProbeTokenKey $operation.BeforeToken -AuthorizationOnly) -cne (Get-WelaWmiProbeTokenKey $ParentToken -AuthorizationOnly) -or (Get-WelaWmiProbeTokenKey $operation.BeforeToken) -cne (Get-WelaWmiProbeTokenKey $operation.AfterToken)){throw 'Worker identity/logon/group attributes differ from the parent or changed during output.'} + if((Get-WelaTranscriptProbeKey $operation.PolicyBefore) -cne (Get-WelaTranscriptProbeKey $State.Policy) -or (Get-WelaTranscriptProbeKey $operation.PolicyAfter) -cne (Get-WelaTranscriptProbeKey $State.Policy)){throw 'Native worker policy differs from the observed policy.'} + $begin=ConvertTo-WelaArrivalUtc $operation.StartedUtc;$end=ConvertTo-WelaArrivalUtc $operation.CompletedUtc + if($begin -lt $launched -or $end -lt $begin -or $end -gt $exited -or $operation.StartOffsetMinutes -ne $State.OffsetMinutes -or $operation.EndOffsetMinutes -ne $State.OffsetMinutes -or $operation.Computer -ine $State.Host.Computer -or $operation.HeaderUser -ine $operation.BeforeToken.Name){throw 'Worker time, time-zone or host context differs.'} + $assembly=Resolve-WelaArrivalPath $operation.Assembly.Path + $windows=[Environment]::GetFolderPath([Environment+SpecialFolder]::Windows).TrimEnd('\')+'\' + if(-not $assembly.StartsWith($windows,[StringComparison]::OrdinalIgnoreCase) -or [IO.Path]::GetFileName($assembly) -ine 'System.Management.Automation.dll' -or $operation.Assembly.FullName -cnotlike 'System.Management.Automation, Version=3.0.0.0,*' -or (Get-FileHash -LiteralPath $assembly -Algorithm SHA256).Hash.ToLowerInvariant() -cne $operation.Assembly.Sha256){throw 'Native worker assembly evidence differs.'} + $operation|Add-Member NoteProperty LaunchedUtc $launched.ToString('o');$operation|Add-Member NoteProperty ExitedUtc $exited.ToString('o') + $operation + }finally{try{if($started -and -not $process.HasExited){$process.Kill();if(-not $process.WaitForExit(3000)){throw 'Fixed worker termination was not confirmed.'}}}finally{$process.Dispose()}} +} +function ConvertFrom-WelaTranscriptProbeBytes { + param([byte[]]$Bytes) + $offset=0;$encoding=[Text.UTF8Encoding]::new($false,$true) + if($Bytes.Length -ge 3 -and $Bytes[0] -eq 239 -and $Bytes[1] -eq 187 -and $Bytes[2] -eq 191){$offset=3} + elseif($Bytes.Length -ge 2 -and $Bytes[0] -eq 255 -and $Bytes[1] -eq 254){$offset=2;$encoding=[Text.UnicodeEncoding]::new($false,$true,$true)} + elseif($Bytes.Length -ge 2 -and $Bytes[0] -eq 254 -and $Bytes[1] -eq 255){$offset=2;$encoding=[Text.UnicodeEncoding]::new($true,$true,$true)} + $text=$encoding.GetString($Bytes,$offset,$Bytes.Length-$offset) + if($text.Contains([string][char]0)){throw 'Transcript contains embedded NUL characters.'} + $text -replace "`r`n","`n" +} +function Test-WelaTranscriptProbeText { + param([string]$Text,$Operation) + $header=($Operation.Resources.TranscriptPrologue -replace "`r`n","`n").TrimEnd("`r","`n") + $footer=($Operation.Resources.TranscriptEpilogue -replace "`r`n","`n").TrimEnd("`r","`n") + if(-not $header -or -not $footer -or $header.Length -gt 8192 -or $footer.Length -gt 8192){throw 'Unknown native transcript resource templates.'} + $pattern=[regex]::Escape($header);$tail=[regex]::Escape($footer) + $fields=[ordered]@{'{0:yyyyMMddHHmmss}'='(?\d{14})';'{1}'='(?[^\n]{1,512})';'{2}'='(?[^\n]{1,512})';'{3}'='(?[^\n]{0,512})';'{4}'='(?[^\n]{1,255})';'{5}'='(?[^\n]{1,512})';'{6}'='(?[^\n]{1,4096})';'{7}'='(?\d{1,10})';'{8}'='(?[\s\S]{1,8192}?)'} + foreach($key in $fields.Keys){$escaped=[regex]::Escape($key);if(-not $pattern.Contains($escaped)){throw 'Unrecognized native transcript prologue schema.'};$pattern=$pattern.Replace($escaped,$fields[$key])} + $tail=$tail.Replace([regex]::Escape('{0:yyyyMMddHHmmss}'),'(?\d{14})') + $match=[regex]::Match($Text,'\A'+$pattern+'\n(?[\s\S]*?)\n'+$tail+'\n*\z',[Text.RegularExpressions.RegexOptions]::CultureInvariant,[TimeSpan]::FromSeconds(1)) + if(-not $match.Success){return $false} + foreach($template in @($header,$footer)){$prefix=(@($template -split "`n"|Select-Object -First 2) -join "`n");if([regex]::Matches($Text,[regex]::Escape($prefix)).Count -ne 1){return $false}} + if($match.Groups['User'].Value -ine $Operation.HeaderUser -or $match.Groups['RunAs'].Value -ine $Operation.BeforeToken.Name -or $match.Groups['Configuration'].Value -cne '' -or $match.Groups['Machine'].Value -ine $Operation.Computer -or $match.Groups['OS'].Value -cne $Operation.OsVersion -or $match.Groups['Command'].Value -cne $Operation.CommandLine -or [long]$match.Groups['Pid'].Value -ne $Operation.ProcessId){return $false} + $versions=@($match.Groups['Versions'].Value -split "`n") + if(@($versions|Where-Object{$_ -ceq ('PSVersion: '+$Operation.EngineVersion)}).Count -ne 1 -or @($versions|Where-Object{$_ -ceq 'PSEdition: Desktop'}).Count -ne 1){return $false} + $body=@($match.Groups['Body'].Value -split "`n");$begin='WELA-TRANSCRIPT-BEGIN:'+$Operation.Nonce+':'+$Operation.ProcessId;$end='WELA-TRANSCRIPT-END:'+$Operation.Nonce+':'+$Operation.ProcessId + if(@($body|Where-Object{$_ -ceq $begin}).Count -ne 1 -or @($body|Where-Object{$_ -ceq $end}).Count -ne 1 -or [Array]::IndexOf($body,$begin) -ge [Array]::IndexOf($body,$end)){return $false} + $offset=[TimeSpan]::FromMinutes($Operation.StartOffsetMinutes) + $first=[DateTimeOffset]::new([DateTime]::ParseExact($match.Groups['Start'].Value,'yyyyMMddHHmmss',[Globalization.CultureInfo]::InvariantCulture),$offset) + $last=[DateTimeOffset]::new([DateTime]::ParseExact($match.Groups['End'].Value,'yyyyMMddHHmmss',[Globalization.CultureInfo]::InvariantCulture),$offset) + return $first -ge (ConvertTo-WelaArrivalUtc $Operation.LaunchedUtc).AddSeconds(-1) -and $first -le (ConvertTo-WelaArrivalUtc $Operation.StartedUtc).AddSeconds(1) -and $last -ge (ConvertTo-WelaArrivalUtc $Operation.CompletedUtc).AddSeconds(-1) -and $last -le (ConvertTo-WelaArrivalUtc $Operation.ExitedUtc).AddSeconds(1) -and $last -ge $first +} +function Write-WelaTranscriptProbeArtifact { + param([string]$Root,[string]$Name,[byte[]]$Bytes) + if($Bytes.Length -gt 4194304){throw 'Evidence artifact exceeds four MiB.'} + $stream=[IO.File]::Open((Join-Path $Root $Name),[IO.FileMode]::CreateNew,[IO.FileAccess]::ReadWrite,[IO.FileShare]::None) + try{$stream.Write($Bytes,0,$Bytes.Length);$stream.Flush($true);$stream.Position=0;$sha=[Security.Cryptography.SHA256]::Create();try{$hash=([BitConverter]::ToString($sha.ComputeHash($stream))).Replace('-','').ToLowerInvariant()}finally{$sha.Dispose()};if($hash -cne (Get-WelaArrivalHash $Bytes)){throw 'Written evidence bytes differ.'}}finally{$stream.Dispose()} + [pscustomobject]@{Name=$Name;Bytes=$Bytes.Length;Sha256=$hash} +} +function Invoke-WelaTranscriptProbe { + param([ValidateSet('Plan','Run')][string]$Action='Plan',[string]$Directory,[string]$OutputPath) + if(($Action -eq 'Run') -ne (-not [string]::IsNullOrWhiteSpace($OutputPath))){throw 'Run requires a new TranscriptProbeOutputPath; Plan starts no worker or explicit output.'} + if(-not $Directory){throw 'Select the existing local TranscriptProbeDirectory.'} + Initialize-WelaTranscriptProbe + $directoryPath=Resolve-WelaArrivalPath $Directory + if(-not [IO.Directory]::Exists($directoryPath)){throw 'Selected transcript directory must already exist.'} + $handle=[Wela.TranscriptProbe.Item]::Directory($directoryPath);$heldFiles=@();$heldFolders=@();$output=$null + try{ + $state=Get-WelaTranscriptProbeState $directoryPath $handle;$stateKey=Get-WelaTranscriptProbeStateKey $state + $dates=@(-1,0,1|ForEach-Object{[DateTime]::Today.AddDays($_).ToString('yyyyMMdd',[Globalization.CultureInfo]::InvariantCulture)}) + $before=Get-WelaTranscriptProbeInventory $directoryPath $dates + if($Action -eq 'Plan'){return [pscustomobject]@{SchemaVersion=1;Kind='WelaAutomaticTranscriptPlan';Action='Plan';ExitCode=0;Status='ReadyToProbe';State=$state;Inventory=$before;Token=[Wela.WmiProbe.Native]::Snapshot();ReadyRuleCredit=0;SigmaEvtxCredit=0;WriterAuthorization='Unverified';Scope='One new native Windows PowerShell5.1 automatic transcript under this local current identity only'}} + $output=New-WelaArrivalOutput $OutputPath $directoryPath + $report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaAutomaticTranscriptProbe';Action='Run';Status='Unverified';ExitCode=1;RecordedUtc=[DateTime]::UtcNow.ToString('o');Before=$state;After=$null;InventoryBefore=$before;InventoryAfter=$null;ParentBefore=$null;ParentAfter=$null;Worker=$null;Transcript=$null;Artifacts=@();Diagnostic='';OutputPath=$output;ReadyRuleCredit=0;SigmaEvtxCredit=0;ConfigurationChanges=0;WriterAuthorization='Unverified';PowerShell7Sessions='Not assessed';Collection='Not verified';Scope='One fixed native5.1 completed automatic text transcript; no retention, immutable-storage or EVTX/Sigma claim'} + try{ + # Prepare metadata and evidence storage before capturing the actual process-token interval. + foreach($folder in $before.Folders|Where-Object Exists){$held=[Wela.TranscriptProbe.Item]::Directory($folder.Observation.Path);$heldFolders+= $held;if((Get-WelaTranscriptProbeObjectKey $held.Snapshot() -Directory) -cne (Get-WelaTranscriptProbeObjectKey $folder.Observation -Directory)){throw 'Date-directory changed before worker.'}} + $fresh=Get-WelaTranscriptProbeState $directoryPath $handle;if((Get-WelaTranscriptProbeStateKey $fresh) -cne $stateKey){throw 'Policy, destination, source or host changed before worker.'} + $inventory=Get-WelaTranscriptProbeInventory $directoryPath $dates + Assert-WelaTranscriptProbeInventory $before $inventory + # Newly created unrelated files during preparation become baseline, never candidate evidence. + $before=$inventory;$report.InventoryBefore=$before + $token=[Wela.WmiProbe.Native]::Snapshot();$report.ParentBefore=$token + $operation=Start-WelaTranscriptProbeWorker $state ([guid]::NewGuid().ToString('N')) $token;$report.Worker=$operation + $after=Get-WelaTranscriptProbeInventory $directoryPath $dates;$report.InventoryAfter=$after;Assert-WelaTranscriptProbeInventory $before $after + foreach($folder in $after.Folders|Where-Object Exists){$held=[Wela.TranscriptProbe.Item]::Directory($folder.Observation.Path);$heldFolders+=$held;if((Get-WelaTranscriptProbeObjectKey $held.Snapshot() -Directory) -cne (Get-WelaTranscriptProbeObjectKey $folder.Observation -Directory)){throw 'Date directory changed after worker.'}} + $candidates=@($after.Files|Where-Object{$_.Identity -cnotin @($before.Files.Identity)}) + if($candidates.Count -gt 32){throw 'Fresh transcript candidates exceed the 32-file bound.'} + $matches=@();$total=0 + foreach($candidate in $candidates){ + if([IO.Path]::GetFileName($candidate.Path) -cnotlike 'PowerShell_transcript*.txt'){throw 'Unexpected fresh file in the selected date scope.'} + if((ConvertTo-WelaArrivalUtc $candidate.CreatedUtc) -lt (ConvertTo-WelaArrivalUtc $operation.LaunchedUtc).AddSeconds(-2) -or (ConvertTo-WelaArrivalUtc $candidate.WrittenUtc) -gt (ConvertTo-WelaArrivalUtc $operation.ExitedUtc).AddSeconds(2)){throw 'Fresh transcript file timestamps are outside the worker interval.'} + $file=[Wela.TranscriptProbe.Item]::File($candidate.Path);$heldFiles+=$file + $observation=$file.Snapshot();if((Get-WelaTranscriptProbeObjectKey $observation) -cne (Get-WelaTranscriptProbeObjectKey $candidate)){throw 'Candidate identity or contents changed after enumeration.'} + $bytes=$file.Read(1048576);$total+=$bytes.Length;if($total -gt 4194304){throw 'Fresh transcript reads exceed four MiB.'} + $text=ConvertFrom-WelaTranscriptProbeBytes $bytes + if(Test-WelaTranscriptProbeText $text $operation){$matches+=[pscustomobject]@{Observation=$observation;Bytes=$bytes;Handle=$file}} + } + if($matches.Count -ne 1){throw ('Expected one fresh completed automatic transcript; matching files: '+$matches.Count+'. Writer authorization remains unverified.')} + $report.After=Get-WelaTranscriptProbeState $directoryPath $handle + if((Get-WelaTranscriptProbeStateKey $report.After) -cne $stateKey){throw 'Policy, destination, source or host changed during the probe.'} + $final=Get-WelaTranscriptProbeInventory $directoryPath $dates;Assert-WelaTranscriptProbeInventory $after $final + if((Get-WelaTranscriptProbeKey @($after.Files.Path)) -cne (Get-WelaTranscriptProbeKey @($final.Files.Path))){throw 'Candidate inventory changed before final verification.'} + if((Get-WelaTranscriptProbeObjectKey $matches[0].Handle.Snapshot()) -cne (Get-WelaTranscriptProbeObjectKey $matches[0].Observation)){throw 'Matching transcript changed before evidence capture.'} + $report.ParentAfter=[Wela.WmiProbe.Native]::Snapshot() + if((Get-WelaWmiProbeTokenKey $report.ParentBefore) -cne (Get-WelaWmiProbeTokenKey $report.ParentAfter)){throw 'Parent authorization context changed during the probe.'} + $report.Artifacts+=Write-WelaTranscriptProbeArtifact $output 'transcript.txt' $matches[0].Bytes + $report.Transcript=$matches[0].Observation;$report.Status='CompletedAutomaticTranscript';$report.WriterAuthorization='ObservedForThisChild';$report.ExitCode=0 + }catch{$report.Diagnostic=$_.Exception.Message} + $report.Artifacts+=Write-WelaTranscriptProbeArtifact $output 'worker.json' ([Text.UTF8Encoding]::new($false).GetBytes((ConvertTo-Json -InputObject $report.Worker -Depth 18))) + $null=Write-WelaTranscriptProbeArtifact $output 'result.json' ([Text.UTF8Encoding]::new($false).GetBytes(($report|ConvertTo-Json -Depth 22))) + return $report + }finally{foreach($file in $heldFiles){$file.Dispose()};foreach($folder in $heldFolders){$folder.Dispose()};$handle.Dispose()} +} diff --git a/scripts/TranscriptProbeNative.cs b/scripts/TranscriptProbeNative.cs new file mode 100644 index 00000000..4cd22f82 --- /dev/null +++ b/scripts/TranscriptProbeNative.cs @@ -0,0 +1,66 @@ +// Read-only local identity/descriptor/file access and bounded pipe drains. +using System; +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Win32.SafeHandles; +namespace Wela.TranscriptProbe { + public sealed class Observation { + public string Path, Identity, CreatedUtc, WrittenUtc, Descriptor; + public uint Attributes, Links; public long Length; + } + public sealed class Capture {public string Text, Error;public bool Exceeded;} + public sealed class Item : IDisposable { + [StructLayout(LayoutKind.Sequential,Pack=4)] struct Info {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 SafeFileHandle CreateFile(string path,uint access,uint share,IntPtr security,uint disposition,uint flags,IntPtr template); + [DllImport("kernel32.dll",SetLastError=true)] static extern bool GetFileInformationByHandle(SafeFileHandle handle,out Info value); + [DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern uint GetFinalPathNameByHandle(SafeFileHandle handle,StringBuilder text,uint length,uint flags); + [DllImport("advapi32.dll")] static extern uint GetSecurityInfo(SafeFileHandle 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("kernel32.dll")] static extern IntPtr LocalFree(IntPtr memory); + SafeFileHandle handle; FileStream stream; string path; bool directory; + public const string SourceSha256 = "__WELA_TRANSCRIPT_SOURCE_SHA256__"; + Item(string path,bool directory,bool content) { + this.path=System.IO.Path.GetFullPath(path);this.directory=directory; + handle=CreateFile(this.path,content?0x80020000u:0x20080u,directory?3u:(content?1u:7u),IntPtr.Zero,3,0x02200000,IntPtr.Zero); + if(handle.IsInvalid){int error=Marshal.GetLastWin32Error();handle.Dispose();throw new Win32Exception(error);} + try {Snapshot();if(content)stream=new FileStream(handle,FileAccess.Read,4096,false);}catch{Dispose();throw;} + } + public static Item Directory(string path){return new Item(path,true,false);} + public static Item Metadata(string path){return new Item(path,false,false);} + public static Item File(string path){return new Item(path,false,true);} + public Observation Snapshot() { + Info value;if(!GetFileInformationByHandle(handle,out value))throw new Win32Exception(Marshal.GetLastWin32Error()); + if((value.Attributes&1024)!=0||((value.Attributes&16)!=0)!=directory)throw new InvalidOperationException("Unexpected reparse point or object type."); + if(!directory&&value.Links!=1)throw new InvalidOperationException("Transcript files must have one link."); + StringBuilder buffer=new StringBuilder(32768);uint length=GetFinalPathNameByHandle(handle,buffer,(uint)buffer.Capacity,0); + if(length==0||length>=buffer.Capacity)throw new InvalidOperationException("Unknown native object path."); + string final=buffer.ToString();if(final.StartsWith(@"\\?\",StringComparison.Ordinal))final=final.Substring(4); + if(!String.Equals(final.TrimEnd('\\'),path.TrimEnd('\\'),StringComparison.OrdinalIgnoreCase))throw new InvalidOperationException("Native object path changed or resolves elsewhere."); + IntPtr owner,group,dacl,sacl,sd;uint error=GetSecurityInfo(handle,1,7,out owner,out group,out dacl,out sacl,out sd); + if(error!=0)throw new Win32Exception((int)error); + string descriptor; + try {uint size=GetSecurityDescriptorLength(sd);if(size<20||size>65536)throw new InvalidOperationException("Invalid descriptor bound.");byte[] bytes=new byte[size];Marshal.Copy(sd,bytes,0,bytes.Length);descriptor=Convert.ToBase64String(bytes);}finally{LocalFree(sd);} + return new Observation{Path=final,Identity=value.Volume.ToString("x8")+":"+value.IndexHigh.ToString("x8")+value.IndexLow.ToString("x8"),CreatedUtc=DateTime.FromFileTimeUtc(value.Created).ToString("o"),WrittenUtc=DateTime.FromFileTimeUtc(value.Written).ToString("o"),Attributes=value.Attributes,Links=value.Links,Length=((long)value.SizeHigh<<32)|value.SizeLow,Descriptor=descriptor}; + } + public byte[] Read(int maximum) { + if(stream==null)throw new InvalidOperationException("Object was not opened for content."); + Observation before=Snapshot();if(before.Length<1||before.Length>maximum)throw new InvalidOperationException("Transcript is empty or exceeds its byte bound."); + byte[] bytes=new byte[(int)before.Length];stream.Position=0;int offset=0; + while(offset Drain(TextReader reader,int maximum) { + return Task.Factory.StartNew(()=>{Capture result=new Capture();StringBuilder text=new StringBuilder();char[] buffer=new char[2048]; + try {int count;while((count=reader.Read(buffer,0,buffer.Length))>0){int retain=Math.Min(count,Math.Max(0,maximum-text.Length));if(retain0)text.Append(buffer,0,retain);}} + catch(Exception error){result.Error=error.GetType().FullName;} + result.Text=text.ToString();return result;}); + } + } +} diff --git a/scripts/TranscriptProbeWorker.ps1 b/scripts/TranscriptProbeWorker.ps1 new file mode 100644 index 00000000..5ff51276 --- /dev/null +++ b/scripts/TranscriptProbeWorker.ps1 @@ -0,0 +1,40 @@ +# Fixed native5.1 worker. Automatic policy is the sole transcription producer. +param([Parameter(Mandatory)][ValidatePattern('^[a-f0-9]{32}$')][string]$Nonce) +$ErrorActionPreference='Stop' +[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false) +if($PSVersionTable.PSEdition -cne 'Desktop' -or $PSVersionTable.PSVersion.Major -ne 5 -or $PSVersionTable.PSVersion.Minor -ne 1 -or -not [Environment]::Is64BitProcess){throw 'Native Windows PowerShell5.1 is required.'} +$script:ScriptRoot=Split-Path $PSScriptRoot -Parent +. (Join-Path $PSScriptRoot 'WmiProbe.ps1') +. (Join-Path $PSScriptRoot 'PowerShellTranscription.ps1') +Initialize-WelaWmiProbeNative +$assembly=[psobject].Assembly +$types=@($assembly.GetTypes()|Where-Object Name -eq 'InternalHostUserInterfaceStrings') +if($types.Count -ne 1){throw 'Native transcript resource type is unknown.'} +$resources=[ordered]@{} +foreach($name in @('TranscriptPrologue','TranscriptEpilogue')){ + $property=$types[0].GetProperty($name,[Reflection.BindingFlags]'Public,NonPublic,Static') + if(-not $property){throw 'Native transcript resource is unavailable.'} + $value=$property.GetValue($null,$null) + if($value -isnot [string] -or $value.Length -gt 8192 -or -not $value.Contains('{0:yyyyMMddHHmmss}')){throw 'Unrecognized native transcript resource.'} + $resources[$name]=$value +} +$assemblyPath=$assembly.Location;$assemblyHash=(Get-FileHash -LiteralPath $assemblyPath -Algorithm SHA256).Hash.ToLowerInvariant() +$policyBefore=@(Get-WelaTranscriptPolicy @('Registry64','Registry32'));Test-WelaTranscriptSharedPolicy $policyBefore +$before=[Wela.WmiProbe.Native]::Snapshot();$start=[DateTimeOffset]::Now +Microsoft.PowerShell.Utility\Write-Output ('WELA-TRANSCRIPT-BEGIN:'+${Nonce}+':'+$PID) +$policyAfter=@(Get-WelaTranscriptPolicy @('Registry64','Registry32')) +if(($policyBefore|ConvertTo-Json -Depth 12 -Compress) -cne ($policyAfter|ConvertTo-Json -Depth 12 -Compress)){throw 'Worker policy changed.'} +if((Get-FileHash -LiteralPath $assemblyPath -Algorithm SHA256).Hash.ToLowerInvariant() -cne $assemblyHash){throw 'Worker engine assembly changed.'} +$after=[Wela.WmiProbe.Native]::Snapshot() +if((Get-WelaWmiProbeTokenKey $before) -cne (Get-WelaWmiProbeTokenKey $after)){throw 'Worker token changed.'} +Microsoft.PowerShell.Utility\Write-Output ('WELA-TRANSCRIPT-END:'+${Nonce}+':'+$PID) +$end=[DateTimeOffset]::Now +$operation=[pscustomobject][ordered]@{ + Nonce=$Nonce;ProcessId=$PID;Engine=(Get-Process -Id $PID).Path;EngineVersion=$PSVersionTable.PSVersion.ToString();Edition=$PSVersionTable.PSEdition + StartedUtc=$start.UtcDateTime.ToString('o');CompletedUtc=$end.UtcDateTime.ToString('o');StartOffsetMinutes=$start.Offset.TotalMinutes;EndOffsetMinutes=$end.Offset.TotalMinutes + BeforeToken=$before;AfterToken=$after;PolicyBefore=$policyBefore;PolicyAfter=$policyAfter + Computer=[Environment]::MachineName;HeaderUser=([Environment]::UserDomainName+'\'+[Environment]::UserName);OsVersion=[Environment]::OSVersion.VersionString + CommandLine=[Environment]::CommandLine;Arguments=@([Environment]::GetCommandLineArgs());UiCulture=[Globalization.CultureInfo]::CurrentUICulture.Name + Assembly=[pscustomobject]@{Path=$assemblyPath;FullName=$assembly.FullName;Sha256=$assemblyHash};Resources=[pscustomobject]$resources +} +[Console]::WriteLine('WELA-WORKER-JSON:'+($operation|ConvertTo-Json -Depth 16 -Compress)) diff --git a/tests/TranscriptProbe.Tests.ps1 b/tests/TranscriptProbe.Tests.ps1 new file mode 100644 index 00000000..bd42bd54 --- /dev/null +++ b/tests/TranscriptProbe.Tests.ps1 @@ -0,0 +1,71 @@ +$ErrorActionPreference='Stop';$script:ScriptRoot=Split-Path $PSScriptRoot -Parent +. (Join-Path $script:ScriptRoot 'scripts/WefArrival.ps1') +. (Join-Path $script:ScriptRoot 'scripts/PowerShellTranscription.ps1') +. (Join-Path $script:ScriptRoot 'scripts/TranscriptProbe.ps1') +$script:passed=0 +function Assert($condition,[string]$message){if(-not $condition){throw $message};$script:passed++} +function Rejects([scriptblock]$action){$caught=$false;try{&$action|Out-Null}catch{$caught=$true};Assert $caught 'Expected refusal'} +function Clone($object){$object|ConvertTo-Json -Depth 20|ConvertFrom-Json} +$header="**********************`nWindows PowerShell transcript start`nStart time: {0:yyyyMMddHHmmss}`nUsername: {1}`nRunAs User: {2}`nConfiguration Name: {3}`nMachine: {4} ({5})`nHost Application: {6}`nProcess ID: {7}`n{8}`n**********************" +$footer="**********************`nWindows PowerShell transcript end`nEnd time: {0:yyyyMMddHHmmss}`n**********************" +$operation=[pscustomobject]@{Resources=[pscustomobject]@{TranscriptPrologue=$header;TranscriptEpilogue=$footer};Nonce=('a'*32);ProcessId=123;HeaderUser='HOST\writer';BeforeToken=[pscustomobject]@{Name='HOST\writer'};Computer='HOST';OsVersion='Microsoft Windows NT 10.0.20348.0';CommandLine='powershell.exe fixed';EngineVersion='5.1.20348.1000';StartOffsetMinutes=0;LaunchedUtc='2026-09-21T00:00:00.1000000Z';StartedUtc='2026-09-21T00:00:01.0000000Z';CompletedUtc='2026-09-21T00:00:02.0000000Z';ExitedUtc='2026-09-21T00:00:03.0000000Z'} +function MakeText($op){ + $begin=[string]::Format([Globalization.CultureInfo]::InvariantCulture,$op.Resources.TranscriptPrologue,@([DateTime]::new(2026,9,21,0,0,0),$op.HeaderUser,$op.BeforeToken.Name,'',$op.Computer,$op.OsVersion,$op.CommandLine,$op.ProcessId,('PSVersion: '+$op.EngineVersion+"`nPSEdition: Desktop`n"))) + $end=[string]::Format([Globalization.CultureInfo]::InvariantCulture,$op.Resources.TranscriptEpilogue,[DateTime]::new(2026,9,21,0,0,2)) + $begin+"`nWELA-TRANSCRIPT-BEGIN:"+$op.Nonce+':'+$op.ProcessId+"`nWELA-TRANSCRIPT-END:"+$op.Nonce+':'+$op.ProcessId+"`n"+$end+"`n" +} +$text=MakeText $operation +Assert (Test-WelaTranscriptProbeText $text $operation) 'Complete native transcript framing and markers match' +foreach($case in @( + $text.Replace('Process ID: 123','Process ID: 124'), + $text.Replace('RunAs User: HOST\writer','RunAs User: HOST\other'), + $text.Replace('PSVersion: 5.1.20348.1000','PSVersion: 7.5.0'), + $text.Replace('PSEdition: Desktop','PSEdition: Core'), + $text.Replace('Windows PowerShell transcript end','Unfinished'), + $text.Replace('WELA-TRANSCRIPT-BEGIN:','PS>WELA-TRANSCRIPT-BEGIN:'), + $text.Replace('WELA-TRANSCRIPT-END:','PS>WELA-TRANSCRIPT-END:'), + $text.Replace('Start time: 20260921000000','Start time: 20250921000000'), + $text.Replace('End time: 20260921000002','End time: 20260921000020'), + $text.Replace(('WELA-TRANSCRIPT-END:'+('a'*32)+':123'),('WELA-TRANSCRIPT-END:'+('b'*32)+':123')), + $text.Replace('Configuration Name: ','Configuration Name: remote'), + ($text+$text), + ($text+'trailing payload') +)){Assert (-not (Test-WelaTranscriptProbeText $case $operation)) 'Incomplete, mismatched or ambiguous content has no transcript proof'} +$marker='WELA-TRANSCRIPT-END:'+('a'*32)+':123' +Assert (-not (Test-WelaTranscriptProbeText ($text.Replace($marker,($marker+"`n"+$marker))) $operation)) 'Duplicate standalone marker is rejected' +$translated=Clone $operation +$translated.Resources.TranscriptPrologue=$header.Replace('Windows PowerShell transcript start','Windows PowerShell トランスクリプト開始').Replace('Username:','ユーザー名:') +$translated.Resources.TranscriptEpilogue=$footer.Replace('Windows PowerShell transcript end','Windows PowerShell トランスクリプト終了') +Assert (Test-WelaTranscriptProbeText (MakeText $translated) $translated) 'Runtime-supplied localized resources drive matching' +foreach($encoding in @([Text.UTF8Encoding]::new($true),[Text.UnicodeEncoding]::new($false,$true),[Text.UnicodeEncoding]::new($true,$true))){$bytes=[byte[]]@($encoding.GetPreamble()+$encoding.GetBytes($text.Replace("`n","`r`n")));Assert ((ConvertFrom-WelaTranscriptProbeBytes $bytes) -ceq $text) 'Supported transcript byte encoding roundtrips'} +Rejects {ConvertFrom-WelaTranscriptProbeBytes ([byte[]]@(0xc3,0x28))} +Rejects {ConvertFrom-WelaTranscriptProbeBytes ([byte[]]@(65,0,66))} +$directory=[pscustomobject]@{Path='C:\T';Identity='id1';CreatedUtc='2026-09-21T00:00:00Z';WrittenUtc='2026-09-21T00:00:00Z';Attributes=16;Descriptor='acl';Length=0;Links=1} +$new=Clone $directory;$new.WrittenUtc='2026-09-21T00:01:00Z' +Assert ((Get-WelaTranscriptProbeObjectKey $directory -Directory) -ceq (Get-WelaTranscriptProbeObjectKey $new -Directory)) 'Directory child creation does not replace directory identity' +foreach($field in @('Identity','Descriptor','CreatedUtc','Path')){$changed=Clone $directory;$changed.$field='changed';Assert ((Get-WelaTranscriptProbeObjectKey $directory -Directory) -cne (Get-WelaTranscriptProbeObjectKey $changed -Directory)) 'Directory identity/descriptor drift is visible'} +$oldFile=Clone $directory;$oldFile.Path='C:\T\20260921\old.txt';$oldFile.Attributes=32;$oldFile.Identity='old-file';$oldFile.Length=100 +$inventory=[pscustomobject]@{Folders=@([pscustomobject]@{Date='20260921';Exists=$true;Observation=$directory});Files=@($oldFile)} +$appended=Clone $inventory;$appended.Files[0].Length=200;$appended.Files[0].WrittenUtc='2026-09-21T00:01:00Z' +Assert-WelaTranscriptProbeInventory $inventory $appended;Assert $true 'Existing active transcript append is preserved without reading it' +foreach($field in @('Identity','Descriptor','CreatedUtc','Path')){$changed=Clone $inventory;$changed.Files[0].$field='changed';Rejects {Assert-WelaTranscriptProbeInventory $inventory $changed}} +$changed=Clone $inventory;$changed.Files=@();Rejects {Assert-WelaTranscriptProbeInventory $inventory $changed} +$changed=Clone $inventory;$changed.Folders[0].Exists=$false;Rejects {Assert-WelaTranscriptProbeInventory $inventory $changed} +# Pure typed-policy tests replace only local path resolution, not the policy decision. +function Resolve-WelaArrivalPath {param([string]$Path)if($Path -notmatch '^C:\\'){throw 'Fixture non-local path'};$Path} +function Value($value,$type){[pscustomobject]@{KeyExists=$true;ValueExists=$true;Value=$value;Type=$type}} +$machine=[pscustomobject]@{EnableTranscripting=(Value 1 'DWord');OutputDirectory=(Value 'C:\T' 'String');EnableInvocationHeader=(Value 1 'DWord')} +$user=[pscustomobject]@{EnableTranscripting=[pscustomobject]@{KeyExists=$false;ValueExists=$false;Value=$null;Type=$null};OutputDirectory=[pscustomobject]@{KeyExists=$false;ValueExists=$false;Value=$null;Type=$null};EnableInvocationHeader=[pscustomobject]@{KeyExists=$false;ValueExists=$false;Value=$null;Type=$null}} +$policy=@([pscustomobject]@{View='Registry64';Machine=$machine;CurrentUser=$user},[pscustomobject]@{View='Registry32';Machine=$machine;CurrentUser=$user}) +Assert-WelaTranscriptProbePolicy $policy 'C:\T';Assert $true 'Known enabled matching machine policy accepted' +foreach($field in @('EnableTranscripting','OutputDirectory','EnableInvocationHeader')){$changed=Clone $policy;$changed[0].Machine.$field.Type='Unknown';$changed[1].Machine.$field.Type='Unknown';Rejects {Assert-WelaTranscriptProbePolicy $changed 'C:\T'}} +$changed=Clone $policy;$changed[0].Machine.EnableTranscripting.Value=0;$changed[1].Machine.EnableTranscripting.Value=0;Rejects {Assert-WelaTranscriptProbePolicy $changed 'C:\T'} +Rejects {Assert-WelaTranscriptProbePolicy $policy 'C:\Other'} +$changed=Clone $policy;$changed[1].Machine.OutputDirectory.Value='C:\Other';Rejects {Assert-WelaTranscriptProbePolicy $changed 'C:\T'} +Rejects {Assert-WelaTranscriptProbePolicy @($policy[0]) 'C:\T'} +$engine=(Get-Process -Id $PID).Path +foreach($case in @(@{Args=@('transcript-probe','-Help');Exit=0},@{Args=@('transcript-probe','-Help','-Auto');Exit=1},@{Args=@('transcript-probe','-Help','-TranscriptDirectory','C:\T');Exit=1},@{Args=@('transcript-probe','-Help','-DryRun');Exit=1},@{Args=@('help','-TranscriptProbeAction','Run');Exit=1})){ + $old=$ErrorActionPreference;$ErrorActionPreference='Continue';try{$output=&$engine -NoProfile -File (Join-Path $script:ScriptRoot 'WELA.ps1') @($case.Args) 2>&1;$code=$LASTEXITCODE}finally{$ErrorActionPreference=$old} + Assert ($code -eq $case.Exit) ('CLI boundary '+($case.Args -join ' ')+': '+($output|Out-String)) +} +$global:LASTEXITCODE=0;Write-Host "Transcript probe fixtures passed: $script:passed" diff --git a/tests/TranscriptProbe.Windows.Tests.ps1 b/tests/TranscriptProbe.Windows.Tests.ps1 new file mode 100644 index 00000000..26354892 --- /dev/null +++ b/tests/TranscriptProbe.Windows.Tests.ps1 @@ -0,0 +1,99 @@ +param([switch]$AllowDisposableWriter,[ValidateSet('powershell','pwsh')][string]$TestEngine='powershell') +$ErrorActionPreference='Stop' +if(-not $AllowDisposableWriter -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted' -or $env:OS -ne 'Windows_NT'){throw 'Explicit disposable writer/policy/ACL opt-in on a GitHub-hosted Windows runner 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 'Refusing domain, DC or unknown runner.'} +$root=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$root +. (Join-Path $root 'scripts/PowerShellTranscription.ps1') +$nonce=[guid]::NewGuid().ToString('N');$username='WelaT'+$nonce.Substring(0,12) +$fixture=Join-Path $env:RUNNER_TEMP ('wela-transcript-probe-'+$nonce);$null=New-Item -ItemType Directory $fixture +$codeRoot=Join-Path $fixture 'code';$null=New-Item -ItemType Directory $codeRoot +foreach($path in @('WELA.ps1','scripts','modules','config')){Copy-Item -LiteralPath (Join-Path $root $path) -Destination $codeRoot -Recurse} +$readerHome=Join-Path $fixture 'writer';$destination=Join-Path $fixture 'transcripts';$null=New-Item -ItemType Directory $readerHome,$destination +$engine=(Get-Command $TestEngine -ErrorAction Stop).Source +$policyBefore=@(Get-WelaTranscriptPolicy @('Registry64','Registry32'));$policyBefore|ConvertTo-Json -Depth 12|Set-Content -LiteralPath (Join-Path $fixture 'policy-before.json') -Encoding UTF8 +$ownedSid=$null;$policyTouched=$false;$passed=$false;$originalAcl=$null +function Restore-Policy { + $base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64);$key=$null + try{ + $key=$base.CreateSubKey('SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription') + $key.SetValue('EnableTranscripting',0,[Microsoft.Win32.RegistryValueKind]::DWord) + foreach($name in @('OutputDirectory','EnableInvocationHeader','EnableTranscripting')){ + $value=$policyBefore[0].Machine.$name + if($value.ValueExists){$key.SetValue($name,$value.Value,[Microsoft.Win32.RegistryValueKind]([string]$value.Type))}else{$key.DeleteValue($name,$false)} + } + $remove=-not $policyBefore[0].Machine.EnableTranscripting.KeyExists -and $key.GetValueNames().Count -eq 0 -and $key.GetSubKeyNames().Count -eq 0 + $key.Dispose();$key=$null + if($remove){$base.DeleteSubKey('SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription',$false)} + }finally{if($key){$key.Dispose()};$base.Dispose()} + if((@(Get-WelaTranscriptPolicy @('Registry64','Registry32'))|ConvertTo-Json -Depth 12 -Compress) -cne ($policyBefore|ConvertTo-Json -Depth 12 -Compress)){throw 'Exact typed transcription policy/key restoration failed.'} +} +function Invoke-ProbeAsOwnedUser([string]$Label,[int]$ExpectedExit,[ValidateSet('Plan','Run')][string]$Action='Run'){ + $output=Join-Path $readerHome $Label + # Credentials are passed as a SecureString through the process API, never command-line text. + $arguments='-NoProfile -ExecutionPolicy Bypass -File "'+(Join-Path $codeRoot 'WELA.ps1')+'" transcript-probe -TranscriptProbeAction '+$Action+' -TranscriptProbeDirectory "'+$destination+'"'+$(if($Action -eq 'Run'){' -TranscriptProbeOutputPath "'+$output+'"'}else{''}) + # Own the process handle directly: Windows PowerShell's Start-Process can lose + # ExitCode for alternate-credential children after they exit. + $start=[Diagnostics.ProcessStartInfo]::new();$start.FileName=$engine;$start.Arguments=$arguments + $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 'Native 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 'Reader child exceeded 90 seconds.'} + if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),5000)){throw 'Reader output pipes did not close within five seconds of process exit.'} + $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 'Reader child termination was not confirmed; no acceptance claim.'}}}finally{$process.Dispose()} + } + if($exitCode -ne $ExpectedExit){Get-Content -LiteralPath (Join-Path $readerHome ($Label+'.stderr'));throw "Reader exit $exitCode expected $ExpectedExit"} + if($Action -eq 'Plan'){if(Test-Path -LiteralPath $output){throw 'Plan wrote explicit evidence output.'};return} + $report=Get-Content -LiteralPath (Join-Path $output 'result.json') -Raw|ConvertFrom-Json + if($report.ReadyRuleCredit -ne 0 -or $report.SigmaEvtxCredit -ne 0 -or $report.ConfigurationChanges -ne 0){throw 'Transcript report overclaims coverage or changed configuration.'} + $report +} +try{ + $password=ConvertTo-SecureString ('Wela!7'+[guid]::NewGuid().ToString('N')+'zA#') -AsPlainText -Force + $user=New-LocalUser -Name $username -Password $password -Description ('WELA transcript '+$nonce.Substring(0,20)) -AccountNeverExpires + $ownedSid=$user.SID.Value;Add-LocalGroupMember -SID 'S-1-5-32-545' -Member $user + $acl=Get-Acl $fixture;$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($user.SID,'ReadAndExecute','ContainerInherit,ObjectInherit','None','Allow'));Set-Acl $fixture $acl + $acl=Get-Acl $readerHome;$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($user.SID,'FullControl','ContainerInherit,ObjectInherit','None','Allow'));Set-Acl $readerHome $acl + $acl=[Security.AccessControl.DirectorySecurity]::new();$acl.SetAccessRuleProtection($true,$false) + foreach($sid in @('S-1-5-18','S-1-5-32-544',$ownedSid)){$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new([Security.Principal.SecurityIdentifier]::new($sid),'FullControl','ContainerInherit,ObjectInherit','None','Allow'))} + Set-Acl $destination $acl;$originalAcl=Get-Acl $destination + $policyTouched=$true + Set-WelaTranscriptRegistryValue -Name OutputDirectory -Value $destination -Type String + Set-WelaTranscriptRegistryValue -Name EnableTranscripting -Value 1 -Type DWord + # Exercise invocation headers while preserving the real original typed preference. + $base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64) + try{$key=$base.OpenSubKey('SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription',$true);try{$key.SetValue('EnableInvocationHeader',1,[Microsoft.Win32.RegistryValueKind]::DWord)}finally{$key.Dispose()}}finally{$base.Dispose()} + $configured=@(Get-WelaTranscriptPolicy @('Registry64','Registry32'))|ConvertTo-Json -Depth 12 -Compress + Invoke-ProbeAsOwnedUser 'plan' 0 'Plan' + $allowed=Invoke-ProbeAsOwnedUser 'allowed' 0 + if($allowed.Status -ne 'CompletedAutomaticTranscript' -or $allowed.WriterAuthorization -ne 'ObservedForThisChild' -or $allowed.Worker.BeforeToken.Sid -cne $ownedSid -or $allowed.ParentBefore.Sid -cne $ownedSid -or $allowed.Worker.BeforeToken.AuthenticationId -cne $allowed.ParentBefore.AuthenticationId -or @($allowed.Worker.BeforeToken.Groups|Where-Object Sid -eq 'S-1-5-32-544').Count){throw 'No completed automatic native5.1 transcript from the actual owned standard-user logon.'} + if($allowed.Worker.Engine -notlike '*\System32\WindowsPowerShell\v1.0\powershell.exe' -or $allowed.Worker.Edition -cne 'Desktop'){throw 'Wrong transcript engine.'} + $artifact=@($allowed.Artifacts|Where-Object Name -eq 'transcript.txt') + if($artifact.Count -ne 1 -or (Get-FileHash -LiteralPath (Join-Path $allowed.OutputPath 'transcript.txt') -Algorithm SHA256).Hash.ToLowerInvariant() -cne $artifact[0].Sha256){throw 'Transcript evidence hash mismatch.'} + if((@(Get-WelaTranscriptPolicy @('Registry64','Registry32'))|ConvertTo-Json -Depth 12 -Compress) -cne $configured -or (Get-Acl $destination).Sddl -cne $originalAcl.Sddl){throw 'Probe changed configured policy or root ACL.'} + $deny=Get-Acl $destination + $deny.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($user.SID,'Write','ContainerInherit,ObjectInherit','None','Deny'));Set-Acl $destination $deny + $deniedAcl=(Get-Acl $destination).Sddl + $denied=Invoke-ProbeAsOwnedUser 'denied' 1 + if($denied.Status -eq 'CompletedAutomaticTranscript' -or $denied.WriterAuthorization -ne 'Unverified' -or $denied.Transcript){throw 'Denied writer gained positive transcript proof.'} + if((Get-Acl $destination).Sddl -cne $deniedAcl -or (@(Get-WelaTranscriptPolicy @('Registry64','Registry32'))|ConvertTo-Json -Depth 12 -Compress) -cne $configured){throw 'Denied run changed the explicit deny or policy.'} + $passed=$true +}finally{ + $errors=@() + if($policyTouched){try{Restore-Policy}catch{$errors+=[string]$_}} + if($originalAcl){try{Set-Acl $destination $originalAcl;if((Get-Acl $destination).Sddl -cne $originalAcl.Sddl){throw 'Owned destination ACL restoration failed.'}}catch{$errors+=[string]$_}} + if($ownedSid){try{$current=Get-LocalUser -Name $username -ErrorAction Stop;if($current.SID.Value -cne $ownedSid){throw '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;CleanupErrors=$errors;OwnedSid=$ownedSid;PolicyRestored=($errors.Count -eq 0);Scope='Actual local standard-user automatic native5.1 transcript and explicit denied writer; no UNC, PS7-session, collector or Sigma claim'}|ConvertTo-Json -Depth 6|Set-Content -LiteralPath (Join-Path $fixture 'acceptance.json') -Encoding UTF8 + Write-Host "Native automatic transcript evidence: $fixture" + if($errors.Count){throw ($errors -join '; ')} +} +if(-not $passed){throw 'Native transcript acceptance incomplete.'} diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index b91d1c7d..40bfad8e 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,6 +7,8 @@ **改善:** +- 既に有効なローカル保存先で、現在の実行ユーザーによる固定 Windows PowerShell 5.1 子プロセスの完了した自動トランスクリプトを検証する `transcript-probe` を追加しました。実トークン・ログオン、標準ヘッダー/フッター、nonce・PID・時刻、件数を制限したファイル識別と出典確認により不明な結果を保持し、ポリシーやACLの変更・明示的トランスクリプト開始・Sigma/EVTX加算は行いません。Server 2022/2025と両WELA実行エンジン向けに、使い捨て標準ユーザーの成功・拒否テストを追加しました。 (@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..eda305eb 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,6 +7,8 @@ **Improvements:** +- Added opt-in `transcript-probe` to verify a fixed current-account Windows PowerShell 5.1 child produces its own completed automatic transcript in an already enabled local destination. Actual token/logon, native header/footer, nonce/PID/time, bounded file identity and source checks preserve unverified outcomes without changing policy or ACLs, invoking explicit transcription or granting Sigma/EVTX credit. Added disposable standard-writer success/denial tests for Server 2022/2025 under both WELA host engines. (@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 49727ea482f0e6bc56b90a2b859ac8d4b6c0e7d7 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:45:55 +0900 Subject: [PATCH 08/24] Preserve bounded worker diagnostics and PS5 fixture encoding --- CHANGELOG-Japanese.md | 2 +- CHANGELOG.md | 2 +- docs/transcript-probe.md | 2 +- scripts/TranscriptProbe.ps1 | 10 ++++++---- tests/TranscriptProbe.Tests.ps1 | 2 +- website/docs/resources/changelog.ja.md | 2 +- website/docs/resources/changelog.md | 2 +- 7 files changed, 12 insertions(+), 10 deletions(-) diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index 6cda55db..65dcf307 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,7 +4,7 @@ **改善:** -- 既に有効なローカル保存先で、現在の実行ユーザーによる固定 Windows PowerShell 5.1 子プロセスの完了した自動トランスクリプトを検証する `transcript-probe` を追加しました。実トークン・ログオン、標準ヘッダー/フッター、nonce・PID・時刻、件数を制限したファイル識別と出典確認により不明な結果を保持し、ポリシーやACLの変更・明示的トランスクリプト開始・Sigma/EVTX加算は行いません。Server 2022/2025と両WELA実行エンジン向けに、使い捨て標準ユーザーの成功・拒否テストを追加しました。 (@Shirofune-Security) +- 既に有効なローカル保存先で、現在の実行ユーザーによる固定 Windows PowerShell 5.1 子プロセスの完了した自動トランスクリプトを検証する `transcript-probe` を追加しました。実トークン・ログオン、標準ヘッダー/フッター、nonce・PID・時刻、件数を制限したファイル識別と出典確認により不明な結果を保持し、ポリシーやACLの変更・明示的トランスクリプト開始・Sigma/EVTX加算は行いません。Server 2022/2025と両WELA実行エンジン向けに、使い捨て標準ユーザーの成功・拒否テストを追加しました。 (#436) (@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 944951a0..d4ba7544 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ **Improvements:** -- Added opt-in `transcript-probe` to verify a fixed current-account Windows PowerShell 5.1 child produces its own completed automatic transcript in an already enabled local destination. Actual token/logon, native header/footer, nonce/PID/time, bounded file identity and source checks preserve unverified outcomes without changing policy or ACLs, invoking explicit transcription or granting Sigma/EVTX credit. Added disposable standard-writer success/denial tests for Server 2022/2025 under both WELA host engines. (@Shirofune-Security) +- Added opt-in `transcript-probe` to verify a fixed current-account Windows PowerShell 5.1 child produces its own completed automatic transcript in an already enabled local destination. Actual token/logon, native header/footer, nonce/PID/time, bounded file identity and source checks preserve unverified outcomes without changing policy or ACLs, invoking explicit transcription or granting Sigma/EVTX credit. Added disposable standard-writer success/denial tests for Server 2022/2025 under both WELA host engines. (#436) (@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/transcript-probe.md b/docs/transcript-probe.md index 32d2c8fb..0bc99e68 100644 --- a/docs/transcript-probe.md +++ b/docs/transcript-probe.md @@ -46,7 +46,7 @@ The inventory covers only the previous, current and next local calendar-date fol The worker has a 30-second deadline. Each redirected output stream retains at most 64 KiB, with bounded pipe-drain and termination waits. The report includes explicit diagnostics for refusal and incomplete evidence; no fallback obtains a positive result. -A completed Run writes `result.json`, `worker.json` and the exact matching `transcript.txt` bytes into the protected output. Failed runs that reached output preparation keep diagnostic artifacts and exit nonzero. Prerequisite failures can occur before an output directory exists. Source transcripts are retained in their configured destination; WELA never removes them. +A completed Run writes `result.json`, `worker.json` and the exact matching `transcript.txt` bytes into the protected output. The result also retains bounded fixed-worker stdout/stderr and launch/exit observations, including when worker validation fails. Failed runs that reached output preparation keep diagnostic artifacts and exit nonzero. Prerequisite failures can occur before an output directory exists. Source transcripts are retained in their configured destination; WELA never removes them. ## Validation diff --git a/scripts/TranscriptProbe.ps1 b/scripts/TranscriptProbe.ps1 index 9a0ebdd7..8c86d941 100644 --- a/scripts/TranscriptProbe.ps1 +++ b/scripts/TranscriptProbe.ps1 @@ -81,7 +81,7 @@ function Assert-WelaTranscriptProbeInventory { } } function Start-WelaTranscriptProbeWorker { - param($State,[string]$Nonce,$ParentToken) + param($State,[string]$Nonce,$ParentToken,$LaunchEvidence) $worker=Join-Path $PSScriptRoot 'TranscriptProbeWorker.ps1' $arguments=@('-NoLogo','-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-File',$worker,'-Nonce',$Nonce) $info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$State.Engine @@ -91,10 +91,12 @@ function Start-WelaTranscriptProbeWorker { $process=[Diagnostics.Process]::new();$process.StartInfo=$info;$started=$false;$launched=[DateTime]::UtcNow try{ if(-not $process.Start()){throw 'Fixed transcript worker did not start.'};$started=$true + $LaunchEvidence.ProcessId=$process.Id;$LaunchEvidence.LaunchedUtc=$launched.ToString('o') $stdout=[Wela.TranscriptProbe.Item]::Drain($process.StandardOutput,65536);$stderr=[Wela.TranscriptProbe.Item]::Drain($process.StandardError,65536) if(-not $process.WaitForExit(30000)){throw 'Fixed transcript worker exceeded thirty seconds.'} - $exited=[DateTime]::UtcNow + $exited=[DateTime]::UtcNow;$LaunchEvidence.ExitedUtc=$exited.ToString('o');$LaunchEvidence.ExitCode=$process.ExitCode if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),3000)){throw 'Worker output pipes did not close within their bound.'} + $LaunchEvidence.Stdout=$stdout.Result;$LaunchEvidence.Stderr=$stderr.Result if($stdout.Result.Exceeded -or $stderr.Result.Exceeded -or $stdout.Result.Error -or $stderr.Result.Error){throw 'Worker output is oversized or incomplete.'} if($process.ExitCode -ne 0 -or $stderr.Result.Text){throw ('Fixed native5.1 worker failed; exit '+$process.ExitCode+'. No transcript fallback was attempted.')} $lines=@(($stdout.Result.Text -replace "`r`n","`n").TrimEnd("`r","`n") -split "`n") @@ -169,7 +171,7 @@ function Invoke-WelaTranscriptProbe { $before=Get-WelaTranscriptProbeInventory $directoryPath $dates if($Action -eq 'Plan'){return [pscustomobject]@{SchemaVersion=1;Kind='WelaAutomaticTranscriptPlan';Action='Plan';ExitCode=0;Status='ReadyToProbe';State=$state;Inventory=$before;Token=[Wela.WmiProbe.Native]::Snapshot();ReadyRuleCredit=0;SigmaEvtxCredit=0;WriterAuthorization='Unverified';Scope='One new native Windows PowerShell5.1 automatic transcript under this local current identity only'}} $output=New-WelaArrivalOutput $OutputPath $directoryPath - $report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaAutomaticTranscriptProbe';Action='Run';Status='Unverified';ExitCode=1;RecordedUtc=[DateTime]::UtcNow.ToString('o');Before=$state;After=$null;InventoryBefore=$before;InventoryAfter=$null;ParentBefore=$null;ParentAfter=$null;Worker=$null;Transcript=$null;Artifacts=@();Diagnostic='';OutputPath=$output;ReadyRuleCredit=0;SigmaEvtxCredit=0;ConfigurationChanges=0;WriterAuthorization='Unverified';PowerShell7Sessions='Not assessed';Collection='Not verified';Scope='One fixed native5.1 completed automatic text transcript; no retention, immutable-storage or EVTX/Sigma claim'} + $report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaAutomaticTranscriptProbe';Action='Run';Status='Unverified';ExitCode=1;RecordedUtc=[DateTime]::UtcNow.ToString('o');Before=$state;After=$null;InventoryBefore=$before;InventoryAfter=$null;ParentBefore=$null;ParentAfter=$null;Worker=$null;WorkerLaunch=[pscustomobject]@{ProcessId=$null;LaunchedUtc=$null;ExitedUtc=$null;ExitCode=$null;Stdout=$null;Stderr=$null};Transcript=$null;Artifacts=@();Diagnostic='';OutputPath=$output;ReadyRuleCredit=0;SigmaEvtxCredit=0;ConfigurationChanges=0;WriterAuthorization='Unverified';PowerShell7Sessions='Not assessed';Collection='Not verified';Scope='One fixed native5.1 completed automatic text transcript; no retention, immutable-storage or EVTX/Sigma claim'} try{ # Prepare metadata and evidence storage before capturing the actual process-token interval. foreach($folder in $before.Folders|Where-Object Exists){$held=[Wela.TranscriptProbe.Item]::Directory($folder.Observation.Path);$heldFolders+= $held;if((Get-WelaTranscriptProbeObjectKey $held.Snapshot() -Directory) -cne (Get-WelaTranscriptProbeObjectKey $folder.Observation -Directory)){throw 'Date-directory changed before worker.'}} @@ -179,7 +181,7 @@ function Invoke-WelaTranscriptProbe { # Newly created unrelated files during preparation become baseline, never candidate evidence. $before=$inventory;$report.InventoryBefore=$before $token=[Wela.WmiProbe.Native]::Snapshot();$report.ParentBefore=$token - $operation=Start-WelaTranscriptProbeWorker $state ([guid]::NewGuid().ToString('N')) $token;$report.Worker=$operation + $operation=Start-WelaTranscriptProbeWorker $state ([guid]::NewGuid().ToString('N')) $token $report.WorkerLaunch;$report.Worker=$operation $after=Get-WelaTranscriptProbeInventory $directoryPath $dates;$report.InventoryAfter=$after;Assert-WelaTranscriptProbeInventory $before $after foreach($folder in $after.Folders|Where-Object Exists){$held=[Wela.TranscriptProbe.Item]::Directory($folder.Observation.Path);$heldFolders+=$held;if((Get-WelaTranscriptProbeObjectKey $held.Snapshot() -Directory) -cne (Get-WelaTranscriptProbeObjectKey $folder.Observation -Directory)){throw 'Date directory changed after worker.'}} $candidates=@($after.Files|Where-Object{$_.Identity -cnotin @($before.Files.Identity)}) diff --git a/tests/TranscriptProbe.Tests.ps1 b/tests/TranscriptProbe.Tests.ps1 index bd42bd54..fe6f804c 100644 --- a/tests/TranscriptProbe.Tests.ps1 +++ b/tests/TranscriptProbe.Tests.ps1 @@ -1,4 +1,4 @@ -$ErrorActionPreference='Stop';$script:ScriptRoot=Split-Path $PSScriptRoot -Parent +$ErrorActionPreference='Stop';$script:ScriptRoot=Split-Path $PSScriptRoot -Parent . (Join-Path $script:ScriptRoot 'scripts/WefArrival.ps1') . (Join-Path $script:ScriptRoot 'scripts/PowerShellTranscription.ps1') . (Join-Path $script:ScriptRoot 'scripts/TranscriptProbe.ps1') diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index 40bfad8e..1aa03151 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,7 +7,7 @@ **改善:** -- 既に有効なローカル保存先で、現在の実行ユーザーによる固定 Windows PowerShell 5.1 子プロセスの完了した自動トランスクリプトを検証する `transcript-probe` を追加しました。実トークン・ログオン、標準ヘッダー/フッター、nonce・PID・時刻、件数を制限したファイル識別と出典確認により不明な結果を保持し、ポリシーやACLの変更・明示的トランスクリプト開始・Sigma/EVTX加算は行いません。Server 2022/2025と両WELA実行エンジン向けに、使い捨て標準ユーザーの成功・拒否テストを追加しました。 (@Shirofune-Security) +- 既に有効なローカル保存先で、現在の実行ユーザーによる固定 Windows PowerShell 5.1 子プロセスの完了した自動トランスクリプトを検証する `transcript-probe` を追加しました。実トークン・ログオン、標準ヘッダー/フッター、nonce・PID・時刻、件数を制限したファイル識別と出典確認により不明な結果を保持し、ポリシーやACLの変更・明示的トランスクリプト開始・Sigma/EVTX加算は行いません。Server 2022/2025と両WELA実行エンジン向けに、使い捨て標準ユーザーの成功・拒否テストを追加しました。 (#436) (@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 eda305eb..a262eaef 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,7 +7,7 @@ **Improvements:** -- Added opt-in `transcript-probe` to verify a fixed current-account Windows PowerShell 5.1 child produces its own completed automatic transcript in an already enabled local destination. Actual token/logon, native header/footer, nonce/PID/time, bounded file identity and source checks preserve unverified outcomes without changing policy or ACLs, invoking explicit transcription or granting Sigma/EVTX credit. Added disposable standard-writer success/denial tests for Server 2022/2025 under both WELA host engines. (@Shirofune-Security) +- Added opt-in `transcript-probe` to verify a fixed current-account Windows PowerShell 5.1 child produces its own completed automatic transcript in an already enabled local destination. Actual token/logon, native header/footer, nonce/PID/time, bounded file identity and source checks preserve unverified outcomes without changing policy or ACLs, invoking explicit transcription or granting Sigma/EVTX credit. Added disposable standard-writer success/denial tests for Server 2022/2025 under both WELA host engines. (#436) (@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 09/24] 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 51eda06a6f013854807545e41f00a2e15178d0f0 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:53:50 +0900 Subject: [PATCH 10/24] Use native module paths and transcript header command formatting --- .github/workflows/transcript-probe.yml | 1 + scripts/TranscriptProbe.ps1 | 4 ++-- scripts/TranscriptProbeWorker.ps1 | 7 ++++++- tests/TranscriptProbe.Tests.ps1 | 5 +++-- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/transcript-probe.yml b/.github/workflows/transcript-probe.yml index 39e93f4c..1308df73 100644 --- a/.github/workflows/transcript-probe.yml +++ b/.github/workflows/transcript-probe.yml @@ -42,5 +42,6 @@ jobs: ${{ runner.temp }}/wela-transcript-probe-*/acceptance.json ${{ runner.temp }}/wela-transcript-probe-*/policy-before.json ${{ runner.temp }}/wela-transcript-probe-*/writer/ + ${{ runner.temp }}/wela-transcript-probe-*/transcripts/ if-no-files-found: warn retention-days: 7 diff --git a/scripts/TranscriptProbe.ps1 b/scripts/TranscriptProbe.ps1 index 8c86d941..8281a5e5 100644 --- a/scripts/TranscriptProbe.ps1 +++ b/scripts/TranscriptProbe.ps1 @@ -105,7 +105,7 @@ function Start-WelaTranscriptProbeWorker { $operation=ConvertFrom-WelaArrivalJson $json[0].Substring('WELA-WORKER-JSON:'.Length) if($operation.Nonce -cne $Nonce -or $operation.ProcessId -ne $process.Id -or $operation.Engine -ine $State.Engine -or $operation.Edition -cne 'Desktop' -or $operation.EngineVersion -cnotmatch '^5\.1\.\d+\.\d+$'){throw 'Fixed worker engine/identity response differs.'} $actualArgs=@($operation.Arguments|Select-Object -Skip 1) - if((Get-WelaTranscriptProbeKey $actualArgs) -cne (Get-WelaTranscriptProbeKey $arguments) -or $operation.Arguments[0] -ine $State.Engine){throw 'Worker command arguments differ from the fixed launch.'} + if((Get-WelaTranscriptProbeKey $actualArgs) -cne (Get-WelaTranscriptProbeKey $arguments) -or $operation.Arguments[0] -ine $State.Engine -or $operation.HeaderCommandLine -cne ($operation.Arguments -join ' ')){throw 'Worker command arguments differ from the fixed launch.'} if(@($lines|Where-Object{$_ -ceq ('WELA-TRANSCRIPT-BEGIN:'+${Nonce}+':'+$process.Id)}).Count -ne 1 -or @($lines|Where-Object{$_ -ceq ('WELA-TRANSCRIPT-END:'+${Nonce}+':'+$process.Id)}).Count -ne 1){throw 'Fixed worker output markers are missing or ambiguous.'} if((Get-WelaWmiProbeTokenKey $operation.BeforeToken -AuthorizationOnly) -cne (Get-WelaWmiProbeTokenKey $ParentToken -AuthorizationOnly) -or (Get-WelaWmiProbeTokenKey $operation.BeforeToken) -cne (Get-WelaWmiProbeTokenKey $operation.AfterToken)){throw 'Worker identity/logon/group attributes differ from the parent or changed during output.'} if((Get-WelaTranscriptProbeKey $operation.PolicyBefore) -cne (Get-WelaTranscriptProbeKey $State.Policy) -or (Get-WelaTranscriptProbeKey $operation.PolicyAfter) -cne (Get-WelaTranscriptProbeKey $State.Policy)){throw 'Native worker policy differs from the observed policy.'} @@ -140,7 +140,7 @@ function Test-WelaTranscriptProbeText { $match=[regex]::Match($Text,'\A'+$pattern+'\n(?[\s\S]*?)\n'+$tail+'\n*\z',[Text.RegularExpressions.RegexOptions]::CultureInvariant,[TimeSpan]::FromSeconds(1)) if(-not $match.Success){return $false} foreach($template in @($header,$footer)){$prefix=(@($template -split "`n"|Select-Object -First 2) -join "`n");if([regex]::Matches($Text,[regex]::Escape($prefix)).Count -ne 1){return $false}} - if($match.Groups['User'].Value -ine $Operation.HeaderUser -or $match.Groups['RunAs'].Value -ine $Operation.BeforeToken.Name -or $match.Groups['Configuration'].Value -cne '' -or $match.Groups['Machine'].Value -ine $Operation.Computer -or $match.Groups['OS'].Value -cne $Operation.OsVersion -or $match.Groups['Command'].Value -cne $Operation.CommandLine -or [long]$match.Groups['Pid'].Value -ne $Operation.ProcessId){return $false} + if($match.Groups['User'].Value -ine $Operation.HeaderUser -or $match.Groups['RunAs'].Value -ine $Operation.BeforeToken.Name -or $match.Groups['Configuration'].Value -cne '' -or $match.Groups['Machine'].Value -ine $Operation.Computer -or $match.Groups['OS'].Value -cne $Operation.OsVersion -or $match.Groups['Command'].Value -cne $Operation.HeaderCommandLine -or [long]$match.Groups['Pid'].Value -ne $Operation.ProcessId){return $false} $versions=@($match.Groups['Versions'].Value -split "`n") if(@($versions|Where-Object{$_ -ceq ('PSVersion: '+$Operation.EngineVersion)}).Count -ne 1 -or @($versions|Where-Object{$_ -ceq 'PSEdition: Desktop'}).Count -ne 1){return $false} $body=@($match.Groups['Body'].Value -split "`n");$begin='WELA-TRANSCRIPT-BEGIN:'+$Operation.Nonce+':'+$Operation.ProcessId;$end='WELA-TRANSCRIPT-END:'+$Operation.Nonce+':'+$Operation.ProcessId diff --git a/scripts/TranscriptProbeWorker.ps1 b/scripts/TranscriptProbeWorker.ps1 index 5ff51276..1b14feb0 100644 --- a/scripts/TranscriptProbeWorker.ps1 +++ b/scripts/TranscriptProbeWorker.ps1 @@ -3,6 +3,11 @@ param([Parameter(Mandatory)][ValidatePattern('^[a-f0-9]{32}$')][string]$Nonce) $ErrorActionPreference='Stop' [Console]::OutputEncoding=[Text.UTF8Encoding]::new($false) if($PSVersionTable.PSEdition -cne 'Desktop' -or $PSVersionTable.PSVersion.Major -ne 5 -or $PSVersionTable.PSVersion.Minor -ne 1 -or -not [Environment]::Is64BitProcess){throw 'Native Windows PowerShell5.1 is required.'} +# A PowerShell7 parent can pass a PSModulePath without the native5.1 modules. +# Load only the fixed installed native modules, independent of caller module search paths. +foreach($module in @('Microsoft.PowerShell.Utility','Microsoft.PowerShell.Management')){ + Import-Module ([IO.Path]::Combine($PSHOME,'Modules',$module,($module+'.psd1'))) -ErrorAction Stop +} $script:ScriptRoot=Split-Path $PSScriptRoot -Parent . (Join-Path $PSScriptRoot 'WmiProbe.ps1') . (Join-Path $PSScriptRoot 'PowerShellTranscription.ps1') @@ -34,7 +39,7 @@ $operation=[pscustomobject][ordered]@{ StartedUtc=$start.UtcDateTime.ToString('o');CompletedUtc=$end.UtcDateTime.ToString('o');StartOffsetMinutes=$start.Offset.TotalMinutes;EndOffsetMinutes=$end.Offset.TotalMinutes BeforeToken=$before;AfterToken=$after;PolicyBefore=$policyBefore;PolicyAfter=$policyAfter Computer=[Environment]::MachineName;HeaderUser=([Environment]::UserDomainName+'\'+[Environment]::UserName);OsVersion=[Environment]::OSVersion.VersionString - CommandLine=[Environment]::CommandLine;Arguments=@([Environment]::GetCommandLineArgs());UiCulture=[Globalization.CultureInfo]::CurrentUICulture.Name + CommandLine=[Environment]::CommandLine;HeaderCommandLine=([Environment]::GetCommandLineArgs() -join ' ');Arguments=@([Environment]::GetCommandLineArgs());UiCulture=[Globalization.CultureInfo]::CurrentUICulture.Name Assembly=[pscustomobject]@{Path=$assemblyPath;FullName=$assembly.FullName;Sha256=$assemblyHash};Resources=[pscustomobject]$resources } [Console]::WriteLine('WELA-WORKER-JSON:'+($operation|ConvertTo-Json -Depth 16 -Compress)) diff --git a/tests/TranscriptProbe.Tests.ps1 b/tests/TranscriptProbe.Tests.ps1 index fe6f804c..5baa5484 100644 --- a/tests/TranscriptProbe.Tests.ps1 +++ b/tests/TranscriptProbe.Tests.ps1 @@ -8,9 +8,9 @@ function Rejects([scriptblock]$action){$caught=$false;try{&$action|Out-Null}catc function Clone($object){$object|ConvertTo-Json -Depth 20|ConvertFrom-Json} $header="**********************`nWindows PowerShell transcript start`nStart time: {0:yyyyMMddHHmmss}`nUsername: {1}`nRunAs User: {2}`nConfiguration Name: {3}`nMachine: {4} ({5})`nHost Application: {6}`nProcess ID: {7}`n{8}`n**********************" $footer="**********************`nWindows PowerShell transcript end`nEnd time: {0:yyyyMMddHHmmss}`n**********************" -$operation=[pscustomobject]@{Resources=[pscustomobject]@{TranscriptPrologue=$header;TranscriptEpilogue=$footer};Nonce=('a'*32);ProcessId=123;HeaderUser='HOST\writer';BeforeToken=[pscustomobject]@{Name='HOST\writer'};Computer='HOST';OsVersion='Microsoft Windows NT 10.0.20348.0';CommandLine='powershell.exe fixed';EngineVersion='5.1.20348.1000';StartOffsetMinutes=0;LaunchedUtc='2026-09-21T00:00:00.1000000Z';StartedUtc='2026-09-21T00:00:01.0000000Z';CompletedUtc='2026-09-21T00:00:02.0000000Z';ExitedUtc='2026-09-21T00:00:03.0000000Z'} +$operation=[pscustomobject]@{Resources=[pscustomobject]@{TranscriptPrologue=$header;TranscriptEpilogue=$footer};Nonce=('a'*32);ProcessId=123;HeaderUser='HOST\writer';BeforeToken=[pscustomobject]@{Name='HOST\writer'};Computer='HOST';OsVersion='Microsoft Windows NT 10.0.20348.0';CommandLine='"powershell.exe" fixed';HeaderCommandLine='powershell.exe fixed';EngineVersion='5.1.20348.1000';StartOffsetMinutes=0;LaunchedUtc='2026-09-21T00:00:00.1000000Z';StartedUtc='2026-09-21T00:00:01.0000000Z';CompletedUtc='2026-09-21T00:00:02.0000000Z';ExitedUtc='2026-09-21T00:00:03.0000000Z'} function MakeText($op){ - $begin=[string]::Format([Globalization.CultureInfo]::InvariantCulture,$op.Resources.TranscriptPrologue,@([DateTime]::new(2026,9,21,0,0,0),$op.HeaderUser,$op.BeforeToken.Name,'',$op.Computer,$op.OsVersion,$op.CommandLine,$op.ProcessId,('PSVersion: '+$op.EngineVersion+"`nPSEdition: Desktop`n"))) + $begin=[string]::Format([Globalization.CultureInfo]::InvariantCulture,$op.Resources.TranscriptPrologue,@([DateTime]::new(2026,9,21,0,0,0),$op.HeaderUser,$op.BeforeToken.Name,'',$op.Computer,$op.OsVersion,$op.HeaderCommandLine,$op.ProcessId,('PSVersion: '+$op.EngineVersion+"`nPSEdition: Desktop`n"))) $end=[string]::Format([Globalization.CultureInfo]::InvariantCulture,$op.Resources.TranscriptEpilogue,[DateTime]::new(2026,9,21,0,0,2)) $begin+"`nWELA-TRANSCRIPT-BEGIN:"+$op.Nonce+':'+$op.ProcessId+"`nWELA-TRANSCRIPT-END:"+$op.Nonce+':'+$op.ProcessId+"`n"+$end+"`n" } @@ -18,6 +18,7 @@ $text=MakeText $operation Assert (Test-WelaTranscriptProbeText $text $operation) 'Complete native transcript framing and markers match' foreach($case in @( $text.Replace('Process ID: 123','Process ID: 124'), + $text.Replace('Host Application: powershell.exe fixed','Host Application: powershell.exe other'), $text.Replace('RunAs User: HOST\writer','RunAs User: HOST\other'), $text.Replace('PSVersion: 5.1.20348.1000','PSVersion: 7.5.0'), $text.Replace('PSEdition: Desktop','PSEdition: Core'), From 8882d7c9ee526ef27fb2071519c05979e9f52a28 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:55:35 +0900 Subject: [PATCH 11/24] Clarify character bounds for worker diagnostics --- docs/transcript-probe.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/transcript-probe.md b/docs/transcript-probe.md index 0bc99e68..19f1ac8e 100644 --- a/docs/transcript-probe.md +++ b/docs/transcript-probe.md @@ -44,7 +44,7 @@ This is local consistency evidence, not tamper-proof attestation against another The inventory covers only the previous, current and next local calendar-date folders, with at most 256 entries in total. Existing transcript contents are never read. The verifier considers at most 32 new file identities, reads at most 1 MiB per candidate and 4 MiB in total, and accepts exactly one matching transcript. Unexpected directories, names, encodings or candidate times remain unverified. Busy destinations can exceed these conservative bounds. -The worker has a 30-second deadline. Each redirected output stream retains at most 64 KiB, with bounded pipe-drain and termination waits. The report includes explicit diagnostics for refusal and incomplete evidence; no fallback obtains a positive result. +The worker has a 30-second deadline. Each redirected output stream retains at most 65,536 characters, with bounded pipe-drain and termination waits. The report includes explicit diagnostics for refusal and incomplete evidence; no fallback obtains a positive result. A completed Run writes `result.json`, `worker.json` and the exact matching `transcript.txt` bytes into the protected output. The result also retains bounded fixed-worker stdout/stderr and launch/exit observations, including when worker validation fails. Failed runs that reached output preparation keep diagnostic artifacts and exit nonzero. Prerequisite failures can occur before an output directory exists. Source transcripts are retained in their configured destination; WELA never removes them. 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 12/24] 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 13/24] 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 14/24] 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 15/24] 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 16/24] 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 17/24] 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 9891d812ad7acd7c0c9c50f0612e8e1ab1121cdb Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 10:25:49 +0900 Subject: [PATCH 18/24] Include automatic transcript guide in release artifacts --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8e912d6c..eaab3956 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/transcript-probe.md -Destination release-binaries/docs/ - name: Set Artifact Name if: contains(matrix.info.os, 'windows') == true 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 19/24] 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 20/24] 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 21/24] 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 a86d2f85ce3ab1841273aa4d1083f3fc8c997210 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 10:33:42 +0900 Subject: [PATCH 22/24] Package the linked transcription configuration guide --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eaab3956..64cb522e 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, ./docs/transcript-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/transcript-probe.md, ./docs/powershell-transcription.md -Destination release-binaries/docs/ - name: Set Artifact Name if: contains(matrix.info.os, 'windows') == true 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 23/24] 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 24/24] 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})}}