Integrate reviewed WMI recovery with combined dev auditing batch

This commit is contained in:
Shirofune-Security committed 2026-09-22 15:06:51 +09:00
commit bfa286bf7d
46 files changed
+2449 -47

No files matched your search

+16 -7
View File
@@ -58,25 +58,37 @@ 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
/scripts/CustomAuditProfiles.ps1 text eol=lf
# Reviewed WEC state plans bind native setter and runtime source bytes.
/scripts/WecState* text eol=lf
/scripts/WecRuntime* text eol=lf
/tests/WecState* text eol=lf
/scripts/WecListener* text eol=lf
# Existing-file read receipts bind identical native/worker source bytes.
/scripts/FileAccessProbe* text eol=lf
/tests/FileAccessProbe* text eol=lf
# Disposable native provider configuration fixture
tests/NativeProviderConfigure.Windows.Tests.ps1 text eol=lf
# Disposable public registry lifecycle fixture bytes are retained in evidence.
/tests/RegistrySacl* text eol=lf
/scripts/WecAuthorization* text eol=lf
@@ -84,19 +96,16 @@ tests/NativeProviderConfigure.Windows.Tests.ps1 text eol=lf
# Reviewed channel restoration binds exact installed source bytes.
/scripts/ChannelRecovery.ps1 text eol=lf
/tests/ChannelRecovery*.ps1 text eol=lf
# Registry recovery plans bind identical source bytes across native hosts.
/scripts/RegistrySaclRecovery* text eol=lf
/tests/RegistrySaclRecovery* text eol=lf
/scripts/SelectedSaclDescendants.ps1 text eol=lf
/scripts/AuditRecovery.ps1 text eol=lf
/scripts/EvtxRecovery.ps1 text eol=lf
# Collector inventory reads native UTF16 names and bounded Unicode XML.
/modules/WecSubscriptionInventory.cs text eol=lf
/tests/WecCollectorObservation* text eol=lf
/tests/WecSubscriptionInventory* text eol=lf
/tests/TokenRightAttribution*.ps1 text eol=lf
/tests/TokenRightAttribution*.cs text eol=lf
# Disposable public OneSettings fixture source identity.
+14
View File
@@ -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
+34
View File
@@ -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 }}'
+6 -3
View File
@@ -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
+40
View File
@@ -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
+1 -1
View File
@@ -41,7 +41,7 @@ jobs:
Copy-Item -Recurse -Path ./scripts -Destination release-binaries/
Copy-Item -Recurse -Path ./modules -Destination release-binaries/
New-Item -ItemType Directory -Path release-binaries/docs -Force | Out-Null
Copy-Item -Path ./docs/gpo-audit-packages.md, ./docs/gpo-package-deployment.md, ./docs/gpo-creation.md, ./docs/wmi-probe.md, ./docs/firewall-logging.md, ./docs/firewall-logging-recovery.md, ./docs/ipsec-prerequisites.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-ingress.md, ./docs/capi2-probe.md, ./docs/powershell-transcription.md, ./docs/transcription-recovery.md, ./docs/eventlog-recovery.md, ./docs/failed-logon-probe.md, ./docs/wec-listener.md, ./docs/file-access-probe.md, ./docs/applocker-script-probe.md, ./docs/applocker-probe.md, ./docs/selected-sacl-configuration.md, ./docs/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/wmi-sacl-recovery.md -Destination release-binaries/docs/
Copy-Item -Path ./docs/gpo-audit-packages.md, ./docs/gpo-package-deployment.md, ./docs/gpo-creation.md, ./docs/wmi-probe.md, ./docs/firewall-logging.md, ./docs/firewall-logging-recovery.md, ./docs/ipsec-prerequisites.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-ingress.md, ./docs/capi2-probe.md, ./docs/powershell-transcription.md, ./docs/transcription-recovery.md, ./docs/eventlog-recovery.md, ./docs/failed-logon-probe.md, ./docs/wec-listener.md, ./docs/file-access-probe.md, ./docs/applocker-script-probe.md, ./docs/applocker-probe.md, ./docs/selected-sacl-configuration.md, ./docs/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/wmi-sacl-recovery.md, ./docs/transcript-probe.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
+47
View File
@@ -0,0 +1,47 @@
name: Native automatic transcription probe
on:
push:
paths: ['WELA.ps1', 'scripts/TranscriptProbe*', 'scripts/PowerShellTranscription.ps1', 'scripts/WmiProbe*', 'scripts/ChannelRead.ps1', 'scripts/WefArrival.ps1', 'tests/TranscriptProbe*', '.github/workflows/transcript-probe.yml']
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
actual-writer:
strategy:
fail-fast: false
matrix:
os: [windows-2022, windows-2025]
engine: [powershell, pwsh]
runs-on: ${{ matrix.os }}
timeout-minutes: 15
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
- name: Correlation and refusal fixtures (Windows PowerShell5.1)
if: matrix.engine == 'powershell'
shell: powershell
run: ./tests/TranscriptProbe.Tests.ps1
- name: Actual standard writer, denial and restoration (Windows PowerShell5.1 host)
if: matrix.engine == 'powershell'
shell: powershell
run: ./tests/TranscriptProbe.Windows.Tests.ps1 -AllowDisposableWriter -TestEngine powershell
- name: Correlation and refusal fixtures (PowerShell7)
if: matrix.engine == 'pwsh'
shell: pwsh
run: ./tests/TranscriptProbe.Tests.ps1
- name: Actual standard writer, denial and restoration (PowerShell7 host)
if: matrix.engine == 'pwsh'
shell: pwsh
run: ./tests/TranscriptProbe.Windows.Tests.ps1 -AllowDisposableWriter -TestEngine pwsh
- name: Retain native probe and cleanup evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: transcript-probe-${{ matrix.os }}-${{ matrix.engine }}
path: |
${{ runner.temp }}/wela-transcript-probe-*/acceptance.json
${{ runner.temp }}/wela-transcript-probe-*/policy-before.json
${{ runner.temp }}/wela-transcript-probe-*/writer/
${{ runner.temp }}/wela-transcript-probe-*/transcripts/
if-no-files-found: warn
retention-days: 7
+10
View File
@@ -2,10 +2,20 @@
## 2.2.0 [2026/xx/xx] - Dev Release
- 完了したジャーナルから、プロセス作成・PowerShell ログ用の 3 つの DWORD 値を `audit-recovery` で復元できるようにしました。ネイティブ API による値のみの復元、他の値の変更検知、レジストリキーの保持に対応します。 ([#435](https://github.com/Yamato-Security/WELA/pull/435))
**改善:**
- 追加が証明された親名前空間のみの明示的な監査ACEを1つ削除する `wmi-sacl-recovery` の Plan/Recover を追加しました。完了済み設定の整合する記録、現在の完全な記述子、レビュー済みハッシュ、監査縮小への明示的同意を必須とし、他の記述子情報・ACE・トークン権限を保持します。部分書き込みの証跡と所有する実機名前空間の後始末を検証し、過去の名前空間・操作者の同一性やイベント・Sigmaの準備完了は保証しません。 (関連 #372, #365) (@Shirofune-Security)
- 既に有効なローカル保存先で、現在の実行ユーザーによる固定 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-<nonce>.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)
+10
View File
@@ -2,10 +2,20 @@
## 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:**
- Add reviewed `wmi-sacl-recovery` Plan/Recover for one proven explicit parent-only namespace audit ACE. Require matching completed configuration evidence, current full descriptor, reviewed hash and explicit audit-reduction consent; preserve all other descriptor fields, remaining ACEs and token privileges, retain partial-write evidence, and test owned native namespace cleanup. Historical namespace/operator identity and event/Sigma readiness remain unclaimed. (Related #372, #365) (@Shirofune-Security)
- Added opt-in `transcript-probe` to verify a fixed current-account Windows PowerShell 5.1 child produces its own completed automatic transcript in an already enabled local destination. Actual token/logon, native header/footer, nonce/PID/time, bounded file identity and source checks preserve unverified outcomes without changing policy or ACLs, invoking explicit transcription or granting Sigma/EVTX credit. Added disposable standard-writer success/denial tests for Server 2022/2025 under both WELA host engines. (#436) (@Shirofune-Security)
- Added 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-<nonce>.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)
+47 -2
View File
@@ -66,6 +66,10 @@
[string]$WmiRecoveryPlanHash,
[string]$WmiRecoveryOutputPath,
[switch]$WmiRecoveryAllowAuditReduction,
[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,
@@ -87,6 +91,9 @@
[string]$RuleManifestPath,
[ValidateSet('Audit', 'Plan', 'Configure')][string]$TranscriptionAction = 'Audit',
[string]$TranscriptDirectory,
[ValidateSet('Plan','Run')][string]$TranscriptProbeAction = 'Plan',
[string]$TranscriptProbeDirectory,
[string]$TranscriptProbeOutputPath,
[ValidateSet('Audit','Plan','Configure')][string]$LdapAction = 'Audit',
[ValidateSet('Preserve','Diagnostic','MdiCleanup')][string]$LdapMode = 'Preserve',
[ValidateRange(1,2147483647)][int]$LdapSearchTimeMs,
@@ -148,6 +155,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,
@@ -271,10 +286,12 @@ $SaclTargetsPath = Join-Path $ScriptRoot "config/audit_sacl_targets.json"
. (Join-Path $ScriptRoot "scripts/WmiNamespaceAuditing.ps1")
. (Join-Path $ScriptRoot "scripts/WmiSaclRecovery.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")
. (Join-Path $ScriptRoot "scripts/PowerShellTranscription.ps1")
. (Join-Path $ScriptRoot "scripts/TranscriptProbe.ps1")
Import-Module (Join-Path $ScriptRoot "modules/AuditProfiles.psm1") -ErrorAction Stop
Import-Module (Join-Path $ScriptRoot "modules/RuleEligibility.psm1") -ErrorAction Stop
Import-Module (Join-Path $ScriptRoot "modules/AuditCatalog.psm1") -ErrorAction Stop
@@ -308,6 +325,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 用に両方を扱う。
@@ -2058,6 +2076,7 @@ Usage:
./WELA.ps1 event-measurement -MeasurementChannel Security -MeasurementAction Run -MeasurementOutputPath C:\Evidence\new-sample -MeasurementExportEvtx
./WELA.ps1 rule-eligibility -RuleEvidencePath reviewed-lab-evidence.json -ResultsPath evidence-review.json
./WELA.ps1 smb-auditing -SmbAction Configure -DryRun
./WELA.ps1 transcript-probe -Help # Verify one automatic native5.1 transcript under the actual identity
./WELA.ps1 file-access-probe -Help
./WELA.ps1 transcription-recovery -Help
./WELA.ps1 powershell-transcription -TranscriptionAction Plan -TranscriptDirectory C:\Transcripts -ResultsPath transcription-plan.json
@@ -2102,6 +2121,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
@@ -2120,6 +2140,9 @@ Write-Host ""
Write-Host "WELA v$WELAVersion - $WELAReleaseName"
Write-Host ""
if ($Cmd -ne 'transcript-probe' -and @($PSBoundParameters.Keys | Where-Object { $_ -like 'TranscriptProbe*' }).Count) { throw 'TranscriptProbe options require transcript-probe. No command was run.' }
if ($Cmd -eq 'transcript-probe' -and @($PSBoundParameters.Keys | Where-Object { $_ -notin @('Cmd','TranscriptProbeAction','TranscriptProbeDirectory','TranscriptProbeOutputPath','Help') }).Count) { throw 'transcript-probe accepts only dedicated action/directory/output options. No command was run.' }
if ($Cmd -ne 'firewall-recovery' -and @($PSBoundParameters.Keys | Where-Object { $_ -like 'FirewallRecovery*' }).Count) {throw 'FirewallRecovery options require firewall-recovery. No command was run.'}
if ($Cmd -eq 'firewall-recovery' -and ($args.Count -or @($PSBoundParameters.Keys | Where-Object { $_ -notin @('Cmd','FirewallRecoveryAction','FirewallRecoveryProfile','FirewallRecoveryJournalPath','FirewallRecoveryResultsPath','FirewallRecoveryPlanPath','FirewallRecoveryPlanHash','FirewallRecoveryOutputPath','Auto','DryRun','Help') }).Count)) {throw 'firewall-recovery accepts only dedicated options, Auto and DryRun. No command was run.'}
if ($Cmd -ne 'channel-read' -and @($PSBoundParameters.Keys | Where-Object { $_ -like 'ChannelRead*' }).Count) { throw 'ChannelRead options require channel-read. No command was run.' }
@@ -2129,7 +2152,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) {
@@ -2181,6 +2204,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.'}
@@ -2228,6 +2253,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.'}
@@ -2306,7 +2333,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
@@ -2438,6 +2465,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
@@ -2537,6 +2570,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-<nonce>.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
@@ -2763,6 +2802,12 @@ switch ($Cmd.ToLower()) {
if ($report.ExitCode) { exit $report.ExitCode }
} catch { Write-Host "[Failed] SMB auditing: $_" -ForegroundColor Red; exit 1 }
}
'transcript-probe' {
if ($Help) { Write-Host 'Usage: ./WELA.ps1 transcript-probe [-TranscriptProbeAction Plan|Run] -TranscriptProbeDirectory existing-local-policy-directory [-TranscriptProbeOutputPath new-private-directory]. Run starts one fixed native5.1 child using existing automatic transcription policy; no policy or destination changes. See docs/transcript-probe.md.'; return }
$report=Invoke-WelaTranscriptProbe -Action $TranscriptProbeAction -Directory $TranscriptProbeDirectory -OutputPath $TranscriptProbeOutputPath
$report | Select-Object Action,Status,WriterAuthorization,Diagnostic,OutputPath | Format-List | Out-Host
if ($report.ExitCode) { exit $report.ExitCode }
}
'powershell-transcription' {
if ($Help) {
Write-Host 'Usage: ./WELA.ps1 powershell-transcription [-TranscriptionAction Audit|Plan|Configure] [-TranscriptDirectory absolute-existing-directory] [-Auto] [-DryRun] [-BackupPath new-directory] [-ResultsPath file.json]'
+19 -1
View File
@@ -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.
+27
View File
@@ -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-<random-guid>.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).
+8 -2
View File
@@ -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.
+65
View File
@@ -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 `<id>.pending.json` and `<id>.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-<id>.pending.json `
-FileSaclRecoveryConfirmedPath C:\Evidence\receipts\sacl-<id>.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).
+2
View File
@@ -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.
+2
View File
@@ -73,3 +73,5 @@ The mock suite covers typed values, shared views, idempotence, ordering, destina
Native CI passed on both Server 2022 and Server 2025 under Windows PowerShell 5.1 and PowerShell 7 in [run 35439090461](https://github.com/Yamato-Security/WELA/actions/runs/35439090461): 14 native assertions per OS/host combination, including fresh x64/x86 Windows PowerShell 5.1 transcript markers and verified restoration (56 native assertions total). Production/central validation still requires actual client, server, DC and service identities: test a benign new session, record the transcript and effective policy, verify unauthorized read/modify attempts fail, check collection and quotas/retention, and verify recovery. CI's local private folder does not satisfy the central authorization/ingestion acceptance criterion. Sysmon and external telemetry are out of scope.
Reviewed CIS references: [Windows 11 Enterprise v4.0.0, PDF pages 1286–1287](https://rayasec.com/wp-content/uploads/CIS-Benchmark/Microsoft-Windows-Desktop/CIS_Microsoft_Windows_11_Enterprise_Benchmark_v4.0.0.pdf#page=1286), [Windows Server 2022 v4.0.0, PDF pages 1029–1030](https://rayasec.com/wp-content/uploads/CIS-Benchmark/Microsoft-Windows-Server/CIS_Microsoft_Windows_Server_2022_Benchmark_v4.0.0.pdf#page=1029).
For a fixed child under the actual current account, see the optional [automatic transcription probe](transcript-probe.md). It verifies completed local automatic output without changing policy or granting EVTX/Sigma credit.
+55
View File
@@ -0,0 +1,55 @@
# Automatic Windows PowerShell transcription probe
`transcript-probe` checks whether one fixed Windows PowerShell 5.1 child, launched as the current WELA account, produces its own completed **automatic** transcript in an already configured local destination. It does not change policy, ACLs, services or shares. It never calls `Start-Transcript` as a fallback. Transcripts are not EVTX, and the report always grants zero Sigma/EVTX credit.
This is the current-account local-writer acceptance portion of #376. The existing [transcription audit/configure command](powershell-transcription.md) remains separate. UNC/share acceptance, remote collection, retention and testing other writer accounts remain separate work.
## Commands
Run from native 64-bit Windows PowerShell 5.1 or PowerShell 7 on a supported Windows 11, Server 2022 or Server 2025 host. The child being tested is always native Windows PowerShell 5.1; running WELA in PowerShell 7 does not test PowerShell 7 transcription.
```powershell
# Default Plan: inspect the selected destination and prerequisites.
.\WELA.ps1 transcript-probe -TranscriptProbeDirectory C:\Transcripts
# Explicit Run: one fixed child, then verify its completed automatic transcript.
.\WELA.ps1 transcript-probe -TranscriptProbeAction Run `
-TranscriptProbeDirectory C:\Transcripts `
-TranscriptProbeOutputPath C:\Evidence\transcript-unique-run
```
The output directory must be new, have an existing parent, and be outside the transcript destination. WELA creates it with access for the current account, SYSTEM and Administrators. `Plan` launches no probe child and creates no explicit evidence directory. An already enabled transcription policy can naturally transcribe the WELA invocation itself, including a Plan invocation.
An enabled machine `EnableTranscripting` DWORD policy and an explicit literal `OutputDirectory` string must already exist and match the selected local fixed-drive directory. Both shared registry views must agree. Current-user policy and invocation-header settings are also recorded and checked for known types. This initial command does not infer default destinations or accept a current-user-only policy. Winmgmt must already be running for read-only host observations.
The account needs directory/date-folder metadata and listing access, plus read access to the new transcript. A write-only drop-box destination may accept automatic transcripts but cannot be proven by this verifier. Permission failures remain unverified; WELA does not broaden access to obtain proof.
## What a successful result means
`Status: CompletedAutomaticTranscript` and `WriterAuthorization: ObservedForThisChild` mean the local verifier observed exactly one fresh matching completed transcript from the fixed child during this run. The evidence binds the following:
- The actual child PID, executable, PowerShell 5.1 Desktop version, command arguments and loaded engine assembly hash.
- Actual current-account SID, logon authentication ID and group attributes in the parent and child. Full before/after token observations are retained within each process; cross-process matching uses the authorization identity and groups because process startup can change privilege flags.
- Unique standalone begin/end output lines, the native engine's localized header/footer resource templates, header identity/PID/host command, and header/footer timestamps within the observed launch/exit window.
- Existing policy, executable/source hashes, host and time-zone observations, plus handle-based destination/date-folder identity and owner/group/DACL observations before and after the operation.
- Bounded raw matching transcript bytes, SHA-256 hashes and a matching file handle retained through final checks. Reparse points, multi-link files and identity/descriptor drift are refused.
The fixed child uses `-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File` with WELA's bundled worker and a generated nonce. The execution-policy option is local to that process; it does not change stored policy or override enforced Group Policy. No arbitrary command, credential or alternative executable can be supplied to this command.
Output preparation and initial observations precede the parent token interval. The measured interval covers the worker and transcript verification; the report retains both parent token snapshots. The child records its own before/after interval. Token differences, ambiguous transcripts, incomplete output, unexpected formats or context drift fail verification. A completed transcript proves this observed operation, not continuing authorization, other users' access, remote share acceptance, reliable collection or application of every baseline recommendation.
This is local consistency evidence, not tamper-proof attestation against another process controlled by the same account or an administrator. The fixed nonce, PID, command and time checks provide correlation; they do not establish exclusive writer attribution against a malicious local actor.
## Bounds and artifacts
The inventory covers only the previous, current and next local calendar-date folders, with at most 256 entries in total. Existing transcript contents are never read. The verifier considers at most 32 new file identities, reads at most 1 MiB per candidate and 4 MiB in total, and accepts exactly one matching transcript. Unexpected directories, names, encodings or candidate times remain unverified. Busy destinations can exceed these conservative bounds.
The worker has a 30-second deadline. Each redirected output stream retains at most 65,536 characters, with bounded pipe-drain and termination waits. The report includes explicit diagnostics for refusal and incomplete evidence; no fallback obtains a positive result.
A completed Run writes `result.json`, `worker.json` and the exact matching `transcript.txt` bytes into the protected output. The result also retains bounded fixed-worker stdout/stderr and launch/exit observations, including when worker validation fails. Failed runs that reached output preparation keep diagnostic artifacts and exit nonzero. Prerequisite failures can occur before an output directory exists. Source transcripts are retained in their configured destination; WELA never removes them.
## Validation
Portable fixtures exercise the actual native-format matcher, identity/time/nonce/version refusals, localization templates, encoding limits, policy types, drift and dedicated CLI guards. The opt-in hosted Windows fixture provisions only its own standard account and destination, enables a temporary machine transcription policy, and invokes the public command in fresh processes. It tests an allowed writer and then a denied writer, preserves both original typed policy views, restores the original destination ACL and removes only the owned account. This fixture is gated to disposable standalone GitHub-hosted Server 2022/2025 machines and both WELA host engines. It never substitutes an explicit transcript for automatic policy output.
Microsoft documents automatic policy transcription and machine-policy precedence in [Turn on PowerShell Transcription](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_group_policy_settings?view=powershell-5.1#turn-on-powershell-transcription). The verifier reads the actual installed engine's transcript resource templates rather than assuming an English header.
+29 -5
View File
@@ -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
}
+160
View File
@@ -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-<random-guid>.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
}
+71
View File
@@ -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<Answer> answers=new List<Answer>();HashSet<IntPtr> seen=new HashSet<IntPtr>();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);}
}
}
}
+15
View File
@@ -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
+71 -12
View File
@@ -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)
+183
View File
@@ -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}
}
+118
View File
@@ -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<string,int> Counts(RawAcl acl) {
Dictionary<string,int> counts=new Dictionary<string,int>(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<string,int> 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<string,int> 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<string,int> 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<Ace> entries=new List<Ace>();
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<sd.SystemAcl.Count;i++)if(Descriptor.Bytes(sd.SystemAcl[i])==added){if(index!=-1)throw new InvalidOperationException("Audit ACE is not unique.");index=i;}
if(index<0)throw new InvalidOperationException("Audit ACE is absent.");CommonAce ace=sd.SystemAcl[index] as CommonAce;
if(ace==null||ace.IsCallback||ace.AceType!=AceType.SystemAudit||((int)ace.AceFlags!=64&&(int)ace.AceFlags!=128&&(int)ace.AceFlags!=192))throw new InvalidOperationException("Only an explicit ordinary audit ACE can be removed.");
sd.SystemAcl.RemoveAce(index);byte[] bytes=new byte[sd.SystemAcl.BinaryLength];sd.SystemAcl.GetBinaryForm(bytes,0);IntPtr buffer=Marshal.AllocHGlobal(bytes.Length);
try{Marshal.Copy(bytes,0,buffer,bytes.Length);WriteAttempted=true;uint error=SetSecurityInfo(handle,1,8,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,buffer);if(error!=0)throw new Win32Exception((int)error,"SACL-only removal failed.");}finally{Marshal.FreeHGlobal(buffer);}
Snapshot after=Read();AfterObservation=after;if(after.Identity!=before.Identity)throw new InvalidOperationException("File identity changed during removal.");Descriptor.Removed(before.DescriptorBase64,after.DescriptorBase64,added);return after;
}
public void Dispose(){try{if(handle!=IntPtr.Zero){CloseHandle(handle);handle=IntPtr.Zero;}}finally{if(privilege!=null){privilege.Dispose();privilege=null;}}}
}
}
+74
View File
@@ -0,0 +1,74 @@
# Value-only recovery for three built-in logging switches. No arbitrary registry replay.
function Get-WelaNamedRecoveryCatalog {
foreach ($item in @(
@('HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System\Audit','ProcessCreationIncludeCmdLine_Enabled'),
@('HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging','EnableScriptBlockLogging'),
@('HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ModuleLogging','EnableModuleLogging')
)) {[pscustomobject]@{Id=('Registry/'+$item[0]+'/'+$item[1]);Path=$item[0];Name=$item[1]}}
}
function Get-WelaNamedRecoverySources {
foreach ($relative in @('scripts/NamedRegistryRecovery.ps1','scripts/NamedRegistryRecoveryNative.cs','scripts/AuditRecovery.ps1','scripts/Configuration.ps1')) {
[pscustomobject]@{Path=$relative;Sha256=(Get-FileHash -LiteralPath (Join-Path $PSScriptRoot ('../'+$relative)) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()}
}
}
function Initialize-WelaNamedRecoveryNative {
$path=Join-Path $PSScriptRoot 'NamedRegistryRecoveryNative.cs'
$bytes=[IO.File]::ReadAllBytes($path);$hash=Get-WelaRecoveryHash $bytes
if ('Wela.NamedRegistryRecovery.Key' -as [type]) {
if ([Wela.NamedRegistryRecovery.Key]::SourceSha256 -cne $hash) {throw 'Loaded named-registry native source differs; start a fresh process.'}
return
}
$source=(New-Object Text.UTF8Encoding($false,$true)).GetString($bytes).Replace('__WELA_SOURCE_SHA256__',$hash)
Add-Type -TypeDefinition $source -ErrorAction Stop
}
function Assert-WelaNamedRecoveryValue {
param($State)
if ($State.KeyExists -isnot [bool] -or $State.ValueExists -isnot [bool]) {throw 'Logging registry state requires typed existence flags.'}
if ($State.ValueExists) {
if (-not $State.KeyExists -or $State.Type -cne 'DWord' -or ($State.Value -isnot [int] -and $State.Value -isnot [long]) -or $State.Value -notin @(0,1)) {throw 'Only prior DWORD 0/1 or value absence is supported.'}
} elseif ($null -ne $State.Value -or $null -ne $State.Type) {throw 'Absent logging value has inconsistent state.'}
}
function Get-WelaNamedRecoveryGuard {
param($Observation)
[pscustomobject][ordered]@{ObjectName=$Observation.ObjectName;OtherValues=$Observation.OtherValues;Children=$Observation.Children;Security=$Observation.Security}
}
function Get-WelaNamedRecoveryState {
param($Observation)
[pscustomobject]@{KeyExists=$true;ValueExists=[bool]$Observation.Exists;Value=$(if ($Observation.Exists) {[int]$Observation.Value} else {$null});Type=$(if ($Observation.Exists) {'DWord'} else {$null})}
}
function Open-WelaNamedRecoveryKey {
param($Target,[bool]$Write=$false)
$known=@(Get-WelaNamedRecoveryCatalog | Where-Object {$_.Path -ceq $Target.Path -and $_.Name -ceq $Target.Name})
if ($known.Count -ne 1) {throw 'Unknown logging recovery target.'}
Initialize-WelaNamedRecoveryNative
[Wela.NamedRegistryRecovery.Key]::new($Target.Path,$Write)
}
function Get-WelaNamedRecoveryObservation {
param($Target)
$key=Open-WelaNamedRecoveryKey $Target
try {
$observation=$key.Read($Target.Name)
if ($observation.ObjectName -ine ('\REGISTRY\MACHINE\'+$Target.Path.Substring(6))) {throw 'Native registry name does not match the selected path.'}
$observation
} finally {$key.Dispose()}
}
function Assert-WelaNamedRecoveryGuard {
param($Control,$Observation)
if ((Get-WelaRecoveryKey (Get-WelaNamedRecoveryGuard $Observation)) -cne (Get-WelaRecoveryKey $Control.RegistryGuard)) {throw 'Logging registry path, other values, children or security changed since planning.'}
}
function Set-WelaNamedRecoveryValue {
param($Control)
$key=Open-WelaNamedRecoveryKey $Control.Target $true
try {
$before=$key.Read($Control.Target.Name)
Assert-WelaNamedRecoveryGuard $Control $before
if ((Get-WelaRecoveryKey (Get-WelaNamedRecoveryState $before)) -cne (Get-WelaRecoveryKey $Control.Expected)) {throw 'Logging value changed before recovery.'}
$value=if ($Control.RecoverTo.ValueExists) {[int]$Control.RecoverTo.Value} else {0}
$after=$key.Restore($Control.Target.Name,$before,$Control.RecoverTo.ValueExists,$value)
Assert-WelaNamedRecoveryGuard $Control $after
# Reopen the selected path after the handle-based write to detect visible path drift.
$fresh=Get-WelaNamedRecoveryObservation $Control.Target
Assert-WelaNamedRecoveryGuard $Control $fresh
if ((Get-WelaRecoveryKey (Get-WelaNamedRecoveryState $fresh)) -cne (Get-WelaRecoveryKey $Control.RecoverTo)) {throw 'Reopened logging value differs after recovery.'}
} finally {$key.Dispose()}
}
+89
View File
@@ -0,0 +1,89 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
namespace Wela.NamedRegistryRecovery {
public sealed class Observation {
public bool Exists; public int Value; public string ObjectName, OtherValues, Children, Security, LastWrite;
}
public sealed class Key : IDisposable {
public const string SourceSha256 = "__WELA_SOURCE_SHA256__";
IntPtr handle;
[DllImport("advapi32.dll",CharSet=CharSet.Unicode)] static extern int RegOpenKeyEx(IntPtr key,string sub,uint options,uint access,out IntPtr result);
[DllImport("advapi32.dll")] static extern int RegCloseKey(IntPtr key);
[DllImport("advapi32.dll",CharSet=CharSet.Unicode)] static extern int RegQueryValueEx(IntPtr key,string name,IntPtr reserved,out uint type,byte[] data,ref uint size);
[DllImport("advapi32.dll",CharSet=CharSet.Unicode)] static extern int RegEnumValue(IntPtr key,uint index,StringBuilder name,ref uint nameLength,IntPtr reserved,out uint type,byte[] data,ref uint size);
[DllImport("advapi32.dll",CharSet=CharSet.Unicode)] static extern int RegEnumKeyEx(IntPtr key,uint index,StringBuilder name,ref uint nameLength,IntPtr reserved,IntPtr cls,IntPtr clsLength,out long lastWrite);
[DllImport("advapi32.dll",CharSet=CharSet.Unicode)] static extern int RegQueryInfoKey(IntPtr key,IntPtr cls,IntPtr clsLength,IntPtr reserved,out uint subkeys,IntPtr maxSub,IntPtr maxClass,out uint values,IntPtr maxName,IntPtr maxValue,IntPtr security,out long lastWrite);
[DllImport("advapi32.dll")] static extern int RegGetKeySecurity(IntPtr key,uint information,byte[] descriptor,ref uint size);
[DllImport("advapi32.dll",CharSet=CharSet.Unicode)] static extern int RegSetValueEx(IntPtr key,string name,int reserved,uint type,byte[] data,uint size);
[DllImport("advapi32.dll",CharSet=CharSet.Unicode)] static extern int RegDeleteValue(IntPtr key,string name);
[DllImport("ntdll.dll")] static extern int NtQueryKey(IntPtr key,int informationClass,byte[] information,int length,out int resultLength);
static void Check(int error){if(error!=0)throw new Win32Exception(error);}
static string Hash(byte[] bytes){using(var sha=SHA256.Create())return BitConverter.ToString(sha.ComputeHash(bytes)).Replace("-","").ToLowerInvariant();}
static string HashStrings(List<string> 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;i<parts.Length;i++) {
if(parts[i].Length==0 || parts[i]=="." || parts[i]=="..")throw new InvalidOperationException("Ambiguous registry path.");
IntPtr next;Check(RegOpenKeyEx(parent,parts[i],8,0x20119U | ((write && i==parts.Length-1)?2U:0U),out next));
if(owned)RegCloseKey(parent);parent=next;owned=true;
uint type,size=0;int error=RegQueryValueEx(parent,"SymbolicLinkValue",IntPtr.Zero,out type,null,ref size);
if(error!=0 && error!=2 && error!=234)Check(error);
if((error==0 || error==234) && type==6)throw new InvalidOperationException("Registry links are unsupported.");
}
handle=parent;owned=false;
} finally {if(owned)RegCloseKey(parent);}
}
string Name() {
int required;int status=NtQueryKey(handle,3,null,0,out required);
if(status!=unchecked((int)0xC0000023) && status!=unchecked((int)0x80000005))throw new InvalidOperationException("Cannot size native registry identity: "+status);
if(required<4 || required>65536)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<string>();long total=0;
for(uint i=0;i<values;i++) {
var name=new StringBuilder(16384);uint nameLength=16384,type,size=65536;byte[] data=new byte[size];Check(RegEnumValue(handle,i,name,ref nameLength,IntPtr.Zero,out type,data,ref size));
total+=size;if(total>1048576)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<string>();
for(uint i=0;i<subkeys;i++){var name=new StringBuilder(256);uint length=256;long childTime;Check(RegEnumKeyEx(handle,i,name,ref length,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,out childTime));children.Add(Enc(name.ToString()));}
result.Children=HashStrings(children);
uint securitySize=0;int code=RegGetKeySecurity(handle,7,null,ref securitySize);if(code!=122)Check(code);
if(securitySize<20 || securitySize>65536)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;}}
}
}
+214
View File
@@ -0,0 +1,214 @@
# Fixed native automatic-transcription evidence; never provisions a destination or changes policy.
function Initialize-WelaTranscriptProbe {
if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'transcript-probe requires native 64-bit Windows.'}
$bytes=[IO.File]::ReadAllBytes((Join-Path $PSScriptRoot 'TranscriptProbeNative.cs'));$hash=Get-WelaArrivalHash $bytes
if(-not ('Wela.TranscriptProbe.Item' -as [type])){Add-Type -TypeDefinition ([Text.UTF8Encoding]::new($false,$true).GetString($bytes).Replace('__WELA_TRANSCRIPT_SOURCE_SHA256__',$hash)) -ErrorAction Stop}
if([Wela.TranscriptProbe.Item]::SourceSha256 -cne $hash){throw 'Loaded transcript helper differs from source; start a fresh PowerShell process.'}
Initialize-WelaWmiProbeNative
}
function Get-WelaTranscriptProbeKey {param($Value) ConvertTo-Json -InputObject $Value -Depth 20 -Compress}
function Get-WelaTranscriptProbeSources {
$result=[ordered]@{}
foreach($name in @('WELA.ps1','scripts/TranscriptProbe.ps1','scripts/TranscriptProbeWorker.ps1','scripts/TranscriptProbeNative.cs','scripts/PowerShellTranscription.ps1','scripts/WmiProbe.ps1','scripts/WmiProbeNative.cs','scripts/ChannelRead.ps1','scripts/WefArrival.ps1','modules/AuditProfiles.psm1')){$result[$name]=(Get-FileHash -LiteralPath (Join-Path $script:ScriptRoot $name) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()}
[pscustomobject]$result
}
function Get-WelaTranscriptProbeObjectKey {
param($Observation,[switch]$Directory)
$value=[ordered]@{Path=$Observation.Path;Identity=$Observation.Identity;CreatedUtc=$Observation.CreatedUtc;Attributes=$Observation.Attributes;Descriptor=$Observation.Descriptor}
if(-not $Directory){$value.Length=$Observation.Length;$value.WrittenUtc=$Observation.WrittenUtc;$value.Links=$Observation.Links}
Get-WelaTranscriptProbeKey ([pscustomobject]$value)
}
function Assert-WelaTranscriptProbePolicy {
param([array]$Policy,[string]$Directory)
Test-WelaTranscriptSharedPolicy $Policy
if($Policy.Count -ne 2 -or $Policy[0].View -cne 'Registry64' -or $Policy[1].View -cne 'Registry32'){throw 'Both canonical shared policy views are required.'}
foreach($view in $Policy){
$machine=$view.Machine
if(-not $machine.EnableTranscripting.ValueExists -or $machine.EnableTranscripting.Type -cne 'DWord' -or $machine.EnableTranscripting.Value -ne 1 -or -not $machine.OutputDirectory.ValueExists -or $machine.OutputDirectory.Type -cne 'String' -or $machine.OutputDirectory.Value -isnot [string]){throw 'An already enabled machine transcription policy with explicit literal output is required.'}
$path=Resolve-WelaArrivalPath $machine.OutputDirectory.Value
if(-not $path.Equals($Directory,[StringComparison]::OrdinalIgnoreCase)){throw 'Selected destination does not match the current machine transcription policy.'}
foreach($hive in @('Machine','CurrentUser')){
foreach($name in @('EnableTranscripting','EnableInvocationHeader')){$value=$view.$hive.$name;if($value.ValueExists -and ($value.Type -cne 'DWord' -or $value.Value -notin @(0,1))){throw 'Unknown typed transcription policy value.'}}
$value=$view.$hive.OutputDirectory;if($value.ValueExists -and ($value.Type -cne 'String' -or $value.Value -isnot [string])){throw 'Unknown transcription destination value type.'}
}
}
}
function Get-WelaTranscriptProbeState {
param([string]$Directory,$Handle)
if((Get-Service Winmgmt -ErrorAction Stop).Status -ne 'Running'){throw 'Winmgmt must already be running for host observations; no service is started.'}
$capability=Get-WelaTranscriptCapability;if($capability.Status -cne 'Supported'){throw $capability.Diagnostic}
$engine=Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::Windows)) 'System32\WindowsPowerShell\v1.0\powershell.exe'
$engine=Resolve-WelaArrivalPath $engine
$policy=@(Get-WelaTranscriptPolicy @('Registry64','Registry32'));Assert-WelaTranscriptProbePolicy $policy $Directory
[pscustomobject][ordered]@{Host=(Get-WelaChannelReadHost);Engine=$engine;EngineHash=(Get-FileHash -LiteralPath $engine -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant();InstalledVersion=$capability.EngineVersion;Policy=$policy;Directory=$Handle.Snapshot();TimeZone=[TimeZoneInfo]::Local.Id;OffsetMinutes=[DateTimeOffset]::Now.Offset.TotalMinutes;Sources=(Get-WelaTranscriptProbeSources)}
}
function Get-WelaTranscriptProbeStateKey {
param($State)
Get-WelaTranscriptProbeKey ([pscustomobject][ordered]@{Host=$State.Host;Engine=$State.Engine;EngineHash=$State.EngineHash;InstalledVersion=$State.InstalledVersion;Policy=$State.Policy;Directory=(Get-WelaTranscriptProbeObjectKey $State.Directory -Directory);TimeZone=$State.TimeZone;OffsetMinutes=$State.OffsetMinutes;Sources=$State.Sources})
}
function Get-WelaTranscriptProbeInventory {
param([string]$Directory,[string[]]$Dates)
$folders=@();$files=@()
foreach($date in $Dates){
if($date -cnotmatch '^\d{8}$'){throw 'Invalid bounded transcript date scope.'}
$path=Join-Path $Directory $date
if(-not [IO.Directory]::Exists($path)){if(Test-Path -LiteralPath $path){throw 'Expected date folder is not a directory.'};$folders+=[pscustomobject]@{Date=$date;Exists=$false;Observation=$null};continue}
$null=Resolve-WelaArrivalPath $path;$handle=[Wela.TranscriptProbe.Item]::Directory($path)
try{
$observation=$handle.Snapshot();$folders+=[pscustomobject]@{Date=$date;Exists=$true;Observation=$observation}
foreach($entry in [IO.Directory]::EnumerateFileSystemEntries($path)){
if($files.Count -ge 256){throw 'Current-date inventory reached its 256-entry limit.'}
$item=Get-Item -LiteralPath $entry -Force -ErrorAction Stop
if($item -isnot [IO.FileInfo] -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)){throw 'Unexpected directory or reparse entry in the current-date scope.'}
$file=[Wela.TranscriptProbe.Item]::Metadata($entry)
try{$files+=$file.Snapshot()}finally{$file.Dispose()}
}
if((Get-WelaTranscriptProbeObjectKey $handle.Snapshot() -Directory) -cne (Get-WelaTranscriptProbeObjectKey $observation -Directory)){throw 'Date-directory identity or descriptor changed during enumeration.'}
}finally{$handle.Dispose()}
}
[pscustomobject]@{Dates=$Dates;Folders=$folders;Files=@($files|Sort-Object Path)}
}
function Assert-WelaTranscriptProbeInventory {
param($Before,$After)
foreach($folder in $Before.Folders|Where-Object Exists){
$match=@($After.Folders|Where-Object Date -eq $folder.Date)
if($match.Count -ne 1 -or -not $match[0].Exists -or (Get-WelaTranscriptProbeObjectKey $folder.Observation -Directory) -cne (Get-WelaTranscriptProbeObjectKey $match[0].Observation -Directory)){throw 'An existing date directory changed or disappeared.'}
}
foreach($file in $Before.Files){
$match=@($After.Files|Where-Object Path -eq $file.Path)
# Existing sessions can append to their transcripts. Their bytes are never read.
if($match.Count -ne 1 -or $match[0].Identity -cne $file.Identity -or $match[0].CreatedUtc -cne $file.CreatedUtc -or $match[0].Descriptor -cne $file.Descriptor){throw 'An existing transcript was replaced, removed or had its descriptor changed.'}
}
}
function Start-WelaTranscriptProbeWorker {
param($State,[string]$Nonce,$ParentToken,$LaunchEvidence)
$worker=Join-Path $PSScriptRoot 'TranscriptProbeWorker.ps1'
$arguments=@('-NoLogo','-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-File',$worker,'-Nonce',$Nonce)
$info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$State.Engine
$info.Arguments='-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "'+$worker+'" -Nonce '+$Nonce
$info.UseShellExecute=$false;$info.CreateNoWindow=$true;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true
$info.StandardOutputEncoding=[Text.UTF8Encoding]::new($false,$true);$info.StandardErrorEncoding=[Text.UTF8Encoding]::new($false,$true)
$process=[Diagnostics.Process]::new();$process.StartInfo=$info;$started=$false;$launched=[DateTime]::UtcNow
try{
if(-not $process.Start()){throw 'Fixed transcript worker did not start.'};$started=$true
$LaunchEvidence.ProcessId=$process.Id;$LaunchEvidence.LaunchedUtc=$launched.ToString('o')
$stdout=[Wela.TranscriptProbe.Item]::Drain($process.StandardOutput,65536);$stderr=[Wela.TranscriptProbe.Item]::Drain($process.StandardError,65536)
if(-not $process.WaitForExit(30000)){throw 'Fixed transcript worker exceeded thirty seconds.'}
$exited=[DateTime]::UtcNow;$LaunchEvidence.ExitedUtc=$exited.ToString('o');$LaunchEvidence.ExitCode=$process.ExitCode
if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),3000)){throw 'Worker output pipes did not close within their bound.'}
$LaunchEvidence.Stdout=$stdout.Result;$LaunchEvidence.Stderr=$stderr.Result
if($stdout.Result.Exceeded -or $stderr.Result.Exceeded -or $stdout.Result.Error -or $stderr.Result.Error){throw 'Worker output is oversized or incomplete.'}
if($process.ExitCode -ne 0 -or $stderr.Result.Text){throw ('Fixed native5.1 worker failed; exit '+$process.ExitCode+'. No transcript fallback was attempted.')}
$lines=@(($stdout.Result.Text -replace "`r`n","`n").TrimEnd("`r","`n") -split "`n")
$json=@($lines|Where-Object{$_ -clike 'WELA-WORKER-JSON:*'})
if($lines.Count -ne 3 -or $json.Count -ne 1){throw 'Unexpected worker output framing.'}
$operation=ConvertFrom-WelaArrivalJson $json[0].Substring('WELA-WORKER-JSON:'.Length)
if($operation.Nonce -cne $Nonce -or $operation.ProcessId -ne $process.Id -or $operation.Engine -ine $State.Engine -or $operation.Edition -cne 'Desktop' -or $operation.EngineVersion -cnotmatch '^5\.1\.\d+\.\d+$'){throw 'Fixed worker engine/identity response differs.'}
$actualArgs=@($operation.Arguments|Select-Object -Skip 1)
if((Get-WelaTranscriptProbeKey $actualArgs) -cne (Get-WelaTranscriptProbeKey $arguments) -or $operation.Arguments[0] -ine $State.Engine -or $operation.HeaderCommandLine -cne ($operation.Arguments -join ' ')){throw 'Worker command arguments differ from the fixed launch.'}
if(@($lines|Where-Object{$_ -ceq ('WELA-TRANSCRIPT-BEGIN:'+${Nonce}+':'+$process.Id)}).Count -ne 1 -or @($lines|Where-Object{$_ -ceq ('WELA-TRANSCRIPT-END:'+${Nonce}+':'+$process.Id)}).Count -ne 1){throw 'Fixed worker output markers are missing or ambiguous.'}
if((Get-WelaWmiProbeTokenKey $operation.BeforeToken -AuthorizationOnly) -cne (Get-WelaWmiProbeTokenKey $ParentToken -AuthorizationOnly) -or (Get-WelaWmiProbeTokenKey $operation.BeforeToken) -cne (Get-WelaWmiProbeTokenKey $operation.AfterToken)){throw 'Worker identity/logon/group attributes differ from the parent or changed during output.'}
if((Get-WelaTranscriptProbeKey $operation.PolicyBefore) -cne (Get-WelaTranscriptProbeKey $State.Policy) -or (Get-WelaTranscriptProbeKey $operation.PolicyAfter) -cne (Get-WelaTranscriptProbeKey $State.Policy)){throw 'Native worker policy differs from the observed policy.'}
$begin=ConvertTo-WelaArrivalUtc $operation.StartedUtc;$end=ConvertTo-WelaArrivalUtc $operation.CompletedUtc
if($begin -lt $launched -or $end -lt $begin -or $end -gt $exited -or $operation.StartOffsetMinutes -ne $State.OffsetMinutes -or $operation.EndOffsetMinutes -ne $State.OffsetMinutes -or $operation.Computer -ine $State.Host.Computer -or $operation.HeaderUser -ine $operation.BeforeToken.Name){throw 'Worker time, time-zone or host context differs.'}
$assembly=Resolve-WelaArrivalPath $operation.Assembly.Path
$windows=[Environment]::GetFolderPath([Environment+SpecialFolder]::Windows).TrimEnd('\')+'\'
if(-not $assembly.StartsWith($windows,[StringComparison]::OrdinalIgnoreCase) -or [IO.Path]::GetFileName($assembly) -ine 'System.Management.Automation.dll' -or $operation.Assembly.FullName -cnotlike 'System.Management.Automation, Version=3.0.0.0,*' -or (Get-FileHash -LiteralPath $assembly -Algorithm SHA256).Hash.ToLowerInvariant() -cne $operation.Assembly.Sha256){throw 'Native worker assembly evidence differs.'}
$operation|Add-Member NoteProperty LaunchedUtc $launched.ToString('o');$operation|Add-Member NoteProperty ExitedUtc $exited.ToString('o')
$operation
}finally{try{if($started -and -not $process.HasExited){$process.Kill();if(-not $process.WaitForExit(3000)){throw 'Fixed worker termination was not confirmed.'}}}finally{$process.Dispose()}}
}
function ConvertFrom-WelaTranscriptProbeBytes {
param([byte[]]$Bytes)
$offset=0;$encoding=[Text.UTF8Encoding]::new($false,$true)
if($Bytes.Length -ge 3 -and $Bytes[0] -eq 239 -and $Bytes[1] -eq 187 -and $Bytes[2] -eq 191){$offset=3}
elseif($Bytes.Length -ge 2 -and $Bytes[0] -eq 255 -and $Bytes[1] -eq 254){$offset=2;$encoding=[Text.UnicodeEncoding]::new($false,$true,$true)}
elseif($Bytes.Length -ge 2 -and $Bytes[0] -eq 254 -and $Bytes[1] -eq 255){$offset=2;$encoding=[Text.UnicodeEncoding]::new($true,$true,$true)}
$text=$encoding.GetString($Bytes,$offset,$Bytes.Length-$offset)
if($text.Contains([string][char]0)){throw 'Transcript contains embedded NUL characters.'}
$text -replace "`r`n","`n"
}
function Test-WelaTranscriptProbeText {
param([string]$Text,$Operation)
$header=($Operation.Resources.TranscriptPrologue -replace "`r`n","`n").TrimEnd("`r","`n")
$footer=($Operation.Resources.TranscriptEpilogue -replace "`r`n","`n").TrimEnd("`r","`n")
if(-not $header -or -not $footer -or $header.Length -gt 8192 -or $footer.Length -gt 8192){throw 'Unknown native transcript resource templates.'}
$pattern=[regex]::Escape($header);$tail=[regex]::Escape($footer)
$fields=[ordered]@{'{0:yyyyMMddHHmmss}'='(?<Start>\d{14})';'{1}'='(?<User>[^\n]{1,512})';'{2}'='(?<RunAs>[^\n]{1,512})';'{3}'='(?<Configuration>[^\n]{0,512})';'{4}'='(?<Machine>[^\n]{1,255})';'{5}'='(?<OS>[^\n]{1,512})';'{6}'='(?<Command>[^\n]{1,4096})';'{7}'='(?<Pid>\d{1,10})';'{8}'='(?<Versions>[\s\S]{1,8192}?)'}
foreach($key in $fields.Keys){$escaped=[regex]::Escape($key);if(-not $pattern.Contains($escaped)){throw 'Unrecognized native transcript prologue schema.'};$pattern=$pattern.Replace($escaped,$fields[$key])}
$tail=$tail.Replace([regex]::Escape('{0:yyyyMMddHHmmss}'),'(?<End>\d{14})')
$match=[regex]::Match($Text,'\A'+$pattern+'\n(?<Body>[\s\S]*?)\n'+$tail+'\n*\z',[Text.RegularExpressions.RegexOptions]::CultureInvariant,[TimeSpan]::FromSeconds(1))
if(-not $match.Success){return $false}
foreach($template in @($header,$footer)){$prefix=(@($template -split "`n"|Select-Object -First 2) -join "`n");if([regex]::Matches($Text,[regex]::Escape($prefix)).Count -ne 1){return $false}}
if($match.Groups['User'].Value -ine $Operation.HeaderUser -or $match.Groups['RunAs'].Value -ine $Operation.BeforeToken.Name -or $match.Groups['Configuration'].Value -cne '' -or $match.Groups['Machine'].Value -ine $Operation.Computer -or $match.Groups['OS'].Value -cne $Operation.OsVersion -or $match.Groups['Command'].Value -cne $Operation.HeaderCommandLine -or [long]$match.Groups['Pid'].Value -ne $Operation.ProcessId){return $false}
$versions=@($match.Groups['Versions'].Value -split "`n")
if(@($versions|Where-Object{$_ -ceq ('PSVersion: '+$Operation.EngineVersion)}).Count -ne 1 -or @($versions|Where-Object{$_ -ceq 'PSEdition: Desktop'}).Count -ne 1){return $false}
$body=@($match.Groups['Body'].Value -split "`n");$begin='WELA-TRANSCRIPT-BEGIN:'+$Operation.Nonce+':'+$Operation.ProcessId;$end='WELA-TRANSCRIPT-END:'+$Operation.Nonce+':'+$Operation.ProcessId
if(@($body|Where-Object{$_ -ceq $begin}).Count -ne 1 -or @($body|Where-Object{$_ -ceq $end}).Count -ne 1 -or [Array]::IndexOf($body,$begin) -ge [Array]::IndexOf($body,$end)){return $false}
$offset=[TimeSpan]::FromMinutes($Operation.StartOffsetMinutes)
$first=[DateTimeOffset]::new([DateTime]::ParseExact($match.Groups['Start'].Value,'yyyyMMddHHmmss',[Globalization.CultureInfo]::InvariantCulture),$offset)
$last=[DateTimeOffset]::new([DateTime]::ParseExact($match.Groups['End'].Value,'yyyyMMddHHmmss',[Globalization.CultureInfo]::InvariantCulture),$offset)
return $first -ge (ConvertTo-WelaArrivalUtc $Operation.LaunchedUtc).AddSeconds(-1) -and $first -le (ConvertTo-WelaArrivalUtc $Operation.StartedUtc).AddSeconds(1) -and $last -ge (ConvertTo-WelaArrivalUtc $Operation.CompletedUtc).AddSeconds(-1) -and $last -le (ConvertTo-WelaArrivalUtc $Operation.ExitedUtc).AddSeconds(1) -and $last -ge $first
}
function Write-WelaTranscriptProbeArtifact {
param([string]$Root,[string]$Name,[byte[]]$Bytes)
if($Bytes.Length -gt 4194304){throw 'Evidence artifact exceeds four MiB.'}
$stream=[IO.File]::Open((Join-Path $Root $Name),[IO.FileMode]::CreateNew,[IO.FileAccess]::ReadWrite,[IO.FileShare]::None)
try{$stream.Write($Bytes,0,$Bytes.Length);$stream.Flush($true);$stream.Position=0;$sha=[Security.Cryptography.SHA256]::Create();try{$hash=([BitConverter]::ToString($sha.ComputeHash($stream))).Replace('-','').ToLowerInvariant()}finally{$sha.Dispose()};if($hash -cne (Get-WelaArrivalHash $Bytes)){throw 'Written evidence bytes differ.'}}finally{$stream.Dispose()}
[pscustomobject]@{Name=$Name;Bytes=$Bytes.Length;Sha256=$hash}
}
function Invoke-WelaTranscriptProbe {
param([ValidateSet('Plan','Run')][string]$Action='Plan',[string]$Directory,[string]$OutputPath)
if(($Action -eq 'Run') -ne (-not [string]::IsNullOrWhiteSpace($OutputPath))){throw 'Run requires a new TranscriptProbeOutputPath; Plan starts no worker or explicit output.'}
if(-not $Directory){throw 'Select the existing local TranscriptProbeDirectory.'}
Initialize-WelaTranscriptProbe
$directoryPath=Resolve-WelaArrivalPath $Directory
if(-not [IO.Directory]::Exists($directoryPath)){throw 'Selected transcript directory must already exist.'}
$handle=[Wela.TranscriptProbe.Item]::Directory($directoryPath);$heldFiles=@();$heldFolders=@();$output=$null
try{
$state=Get-WelaTranscriptProbeState $directoryPath $handle;$stateKey=Get-WelaTranscriptProbeStateKey $state
$dates=@(-1,0,1|ForEach-Object{[DateTime]::Today.AddDays($_).ToString('yyyyMMdd',[Globalization.CultureInfo]::InvariantCulture)})
$before=Get-WelaTranscriptProbeInventory $directoryPath $dates
if($Action -eq 'Plan'){return [pscustomobject]@{SchemaVersion=1;Kind='WelaAutomaticTranscriptPlan';Action='Plan';ExitCode=0;Status='ReadyToProbe';State=$state;Inventory=$before;Token=[Wela.WmiProbe.Native]::Snapshot();ReadyRuleCredit=0;SigmaEvtxCredit=0;WriterAuthorization='Unverified';Scope='One new native Windows PowerShell5.1 automatic transcript under this local current identity only'}}
$output=New-WelaArrivalOutput $OutputPath $directoryPath
$report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaAutomaticTranscriptProbe';Action='Run';Status='Unverified';ExitCode=1;RecordedUtc=[DateTime]::UtcNow.ToString('o');Before=$state;After=$null;InventoryBefore=$before;InventoryAfter=$null;ParentBefore=$null;ParentAfter=$null;Worker=$null;WorkerLaunch=[pscustomobject]@{ProcessId=$null;LaunchedUtc=$null;ExitedUtc=$null;ExitCode=$null;Stdout=$null;Stderr=$null};Transcript=$null;Artifacts=@();Diagnostic='';OutputPath=$output;ReadyRuleCredit=0;SigmaEvtxCredit=0;ConfigurationChanges=0;WriterAuthorization='Unverified';PowerShell7Sessions='Not assessed';Collection='Not verified';Scope='One fixed native5.1 completed automatic text transcript; no retention, immutable-storage or EVTX/Sigma claim'}
try{
# Prepare metadata and evidence storage before capturing the actual process-token interval.
foreach($folder in $before.Folders|Where-Object Exists){$held=[Wela.TranscriptProbe.Item]::Directory($folder.Observation.Path);$heldFolders+= $held;if((Get-WelaTranscriptProbeObjectKey $held.Snapshot() -Directory) -cne (Get-WelaTranscriptProbeObjectKey $folder.Observation -Directory)){throw 'Date-directory changed before worker.'}}
$fresh=Get-WelaTranscriptProbeState $directoryPath $handle;if((Get-WelaTranscriptProbeStateKey $fresh) -cne $stateKey){throw 'Policy, destination, source or host changed before worker.'}
$inventory=Get-WelaTranscriptProbeInventory $directoryPath $dates
Assert-WelaTranscriptProbeInventory $before $inventory
# Newly created unrelated files during preparation become baseline, never candidate evidence.
$before=$inventory;$report.InventoryBefore=$before
$token=[Wela.WmiProbe.Native]::Snapshot();$report.ParentBefore=$token
$operation=Start-WelaTranscriptProbeWorker $state ([guid]::NewGuid().ToString('N')) $token $report.WorkerLaunch;$report.Worker=$operation
$after=Get-WelaTranscriptProbeInventory $directoryPath $dates;$report.InventoryAfter=$after;Assert-WelaTranscriptProbeInventory $before $after
foreach($folder in $after.Folders|Where-Object Exists){$held=[Wela.TranscriptProbe.Item]::Directory($folder.Observation.Path);$heldFolders+=$held;if((Get-WelaTranscriptProbeObjectKey $held.Snapshot() -Directory) -cne (Get-WelaTranscriptProbeObjectKey $folder.Observation -Directory)){throw 'Date directory changed after worker.'}}
$candidates=@($after.Files|Where-Object{$_.Identity -cnotin @($before.Files.Identity)})
if($candidates.Count -gt 32){throw 'Fresh transcript candidates exceed the 32-file bound.'}
$matches=@();$total=0
foreach($candidate in $candidates){
if([IO.Path]::GetFileName($candidate.Path) -cnotlike 'PowerShell_transcript*.txt'){throw 'Unexpected fresh file in the selected date scope.'}
if((ConvertTo-WelaArrivalUtc $candidate.CreatedUtc) -lt (ConvertTo-WelaArrivalUtc $operation.LaunchedUtc).AddSeconds(-2) -or (ConvertTo-WelaArrivalUtc $candidate.WrittenUtc) -gt (ConvertTo-WelaArrivalUtc $operation.ExitedUtc).AddSeconds(2)){throw 'Fresh transcript file timestamps are outside the worker interval.'}
$file=[Wela.TranscriptProbe.Item]::File($candidate.Path);$heldFiles+=$file
$observation=$file.Snapshot();if((Get-WelaTranscriptProbeObjectKey $observation) -cne (Get-WelaTranscriptProbeObjectKey $candidate)){throw 'Candidate identity or contents changed after enumeration.'}
$bytes=$file.Read(1048576);$total+=$bytes.Length;if($total -gt 4194304){throw 'Fresh transcript reads exceed four MiB.'}
$text=ConvertFrom-WelaTranscriptProbeBytes $bytes
if(Test-WelaTranscriptProbeText $text $operation){$matches+=[pscustomobject]@{Observation=$observation;Bytes=$bytes;Handle=$file}}
}
if($matches.Count -ne 1){throw ('Expected one fresh completed automatic transcript; matching files: '+$matches.Count+'. Writer authorization remains unverified.')}
$report.After=Get-WelaTranscriptProbeState $directoryPath $handle
if((Get-WelaTranscriptProbeStateKey $report.After) -cne $stateKey){throw 'Policy, destination, source or host changed during the probe.'}
$final=Get-WelaTranscriptProbeInventory $directoryPath $dates;Assert-WelaTranscriptProbeInventory $after $final
if((Get-WelaTranscriptProbeKey @($after.Files.Path)) -cne (Get-WelaTranscriptProbeKey @($final.Files.Path))){throw 'Candidate inventory changed before final verification.'}
if((Get-WelaTranscriptProbeObjectKey $matches[0].Handle.Snapshot()) -cne (Get-WelaTranscriptProbeObjectKey $matches[0].Observation)){throw 'Matching transcript changed before evidence capture.'}
$report.ParentAfter=[Wela.WmiProbe.Native]::Snapshot()
if((Get-WelaWmiProbeTokenKey $report.ParentBefore) -cne (Get-WelaWmiProbeTokenKey $report.ParentAfter)){throw 'Parent authorization context changed during the probe.'}
$report.Artifacts+=Write-WelaTranscriptProbeArtifact $output 'transcript.txt' $matches[0].Bytes
$report.Transcript=$matches[0].Observation;$report.Status='CompletedAutomaticTranscript';$report.WriterAuthorization='ObservedForThisChild';$report.ExitCode=0
}catch{$report.Diagnostic=$_.Exception.Message}
$report.Artifacts+=Write-WelaTranscriptProbeArtifact $output 'worker.json' ([Text.UTF8Encoding]::new($false).GetBytes((ConvertTo-Json -InputObject $report.Worker -Depth 18)))
$null=Write-WelaTranscriptProbeArtifact $output 'result.json' ([Text.UTF8Encoding]::new($false).GetBytes(($report|ConvertTo-Json -Depth 22)))
return $report
}finally{foreach($file in $heldFiles){$file.Dispose()};foreach($folder in $heldFolders){$folder.Dispose()};$handle.Dispose()}
}
+66
View File
@@ -0,0 +1,66 @@
// Read-only local identity/descriptor/file access and bounded pipe drains.
using System;
using System.ComponentModel;
using System.IO;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Win32.SafeHandles;
namespace Wela.TranscriptProbe {
public sealed class Observation {
public string Path, Identity, CreatedUtc, WrittenUtc, Descriptor;
public uint Attributes, Links; public long Length;
}
public sealed class Capture {public string Text, Error;public bool Exceeded;}
public sealed class Item : IDisposable {
[StructLayout(LayoutKind.Sequential,Pack=4)] struct Info {public uint Attributes;public long Created,Accessed,Written;public uint Volume,SizeHigh,SizeLow,Links,IndexHigh,IndexLow;}
[DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern SafeFileHandle CreateFile(string path,uint access,uint share,IntPtr security,uint disposition,uint flags,IntPtr template);
[DllImport("kernel32.dll",SetLastError=true)] static extern bool GetFileInformationByHandle(SafeFileHandle handle,out Info value);
[DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern uint GetFinalPathNameByHandle(SafeFileHandle handle,StringBuilder text,uint length,uint flags);
[DllImport("advapi32.dll")] static extern uint GetSecurityInfo(SafeFileHandle handle,uint kind,uint flags,out IntPtr owner,out IntPtr group,out IntPtr dacl,out IntPtr sacl,out IntPtr descriptor);
[DllImport("advapi32.dll")] static extern uint GetSecurityDescriptorLength(IntPtr descriptor);
[DllImport("kernel32.dll")] static extern IntPtr LocalFree(IntPtr memory);
SafeFileHandle handle; FileStream stream; string path; bool directory;
public const string SourceSha256 = "__WELA_TRANSCRIPT_SOURCE_SHA256__";
Item(string path,bool directory,bool content) {
this.path=System.IO.Path.GetFullPath(path);this.directory=directory;
handle=CreateFile(this.path,content?0x80020000u:0x20080u,directory?3u:(content?1u:7u),IntPtr.Zero,3,0x02200000,IntPtr.Zero);
if(handle.IsInvalid){int error=Marshal.GetLastWin32Error();handle.Dispose();throw new Win32Exception(error);}
try {Snapshot();if(content)stream=new FileStream(handle,FileAccess.Read,4096,false);}catch{Dispose();throw;}
}
public static Item Directory(string path){return new Item(path,true,false);}
public static Item Metadata(string path){return new Item(path,false,false);}
public static Item File(string path){return new Item(path,false,true);}
public Observation Snapshot() {
Info value;if(!GetFileInformationByHandle(handle,out value))throw new Win32Exception(Marshal.GetLastWin32Error());
if((value.Attributes&1024)!=0||((value.Attributes&16)!=0)!=directory)throw new InvalidOperationException("Unexpected reparse point or object type.");
if(!directory&&value.Links!=1)throw new InvalidOperationException("Transcript files must have one link.");
StringBuilder buffer=new StringBuilder(32768);uint length=GetFinalPathNameByHandle(handle,buffer,(uint)buffer.Capacity,0);
if(length==0||length>=buffer.Capacity)throw new InvalidOperationException("Unknown native object path.");
string final=buffer.ToString();if(final.StartsWith(@"\\?\",StringComparison.Ordinal))final=final.Substring(4);
if(!String.Equals(final.TrimEnd('\\'),path.TrimEnd('\\'),StringComparison.OrdinalIgnoreCase))throw new InvalidOperationException("Native object path changed or resolves elsewhere.");
IntPtr owner,group,dacl,sacl,sd;uint error=GetSecurityInfo(handle,1,7,out owner,out group,out dacl,out sacl,out sd);
if(error!=0)throw new Win32Exception((int)error);
string descriptor;
try {uint size=GetSecurityDescriptorLength(sd);if(size<20||size>65536)throw new InvalidOperationException("Invalid descriptor bound.");byte[] bytes=new byte[size];Marshal.Copy(sd,bytes,0,bytes.Length);descriptor=Convert.ToBase64String(bytes);}finally{LocalFree(sd);}
return new Observation{Path=final,Identity=value.Volume.ToString("x8")+":"+value.IndexHigh.ToString("x8")+value.IndexLow.ToString("x8"),CreatedUtc=DateTime.FromFileTimeUtc(value.Created).ToString("o"),WrittenUtc=DateTime.FromFileTimeUtc(value.Written).ToString("o"),Attributes=value.Attributes,Links=value.Links,Length=((long)value.SizeHigh<<32)|value.SizeLow,Descriptor=descriptor};
}
public byte[] Read(int maximum) {
if(stream==null)throw new InvalidOperationException("Object was not opened for content.");
Observation before=Snapshot();if(before.Length<1||before.Length>maximum)throw new InvalidOperationException("Transcript is empty or exceeds its byte bound.");
byte[] bytes=new byte[(int)before.Length];stream.Position=0;int offset=0;
while(offset<bytes.Length){int read=stream.Read(bytes,offset,bytes.Length-offset);if(read==0)throw new EndOfStreamException();offset+=read;}
if(stream.ReadByte()!=-1)throw new InvalidOperationException("Transcript grew while reading.");
Observation after=Snapshot();if(before.Length!=after.Length||before.Identity!=after.Identity||before.WrittenUtc!=after.WrittenUtc||before.Descriptor!=after.Descriptor)throw new InvalidOperationException("Transcript changed while reading.");
return bytes;
}
public void Dispose(){if(stream!=null){stream.Dispose();stream=null;}if(handle!=null){handle.Dispose();handle=null;}}
public static Task<Capture> Drain(TextReader reader,int maximum) {
return Task.Factory.StartNew(()=>{Capture result=new Capture();StringBuilder text=new StringBuilder();char[] buffer=new char[2048];
try {int count;while((count=reader.Read(buffer,0,buffer.Length))>0){int retain=Math.Min(count,Math.Max(0,maximum-text.Length));if(retain<count)result.Exceeded=true;if(retain>0)text.Append(buffer,0,retain);}}
catch(Exception error){result.Error=error.GetType().FullName;}
result.Text=text.ToString();return result;});
}
}
}
+45
View File
@@ -0,0 +1,45 @@
# Fixed native5.1 worker. Automatic policy is the sole transcription producer.
param([Parameter(Mandatory)][ValidatePattern('^[a-f0-9]{32}$')][string]$Nonce)
$ErrorActionPreference='Stop'
[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)
if($PSVersionTable.PSEdition -cne 'Desktop' -or $PSVersionTable.PSVersion.Major -ne 5 -or $PSVersionTable.PSVersion.Minor -ne 1 -or -not [Environment]::Is64BitProcess){throw 'Native Windows PowerShell5.1 is required.'}
# A PowerShell7 parent can pass a PSModulePath without the native5.1 modules.
# Load only the fixed installed native modules, independent of caller module search paths.
foreach($module in @('Microsoft.PowerShell.Utility','Microsoft.PowerShell.Management')){
Import-Module ([IO.Path]::Combine($PSHOME,'Modules',$module,($module+'.psd1'))) -ErrorAction Stop
}
$script:ScriptRoot=Split-Path $PSScriptRoot -Parent
. (Join-Path $PSScriptRoot 'WmiProbe.ps1')
. (Join-Path $PSScriptRoot 'PowerShellTranscription.ps1')
Initialize-WelaWmiProbeNative
$assembly=[psobject].Assembly
$types=@($assembly.GetTypes()|Where-Object Name -eq 'InternalHostUserInterfaceStrings')
if($types.Count -ne 1){throw 'Native transcript resource type is unknown.'}
$resources=[ordered]@{}
foreach($name in @('TranscriptPrologue','TranscriptEpilogue')){
$property=$types[0].GetProperty($name,[Reflection.BindingFlags]'Public,NonPublic,Static')
if(-not $property){throw 'Native transcript resource is unavailable.'}
$value=$property.GetValue($null,$null)
if($value -isnot [string] -or $value.Length -gt 8192 -or -not $value.Contains('{0:yyyyMMddHHmmss}')){throw 'Unrecognized native transcript resource.'}
$resources[$name]=$value
}
$assemblyPath=$assembly.Location;$assemblyHash=(Get-FileHash -LiteralPath $assemblyPath -Algorithm SHA256).Hash.ToLowerInvariant()
$policyBefore=@(Get-WelaTranscriptPolicy @('Registry64','Registry32'));Test-WelaTranscriptSharedPolicy $policyBefore
$before=[Wela.WmiProbe.Native]::Snapshot();$start=[DateTimeOffset]::Now
Microsoft.PowerShell.Utility\Write-Output ('WELA-TRANSCRIPT-BEGIN:'+${Nonce}+':'+$PID)
$policyAfter=@(Get-WelaTranscriptPolicy @('Registry64','Registry32'))
if(($policyBefore|ConvertTo-Json -Depth 12 -Compress) -cne ($policyAfter|ConvertTo-Json -Depth 12 -Compress)){throw 'Worker policy changed.'}
if((Get-FileHash -LiteralPath $assemblyPath -Algorithm SHA256).Hash.ToLowerInvariant() -cne $assemblyHash){throw 'Worker engine assembly changed.'}
$after=[Wela.WmiProbe.Native]::Snapshot()
if((Get-WelaWmiProbeTokenKey $before) -cne (Get-WelaWmiProbeTokenKey $after)){throw 'Worker token changed.'}
Microsoft.PowerShell.Utility\Write-Output ('WELA-TRANSCRIPT-END:'+${Nonce}+':'+$PID)
$end=[DateTimeOffset]::Now
$operation=[pscustomobject][ordered]@{
Nonce=$Nonce;ProcessId=$PID;Engine=(Get-Process -Id $PID).Path;EngineVersion=$PSVersionTable.PSVersion.ToString();Edition=$PSVersionTable.PSEdition
StartedUtc=$start.UtcDateTime.ToString('o');CompletedUtc=$end.UtcDateTime.ToString('o');StartOffsetMinutes=$start.Offset.TotalMinutes;EndOffsetMinutes=$end.Offset.TotalMinutes
BeforeToken=$before;AfterToken=$after;PolicyBefore=$policyBefore;PolicyAfter=$policyAfter
Computer=[Environment]::MachineName;HeaderUser=([Environment]::UserDomainName+'\'+[Environment]::UserName);OsVersion=[Environment]::OSVersion.VersionString
CommandLine=[Environment]::CommandLine;HeaderCommandLine=([Environment]::GetCommandLineArgs() -join ' ');Arguments=@([Environment]::GetCommandLineArgs());UiCulture=[Globalization.CultureInfo]::CurrentUICulture.Name
Assembly=[pscustomobject]@{Path=$assemblyPath;FullName=$assembly.FullName;Sha256=$assemblyHash};Resources=[pscustomobject]$resources
}
[Console]::WriteLine('WELA-WORKER-JSON:'+($operation|ConvertTo-Json -Depth 16 -Compress))
+14
View File
@@ -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
+11
View File
@@ -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
+66
View File
@@ -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=@'
<Event xmlns="http://schemas.microsoft.com/win/2004/08/events/event"><System><Provider Name="Microsoft-Windows-DNS-Client" Guid="{1c95126e-7eea-49a9-a3fe-a378b03ddb4d}"/><EventID>3008</EventID><Version>0</Version><EventRecordID>10</EventRecordID><Channel>Microsoft-Windows-DNS-Client/Operational</Channel><Computer>host</Computer><TimeCreated SystemTime="2026-01-01T00:00:00.5000000Z"/><Execution ProcessID="123"/></System><EventData><Data Name="QueryName">wela-0123456789abcdef0123456789abcdef.wela.test.</Data><Data Name="QueryType">1</Data><Data Name="QueryOptions">0x2019ee</Data><Data Name="QueryStatus">0</Data><Data Name="QueryResults">192.0.2.1;</Data></EventData></Event>
'@
Assert (Test-WelaDnsClientProbeEvent $xml $operation $state) 'Exact synthetic native3008 shape matches.'
$mutations=@(
@('>3008<','>3006<'),@('<Version>0','<Version>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'),@('</EventData>','<Data Name="QueryName">duplicate</Data></EventData>'),@('</EventData>','<Data Name="Unknown">extra</Data></EventData>'),@('192.0.2.1;','<Nested/>'),@('<Event xmlns=','<!DOCTYPE Event [<!ENTITY x "no">]><Event xmlns='))
foreach($mutation in $mutations){Assert (-not(Test-WelaDnsClientProbeEvent ($xml.Replace($mutation[0],$mutation[1])) $operation $state)) "Reject mismatched native XML: $($mutation[0])"}
Assert (Test-WelaDnsClientProbeEvent ($xml.Replace('ProcessID="123"','ProcessID="456"')) $operation $state) 'Emitter broker PID remains recorded without invented caller attribution.'
$operation.Query.Status=9003;Assert (Test-WelaDnsClientProbeEvent ($xml.Replace('Name="QueryStatus">0','Name="QueryStatus">9003')) $operation $state) 'Typed NXDOMAIN completion differs from successful resolution.'
$operation.Query.Status=0
# 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."
+75
View File
@@ -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
+29 -7
View File
@@ -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('</Event>','<UserData/></Event>'),$fixture.Xml.Replace('</Event>','<System/></Event>'),('<!DOCTYPE Event [<!ENTITY a SYSTEM "file:///etc/passwd">]>'+$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('<EventRecordID>100','<EventRecordID>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
+16 -7
View File
@@ -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.'
+24
View File
@@ -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
+106
View File
@@ -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."
+37
View File
@@ -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
+107
View File
@@ -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
+82
View File
@@ -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."
@@ -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
+72
View File
@@ -0,0 +1,72 @@
$ErrorActionPreference='Stop';$script:ScriptRoot=Split-Path $PSScriptRoot -Parent
. (Join-Path $script:ScriptRoot 'scripts/WefArrival.ps1')
. (Join-Path $script:ScriptRoot 'scripts/PowerShellTranscription.ps1')
. (Join-Path $script:ScriptRoot 'scripts/TranscriptProbe.ps1')
$script:passed=0
function Assert($condition,[string]$message){if(-not $condition){throw $message};$script:passed++}
function Rejects([scriptblock]$action){$caught=$false;try{&$action|Out-Null}catch{$caught=$true};Assert $caught 'Expected refusal'}
function Clone($object){$object|ConvertTo-Json -Depth 20|ConvertFrom-Json}
$header="**********************`nWindows PowerShell transcript start`nStart time: {0:yyyyMMddHHmmss}`nUsername: {1}`nRunAs User: {2}`nConfiguration Name: {3}`nMachine: {4} ({5})`nHost Application: {6}`nProcess ID: {7}`n{8}`n**********************"
$footer="**********************`nWindows PowerShell transcript end`nEnd time: {0:yyyyMMddHHmmss}`n**********************"
$operation=[pscustomobject]@{Resources=[pscustomobject]@{TranscriptPrologue=$header;TranscriptEpilogue=$footer};Nonce=('a'*32);ProcessId=123;HeaderUser='HOST\writer';BeforeToken=[pscustomobject]@{Name='HOST\writer'};Computer='HOST';OsVersion='Microsoft Windows NT 10.0.20348.0';CommandLine='"powershell.exe" fixed';HeaderCommandLine='powershell.exe fixed';EngineVersion='5.1.20348.1000';StartOffsetMinutes=0;LaunchedUtc='2026-09-21T00:00:00.1000000Z';StartedUtc='2026-09-21T00:00:01.0000000Z';CompletedUtc='2026-09-21T00:00:02.0000000Z';ExitedUtc='2026-09-21T00:00:03.0000000Z'}
function MakeText($op){
$begin=[string]::Format([Globalization.CultureInfo]::InvariantCulture,$op.Resources.TranscriptPrologue,@([DateTime]::new(2026,9,21,0,0,0),$op.HeaderUser,$op.BeforeToken.Name,'',$op.Computer,$op.OsVersion,$op.HeaderCommandLine,$op.ProcessId,('PSVersion: '+$op.EngineVersion+"`nPSEdition: Desktop`n")))
$end=[string]::Format([Globalization.CultureInfo]::InvariantCulture,$op.Resources.TranscriptEpilogue,[DateTime]::new(2026,9,21,0,0,2))
$begin+"`nWELA-TRANSCRIPT-BEGIN:"+$op.Nonce+':'+$op.ProcessId+"`nWELA-TRANSCRIPT-END:"+$op.Nonce+':'+$op.ProcessId+"`n"+$end+"`n"
}
$text=MakeText $operation
Assert (Test-WelaTranscriptProbeText $text $operation) 'Complete native transcript framing and markers match'
foreach($case in @(
$text.Replace('Process ID: 123','Process ID: 124'),
$text.Replace('Host Application: powershell.exe fixed','Host Application: powershell.exe other'),
$text.Replace('RunAs User: HOST\writer','RunAs User: HOST\other'),
$text.Replace('PSVersion: 5.1.20348.1000','PSVersion: 7.5.0'),
$text.Replace('PSEdition: Desktop','PSEdition: Core'),
$text.Replace('Windows PowerShell transcript end','Unfinished'),
$text.Replace('WELA-TRANSCRIPT-BEGIN:','PS>WELA-TRANSCRIPT-BEGIN:'),
$text.Replace('WELA-TRANSCRIPT-END:','PS>WELA-TRANSCRIPT-END:'),
$text.Replace('Start time: 20260921000000','Start time: 20250921000000'),
$text.Replace('End time: 20260921000002','End time: 20260921000020'),
$text.Replace(('WELA-TRANSCRIPT-END:'+('a'*32)+':123'),('WELA-TRANSCRIPT-END:'+('b'*32)+':123')),
$text.Replace('Configuration Name: ','Configuration Name: remote'),
($text+$text),
($text+'trailing payload')
)){Assert (-not (Test-WelaTranscriptProbeText $case $operation)) 'Incomplete, mismatched or ambiguous content has no transcript proof'}
$marker='WELA-TRANSCRIPT-END:'+('a'*32)+':123'
Assert (-not (Test-WelaTranscriptProbeText ($text.Replace($marker,($marker+"`n"+$marker))) $operation)) 'Duplicate standalone marker is rejected'
$translated=Clone $operation
$translated.Resources.TranscriptPrologue=$header.Replace('Windows PowerShell transcript start','Windows PowerShell トランスクリプト開始').Replace('Username:','ユーザー名:')
$translated.Resources.TranscriptEpilogue=$footer.Replace('Windows PowerShell transcript end','Windows PowerShell トランスクリプト終了')
Assert (Test-WelaTranscriptProbeText (MakeText $translated) $translated) 'Runtime-supplied localized resources drive matching'
foreach($encoding in @([Text.UTF8Encoding]::new($true),[Text.UnicodeEncoding]::new($false,$true),[Text.UnicodeEncoding]::new($true,$true))){$bytes=[byte[]]@($encoding.GetPreamble()+$encoding.GetBytes($text.Replace("`n","`r`n")));Assert ((ConvertFrom-WelaTranscriptProbeBytes $bytes) -ceq $text) 'Supported transcript byte encoding roundtrips'}
Rejects {ConvertFrom-WelaTranscriptProbeBytes ([byte[]]@(0xc3,0x28))}
Rejects {ConvertFrom-WelaTranscriptProbeBytes ([byte[]]@(65,0,66))}
$directory=[pscustomobject]@{Path='C:\T';Identity='id1';CreatedUtc='2026-09-21T00:00:00Z';WrittenUtc='2026-09-21T00:00:00Z';Attributes=16;Descriptor='acl';Length=0;Links=1}
$new=Clone $directory;$new.WrittenUtc='2026-09-21T00:01:00Z'
Assert ((Get-WelaTranscriptProbeObjectKey $directory -Directory) -ceq (Get-WelaTranscriptProbeObjectKey $new -Directory)) 'Directory child creation does not replace directory identity'
foreach($field in @('Identity','Descriptor','CreatedUtc','Path')){$changed=Clone $directory;$changed.$field='changed';Assert ((Get-WelaTranscriptProbeObjectKey $directory -Directory) -cne (Get-WelaTranscriptProbeObjectKey $changed -Directory)) 'Directory identity/descriptor drift is visible'}
$oldFile=Clone $directory;$oldFile.Path='C:\T\20260921\old.txt';$oldFile.Attributes=32;$oldFile.Identity='old-file';$oldFile.Length=100
$inventory=[pscustomobject]@{Folders=@([pscustomobject]@{Date='20260921';Exists=$true;Observation=$directory});Files=@($oldFile)}
$appended=Clone $inventory;$appended.Files[0].Length=200;$appended.Files[0].WrittenUtc='2026-09-21T00:01:00Z'
Assert-WelaTranscriptProbeInventory $inventory $appended;Assert $true 'Existing active transcript append is preserved without reading it'
foreach($field in @('Identity','Descriptor','CreatedUtc','Path')){$changed=Clone $inventory;$changed.Files[0].$field='changed';Rejects {Assert-WelaTranscriptProbeInventory $inventory $changed}}
$changed=Clone $inventory;$changed.Files=@();Rejects {Assert-WelaTranscriptProbeInventory $inventory $changed}
$changed=Clone $inventory;$changed.Folders[0].Exists=$false;Rejects {Assert-WelaTranscriptProbeInventory $inventory $changed}
# Pure typed-policy tests replace only local path resolution, not the policy decision.
function Resolve-WelaArrivalPath {param([string]$Path)if($Path -notmatch '^C:\\'){throw 'Fixture non-local path'};$Path}
function Value($value,$type){[pscustomobject]@{KeyExists=$true;ValueExists=$true;Value=$value;Type=$type}}
$machine=[pscustomobject]@{EnableTranscripting=(Value 1 'DWord');OutputDirectory=(Value 'C:\T' 'String');EnableInvocationHeader=(Value 1 'DWord')}
$user=[pscustomobject]@{EnableTranscripting=[pscustomobject]@{KeyExists=$false;ValueExists=$false;Value=$null;Type=$null};OutputDirectory=[pscustomobject]@{KeyExists=$false;ValueExists=$false;Value=$null;Type=$null};EnableInvocationHeader=[pscustomobject]@{KeyExists=$false;ValueExists=$false;Value=$null;Type=$null}}
$policy=@([pscustomobject]@{View='Registry64';Machine=$machine;CurrentUser=$user},[pscustomobject]@{View='Registry32';Machine=$machine;CurrentUser=$user})
Assert-WelaTranscriptProbePolicy $policy 'C:\T';Assert $true 'Known enabled matching machine policy accepted'
foreach($field in @('EnableTranscripting','OutputDirectory','EnableInvocationHeader')){$changed=Clone $policy;$changed[0].Machine.$field.Type='Unknown';$changed[1].Machine.$field.Type='Unknown';Rejects {Assert-WelaTranscriptProbePolicy $changed 'C:\T'}}
$changed=Clone $policy;$changed[0].Machine.EnableTranscripting.Value=0;$changed[1].Machine.EnableTranscripting.Value=0;Rejects {Assert-WelaTranscriptProbePolicy $changed 'C:\T'}
Rejects {Assert-WelaTranscriptProbePolicy $policy 'C:\Other'}
$changed=Clone $policy;$changed[1].Machine.OutputDirectory.Value='C:\Other';Rejects {Assert-WelaTranscriptProbePolicy $changed 'C:\T'}
Rejects {Assert-WelaTranscriptProbePolicy @($policy[0]) 'C:\T'}
$engine=(Get-Process -Id $PID).Path
foreach($case in @(@{Args=@('transcript-probe','-Help');Exit=0},@{Args=@('transcript-probe','-Help','-Auto');Exit=1},@{Args=@('transcript-probe','-Help','-TranscriptDirectory','C:\T');Exit=1},@{Args=@('transcript-probe','-Help','-DryRun');Exit=1},@{Args=@('help','-TranscriptProbeAction','Run');Exit=1})){
$old=$ErrorActionPreference;$ErrorActionPreference='Continue';try{$output=&$engine -NoProfile -File (Join-Path $script:ScriptRoot 'WELA.ps1') @($case.Args) 2>&1;$code=$LASTEXITCODE}finally{$ErrorActionPreference=$old}
Assert ($code -eq $case.Exit) ('CLI boundary '+($case.Args -join ' ')+': '+($output|Out-String))
}
$global:LASTEXITCODE=0;Write-Host "Transcript probe fixtures passed: $script:passed"
+99
View File
@@ -0,0 +1,99 @@
param([switch]$AllowDisposableWriter,[ValidateSet('powershell','pwsh')][string]$TestEngine='powershell')
$ErrorActionPreference='Stop'
if(-not $AllowDisposableWriter -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted' -or $env:OS -ne 'Windows_NT'){throw 'Explicit disposable writer/policy/ACL opt-in on a GitHub-hosted Windows runner required.'}
$computer=Get-CimInstance Win32_ComputerSystem;$os=Get-CimInstance Win32_OperatingSystem
if($computer.PartOfDomain -or $computer.DomainRole -ne 2 -or $os.ProductType -ne 3 -or [int]$os.BuildNumber -notin @(20348,26100)){throw 'Refusing domain, DC or unknown runner.'}
$root=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$root
. (Join-Path $root 'scripts/PowerShellTranscription.ps1')
$nonce=[guid]::NewGuid().ToString('N');$username='WelaT'+$nonce.Substring(0,12)
$fixture=Join-Path $env:RUNNER_TEMP ('wela-transcript-probe-'+$nonce);$null=New-Item -ItemType Directory $fixture
$codeRoot=Join-Path $fixture 'code';$null=New-Item -ItemType Directory $codeRoot
foreach($path in @('WELA.ps1','scripts','modules','config')){Copy-Item -LiteralPath (Join-Path $root $path) -Destination $codeRoot -Recurse}
$readerHome=Join-Path $fixture 'writer';$destination=Join-Path $fixture 'transcripts';$null=New-Item -ItemType Directory $readerHome,$destination
$engine=(Get-Command $TestEngine -ErrorAction Stop).Source
$policyBefore=@(Get-WelaTranscriptPolicy @('Registry64','Registry32'));$policyBefore|ConvertTo-Json -Depth 12|Set-Content -LiteralPath (Join-Path $fixture 'policy-before.json') -Encoding UTF8
$ownedSid=$null;$policyTouched=$false;$passed=$false;$originalAcl=$null
function Restore-Policy {
$base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64);$key=$null
try{
$key=$base.CreateSubKey('SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription')
$key.SetValue('EnableTranscripting',0,[Microsoft.Win32.RegistryValueKind]::DWord)
foreach($name in @('OutputDirectory','EnableInvocationHeader','EnableTranscripting')){
$value=$policyBefore[0].Machine.$name
if($value.ValueExists){$key.SetValue($name,$value.Value,[Microsoft.Win32.RegistryValueKind]([string]$value.Type))}else{$key.DeleteValue($name,$false)}
}
$remove=-not $policyBefore[0].Machine.EnableTranscripting.KeyExists -and $key.GetValueNames().Count -eq 0 -and $key.GetSubKeyNames().Count -eq 0
$key.Dispose();$key=$null
if($remove){$base.DeleteSubKey('SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription',$false)}
}finally{if($key){$key.Dispose()};$base.Dispose()}
if((@(Get-WelaTranscriptPolicy @('Registry64','Registry32'))|ConvertTo-Json -Depth 12 -Compress) -cne ($policyBefore|ConvertTo-Json -Depth 12 -Compress)){throw 'Exact typed transcription policy/key restoration failed.'}
}
function Invoke-ProbeAsOwnedUser([string]$Label,[int]$ExpectedExit,[ValidateSet('Plan','Run')][string]$Action='Run'){
$output=Join-Path $readerHome $Label
# Credentials are passed as a SecureString through the process API, never command-line text.
$arguments='-NoProfile -ExecutionPolicy Bypass -File "'+(Join-Path $codeRoot 'WELA.ps1')+'" transcript-probe -TranscriptProbeAction '+$Action+' -TranscriptProbeDirectory "'+$destination+'"'+$(if($Action -eq 'Run'){' -TranscriptProbeOutputPath "'+$output+'"'}else{''})
# Own the process handle directly: Windows PowerShell's Start-Process can lose
# ExitCode for alternate-credential children after they exit.
$start=[Diagnostics.ProcessStartInfo]::new();$start.FileName=$engine;$start.Arguments=$arguments
$start.UseShellExecute=$false;$start.CreateNoWindow=$true;$start.WorkingDirectory=$readerHome
$start.UserName=$username;$start.Domain=[Environment]::MachineName;$start.Password=$password;$start.LoadUserProfile=$true
$start.RedirectStandardOutput=$true;$start.RedirectStandardError=$true
$start.EnvironmentVariables['TEMP']=$readerHome;$start.EnvironmentVariables['TMP']=$readerHome
$process=[Diagnostics.Process]::new();$process.StartInfo=$start;$started=$false
try{
if(-not $process.Start()){throw 'Native reader process did not start.'};$started=$true
$stdout=$process.StandardOutput.ReadToEndAsync();$stderr=$process.StandardError.ReadToEndAsync()
if(-not $process.WaitForExit(90000)){$process.Kill();$null=$process.WaitForExit(5000);throw 'Reader child exceeded 90 seconds.'}
if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),5000)){throw 'Reader output pipes did not close within five seconds of process exit.'}
$exitCode=$process.ExitCode
[IO.File]::WriteAllText((Join-Path $readerHome ($Label+'.stdout')),$stdout.GetAwaiter().GetResult())
[IO.File]::WriteAllText((Join-Path $readerHome ($Label+'.stderr')),$stderr.GetAwaiter().GetResult())
}finally{
try{if($started -and -not $process.HasExited){$process.Kill();if(-not $process.WaitForExit(5000)){throw 'Reader child termination was not confirmed; no acceptance claim.'}}}finally{$process.Dispose()}
}
if($exitCode -ne $ExpectedExit){Get-Content -LiteralPath (Join-Path $readerHome ($Label+'.stderr'));throw "Reader exit $exitCode expected $ExpectedExit"}
if($Action -eq 'Plan'){if(Test-Path -LiteralPath $output){throw 'Plan wrote explicit evidence output.'};return}
$report=Get-Content -LiteralPath (Join-Path $output 'result.json') -Raw|ConvertFrom-Json
if($report.ReadyRuleCredit -ne 0 -or $report.SigmaEvtxCredit -ne 0 -or $report.ConfigurationChanges -ne 0){throw 'Transcript report overclaims coverage or changed configuration.'}
$report
}
try{
$password=ConvertTo-SecureString ('Wela!7'+[guid]::NewGuid().ToString('N')+'zA#') -AsPlainText -Force
$user=New-LocalUser -Name $username -Password $password -Description ('WELA transcript '+$nonce.Substring(0,20)) -AccountNeverExpires
$ownedSid=$user.SID.Value;Add-LocalGroupMember -SID 'S-1-5-32-545' -Member $user
$acl=Get-Acl $fixture;$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($user.SID,'ReadAndExecute','ContainerInherit,ObjectInherit','None','Allow'));Set-Acl $fixture $acl
$acl=Get-Acl $readerHome;$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($user.SID,'FullControl','ContainerInherit,ObjectInherit','None','Allow'));Set-Acl $readerHome $acl
$acl=[Security.AccessControl.DirectorySecurity]::new();$acl.SetAccessRuleProtection($true,$false)
foreach($sid in @('S-1-5-18','S-1-5-32-544',$ownedSid)){$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new([Security.Principal.SecurityIdentifier]::new($sid),'FullControl','ContainerInherit,ObjectInherit','None','Allow'))}
Set-Acl $destination $acl;$originalAcl=Get-Acl $destination
$policyTouched=$true
Set-WelaTranscriptRegistryValue -Name OutputDirectory -Value $destination -Type String
Set-WelaTranscriptRegistryValue -Name EnableTranscripting -Value 1 -Type DWord
# Exercise invocation headers while preserving the real original typed preference.
$base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64)
try{$key=$base.OpenSubKey('SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription',$true);try{$key.SetValue('EnableInvocationHeader',1,[Microsoft.Win32.RegistryValueKind]::DWord)}finally{$key.Dispose()}}finally{$base.Dispose()}
$configured=@(Get-WelaTranscriptPolicy @('Registry64','Registry32'))|ConvertTo-Json -Depth 12 -Compress
Invoke-ProbeAsOwnedUser 'plan' 0 'Plan'
$allowed=Invoke-ProbeAsOwnedUser 'allowed' 0
if($allowed.Status -ne 'CompletedAutomaticTranscript' -or $allowed.WriterAuthorization -ne 'ObservedForThisChild' -or $allowed.Worker.BeforeToken.Sid -cne $ownedSid -or $allowed.ParentBefore.Sid -cne $ownedSid -or $allowed.Worker.BeforeToken.AuthenticationId -cne $allowed.ParentBefore.AuthenticationId -or @($allowed.Worker.BeforeToken.Groups|Where-Object Sid -eq 'S-1-5-32-544').Count){throw 'No completed automatic native5.1 transcript from the actual owned standard-user logon.'}
if($allowed.Worker.Engine -notlike '*\System32\WindowsPowerShell\v1.0\powershell.exe' -or $allowed.Worker.Edition -cne 'Desktop'){throw 'Wrong transcript engine.'}
$artifact=@($allowed.Artifacts|Where-Object Name -eq 'transcript.txt')
if($artifact.Count -ne 1 -or (Get-FileHash -LiteralPath (Join-Path $allowed.OutputPath 'transcript.txt') -Algorithm SHA256).Hash.ToLowerInvariant() -cne $artifact[0].Sha256){throw 'Transcript evidence hash mismatch.'}
if((@(Get-WelaTranscriptPolicy @('Registry64','Registry32'))|ConvertTo-Json -Depth 12 -Compress) -cne $configured -or (Get-Acl $destination).Sddl -cne $originalAcl.Sddl){throw 'Probe changed configured policy or root ACL.'}
$deny=Get-Acl $destination
$deny.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($user.SID,'Write','ContainerInherit,ObjectInherit','None','Deny'));Set-Acl $destination $deny
$deniedAcl=(Get-Acl $destination).Sddl
$denied=Invoke-ProbeAsOwnedUser 'denied' 1
if($denied.Status -eq 'CompletedAutomaticTranscript' -or $denied.WriterAuthorization -ne 'Unverified' -or $denied.Transcript){throw 'Denied writer gained positive transcript proof.'}
if((Get-Acl $destination).Sddl -cne $deniedAcl -or (@(Get-WelaTranscriptPolicy @('Registry64','Registry32'))|ConvertTo-Json -Depth 12 -Compress) -cne $configured){throw 'Denied run changed the explicit deny or policy.'}
$passed=$true
}finally{
$errors=@()
if($policyTouched){try{Restore-Policy}catch{$errors+=[string]$_}}
if($originalAcl){try{Set-Acl $destination $originalAcl;if((Get-Acl $destination).Sddl -cne $originalAcl.Sddl){throw 'Owned destination ACL restoration failed.'}}catch{$errors+=[string]$_}}
if($ownedSid){try{$current=Get-LocalUser -Name $username -ErrorAction Stop;if($current.SID.Value -cne $ownedSid){throw 'Account identity changed; refusing deletion.'};Remove-LocalUser -SID $ownedSid -ErrorAction Stop;if(Get-LocalUser -SID $ownedSid -ErrorAction SilentlyContinue){throw 'Owned account remains.'}}catch{$errors+=[string]$_}}
[pscustomobject]@{Passed=$passed;CleanupErrors=$errors;OwnedSid=$ownedSid;PolicyRestored=($errors.Count -eq 0);Scope='Actual local standard-user automatic native5.1 transcript and explicit denied writer; no UNC, PS7-session, collector or Sigma claim'}|ConvertTo-Json -Depth 6|Set-Content -LiteralPath (Join-Path $fixture 'acceptance.json') -Encoding UTF8
Write-Host "Native automatic transcript evidence: $fixture"
if($errors.Count){throw ($errors -join '; ')}
}
if(-not $passed){throw 'Native transcript acceptance incomplete.'}
+81
View File
@@ -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"
}
+10
View File
@@ -5,10 +5,20 @@
## 2.2.0 [2026/xx/xx] - Dev Release
- 完了したジャーナルから、プロセス作成・PowerShell ログ用の 3 つの DWORD 値を `audit-recovery` で復元できるようにしました。ネイティブ API による値のみの復元、他の値の変更検知、レジストリキーの保持に対応します。 ([#435](https://github.com/Yamato-Security/WELA/pull/435))
**改善:**
- 追加が証明された親名前空間のみの明示的な監査ACEを1つ削除する `wmi-sacl-recovery` の Plan/Recover を追加しました。完了済み設定の整合する記録、現在の完全な記述子、レビュー済みハッシュ、監査縮小への明示的同意を必須とし、他の記述子情報・ACE・トークン権限を保持します。部分書き込みの証跡と所有する実機名前空間の後始末を検証し、過去の名前空間・操作者の同一性やイベント・Sigmaの準備完了は保証しません。 (関連 #372, #365) (@Shirofune-Security)
- 既に有効なローカル保存先で、現在の実行ユーザーによる固定 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-<nonce>.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)
+10
View File
@@ -5,10 +5,20 @@
## 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:**
- Add reviewed `wmi-sacl-recovery` Plan/Recover for one proven explicit parent-only namespace audit ACE. Require matching completed configuration evidence, current full descriptor, reviewed hash and explicit audit-reduction consent; preserve all other descriptor fields, remaining ACEs and token privileges, retain partial-write evidence, and test owned native namespace cleanup. Historical namespace/operator identity and event/Sigma readiness remain unclaimed. (Related #372, #365) (@Shirofune-Security)
- Added opt-in `transcript-probe` to verify a fixed current-account Windows PowerShell 5.1 child produces its own completed automatic transcript in an already enabled local destination. Actual token/logon, native header/footer, nonce/PID/time, bounded file identity and source checks preserve unverified outcomes without changing policy or ACLs, invoking explicit transcription or granting Sigma/EVTX credit. Added disposable standard-writer success/denial tests for Server 2022/2025 under both WELA host engines. (#436) (@Shirofune-Security)
- Added 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-<nonce>.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)