diff --git a/.gitattributes b/.gitattributes index f42c64cb..1305f721 100644 --- a/.gitattributes +++ b/.gitattributes @@ -58,11 +58,23 @@ 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 +# 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 /scripts/TranscriptProbe* text eol=lf /scripts/PowerShellTranscription.ps1 text eol=lf /scripts/WefArrival.ps1 text eol=lf /tests/TranscriptProbe*.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 /scripts/Capi2Probe* text eol=lf /tests/Capi2Probe* 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/.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/.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/.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/.github/workflows/release.yml b/.github/workflows/release.yml index 89d9b01a..ffc62313 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, ./docs/powershell-transcription.md, ./docs/firewall-logging.md, ./docs/firewall-logging-recovery.md, ./docs/ipsec-prerequisites.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-ingress.md, ./docs/capi2-probe.md, ./docs/transcription-recovery.md, ./docs/eventlog-recovery.md, ./docs/failed-logon-probe.md, ./docs/wec-listener.md, ./docs/file-access-probe.md, ./docs/applocker-script-probe.md, ./docs/applocker-probe.md, ./docs/selected-sacl-configuration.md, ./docs/registry-sacl-recovery.md, ./docs/targeted-sacl-planning.md, ./docs/native-registry-sacl-validation.md, ./docs/wec-authorization.md, ./docs/channel-recovery.md, ./docs/outgoing-ntlm.md, ./docs/wec-collector-observation.md, ./docs/native-provider-packs.md, ./docs/native-provider-acceptance.md, ./docs/audit-catalog-mappings.md, ./docs/native-token-right-attribution.md, ./docs/audit-notifications.md, ./docs/native-onesettings-acceptance.md, ./docs/ntlm-auditing.md, ./docs/wef-query.md, ./docs/native-filesystem-sacl-validation.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, ./docs/firewall-logging.md, ./docs/firewall-logging-recovery.md, ./docs/ipsec-prerequisites.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-ingress.md, ./docs/capi2-probe.md, ./docs/transcription-recovery.md, ./docs/eventlog-recovery.md, ./docs/failed-logon-probe.md, ./docs/wec-listener.md, ./docs/file-access-probe.md, ./docs/applocker-script-probe.md, ./docs/applocker-probe.md, ./docs/selected-sacl-configuration.md, ./docs/registry-sacl-recovery.md, ./docs/targeted-sacl-planning.md, ./docs/native-registry-sacl-validation.md, ./docs/wec-authorization.md, ./docs/channel-recovery.md, ./docs/outgoing-ntlm.md, ./docs/wec-collector-observation.md, ./docs/native-provider-packs.md, ./docs/native-provider-acceptance.md, ./docs/audit-catalog-mappings.md, ./docs/native-token-right-attribution.md, ./docs/audit-notifications.md, ./docs/native-onesettings-acceptance.md, ./docs/ntlm-auditing.md, ./docs/wef-query.md, ./docs/native-filesystem-sacl-validation.md, ./docs/file-sacl-recovery.md, ./docs/dns-client-probe.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 b6321ae1..fed6cc3e 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -2,10 +2,18 @@ ## 2.2.0 [2026/xx/xx] - Dev Release +- 完了したジャーナルから、プロセス作成・PowerShell ログ用の 3 つの DWORD 値を `audit-recovery` で復元できるようにしました。ネイティブ API による値のみの復元、他の値の変更検知、レジストリキーの保持に対応します。 ([#435](https://github.com/Yamato-Security/WELA/pull/435)) + **改善:** - 既に有効なローカル保存先で、現在の実行ユーザーによる固定 Windows PowerShell 5.1 子プロセスの完了した自動トランスクリプトを検証する `transcript-probe` を追加しました。実トークン・ログオン、標準ヘッダー/フッター、nonce・PID・時刻、件数を制限したファイル識別と出典確認により不明な結果を保持し、ポリシーやACLの変更・明示的トランスクリプト開始・Sigma/EVTX加算は行いません。Server 2022/2025と両WELA実行エンジン向けに、使い捨て標準ユーザーの成功・拒否テストを追加しました。 (#436) (@Shirofune-Security) +- `file-sacl-recovery` を追加しました。元の計画、変更前後のレシート、成功結果、ソースとホストのハッシュ、実際のファイル識別情報を照合し、変更されていない選択済み単一ファイルに追加した監査 ACE だけを明示的に削除できます。書き込み前の永続記録と SACL 限定の変更・再読込により、無関係な ACE のバイト列と個数、観測した他の記述子要素を保持します。空または null の SACL が存在フラグ付きで残る場合を区別し、元の記述子との完全一致、ポリシー・ディレクトリ・レジストリの復旧、Sigma の利用可能性は保証しません。(#437) (@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) + +- `evtx-recovery` を強化し、実際のプライマリトークンと通常のホスト情報を使って、標準ユーザーでも既存のネイティブアーカイブを検証できるようにしました。バージョン2のレポートはファイル読取拒否と厳密なイベント復元を区別し、トークン・ログオン・変更IDと実装の指紋を照合します。元のイベント生成者と読取者を分離し、既存の権限変更やSigma評価への加算は行いません。使い捨てWindowsテストでは独立した標準ユーザーログオンで実際の拒否と4688 EVTX復元を確認し、テスト用アカウント・ファイルACL・監査設定の復元を検証します。 (#433) (@Shirofune-Security) + - Server2022/2025 と PowerShell5.1/7 で、Token Right Adjusted の正規GUIDに対する Security4703 の実機検証を追加しました。所有する子プロセスの既存権限を固定手順で無効化・復元し、候補となる2つの監査マスクを比較して、実イベント・実行条件・ハッシュとポリシー/トークンの復元を記録します。過去の候補は条件付きのまま維持し、製品用プローブ・全OS共通の対応関係・Sigma加点は追加しません。(関連 #380) (@Shirofune-Security) - OneSettingsポリシーと明示的なPrivacyチャネル設定の公開CLIを実機検証します。Server 2022で実際の適用、型付き復元記録と再読取、冪等性、不正値の拒否を確認し、Server 2025の既存の拒否を維持します。両PowerShellで厳密な後処理を検証し、イベント生成やSigma対応は主張しません。通知コントロール省略時の引数渡しを修正し、既定のAuditは両項目を読み取り、項目未選択のConfigureは非ゼロで失敗します。(関連 #378) (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e941889..a521425b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,18 @@ ## 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. ([#435](https://github.com/Yamato-Security/WELA/pull/435)) + **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. (#436) (@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 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) + +- 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 disposable native Security4703 attribution for the canonical Token Right Adjusted GUID on Server2022/2025 and PowerShell5.1/7. A fixed owned-child privilege disable/restore compares the two historical audit-mask candidates, retains exact event/context/hash evidence and verifies policy/token cleanup. Historical candidates remain conditional; no production probe, universal mapping or Sigma credit. (Related #380) (@Shirofune-Security) - Add native public OneSettings policy and explicit Privacy-channel configuration acceptance: actual apply, typed journals/readback, idempotence and invalid-value refusals on Server 2022; preserve the existing Server 2025 refusal. Both PowerShell engines verify exact fixture cleanup without event or Sigma claims. Fix omitted notification-control dispatch so default Audit reads both controls and Configure without a selection fails with a nonzero exit. (Related #378) (@Shirofune-Security) diff --git a/WELA.ps1 b/WELA.ps1 index 9fb9aa52..16119542 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -58,6 +58,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]$Capi2ProbeAction = 'Plan', [string]$Capi2ProbeOutputPath, [ValidateRange(1,30)][int]$Capi2ProbeTimeoutSeconds = 15, @@ -143,6 +147,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]$TranscriptRecoveryAction = 'Plan', [string]$TranscriptRecoveryJournalPath, [string]$TranscriptRecoveryOriginalResultsPath, @@ -265,6 +277,7 @@ $SaclTargetsPath = Join-Path $ScriptRoot "config/audit_sacl_targets.json" . (Join-Path $ScriptRoot "scripts/AppLockerScriptProbe.ps1") . (Join-Path $ScriptRoot "scripts/WmiNamespaceAuditing.ps1") . (Join-Path $ScriptRoot "scripts/WmiProbe.ps1") +. (Join-Path $ScriptRoot "scripts/DnsClientProbe.ps1") . (Join-Path $ScriptRoot "scripts/FileAccessProbe.ps1") . (Join-Path $ScriptRoot "scripts/Capi2Probe.ps1") . (Join-Path $ScriptRoot "scripts/FailedLogonProbe.ps1") @@ -303,6 +316,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") . (Join-Path $ScriptRoot "scripts/TranscriptionRecovery.ps1") # 64bit の PowerShell と GPO が読むのは Wow6432Node の無いパス。32bit 用に両方を扱う。 @@ -2097,6 +2111,7 @@ Usage: ./WELA.ps1 wec-authorization -Help # Review source SID authorization on a disabled subscription ./WELA.ps1 wec-state -Help # Review enable/disable of one existing subscription ./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 capi2-probe -Help # Fixed offline chain and matched CAPI2 event 11 evidence ./WELA.ps1 failed-logon-probe -Help # Fixed nonexistent local account and matched Security4625 evidence ./WELA.ps1 wmi-probe -Help # Fixed local read and matched namespace Security4662 evidence @@ -2127,7 +2142,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) { @@ -2179,6 +2194,8 @@ if ($Cmd -ne 'file-access-probe' -and @($PSBoundParameters.Keys | Where-Object { if ($Cmd -eq 'file-access-probe' -and ($args.Count -gt 0 -or @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','FileProbeAction','FileProbePath','FileProbeOutputPath','FileProbeTimeoutSeconds','Help')}).Count)) {throw 'file-access-probe accepts only its dedicated options.'} 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 'transcription-recovery' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'TranscriptRecovery*'}).Count) {throw 'TranscriptRecovery options require transcription-recovery.'} if ($Cmd -eq 'transcription-recovery' -and ($args.Count -gt 0 -or @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','TranscriptRecoveryAction','TranscriptRecoveryJournalPath','TranscriptRecoveryOriginalResultsPath','TranscriptRecoveryPlanPath','TranscriptRecoveryPlanHash','TranscriptRecoveryOutputPath','TranscriptRecoveryAllowTemporarySuspension','Auto','DryRun','Help')}).Count)) {throw 'transcription-recovery accepts only its dedicated options, Auto and DryRun.'} if ($Cmd -ne 'audit-recovery' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'Recovery*'}).Count) {throw 'Recovery options require audit-recovery. No command was run.'} @@ -2226,6 +2243,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 'capi2-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'Capi2Probe*'}).Count) {throw 'Capi2Probe options require capi2-probe.'} if ($Cmd -eq 'capi2-probe' -and ($args.Count -or @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','Capi2ProbeAction','Capi2ProbeOutputPath','Capi2ProbeTimeoutSeconds','Help')}).Count)) {throw 'capi2-probe accepts only dedicated probe options.'} if ($Cmd -ne 'failed-logon-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'FailedLogon*'}).Count) {throw 'FailedLogon options require failed-logon-probe.'} @@ -2302,7 +2321,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 'ntlm-auditing' -and $NtlmAuditAction -eq 'Configure') -and -not ($Cmd -eq 'outgoing-ntlm' -and $NtlmAction -eq 'Configure') -and -not ($Cmd -eq 'transcription-recovery' -and $TranscriptRecoveryAction -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 +if ($DryRun -and -not ($Cmd -eq 'file-sacl-recovery' -and $FileSaclRecoveryAction -eq 'Restore') -and -not ($Cmd -eq 'ntlm-auditing' -and $NtlmAuditAction -eq 'Configure') -and -not ($Cmd -eq 'outgoing-ntlm' -and $NtlmAction -eq 'Configure') -and -not ($Cmd -eq 'transcription-recovery' -and $TranscriptRecoveryAction -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 'firewall-recovery' -and $FirewallRecoveryAction -eq 'Restore') -and @@ -2434,6 +2453,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 + } 'file-access-probe' { if ($Help) {Write-Host 'Usage: file-access-probe [-FileProbeAction Plan] -FileProbePath C:\Audit\existing-file.txt; Run additionally requires -FileProbeOutputPath C:\Evidence\new-probe [-FileProbeTimeoutSeconds 15]. Reads one byte and discards it; event matching uses the measured read plus held-handle identity/security readback phase, with the ReadFile return recorded separately. Source-tree/active-engine targets and aliases are refused before hashing. Existing File System success policy, precedence and matching ReadData SACL are required; no policy, ACL or file-data writes. Local4663 success only, no failure/forwarding/Sigma credit. See docs/file-access-probe.md.';return} $report=Invoke-WelaFileAccessProbe -Action $FileProbeAction -FilePath $FileProbePath -OutputPath $FileProbeOutputPath -TimeoutSeconds $FileProbeTimeoutSeconds @@ -2533,6 +2558,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.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} + } 'capi2-probe' { if ($Help) {Write-Host 'Usage: capi2-probe [-Capi2ProbeAction Plan|Run] [-Capi2ProbeOutputPath new-private-directory] [-Capi2ProbeTimeoutSeconds 1..30]. Fixed offline ephemeral certificate-chain build; requires an enabled readable CAPI2 channel. No configuration, trust, TLS or Sigma claim. See docs/capi2-probe.md.';return} $report=Invoke-WelaCapi2Probe -Action $Capi2ProbeAction -OutputPath $Capi2ProbeOutputPath -TimeoutSeconds $Capi2ProbeTimeoutSeconds diff --git a/docs/audit-recovery.md b/docs/audit-recovery.md index f2a0812a..0f5e8acd 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,21 @@ 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. + +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/docs/dns-client-probe.md b/docs/dns-client-probe.md new file mode 100644 index 00000000..2034a96d --- /dev/null +++ b/docs/dns-client-probe.md @@ -0,0 +1,27 @@ +# 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.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. 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. 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/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/docs/file-sacl-recovery.md b/docs/file-sacl-recovery.md new file mode 100644 index 00000000..2b921e18 --- /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 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: + +| 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 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 + +`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/docs/native-provider-packs.md b/docs/native-provider-packs.md index 0af10746..e9b02158 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/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/DnsClientProbe.ps1 b/scripts/DnsClientProbe.ps1 new file mode 100644 index 00000000..fc4f76ab --- /dev/null +++ b/scripts/DnsClientProbe.ps1 @@ -0,0 +1,160 @@ +# 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])){$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 { + 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($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 -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"} + } + } + # 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) + 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,$Report) + Initialize-WelaDnsClientProbeNative + $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 + try{ + $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.'} + 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: 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($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 + $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' + # 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 + $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) + $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]*$' -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} + 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.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.test.' + $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) + $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} + 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..a43a0638 --- /dev/null +++ b/scripts/DnsClientProbeNative.cs @@ -0,0 +1,71 @@ +// 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 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 { + 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; } + // 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; + } + 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: + // 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); + 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); + 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..c73fee82 --- /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=[Wela.DnsClientProbe.Native]::UtcNow().ToString('o') +$query=[Wela.DnsClientProbe.Native]::Query($QueryName,$Resolver) +$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;Clock='GetSystemTimePreciseAsFileTime';ProcessId=$PID;BeforeToken=$before;AfterToken=$after}|ConvertTo-Json -Depth 12 -Compress 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/scripts/FileSaclRecovery.ps1 b/scripts/FileSaclRecovery.ps1 new file mode 100644 index 00000000..cb601620 --- /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 or null 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;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) + $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;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.'} + 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..ed4e09c2 --- /dev/null +++ b/scripts/FileSaclRecoveryNative.cs @@ -0,0 +1,118 @@ +// 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)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});} + 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 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);} + 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 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/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.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 new file mode 100644 index 00000000..ffcf3186 --- /dev/null +++ b/tests/DnsClientProbe.Tests.ps1 @@ -0,0 +1,66 @@ +$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' +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.' +$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})}} +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.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.test.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 +# 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/tests/DnsClientProbe.Windows.Tests.ps1 b/tests/DnsClientProbe.Windows.Tests.ps1 new file mode 100644 index 00000000..4102c78c --- /dev/null +++ b/tests/DnsClientProbe.Windows.Tests.ps1 @@ -0,0 +1,75 @@ +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.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) +$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} +$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.'} + 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[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 + 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\.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.' + 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 + 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 +}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/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/FileSaclRecovery.Cli.Tests.ps1 b/tests/FileSaclRecovery.Cli.Tests.ps1 new file mode 100644 index 00000000..58a8b0b3 --- /dev/null +++ b/tests/FileSaclRecovery.Cli.Tests.ps1 @@ -0,0 +1,24 @@ +$ErrorActionPreference='Stop' +$repo=Split-Path $PSScriptRoot -Parent;$engine=(Get-Process -Id $PID).Path +$cases=@( + @{Args=@('file-sacl-recovery','-Help');Exit=0;Pattern='Usage: file-sacl-recovery'}, + @{Args=@('file-sacl-recovery','-FileSaclRecoveryAction','Restore','-DryRun','-Help');Exit=0;Pattern='Usage:'}, + @{Args=@('file-sacl-recovery','-FileSaclRecoveryAction','Restore','-Auto','-Help');Exit=0;Pattern='Usage:'}, + @{Args=@('file-sacl-recovery','-DryRun','-Help');Exit=1;Pattern='DryRun'}, + @{Args=@('configure','-FileSaclRecoveryAction','Restore','-Help');Exit=1;Pattern='require file-sacl-recovery'}, + @{Args=@('targeted-sacl','-FileSaclRecoveryPlanPath','unread.json','-Help');Exit=1;Pattern='require file-sacl-recovery|targeted-sacl accepts only'}, + @{Args=@('file-sacl-recovery','-TargetSaclIncludeChildren','-Help');Exit=1;Pattern='require targeted-sacl|dedicated'}, + @{Args=@('file-sacl-recovery','-Role','Client','-Help');Exit=1;Pattern='dedicated'}, + @{Args=@('file-sacl-recovery','-ResultsPath','unwritten.json','-Help');Exit=1;Pattern='dedicated'}, + @{Args=@('file-sacl-recovery','-RecoveryPlanPath','unread.json','-Help');Exit=1;Pattern='dedicated'}, + @{Args=@('file-sacl-recovery','-Auto');Exit=1;Pattern='Plan requires four original'}, + @{Args=@('file-sacl-recovery','-FileSaclRecoveryAction','Restore','-Auto');Exit=1;Pattern='Restore requires PlanPath'}, + @{Args=@('file-sacl-recovery','-FileSaclRecoveryAction','Restore','-DryRun','-FileSaclRecoveryOutputPath','unwritten-directory');Exit=1;Pattern='Restore requires PlanPath'}, + @{Args=@('audit-recovery','-RecoveryAction','Restore','-DryRun','-Help');Exit=0;Pattern='Usage: audit-recovery'} +) +foreach($case in $cases){ + $ErrorActionPreference='Continue';try{$text=@(& $engine -NoProfile -File (Join-Path $repo 'WELA.ps1') @($case.Args) 2>&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..7f86dbd5 --- /dev/null +++ b/tests/FileSaclRecovery.Descriptor.Tests.ps1 @@ -0,0 +1,106 @@ +# 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' +# 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 +$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..feb89a0d --- /dev/null +++ b/tests/FileSaclRecovery.Windows.Tests.ps1 @@ -0,0 +1,107 @@ +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 + # 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 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')){ + $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','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.' + $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.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.' + 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/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/tests/fixtures/EvtxReader.Windows.Fixture.ps1 b/tests/fixtures/EvtxReader.Windows.Fixture.ps1 new file mode 100644 index 00000000..f986b518 --- /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 '+$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 ad1217db..54ab032a 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -5,10 +5,18 @@ ## 2.2.0 [2026/xx/xx] - Dev Release +- 完了したジャーナルから、プロセス作成・PowerShell ログ用の 3 つの DWORD 値を `audit-recovery` で復元できるようにしました。ネイティブ API による値のみの復元、他の値の変更検知、レジストリキーの保持に対応します。 ([#435](https://github.com/Yamato-Security/WELA/pull/435)) + **改善:** - 既に有効なローカル保存先で、現在の実行ユーザーによる固定 Windows PowerShell 5.1 子プロセスの完了した自動トランスクリプトを検証する `transcript-probe` を追加しました。実トークン・ログオン、標準ヘッダー/フッター、nonce・PID・時刻、件数を制限したファイル識別と出典確認により不明な結果を保持し、ポリシーやACLの変更・明示的トランスクリプト開始・Sigma/EVTX加算は行いません。Server 2022/2025と両WELA実行エンジン向けに、使い捨て標準ユーザーの成功・拒否テストを追加しました。 (#436) (@Shirofune-Security) +- `file-sacl-recovery` を追加しました。元の計画、変更前後のレシート、成功結果、ソースとホストのハッシュ、実際のファイル識別情報を照合し、変更されていない選択済み単一ファイルに追加した監査 ACE だけを明示的に削除できます。書き込み前の永続記録と SACL 限定の変更・再読込により、無関係な ACE のバイト列と個数、観測した他の記述子要素を保持します。空または null の SACL が存在フラグ付きで残る場合を区別し、元の記述子との完全一致、ポリシー・ディレクトリ・レジストリの復旧、Sigma の利用可能性は保証しません。(#437) (@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) + +- `evtx-recovery` を強化し、実際のプライマリトークンと通常のホスト情報を使って、標準ユーザーでも既存のネイティブアーカイブを検証できるようにしました。バージョン2のレポートはファイル読取拒否と厳密なイベント復元を区別し、トークン・ログオン・変更IDと実装の指紋を照合します。元のイベント生成者と読取者を分離し、既存の権限変更やSigma評価への加算は行いません。使い捨てWindowsテストでは独立した標準ユーザーログオンで実際の拒否と4688 EVTX復元を確認し、テスト用アカウント・ファイルACL・監査設定の復元を検証します。 (#433) (@Shirofune-Security) + - Server2022/2025 と PowerShell5.1/7 で、Token Right Adjusted の正規GUIDに対する Security4703 の実機検証を追加しました。所有する子プロセスの既存権限を固定手順で無効化・復元し、候補となる2つの監査マスクを比較して、実イベント・実行条件・ハッシュとポリシー/トークンの復元を記録します。過去の候補は条件付きのまま維持し、製品用プローブ・全OS共通の対応関係・Sigma加点は追加しません。(関連 #380) (@Shirofune-Security) - OneSettingsポリシーと明示的なPrivacyチャネル設定の公開CLIを実機検証します。Server 2022で実際の適用、型付き復元記録と再読取、冪等性、不正値の拒否を確認し、Server 2025の既存の拒否を維持します。両PowerShellで厳密な後処理を検証し、イベント生成やSigma対応は主張しません。通知コントロール省略時の引数渡しを修正し、既定のAuditは両項目を読み取り、項目未選択のConfigureは非ゼロで失敗します。(関連 #378) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index b409e7a0..88bdbb2b 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -5,10 +5,18 @@ ## 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. ([#435](https://github.com/Yamato-Security/WELA/pull/435)) + **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. (#436) (@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 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) + +- 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 disposable native Security4703 attribution for the canonical Token Right Adjusted GUID on Server2022/2025 and PowerShell5.1/7. A fixed owned-child privilege disable/restore compares the two historical audit-mask candidates, retains exact event/context/hash evidence and verifies policy/token cleanup. Historical candidates remain conditional; no production probe, universal mapping or Sigma credit. (Related #380) (@Shirofune-Security) - Add native public OneSettings policy and explicit Privacy-channel configuration acceptance: actual apply, typed journals/readback, idempotence and invalid-value refusals on Server 2022; preserve the existing Server 2025 refusal. Both PowerShell engines verify exact fixture cleanup without event or Sigma claims. Fix omitted notification-control dispatch so default Audit reads both controls and Configure without a selection fails with a nonzero exit. (Related #378) (@Shirofune-Security)