diff --git a/.github/workflows/evtx-recovery.yml b/.github/workflows/evtx-recovery.yml new file mode 100644 index 00000000..e74d4b95 --- /dev/null +++ b/.github/workflows/evtx-recovery.yml @@ -0,0 +1,40 @@ +name: Native EVTX recovery +on: + push: + branches: ['**'] + paths: + - 'WELA.ps1' + - 'scripts/EvtxRecovery.ps1' + - 'scripts/ControlApplicability.ps1' + - 'scripts/Configuration.ps1' + - 'modules/AuditProfiles.psm1' + - 'tests/EvtxRecovery*' + - 'tests/fixtures/EvtxRecovery*' + - 'scripts/NativeValidation.ps1' + - 'scripts/CustomAuditProfiles.ps1' + - '.github/workflows/evtx-recovery.yml' + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + evtx-recovery: + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Synthetic rejection regressions in Windows PowerShell 5.1 + shell: powershell + run: ./tests/EvtxRecovery.Tests.ps1 + - name: Native EVTX export and recovery with policy restoration + shell: powershell + run: ./tests/EvtxRecovery.Windows.Tests.ps1 -AllowDisposablePolicyWrite + - 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 diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index af75d8e7..d5a7ba11 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,6 +4,8 @@ **改善:** +- `evtx-recovery` を追加し、検証済みのネイティブ Security プローブを EVTX に出力して Windows イベント API で再読込できるようにしました。実際の読取アカウントによる検証、入力・イベントの厳密な比較、新規出力の保護、ハッシュとドリフト検出で空または変更された記録を拒否します。使い捨て Windows テストで実際の出力・復旧を検証し、全体の保存期間、他アカウントのアクセス、Sigma 対応とは区別します。 (#420) (@Shirofune-Security) + - `audit-recovery` を追加し、完了した監査サブカテゴリと優先設定の変更を明示選択して計画・復元できるようにしました。元の記録と結果、ホストと入力の再検証、復元前の記録、最終確認でドリフトを検出し、最小設定の独立した追加ビットを保持します。優先設定は最後に復元します。使い捨て Windows 環境で実際の復元を検証し、過去のホスト同一性、GPO 永続性、Sigma 対応の証明とは区別します。 出力先はローカル固定ドライブに限定し、ネットワークドライブと代替データストリームを拒否します。 (#419) (@Shirofune-Security) - 読み取り専用の`wef-arrival`を追加し、完了したWindows標準4688プローブの資料を検証して、ローカル収集サーバーに元イベントが1件だけ一致するか確認できるようにしました。ハッシュ・形式・環境の厳密な確認、件数を制限したネイティブ検索、実際の読み取りユーザーと変更検出、保護された生XMLの保存により、不完全・曖昧な結果は未検証として保持します。イベントの存在を、配信サブスクリプション・遅延・時刻同期・Sigma利用可能性の証明とは扱いません。ホスト間の正常到着は別途ラボ検証が必要です。 (#418) (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53e9c240..d791d066 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ **Improvements:** +- Added opt-in `evtx-recovery` to export one validated native Security probe and reopen its EVTX through Windows event APIs, with a separate verification action under the actual reader. Strict source/component checks, exact event comparison, protected new output, archive hashes and reader/context drift checks reject empty or changed evidence. Disposable Windows tests cover real export/recovery and empty archives; full retention, other-reader access and Sigma readiness remain separate. (#420) (@Shirofune-Security) + - Added opt-in `audit-recovery` planning and restoration for selected completed audit subcategory and typed precedence writes. Matched journals/results, independently rebuilt plans, actual host/source guards, durable receipts on local fixed drives and final readback refuse drift; minimum masks preserve independent additions and precedence restores last. Native disposable Windows tests verify exact restoration, without historical-identity, policy-persistence or Sigma claims. (#419) (@Shirofune-Security) - Added read-only `wef-arrival` to validate a completed native 4688 probe bundle and query the local collector for one exact original event. Strict hashes/schema/context checks, bounded native queries, actual reader observations, drift checks and protected raw evidence keep failed or ambiguous results unverified. Presence is separate from subscription attribution, latency, clock synchronization and Sigma readiness; positive cross-host acceptance remains pending. (#418) (@Shirofune-Security) diff --git a/WELA.ps1 b/WELA.ps1 index cbfa0469..9b9d30ad 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -77,6 +77,10 @@ [ValidateSet('Plan','Run')][string]$ProbeAction = 'Plan', [string]$ProbeOutputPath, [ValidateRange(1,30)][int]$ProbeTimeoutSeconds = 15, + [ValidateSet('Export','Verify')][string]$EvtxAction = 'Verify', + [string]$EvtxProbePath, + [string]$EvtxArchivePath, + [string]$EvtxOutputPath, [ValidateSet('Plan','Restore')][string]$RecoveryAction = 'Plan', [string]$RecoveryJournalPath, [string]$RecoveryOriginalResultsPath, @@ -127,6 +131,7 @@ Import-Module (Join-Path $ScriptRoot "modules/WefSubscriptions.psm1") -ErrorActi . (Join-Path $ScriptRoot "scripts/TargetedSaclPlanning.ps1") . (Join-Path $ScriptRoot "scripts/GpoAuditPackages.ps1") . (Join-Path $ScriptRoot "scripts/IntuneAuditExport.ps1") +. (Join-Path $ScriptRoot "scripts/EvtxRecovery.ps1") . (Join-Path $ScriptRoot "scripts/AuditRecovery.ps1") # 64bit の PowerShell と GPO が読むのは Wow6432Node の無いパス。32bit 用に両方を扱う。 @@ -1919,6 +1924,8 @@ if ($Cmd -eq 'intune-export' -and @($PSBoundParameters.Keys | Where-Object { $_ throw 'intune-export accepts only Intune target/export options, IncludeOptional and Help. No command was run.' } +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 'audit-recovery' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'Recovery*'}).Count) {throw 'Recovery options require audit-recovery. No command was run.'} if ($Cmd -eq 'audit-recovery' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','RecoveryAction','RecoveryJournalPath','RecoveryOriginalResultsPath','RecoveryControlId','RecoveryPlanPath','RecoveryOutputPath','Auto','DryRun','Help')}).Count) {throw 'audit-recovery accepts only dedicated recovery options, Auto and DryRun. No command was run.'} @@ -2060,6 +2067,12 @@ switch ($Cmd.ToLower()) { if ($report.ExitCode) { exit $report.ExitCode } } catch { Write-Host "[Failed] Intune export: $_" -ForegroundColor Red; exit 1 } } + 'evtx-recovery' { + if ($Help) {Write-Host 'Usage: evtx-recovery -EvtxAction Export -EvtxProbePath validated-probe-directory -EvtxOutputPath new-directory; or -EvtxAction Verify -EvtxProbePath validated-probe-directory -EvtxArchivePath probe.evtx -EvtxOutputPath new-directory. No policy changes. See docs/evtx-recovery.md.';return} + $report=Invoke-WelaEvtxRecovery -Action $EvtxAction -ProbePath $EvtxProbePath -ArchivePath $EvtxArchivePath -OutputPath $EvtxOutputPath + $report + if ($report.ExitCode) {exit $report.ExitCode} + } 'audit-recovery' { if ($Help) {Write-Host 'Usage: audit-recovery [-RecoveryAction Plan] -RecoveryJournalPath before.jsonl -RecoveryOriginalResultsPath results.json -RecoveryControlId IDs -RecoveryOutputPath new-directory; then -RecoveryAction Restore -RecoveryPlanPath reviewed-plan.json -RecoveryOutputPath new-directory [-Auto], or -DryRun without output. See docs/audit-recovery.md.';return} $report=Invoke-WelaAuditRecovery -Action $RecoveryAction -JournalPath $RecoveryJournalPath -OriginalResultsPath $RecoveryOriginalResultsPath -ControlId $RecoveryControlId -PlanPath $RecoveryPlanPath -OutputPath $RecoveryOutputPath -Auto:$Auto -DryRun:$DryRun diff --git a/docs/evtx-recovery.md b/docs/evtx-recovery.md new file mode 100644 index 00000000..01730979 --- /dev/null +++ b/docs/evtx-recovery.md @@ -0,0 +1,24 @@ +# Native EVTX export and recovery evidence + +Related to #382. `evtx-recovery` exports one validated native Security 4688 probe to a new `.evtx` file, reopens it with the Windows event API and compares its original event fields. `Verify` can repeat that read under the intended reader's actual Windows session. Sysmon is excluded. + +```powershell +# First collect a real fixed probe using existing, enabled audit prerequisites. +./WELA.ps1 native-validation -ProbeAction Run -ProbeOutputPath C:\Evidence\probe +# On that source host, export exactly the observed record and verify native readback. +./WELA.ps1 evtx-recovery -EvtxAction Export -EvtxProbePath C:\Evidence\probe -EvtxOutputPath C:\Evidence\archive +# Run under the intended reader account with access to the unchanged source bundle and EVTX. +./WELA.ps1 evtx-recovery -EvtxAction Verify -EvtxProbePath C:\Evidence\probe -EvtxArchivePath C:\Evidence\archive\probe.evtx -EvtxOutputPath C:\Evidence\readback +``` + +`Verify` is the default. The command changes no Windows policy, log settings, retention or permissions on existing evidence. New output directories restrict inherited access to the current user, SYSTEM and local Administrators; any transfer/access arrangement for another reader is an operator task. Local fixed-drive paths only; no network/device paths, alternate streams, reparse traversal, overwrites or output inside the input bundle. Relative paths follow PowerShell's current location. + +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. + +`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. + +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. diff --git a/scripts/EvtxRecovery.ps1 b/scripts/EvtxRecovery.ps1 new file mode 100644 index 00000000..dbe26454 --- /dev/null +++ b/scripts/EvtxRecovery.ps1 @@ -0,0 +1,268 @@ +# Read-only local collector correlation. No subscription, policy, service or process changes. +function Assert-WelaEvtxObject { + param($Value,[string[]]$Fields) + if ($Value -isnot [pscustomobject] -or @($Value.PSObject.Properties).Count -ne $Fields.Count -or + @($Value.PSObject.Properties.Name | Where-Object {$_ -cnotin $Fields}).Count) {throw 'Unexpected or incomplete EVTX evidence object.'} +} +function Get-WelaEvtxHash { + param([byte[]]$Bytes) + $sha=[Security.Cryptography.SHA256]::Create() + try {([BitConverter]::ToString($sha.ComputeHash($Bytes))).Replace('-','').ToLowerInvariant()} finally {$sha.Dispose()} +} +function ConvertFrom-WelaEvtxJson { + param([string]$Text) + # Reuse the merged strict JSON lexer/duplicate-key validator, without executing input. + $null=& (Get-Module AuditProfiles -ErrorAction Stop) {param($value) ConvertFrom-WelaCustomProfileJson $value} $Text + $arguments=@{InputObject=$Text;ErrorAction='Stop'} + if ((Get-Command ConvertFrom-Json).Parameters.ContainsKey('DateKind')) {$arguments.DateKind='String'} + ConvertFrom-Json @arguments +} +function ConvertTo-WelaEvtxUtc { + param($Value) + if ($Value -is [datetime]) { + if ($Value.Kind -ne [DateTimeKind]::Utc) {throw 'Expected an explicit UTC evidence timestamp.'} + return [DateTimeOffset]$Value + } + if ($Value -isnot [string] -or $Value -cnotmatch '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,7})?Z$') {throw 'Expected an explicit UTC evidence timestamp.'} + return [DateTimeOffset]::Parse($Value,[Globalization.CultureInfo]::InvariantCulture) +} +function Resolve-WelaEvtxPath { + param([Parameter(Mandatory)][string]$Path) + if ($Path -match '[\x00-\x1f*?\[\]]') {throw 'EVTX evidence requires exact paths without wildcards or control characters.'} + $provider=$null;$drive=$null + $full=$ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path,[ref]$provider,[ref]$drive) + if ($provider.Name -ne 'FileSystem' -or $full -match '^[\\/]{2}' -or $full.Substring([IO.Path]::GetPathRoot($full).Length).Contains(':')) {throw 'EVTX evidence requires ordinary local filesystem paths, without remote/device paths or streams.'} + $full=[IO.Path]::GetFullPath($full);$ancestor=$full + while ($ancestor) { + $item=Get-Item -LiteralPath $ancestor -Force -ErrorAction SilentlyContinue + if ($item -and ([int]$item.Attributes -band [int][IO.FileAttributes]::ReparsePoint)) {throw 'EVTX evidence cannot traverse reparse points.'} + $parent=[IO.Directory]::GetParent($ancestor);if (-not $parent) {break};$ancestor=$parent.FullName + } + if ([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT -and ([IO.DriveInfo]::new([IO.Path]::GetPathRoot($full))).DriveType -ne [IO.DriveType]::Fixed) {throw 'EVTX evidence requires a local fixed drive.'} + $full +} +function Get-WelaEvtxXmlKey { + param($Node) + # Namespace-aware semantic equality; attribute order/prefixes are not event data. + $attributes=@($Node.Attributes | Where-Object {$_.NamespaceURI -ne 'http://www.w3.org/2000/xmlns/'} | Sort-Object NamespaceURI,LocalName | ForEach-Object {ConvertTo-Json -InputObject @($_.NamespaceURI,$_.LocalName,$_.Value) -Compress}) + $children=@();$text='';$hasElements=@($Node.ChildNodes|Where-Object NodeType -eq Element).Count -gt 0 + foreach ($child in $Node.ChildNodes) { + if ($child.NodeType -eq 'Element') {$children+=Get-WelaEvtxXmlKey $child} + elseif ($child.NodeType -in @('Text','CDATA','SignificantWhitespace')) {$text+=$child.Value} + elseif ($child.NodeType -eq 'Whitespace') {if(-not $hasElements){$text+=$child.Value}} + else {throw 'Unsupported event XML node.'} + } + ConvertTo-Json -InputObject @($Node.NamespaceURI,$Node.LocalName,$attributes,$text,$children) -Depth 30 -Compress +} +function Read-WelaEvtxEvent { + param([string]$Xml) + $settings=New-Object Xml.XmlReaderSettings + $settings.DtdProcessing=[Xml.DtdProcessing]::Prohibit;$settings.XmlResolver=$null;$settings.MaxCharactersInDocument=4194304 + $reader=[Xml.XmlReader]::Create([IO.StringReader]::new($Xml),$settings) + try {$doc=New-Object Xml.XmlDocument;$doc.XmlResolver=$null;$doc.PreserveWhitespace=$true;$doc.Load($reader)} finally {$reader.Dispose()} + $ns='http://schemas.microsoft.com/win/2004/08/events/event';$root=$doc.DocumentElement + if ($root.LocalName -cne 'Event' -or $root.NamespaceURI -cne $ns -or @($root.Attributes|Where-Object NamespaceURI -ne 'http://www.w3.org/2000/xmlns/').Count) {throw 'Unknown event root or attributes.'} + $parts=@{} + foreach ($node in $root.ChildNodes) { + if ($node.NodeType -in @('Whitespace')) {continue} + if ($node.NodeType -ne 'Element' -or $node.NamespaceURI -cne $ns -or $node.LocalName -cnotin @('System','EventData','RenderingInfo') -or $parts.ContainsKey($node.LocalName)) {throw 'Only one System/EventData and optional RenderingInfo are supported.'} + $parts[$node.LocalName]=$node + } + if (-not $parts.System -or -not $parts.EventData) {throw 'Original System and EventData are required.'} + $system=@{} + foreach ($node in $parts.System.ChildNodes) { + if ($node.NodeType -eq 'Whitespace') {continue} + if ($node.NodeType -ne 'Element' -or $node.NamespaceURI -cne $ns -or $system.ContainsKey($node.LocalName)) {throw 'Ambiguous event System data.'} + $system[$node.LocalName]=$node + } + if ($system.Computer.InnerText -cnotmatch '^[\p{L}\p{N}][\p{L}\p{N}_.-]{0,254}$') {throw 'Unsupported source computer identity.'} + $time=ConvertTo-WelaEvtxUtc $system.TimeCreated.GetAttribute('SystemTime') + [pscustomobject]@{Key=((Get-WelaEvtxXmlKey $parts.System)+'|'+(Get-WelaEvtxXmlKey $parts.EventData));Computer=$system.Computer.InnerText;EventUtc=$time;RecordId=$system.EventRecordID.InnerText;RenderingInfoPresent=[bool]$parts.RenderingInfo} +} +function ConvertTo-WelaEvtxState { + param($State) + Assert-WelaEvtxObject $State @('capturedAtUtc','context','hostObservation','auditPolicies','auditPrecedence','commandLineCapture','securityChannelEnabled') + Assert-WelaEvtxObject $State.context @('computer','role','build','patch','domainJoined','installedRoles') + Assert-WelaEvtxObject $State.hostObservation @('Status','Build','UBR','Edition','ProductType','DomainRole','DomainJoined','Domain','Architecture','ProcessorArchitecture','InstalledRoles','RolesStatus','Diagnostic') + foreach ($registry in @($State.auditPrecedence,$State.commandLineCapture)) { + Assert-WelaEvtxObject $registry @('KeyExists','ValueExists','Value','Type') + if ($registry.KeyExists -isnot [bool] -or -not $registry.KeyExists -or $registry.ValueExists -isnot [bool]) {throw 'Unknown registry prerequisite presence.'} + } + foreach ($value in @($State.context.build,$State.hostObservation.ProductType,$State.hostObservation.DomainRole)) {if ($value -isnot [int] -and $value -isnot [long]) {throw 'Mistyped source role/build identity.'}} + if ($State.context.role -cnotin @('Client','MemberServer','DomainController','ADCS') -or $State.context.computer -cnotmatch '^[\p{L}\p{N}][\p{L}\p{N}_.-]{0,254}$') {throw 'Unknown source role or computer.'} + $policies=@{};$catalog=(Import-WelaAuditProfiles).catalog + foreach ($entry in $State.auditPolicies.PSObject.Properties) { + if ($entry.Name -notin $catalog.guid -or ($entry.Value -isnot [int] -and $entry.Value -isnot [long]) -or $entry.Value -notin @(0,1,2,3)) {throw 'Unknown or mistyped native audit policy evidence.'} + $policies[$entry.Name]=$entry.Value + } + if ($policies.Count -ne 59) {throw 'A complete 59-subcategory source snapshot is required.'} + $State.auditPolicies=$policies + Assert-WelaProbePrerequisites $State + if (($State.context.role -eq 'Client' -and $State.context.build -notin @(22000,22621,22631,26100,26200)) -or + ($State.context.role -ne 'Client' -and $State.context.build -notin @(20348,26100)) -or + ($State.context.role -eq 'DomainController' -and $State.context.installedRoles -contains 'ADCS-Cert-Authority')) {throw 'Source role/build is outside the reviewed native probe scope.'} + return $State +} +function Import-WelaEvtxProbe { + param([Parameter(Mandatory)][string]$Path) + $root=Resolve-WelaEvtxPath $Path + if (-not (Test-Path -LiteralPath $root -PathType Container)) {throw 'Probe bundle directory is missing.'} + $expected=@('manifest.json','before-state.json','process.json','event.xml','after-state.json') + $items=@(Get-ChildItem -LiteralPath $root -Force -ErrorAction Stop) + if ($items.Count -ne 5 -or @($items|Where-Object {$_.Name -cnotin $expected -or $_.PSIsContainer -or ([int]$_.Attributes -band [int][IO.FileAttributes]::ReparsePoint)}).Count) {throw 'Expected exactly five regular native probe files.'} + $files=@{};$utf8=New-Object Text.UTF8Encoding($false,$true) + foreach ($item in $items) { + if ($item.Length -gt 4194304) {throw 'Probe artifact exceeds 4 MiB.'} + $bytes=[IO.File]::ReadAllBytes($item.FullName) + if ($bytes.Length -gt 4194304) {throw 'Probe artifact grew beyond 4 MiB.'} + $files[$item.Name]=[pscustomobject]@{Name=$item.Name;Sha256=(Get-WelaEvtxHash $bytes);Text=$utf8.GetString($bytes).TrimStart([char]0xFEFF)} + } + $manifest=ConvertFrom-WelaEvtxJson $files['manifest.json'].Text + Assert-WelaEvtxObject $manifest @('SchemaVersion','Kind','Probe','Action','Status','ExitCode','GeneratedUtc','PolicyChanges','ReadyRuleCredit','Scope','RequiredEvidence','BeforeState','AfterState','Process','Artifacts','Diagnostic','OutputPath') + foreach ($name in @('SchemaVersion','ExitCode','PolicyChanges','ReadyRuleCredit')) {if ($manifest.$name -isnot [int] -and $manifest.$name -isnot [long]) {throw 'Mistyped probe status.'}} + if ($manifest.SchemaVersion -ne 1 -or $manifest.Kind -cne 'WelaNativeProbeComponents' -or $manifest.Probe -cne 'security-4688-command-line-v1' -or $manifest.Action -cne 'Run' -or $manifest.Status -cne 'NativeEventObserved' -or $manifest.ExitCode -ne 0 -or $manifest.PolicyChanges -ne 0 -or $manifest.ReadyRuleCredit -ne 0 -or $manifest.Diagnostic -cne '' -or $manifest.Artifacts -isnot [array] -or $manifest.Artifacts.Count -ne 4) {throw 'Only successful native 4688 probe components are accepted; no readiness evidence is inferred.'} + if ($manifest.Scope -isnot [string] -or [string]::IsNullOrWhiteSpace($manifest.Scope) -or $manifest.OutputPath -isnot [string] -or [string]::IsNullOrWhiteSpace($manifest.OutputPath) -or $manifest.RequiredEvidence -isnot [array] -or ($manifest.RequiredEvidence -join '|') -cne 'Reviewed complete rule and normalization|Backend ingestion|Translated query and successful query result') {throw 'Incomplete source scope or required-evidence metadata.'} + $seen=@{} + foreach ($entry in $manifest.Artifacts) { + Assert-WelaEvtxObject $entry @('path','sha256') + if ($entry.path -cnotin @('before-state.json','process.json','event.xml','after-state.json') -or $seen.ContainsKey($entry.path) -or $entry.sha256 -cnotmatch '^[a-f0-9]{64}$' -or $entry.sha256 -cne $files[$entry.path].Sha256) {throw 'Missing, duplicate or mismatched source artifact hash.'} + $seen[$entry.path]=$true + } + $before=ConvertFrom-WelaEvtxJson $files['before-state.json'].Text + $after=ConvertFrom-WelaEvtxJson $files['after-state.json'].Text + $process=ConvertFrom-WelaEvtxJson $files['process.json'].Text + foreach ($pair in @(@($before,$manifest.BeforeState),@($after,$manifest.AfterState),@($process,$manifest.Process))) { + if ((ConvertTo-Json -InputObject $pair[0] -Depth 20 -Compress) -cne (ConvertTo-Json -InputObject $pair[1] -Depth 20 -Compress)) {throw 'Embedded source metadata differs from its hashed artifact.'} + } + $before=ConvertTo-WelaEvtxState $before;$after=ConvertTo-WelaEvtxState $after + if ((Get-WelaProbeStateKey $before) -cne (Get-WelaProbeStateKey $after)) {throw 'Source context or prerequisites drifted.'} + Assert-WelaEvtxObject $process @('ProcessId','ParentProcessId','Executable','Arguments','Marker','StartedUtc','CompletedUtc','ExitCode') + foreach ($name in @('ProcessId','ParentProcessId')) {if (($process.$name -isnot [int] -and $process.$name -isnot [long]) -or $process.$name -lt 1 -or $process.$name -gt [uint32]::MaxValue) {throw 'Invalid probe process identity.'}} + if (($process.ExitCode -isnot [int] -and $process.ExitCode -isnot [long]) -or $process.ExitCode -ne 0 -or $process.Marker -cnotmatch '^WELA_PROBE_[a-f0-9]{32}$' -or + $process.Arguments -cne ('/d /c echo '+$process.Marker) -or $process.Executable -notmatch '^[A-Za-z]:\\(?:[^<>:"/\\|?*\x00-\x1f]+\\)*System32\\cmd\.exe$') {throw 'Source must describe only the fixed native cmd.exe echo probe.'} + $generated=ConvertTo-WelaEvtxUtc $manifest.GeneratedUtc;$began=ConvertTo-WelaEvtxUtc $before.capturedAtUtc + $started=ConvertTo-WelaEvtxUtc $process.StartedUtc;$completed=ConvertTo-WelaEvtxUtc $process.CompletedUtc;$ended=ConvertTo-WelaEvtxUtc $after.capturedAtUtc + if ($generated -gt $began -or $began -gt $started -or $started -gt $completed -or $completed -gt $ended) {throw 'Source evidence timestamps are out of order.'} + if (-not (Test-WelaProbeEvent -Xml $files['event.xml'].Text -Process $process -State $before -EndUtc $ended.UtcDateTime)) {throw 'Source event does not match the fixed process and context.'} + $event=Read-WelaEvtxEvent $files['event.xml'].Text + [pscustomobject]@{Path=$root;Fingerprint=(@($files.Keys|Sort-Object|ForEach-Object {$_+'='+$files[$_].Sha256})-join ';');Manifest=$manifest;Files=$files;Event=$event} +} +function New-WelaEvtxOutput { + param([string]$Path,[string]$SourcePath) + $full=Resolve-WelaEvtxPath $Path + $comparison=if ([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT) {[StringComparison]::OrdinalIgnoreCase}else{[StringComparison]::Ordinal} + if ($full.Equals($SourcePath,$comparison) -or $full.StartsWith($SourcePath.TrimEnd([IO.Path]::DirectorySeparatorChar)+[IO.Path]::DirectorySeparatorChar,$comparison)) {throw 'Output must be outside the source bundle.'} + if (Test-Path -LiteralPath $full) {throw 'EVTX output must be a new directory; existing evidence is never overwritten.'} + if (-not (Test-Path -LiteralPath ([IO.Path]::GetDirectoryName($full)) -PathType Container)) {throw 'Output parent directory must already exist.'} + $null=New-Item -ItemType Directory -Path $full -ErrorAction Stop + if ([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT) { + $acl=New-Object Security.AccessControl.DirectorySecurity;$acl.SetAccessRuleProtection($true,$false) + foreach ($sid in @([Security.Principal.WindowsIdentity]::GetCurrent().User.Value,'S-1-5-18','S-1-5-32-544')|Select-Object -Unique) { + $acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new([Security.Principal.SecurityIdentifier]::new($sid),'FullControl','ContainerInherit,ObjectInherit','None','Allow')) + } + Set-Acl -LiteralPath $full -AclObject $acl -ErrorAction Stop + } + $full +} +function Write-WelaEvtxArtifact { + param([string]$Root,[string]$Name,[string]$Text) + $null=Resolve-WelaEvtxPath $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()} finally {$stream.Dispose()} + $hash=Get-WelaEvtxHash $bytes + if ($hash -cne (Get-FileHash -LiteralPath $path -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()) {throw 'EVTX artifact readback differs from the written bytes.'} + [pscustomobject]@{Name=$Name;Sha256=$hash;Bytes=$bytes.Length} +} +function Get-WelaEvtxReader { + if ($env:OS -ne 'Windows_NT' -or -not [Environment]::Is64BitProcess) {throw 'EVTX evidence requires native 64-bit Windows.'} + $hostState=Get-WelaDefaultContext + if (-not (Test-WelaDefaultContextComplete $hostState)) {throw 'Complete reader host context is unavailable.'} + $identity=[Security.Principal.WindowsIdentity]::GetCurrent() + 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 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) + $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()} + } + return [pscustomobject]@{Xml=@($events.ToArray());Limit=2} + } finally {$reader.Dispose()} +} +function Export-WelaEvtxNative { + param([string]$Query,[string]$Path) + if (Test-Path -LiteralPath $Path) {throw 'EVTX export never overwrites an existing file.'} + $session=New-Object System.Diagnostics.Eventing.Reader.EventLogSession + try {$session.ExportLog('Security',[System.Diagnostics.Eventing.Reader.PathType]::LogName,$Query,$Path,$false)} finally {$session.Dispose()} +} +function Assert-WelaEvtxSingleEvent { + param($Batch,$Source) + if (@($Batch.Xml).Count -ne 1) {throw 'Expected exactly one recovered native event; empty or multiple records are unverified.'} + if ((Read-WelaEvtxEvent $Batch.Xml[0]).Key -cne $Source.Event.Key) {throw 'Recovered event differs from the original source event.'} +} +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.'} + $source=Import-WelaEvtxProbe $ProbePath + if ($Action -eq 'Verify') { + $archive=Resolve-WelaEvtxPath $ArchivePath + $file=Get-Item -LiteralPath $archive -ErrorAction Stop + if ($file -isnot [IO.FileInfo] -or $file.Extension -ine '.evtx' -or $file.Length -lt 1 -or $file.Length -gt 16777216) {throw 'Expected a local .evtx probe archive of 1 byte..16 MiB.'} + } + $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 + try { + $before=Get-WelaEvtxReader;$report.ReaderBefore=$before + $beforeKey=ConvertTo-Json -InputObject $before -Depth 16 -Compress + $report.Artifacts+=Write-WelaEvtxArtifact $output 'source-event.xml' $source.Files['event.xml'].Text + if ($Action -eq 'Export') { + $expected=ConvertTo-WelaEvtxState $source.Manifest.BeforeState + $current=Get-WelaProbeState + Assert-WelaProbePrerequisites $current + if ((Get-WelaProbeStateKey $current) -cne (Get-WelaProbeStateKey $expected)) {throw 'Live source host or prerequisites differ from the validated probe context.'} + if ($source.Event.RecordId -cnotmatch '^[1-9][0-9]{0,18}$') {throw 'Native source record ID must be a positive bounded decimal.'} + $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 + Assert-WelaEvtxSingleEvent (Read-WelaEvtxNative -Path Security -Live -Query $query) $source + if ((Import-WelaEvtxProbe $ProbePath).Fingerprint -cne $source.Fingerprint) {throw 'Source evidence changed before export.'} + Export-WelaEvtxNative -Query $query -Path $archive + } + $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) + if ($lock.Length -lt 1 -or $lock.Length -gt 16777216) {throw 'Exported probe archive exceeds size bounds.'} + $sha=[Security.Cryptography.SHA256]::Create() + try {$report.ArchiveSha256=([BitConverter]::ToString($sha.ComputeHash($lock))).Replace('-','').ToLowerInvariant()} finally {$sha.Dispose()} + $batch=Read-WelaEvtxNative -Path $archive + $report.RecoveredEvents=@($batch.Xml).Count + Assert-WelaEvtxSingleEvent $batch $source + $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.Status='NativeEventRecovered';$report.ExitCode=0 + } catch {$report.Diagnostic=$_.Exception.Message} + finally { + 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) + return $report +} diff --git a/tests/EvtxRecovery.Tests.ps1 b/tests/EvtxRecovery.Tests.ps1 new file mode 100644 index 00000000..7706a794 --- /dev/null +++ b/tests/EvtxRecovery.Tests.ps1 @@ -0,0 +1,112 @@ +$ErrorActionPreference='Stop' +$repo=Split-Path $PSScriptRoot -Parent +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 $PSScriptRoot 'fixtures/EvtxRecovery.Fixture.ps1') +$script:checks=0 +function Assert($Value,[string]$Message){if(-not $Value){throw "FAIL: $Message"};$script:checks++} +function Reject([scriptblock]$Code,[string]$Pattern){$message='';try{& $Code|Out-Null}catch{$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected $Pattern; got $message"} +function Clone($Value){ConvertFrom-WelaEvtxJson ($Value|ConvertTo-Json -Depth 24)} +function Save($Path,$Value){[IO.File]::WriteAllText($Path,($Value|ConvertTo-Json -Depth 24),[Text.UTF8Encoding]::new($false))} +function Update-Source($Directory,$Name,$Value) { + Save (Join-Path $Directory $Name) $Value + $m=ConvertFrom-WelaEvtxJson (Get-Content (Join-Path $Directory 'manifest.json') -Raw) + ($m.Artifacts|Where-Object path -eq $Name).sha256=(Get-FileHash (Join-Path $Directory $Name)).Hash.ToLowerInvariant() + switch($Name){'before-state.json'{$m.BeforeState=$Value};'after-state.json'{$m.AfterState=$Value};'process.json'{$m.Process=$Value}} + Save (Join-Path $Directory 'manifest.json') $m +} +function Start-WelaProbeProcess {throw 'Forbidden process launch'} +function Invoke-WelaNative {throw 'Forbidden native mutation'} +function Set-ItemProperty {throw 'Forbidden registry mutation'} +$temp=Join-Path ([IO.Path]::GetTempPath()) ('wela-evtx-tests-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $temp +try { + $fixture=New-WelaEvtxFixture (Join-Path $temp 'source') + $source=Import-WelaEvtxProbe $fixture.Directory + Assert ($source.Event.Computer -eq 'source01.lab.test' -and $source.Files.Count -eq 5) 'Completed native-shaped source bundle validates with all hashes' + foreach($case in @('hash','extra','duplicate-json','bad-status','embedded','typed','missing-mask','source-drift','time','process-command','unknown-field','bad-kind','duplicate-artifact')) { + $dir=Join-Path $temp $case;Copy-Item $fixture.Directory $dir -Recurse + $m=Clone $fixture.Manifest + switch($case){ + 'hash'{Add-Content (Join-Path $dir 'event.xml') 'tampered'} + 'extra'{Set-Content (Join-Path $dir 'extra.txt') 'extra'} + 'duplicate-json'{$text=ConvertTo-Json -InputObject $m -Depth 24 -Compress;$t=$text.Replace('"SchemaVersion":1,','"SchemaVersion":1,"SchemaVersion":1,');Assert ($t -cne $text) 'Duplicate-key fixture changed input';[IO.File]::WriteAllText((Join-Path $dir 'manifest.json'),$t)} + 'bad-status'{$m.Status='Unverified';Save (Join-Path $dir 'manifest.json') $m} + 'embedded'{$m.BeforeState.context.computer='different';Save (Join-Path $dir 'manifest.json') $m} + 'typed'{$m.BeforeState.auditPrecedence.Value='1';Update-Source $dir 'before-state.json' $m.BeforeState} + 'missing-mask'{$m.BeforeState.auditPolicies.PSObject.Properties.Remove(@($m.BeforeState.auditPolicies.PSObject.Properties.Name)[0]);Update-Source $dir 'before-state.json' $m.BeforeState} + 'source-drift'{$m.AfterState.auditPolicies.'0CCE922B-69AE-11D9-BED3-505054503030'=3;Update-Source $dir 'after-state.json' $m.AfterState} + 'time'{$m.Process.StartedUtc=[DateTime]::UtcNow.AddDays(1).ToString('o');Update-Source $dir 'process.json' $m.Process} + 'process-command'{$m.Process.Arguments='/c whoami';Update-Source $dir 'process.json' $m.Process} + 'unknown-field'{$m|Add-Member NoteProperty Ready $true;Save (Join-Path $dir 'manifest.json') $m} + 'bad-kind'{$m.Kind='WelaNativeRuleEvidence';Save (Join-Path $dir 'manifest.json') $m} + 'duplicate-artifact'{$m.Artifacts[1]=$m.Artifacts[0];Save (Join-Path $dir 'manifest.json') $m} + } + Reject {Import-WelaEvtxProbe $dir} 'hash|five|Duplicate|successful|differs|DWORD|59-subcategory|drifted|timestamps|fixed|Unexpected' + } + foreach($text in @('{"x":1,"X":2}','{x:1}','{"x":1,}',"{'x':1}",'{"x":NaN}')) {Reject {ConvertFrom-WelaEvtxJson $text} 'JSON|Duplicate|strict'} + $rendered=$fixture.Xml.Replace('','Localized <script> text') + Assert ((Read-WelaEvtxEvent $rendered).Key -ceq $source.Event.Key) 'RenderingInfo does not change original event identity' + Assert ((Read-WelaEvtxEvent ($fixture.Xml.Replace('>LAB','> '))).Key -cne (Read-WelaEvtxEvent ($fixture.Xml.Replace('>LAB','>'))).Key) 'Whitespace-only original payload values remain distinct from empty values' + foreach($change in @(@('source01.lab.test','wrong.lab.test'),@('0x7b','0x7c'),@('S-1-16-16384','S-1-16-8192'),@('%%1936','%%1937'),@('100','101'),@('2','1'),@('WELA_PROBE_0123456789abcdef0123456789abcdef','WELA_PROBE_1123456789abcdef0123456789abcdef'))) { + Assert ((Read-WelaEvtxEvent ($fixture.Xml.Replace($change[0],$change[1]))).Key -cne $source.Event.Key) "Original event mutation stays unmatched: $($change[0])" + } + foreach($xml in @($fixture.Xml.Replace('',''),$fixture.Xml.Replace('',''),(']>'+$fixture.Xml))) {Reject {Read-WelaEvtxEvent $xml} 'System|DTD'} + $script:scenario='match';$script:reads=0;$script:exports=0 + function Get-WelaEvtxReader { + $script: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'})}} + } + 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 'corrupt') {throw 'Invalid native EVTX format'} + if ($scenario -eq 'source-change') {Add-Content -LiteralPath (Join-Path $fixture.Directory 'event.xml') 'tampered'} + $events=@($fixture.Xml) + if ($scenario -eq 'empty' -and -not $Live) {$events=@()} + if ($scenario -eq 'duplicate') {$events=@($fixture.Xml,$fixture.Xml)} + if ($scenario -eq 'wrong') {$events=@($fixture.Xml.Replace('100','101'))} + [pscustomobject]@{Xml=$events;Limit=2} + } + 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 + $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 + } + $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.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')) { + $result=Invoke-Case $case + Assert ($result.Status -eq 'Unverified' -and $result.ExitCode -eq 1 -and $result.Diagnostic) "$case cannot establish recovery" + } + $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 + Assert ($result.Status -eq 'Unverified' -and $result.Diagnostic -match 'exactly one') 'Successful native export with empty archive is not recovery proof' + $count=$exports;$result=Invoke-Case wrong Export + Assert ($result.ExitCode -eq 1 -and $exports -eq $count) 'Changed/reused source record refuses export before creation' + $script:scenario='match' + Reject {Invoke-WelaEvtxRecovery -Action Export -ProbePath $fixture.Directory -ArchivePath $archive -OutputPath (Join-Path $temp 'bad')} 'Export creates' + Reject {Invoke-WelaEvtxRecovery -Action Verify -ProbePath $fixture.Directory -OutputPath (Join-Path $temp 'bad')} 'requires ArchivePath' + Reject {Invoke-WelaEvtxRecovery -ProbePath $fixture.Directory -ArchivePath $archive -OutputPath $fixture.Directory} 'outside|new directory' + Push-Location $temp + try {$script:reads=0;$result=Invoke-WelaEvtxRecovery -ProbePath ./source -ArchivePath ./fixture.evtx -OutputPath ./relative;Assert ($result.ExitCode -eq 0 -and (Test-Path ./relative/manifest.json)) 'Relative paths follow PowerShell location'} finally {Pop-Location} + $result=Invoke-Case source-change + Assert ($result.ExitCode -eq 1 -and $result.Diagnostic -match 'hash') 'Source changed during read is rejected' + $engine=(Get-Process -Id $PID).Path + foreach ($arguments in @(@('configure','-EvtxAction','Export'),@('evtx-recovery','-DryRun'),@('evtx-recovery','-Profile','wela-2.2.0'))) { + $old=$ErrorActionPreference;$ErrorActionPreference='Continue' + $out=& $engine -NoProfile -File (Join-Path $repo 'WELA.ps1') @arguments 2>&1;$exit=$LASTEXITCODE + $ErrorActionPreference=$old + Assert ($exit -ne 0 -and ($out -join ' ') -match 'require evtx-recovery|only its dedicated') 'CLI rejects ignored/incompatible options before dispatch' + } +} finally {Remove-Item -LiteralPath $temp -Recurse -Force} +$global:LASTEXITCODE=0 +Write-Host "EVTX recovery: $script:checks assertions passed." diff --git a/tests/EvtxRecovery.Windows.Tests.ps1 b/tests/EvtxRecovery.Windows.Tests.ps1 new file mode 100644 index 00000000..e4acc7d9 --- /dev/null +++ b/tests/EvtxRecovery.Windows.Tests.ps1 @@ -0,0 +1,74 @@ +param([switch]$AllowDisposablePolicyWrite) +$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.' } +$repo=Split-Path $PSScriptRoot -Parent +. (Join-Path $repo 'scripts/Configuration.ps1') +. (Join-Path $repo 'scripts/ControlApplicability.ps1') +. (Join-Path $repo 'scripts/NativeValidation.ps1') +. (Join-Path $repo 'scripts/EvtxRecovery.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] +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 +try { + $touched=$true + foreach ($control in $controls) { + if (-not (Test-Path -LiteralPath $control.Path)) { $null=New-WelaRegistryKey -Path $control.Path } + $null=New-ItemProperty -LiteralPath $control.Path -Name $control.Name -Value 1 -PropertyType DWord -Force + } + Set-WelaEffectiveAuditPolicy -Guid $guid -Mask 1 -Mode minimum + $destination=Join-Path $root 'collection' + $result=Invoke-WelaNativeValidation -Action Run -OutputPath $destination -TimeoutSeconds 30 + if ($result.ExitCode -ne 0 -or $result.Status -ne 'NativeEventObserved') { throw ($result | ConvertTo-Json -Depth 20) } + if ($result.ReadyRuleCredit -ne 0 -or $result.Artifacts.Count -ne 4) { throw 'Collector mislabeled incomplete evidence.' } + foreach ($artifact in $result.Artifacts) { + if ((Get-FileHash -LiteralPath (Join-Path $destination $artifact.path)).Hash.ToLowerInvariant() -cne $artifact.sha256) { throw 'Native artifact hash mismatch.' } + } + $event=[IO.File]::ReadAllText((Join-Path $destination 'event.xml')) + if (-not (Test-WelaProbeEvent $event $result.Process $result.BeforeState ([DateTime]::UtcNow))) { throw 'Native event cannot be independently matched.' } + $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.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.'} + 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." +} finally { + if ($touched) { + $errors=@() + try { Set-WelaEffectiveAuditPolicy -Guid $guid -Mask $beforeMask -Mode exact } catch { $errors+=$_.Exception.Message } + foreach ($control in $controls) { + try { + if ($control.Before.ValueExists) { $null=New-ItemProperty -LiteralPath $control.Path -Name $control.Name -Value $control.Before.Value -PropertyType $control.Before.Type -Force } + else { Remove-ItemProperty -LiteralPath $control.Path -Name $control.Name -ErrorAction SilentlyContinue } + if (-not $control.Before.KeyExists -and (Test-Path -LiteralPath $control.Path)) { + $key=Get-Item -LiteralPath $control.Path + if ($key.ValueCount -eq 0 -and $key.SubKeyCount -eq 0) { Remove-Item -LiteralPath $control.Path -ErrorAction Stop } + } + $after=Get-WelaRegistryState -Path $control.Path -Name $control.Name + 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 } + $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 } +} +$global:LASTEXITCODE=0 +Write-Host 'Native EVTX export/reopen and exact policy restoration passed.' diff --git a/tests/fixtures/EvtxRecovery.Fixture.ps1 b/tests/fixtures/EvtxRecovery.Fixture.ps1 new file mode 100644 index 00000000..7ad3d9df --- /dev/null +++ b/tests/fixtures/EvtxRecovery.Fixture.ps1 @@ -0,0 +1,19 @@ +# Synthetic source/collector data only. No native event generation or telemetry claim. +function New-WelaEvtxFixture { + param([string]$Directory) + $now=[DateTime]::UtcNow.AddSeconds(-5) + $hostState=[pscustomobject][ordered]@{Status='Observed';Build=20348;UBR=4000;Edition='ServerDatacenter';ProductType=3;DomainRole=3;DomainJoined=$true;Domain='lab.test';Architecture='64-bit';ProcessorArchitecture=9;InstalledRoles=@('Web-Server');RolesStatus='Observed';Diagnostic=''} + $policies=@{};foreach ($p in (Import-WelaAuditProfiles).catalog) {$policies[$p.guid]=0};$policies['0cce922b-69ae-11d9-bed3-505054503030']=1 + $state=[pscustomobject][ordered]@{capturedAtUtc=$now.AddSeconds(-2).ToString('o');context=[pscustomobject]@{computer='source01';role='MemberServer';build=20348;patch='20348.4000';domainJoined=$true;installedRoles=@('Web-Server')};hostObservation=$hostState;auditPolicies=$policies;auditPrecedence=[pscustomobject]@{KeyExists=$true;ValueExists=$true;Value=1;Type='DWord'};commandLineCapture=[pscustomobject]@{KeyExists=$true;ValueExists=$true;Value=1;Type='DWord'};securityChannelEnabled=$true} + $process=[pscustomobject]@{ProcessId=123;ParentProcessId=456;Executable='C:\Windows\System32\cmd.exe';Arguments='/d /c echo WELA_PROBE_0123456789abcdef0123456789abcdef';Marker='WELA_PROBE_0123456789abcdef0123456789abcdef';StartedUtc=$now.ToString('o');CompletedUtc=$now.AddSeconds(1).ToString('o');ExitCode=0} + $before=$state|ConvertTo-Json -Depth 16 + $state.capturedAtUtc=$now.AddSeconds(2).ToString('o');$after=$state|ConvertTo-Json -Depth 16 + $xml=@" +4688201331200x8020000000000000100Securitysource01.lab.testS-1-5-18SOURCE01$LAB0x3e70x7bC:\Windows\System32\cmd.exe%%19360x1c8"C:\Windows\System32\cmd.exe" /d /c echo WELA_PROBE_0123456789abcdef0123456789abcdefS-1-0-0--0x0C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exeS-1-16-16384 +"@ + $null=New-Item -ItemType Directory -Path $Directory + $artifacts=@();foreach ($entry in @(@('before-state.json',$before),@('after-state.json',$after),@('process.json',($process|ConvertTo-Json -Depth 6)),@('event.xml',$xml))) {$artifacts+=Write-WelaProbeArtifact $Directory $entry[0] $entry[1]} + $manifest=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaNativeProbeComponents';Probe='security-4688-command-line-v1';Action='Run';Status='NativeEventObserved';ExitCode=0;GeneratedUtc=$now.AddSeconds(-3).ToString('o');PolicyChanges=0;ReadyRuleCredit=0;Scope='Synthetic test fixture';RequiredEvidence=@('Reviewed complete rule and normalization','Backend ingestion','Translated query and successful query result');BeforeState=(ConvertFrom-Json $before);AfterState=(ConvertFrom-Json $after);Process=$process;Artifacts=$artifacts;Diagnostic='';OutputPath=$Directory} + $null=Write-WelaProbeArtifact $Directory 'manifest.json' ($manifest|ConvertTo-Json -Depth 20) + [pscustomobject]@{Directory=$Directory;Xml=$xml;Host=$hostState;Process=$process;Manifest=$manifest} +} diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index f4ec84a4..1aa8db99 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,6 +7,8 @@ **改善:** +- `evtx-recovery` を追加し、検証済みのネイティブ Security プローブを EVTX に出力して Windows イベント API で再読込できるようにしました。実際の読取アカウントによる検証、入力・イベントの厳密な比較、新規出力の保護、ハッシュとドリフト検出で空または変更された記録を拒否します。使い捨て Windows テストで実際の出力・復旧を検証し、全体の保存期間、他アカウントのアクセス、Sigma 対応とは区別します。 (#420) (@Shirofune-Security) + - `audit-recovery` を追加し、完了した監査サブカテゴリと優先設定の変更を明示選択して計画・復元できるようにしました。元の記録と結果、ホストと入力の再検証、復元前の記録、最終確認でドリフトを検出し、最小設定の独立した追加ビットを保持します。優先設定は最後に復元します。使い捨て Windows 環境で実際の復元を検証し、過去のホスト同一性、GPO 永続性、Sigma 対応の証明とは区別します。 出力先はローカル固定ドライブに限定し、ネットワークドライブと代替データストリームを拒否します。 (#419) (@Shirofune-Security) - 読み取り専用の`wef-arrival`を追加し、完了したWindows標準4688プローブの資料を検証して、ローカル収集サーバーに元イベントが1件だけ一致するか確認できるようにしました。ハッシュ・形式・環境の厳密な確認、件数を制限したネイティブ検索、実際の読み取りユーザーと変更検出、保護された生XMLの保存により、不完全・曖昧な結果は未検証として保持します。イベントの存在を、配信サブスクリプション・遅延・時刻同期・Sigma利用可能性の証明とは扱いません。ホスト間の正常到着は別途ラボ検証が必要です。 (#418) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index ced39b80..33a52357 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,6 +7,8 @@ **Improvements:** +- Added opt-in `evtx-recovery` to export one validated native Security probe and reopen its EVTX through Windows event APIs, with a separate verification action under the actual reader. Strict source/component checks, exact event comparison, protected new output, archive hashes and reader/context drift checks reject empty or changed evidence. Disposable Windows tests cover real export/recovery and empty archives; full retention, other-reader access and Sigma readiness remain separate. (#420) (@Shirofune-Security) + - Added opt-in `audit-recovery` planning and restoration for selected completed audit subcategory and typed precedence writes. Matched journals/results, independently rebuilt plans, actual host/source guards, durable receipts on local fixed drives and final readback refuse drift; minimum masks preserve independent additions and precedence restores last. Native disposable Windows tests verify exact restoration, without historical-identity, policy-persistence or Sigma claims. (#419) (@Shirofune-Security) - Added read-only `wef-arrival` to validate a completed native 4688 probe bundle and query the local collector for one exact original event. Strict hashes/schema/context checks, bounded native queries, actual reader observations, drift checks and protected raw evidence keep failed or ambiguous results unverified. Presence is separate from subscription attribution, latency, clock synchronization and Sigma readiness; positive cross-host acceptance remains pending. (#418) (@Shirofune-Security)