Restore selected audit changes from reviewed recovery evidence (#419)

* Add guarded restoration of selected completed audit writes

* Link changelog to PR 419

* Read recovery host name from Windows instead of environment overrides

* Require local fixed-drive recovery output paths
This commit is contained in:
田中ザック Isaac Mathis authored and GitHub committed 2026-09-20 19:30:49 +09:00
1 parent e5557df038
commit 60006531be
10 files changed
+503 -1

No files matched your search

+37
View File
@@ -0,0 +1,37 @@
name: Guarded audit recovery
on:
push:
branches: ['**']
paths:
- 'WELA.ps1'
- 'scripts/AuditRecovery.ps1'
- 'scripts/ControlApplicability.ps1'
- 'scripts/Configuration.ps1'
- 'modules/AuditProfiles.psm1'
- 'tests/AuditRecovery*'
- '.github/workflows/audit-recovery.yml'
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
audit-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/AuditRecovery.Tests.ps1
- name: Native completed-journal recovery with safety restoration
shell: powershell
run: ./tests/AuditRecovery.Windows.Tests.ps1 -AllowDisposablePolicyWrite
- name: Synthetic rejection regressions in PowerShell 7
shell: pwsh
run: ./tests/AuditRecovery.Tests.ps1
- name: Native recovery from PowerShell 7 with restoration
shell: pwsh
run: ./tests/AuditRecovery.Windows.Tests.ps1 -AllowDisposablePolicyWrite
+2
View File
@@ -4,6 +4,8 @@
**改善:**
- `audit-recovery` を追加し、完了した監査サブカテゴリと優先設定の変更を明示選択して計画・復元できるようにしました。元の記録と結果、ホストと入力の再検証、復元前の記録、最終確認でドリフトを検出し、最小設定の独立した追加ビットを保持します。優先設定は最後に復元します。使い捨て Windows 環境で実際の復元を検証し、過去のホスト同一性、GPO 永続性、Sigma 対応の証明とは区別します。 出力先はローカル固定ドライブに限定し、ネットワークドライブと代替データストリームを拒否します。 (#419) (@Shirofune-Security)
- 読み取り専用の`wef-arrival`を追加し、完了したWindows標準4688プローブの資料を検証して、ローカル収集サーバーに元イベントが1件だけ一致するか確認できるようにしました。ハッシュ・形式・環境の厳密な確認、件数を制限したネイティブ検索、実際の読み取りユーザーと変更検出、保護された生XMLの保存により、不完全・曖昧な結果は未検証として保持します。イベントの存在を、配信サブスクリプション・遅延・時刻同期・Sigma利用可能性の証明とは扱いません。ホスト間の正常到着は別途ラボ検証が必要です。 (#418) (@Shirofune-Security)
- 読み取り専用の `score` JSON・自己完結型 HTML レポートを追加し、高度な監査プロファイルへの適合率と重大度別のネイティブルール検証率を分けて表示します。バージョン付き重み、分子・分母、未確認・除外項目、ソースのハッシュ、記録時の証拠コンテキストを保持します。オフラインでは現在の設定を未確認とし、設定の有効化だけで Ready を加算しません。総合的なセキュリティ評価や Sysmon の検知範囲は示しません。 (#417) (@Shirofune-Security)
- 共有する詳細監査プロファイルと優先設定のセキュリティテンプレートについて、オフラインで計画・出力・検証する`gpo-package`を追加しました。省略項目を保持し、成功または失敗だけの最低要件を両方へ拡張する場合は明示指定を求め、未検証のゼロ値による配備は拒否します。出典・申告対象・全項目レビュー・検証済みハッシュを含む配備用ファイルであり、GPOバックアップではありません。正規のGPMC/LGPO準備と未リンクGPO作成のレビュー手順を文書化しました。ドメイン配備と実イベントの検証は別途ラボで必要となり、Sigma検知範囲には加算しません。 (#415) (@Shirofune-Security)
+2
View File
@@ -4,6 +4,8 @@
**Improvements:**
- 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)
- Added read-only `score` JSON and self-contained HTML reports with separate advanced audit-profile compliance and severity-weighted native rule readiness. Versioned weights, explicit numerators/denominators, unknowns, exclusions, source fingerprints and recorded evidence contexts make each result reviewable. Offline scenarios keep current settings Unknown; enabled settings grant no Ready credit, and no overall security grade or Sysmon coverage is implied. (#417) (@Shirofune-Security)
- Added offline `gpo-package` plan, export and verification for shared advanced audit profiles and the precedence security template. Packages preserve omissions, require explicit expansion of one-sided minimum masks, reject unvalidated zero-mask deployment, and include source/target context, full reviews and verified file hashes. These are deployment components, not GPO backups; genuine GPMC/LGPO preparation and reviewed create-unlinked procedures are documented. Native domain application and event evidence remain separate lab acceptance with no Sigma credit. (#415) (@Shirofune-Security)
+17 -1
View File
@@ -77,6 +77,12 @@
[ValidateSet('Plan','Run')][string]$ProbeAction = 'Plan',
[string]$ProbeOutputPath,
[ValidateRange(1,30)][int]$ProbeTimeoutSeconds = 15,
[ValidateSet('Plan','Restore')][string]$RecoveryAction = 'Plan',
[string]$RecoveryJournalPath,
[string]$RecoveryOriginalResultsPath,
[string[]]$RecoveryControlId,
[string]$RecoveryPlanPath,
[string]$RecoveryOutputPath,
[string]$ArrivalProbePath,
[string]$ArrivalOutputPath,
[switch]$Help
@@ -121,6 +127,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/AuditRecovery.ps1")
# 64bit の PowerShell と GPO が読むのは Wow6432Node の無いパス。32bit 用に両方を扱う。
$PowerShellPolicyRoots = @(
@@ -1912,6 +1919,9 @@ 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 '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.'}
if ($PSBoundParameters.ContainsKey('ProfileFile')) {
if ([string]::IsNullOrWhiteSpace($ProfileFile) -or $Cmd -notin @('profiles','plan','audit','audit-settings','configure')) { throw '-ProfileFile requires profiles, plan, audit, audit-settings or configure. No command was run.' }
if ($Baseline -or ($Cmd -ne 'profiles' -and -not $Profile)) { throw '-ProfileFile requires an explicit -Profile and cannot be combined with -Baseline (profiles lists the file). No command was run.' }
@@ -1986,7 +1996,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 '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 'audit-recovery' -and $RecoveryAction -eq 'Restore') -and -not ($Cmd -eq 'gpo-package' -and $GpoAction -eq 'Export') -and -not ($Cmd -eq 'audit-integrity' -and $IntegrityAction -eq 'Configure') -and -not ($Cmd -eq 'audit-notifications' -and $NotificationAction -eq 'Configure') -and -not ($Cmd -eq 'ldap-diagnostics' -and $LdapAction -eq 'Configure') -and -not ($Cmd -eq 'applocker-readiness' -and $AppLockerAction -eq 'Import') -and $Cmd -notin @('configure', 'configure-eventlogs') -and
-not ($Cmd -eq 'provider-packs' -and $ProviderAction -eq 'Configure') -and
-not ($Cmd -eq 'firewall-logging' -and $FirewallAction -eq 'Configure') -and
-not ($Cmd -eq 'smb-auditing' -and $SmbAction -eq 'Configure') -and
@@ -2050,6 +2060,12 @@ switch ($Cmd.ToLower()) {
if ($report.ExitCode) { exit $report.ExitCode }
} catch { Write-Host "[Failed] Intune export: $_" -ForegroundColor Red; exit 1 }
}
'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
$report
if ($report.ExitCode) {exit $report.ExitCode}
}
'wef-arrival' {
if ($Help) {Write-Host 'Usage: ./WELA.ps1 wef-arrival -ArrivalProbePath existing-native-probe-directory -ArrivalOutputPath new-private-directory. Reads local ForwardedEvents and matches the exact original probe payload. No subscriptions, policy changes, latency or Sigma readiness claims. See docs/wef-arrival.md.'; return}
if (-not $ArrivalProbePath -or -not $ArrivalOutputPath) {throw 'ArrivalProbePath and ArrivalOutputPath are required.'}
+24
View File
@@ -0,0 +1,24 @@
# 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.
```powershell
# Save results during the original configuration.
./WELA.ps1 configure -Profile wela-2.2.0 -BackupPath C:\Evidence\before -ResultsPath C:\Evidence\original.json
# Review a new plan (no policy changes).
./WELA.ps1 audit-recovery -RecoveryJournalPath C:\Evidence\before\before.jsonl -RecoveryOriginalResultsPath C:\Evidence\original.json -RecoveryControlId 'AuditPolicy/Process Creation' -RecoveryOutputPath C:\Evidence\review
# Preview; no output directory or policy writes.
./WELA.ps1 audit-recovery -RecoveryAction Restore -RecoveryPlanPath C:\Evidence\review\plan.json -DryRun
# Explicit restore, with per-control prompts; -Auto accepts these reviewed changes.
./WELA.ps1 audit-recovery -RecoveryAction Restore -RecoveryPlanPath C:\Evidence\review\plan.json -RecoveryOutputPath C:\Evidence\recovered
```
Only unique canonical GUID/name pairs with matching `Applied` final results are eligible. Failed, overridden, partial or unsupported writes require manual review. The plan is independently rebuilt from the original byte-hashed files; altered plans, sources, actual host identity or current post-write state block restoration. Paths follow the PowerShell location. Output directories must be on a local fixed drive: mapped network drives, UNC/device paths, alternate data streams and reparse ancestors are rejected before output creation. This restriction applies to output, not the existing journal/results input handling. New directories and files preserve earlier evidence; recovery writes have durable before-state receipts and immediate/final readback.
Exact masks return to their recorded previous mask. Minimum-mode restoration removes only the requested bits absent before WELA: before `0`, requested Success `1`, final `3` restores to Failure `2`, preserving the independent addition. Recovery cannot determine who changed a bit between observations and does not claim transactional protection from concurrent administrators or GPO.
Subcategory recovery requires enabled DWORD precedence. To restore precedence itself, explicitly select `Registry/HKLM:\SYSTEM\CurrentControlSet\Control\Lsa/SCENoApplyLegacyAuditPolicy` plus **every** journaled audit subcategory. Only prior DWORD 0/1 or value absence under the existing LSA key is supported. Precedence runs last, after all subcategories succeed and are checked again. Failed/declined recovery blocks it. No arbitrary registry paths, services, SACLs, CA restarts or NTLM settings are replayed.
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.
+258
View File
@@ -0,0 +1,258 @@
# Conservative, explicitly selected recovery of completed audit-policy writes.
function ConvertFrom-WelaRecoveryJson {
param([string]$Text)
# ConvertFrom-Json accepts some JavaScript extensions (including single-quoted
# and bare property names). Validate the entire JSON token stream first, so
# those forms cannot bypass duplicate-property tracking below.
$lexical=[regex]'\G(?:[ \t\r\n]+|"(?:\\["\\/bfnrt]|\\u[0-9A-Fa-f]{4}|[^"\\\x00-\x1f])*"|-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?(?![A-Za-z0-9_.+-])|(?:true|false|null)(?![A-Za-z0-9_])|[{}\[\]:,])'
$position=0
while ($position -lt $Text.Length) {
$match=$lexical.Match($Text,$position)
if (-not $match.Success -or $match.Index -ne $position) { throw 'Recovery evidence require strict JSON tokens; JavaScript extensions and invalid escapes are not supported.' }
$position+=$match.Length
}
# Match JSON strings first; braces/property-looking text inside strings is inert.
$withoutStrings=[regex]::Replace($Text,'"(?:\\.|[^"\\])*"','""')
if ($withoutStrings -match '//|/\*|,\s*[}\]]') { throw 'Recovery evidence require strict JSON without comments or trailing commas.' }
$tokens=[regex]::Matches($Text,'"(?:\\.|[^"\\])*"|[{}\[\]:,]')
$stack=New-Object 'System.Collections.Generic.Stack[object]'
for ($i=0;$i -lt $tokens.Count;$i++) {
$token=$tokens[$i].Value
if ($token -eq '{') { $stack.Push(@{}) }
elseif ($token -eq '[') { $stack.Push($null) }
elseif ($token -in @('}',']')) { if (-not $stack.Count) { throw 'Unbalanced recovery JSON.' }; $null=$stack.Pop() }
elseif ($token.StartsWith('"') -and $i+1 -lt $tokens.Count -and $tokens[$i+1].Value -eq ':') {
if (-not $stack.Count -or $null -eq $stack.Peek()) { throw 'JSON property outside object.' }
$holder=ConvertFrom-Json -InputObject ('{'+$token+':null}') -ErrorAction Stop
$name=@($holder.PSObject.Properties.Name)[0]
if ($stack.Peek().ContainsKey($name)) { throw "Duplicate or case-colliding recovery property: $name" }
$stack.Peek()[$name]=$true
}
if ($stack.Count -gt 20) { throw 'Recovery nesting exceeds 20 levels.' }
}
$args=@{InputObject=$Text;ErrorAction='Stop'}
if ((Get-Command ConvertFrom-Json).Parameters.ContainsKey('DateKind')) {$args.DateKind='String'}
ConvertFrom-Json @args
}
function Get-WelaRecoveryHash {
param([byte[]]$Bytes)
$algorithm=[Security.Cryptography.SHA256]::Create()
try { ([BitConverter]::ToString($algorithm.ComputeHash($Bytes))).Replace('-','').ToLowerInvariant() } finally { $algorithm.Dispose() }
}
function Get-WelaRecoveryFile {
param([string]$Path)
$file=Get-Item -LiteralPath $Path -ErrorAction Stop
if ($file -isnot [IO.FileInfo] -or $file.Length -lt 1 -or $file.Length -gt 4194304) {throw 'Recovery input must be a nonempty file of at most 4 MiB.'}
for ($node=$file;$null -ne $node;$node=$node.Parent) {
if ($node.Attributes -band [IO.FileAttributes]::ReparsePoint) {throw 'Recovery paths must not contain reparse points.'}
if ($node -is [IO.FileInfo]) {$node=$node.Directory; if ($node.Attributes -band [IO.FileAttributes]::ReparsePoint) {throw 'Recovery paths must not contain reparse points.'}}
}
$bytes=[IO.File]::ReadAllBytes($file.FullName)
if ($bytes.Length -gt 4194304) {throw 'Recovery input grew beyond the byte limit.'}
[pscustomobject]@{Path=$file.FullName;Sha256=(Get-WelaRecoveryHash $bytes);Text=(New-Object Text.UTF8Encoding($false,$true)).GetString($bytes).TrimStart([char]0xfeff)}
}
function Get-WelaRecoveryHost {
if ($env:OS -ne 'Windows_NT' -or -not [Environment]::Is64BitProcess) {throw 'Recovery requires native 64-bit Windows.'}
$context=Get-WelaDefaultContext
if (-not (Test-WelaDefaultContextComplete $context)) {throw 'Actual host context is incomplete.'}
$machine=Get-WelaRegistryState 'HKLM:\SOFTWARE\Microsoft\Cryptography' MachineGuid
$parsed=[guid]::Empty
if (-not $machine.ValueExists -or $machine.Type -ne 'String' -or -not [guid]::TryParse([string]$machine.Value,[ref]$parsed) -or $parsed -eq [guid]::Empty -or -not [Environment]::MachineName) {throw 'Actual machine identity is unavailable.'}
[pscustomobject][ordered]@{Computer=[Environment]::MachineName;MachineGuid=$parsed.ToString();ContextKey=(Get-WelaDefaultContextKey $context)}
}
function Get-WelaRecoveryKey {param($Value) ConvertTo-Json -InputObject $Value -Depth 20 -Compress}
function Assert-WelaRecoveryMask {param($Value) if (($Value -isnot [int] -and $Value -isnot [long]) -or $Value -notin @(0,1,2,3)) {throw 'Audit mask must be an integer 0..3.'}}
function Get-WelaRecoveryMaskTarget {
param($Before,$Desired,$After)
Assert-WelaRecoveryMask $Before; Assert-WelaRecoveryMask $Desired.Mask; Assert-WelaRecoveryMask $After
if ($Desired.Mode -ceq 'exact') {if ($After -ne $Desired.Mask) {throw 'Exact write lacks matching final state.'};return [int]$Before}
if ($Desired.Mode -cne 'minimum' -or ($After -band $Before) -ne $Before -or ($After -band $Desired.Mask) -ne $Desired.Mask) {throw 'Minimum write has inconsistent final state.'}
# Undo only requested bits absent before WELA. Preserve independent additions.
$added=$Desired.Mask -band (3 -bxor $Before)
return [int]($After -band (3 -bxor $added))
}
function Assert-WelaRecoveryPrecedenceValue {
param($Value)
if ($Value.KeyExists -isnot [bool] -or -not $Value.KeyExists -or $Value.ValueExists -isnot [bool]) {throw 'Precedence requires an existing LSA key and typed value state.'}
if ($Value.ValueExists) {
if ($Value.Type -cne 'DWord' -or ($Value.Value -isnot [int] -and $Value.Value -isnot [long]) -or $Value.Value -notin @(0,1)) {throw 'Unknown prior precedence type/value requires manual recovery.'}
} elseif ($null -ne $Value.Value -or $null -ne $Value.Type) {throw 'Absent precedence value has inconsistent state.'}
}
function New-WelaRecoveryPlan {
param([string]$JournalPath,[string]$OriginalResultsPath,[string[]]$ControlId)
$ErrorActionPreference='Stop'
if (-not $ControlId -or $ControlId.Count -gt 60 -or @($ControlId | Select-Object -Unique).Count -ne $ControlId.Count) {throw 'Select 1..60 unique recovery control IDs explicitly.'}
$hostState=Get-WelaRecoveryHost
$journal=Get-WelaRecoveryFile $JournalPath; $resultFile=Get-WelaRecoveryFile $OriginalResultsPath
$entries=@($journal.Text -split '\r?\n' | Where-Object {$_ -match '\S'} | ForEach-Object {ConvertFrom-WelaRecoveryJson $_})
if ($entries.Count -lt 1 -or $entries.Count -gt 1024) {throw 'Journal must contain 1..1024 entries.'}
$results=ConvertFrom-WelaRecoveryJson $resultFile.Text
if ($results.DryRun -isnot [bool] -or $results.DryRun -or $results.Results -isnot [array] -or $results.Results.Count -gt 2048) {throw 'Original results must describe a completed non-dry-run configuration.'}
$byId=@{}; $final=@{}
foreach ($row in $results.Results) {
if ($row.Id -isnot [string] -or -not $row.Id -or $final.ContainsKey($row.Id)) {throw 'Duplicate or invalid final result ID.'};$final[$row.Id]=$row
}
foreach ($entry in $entries) {
if ($entry.Version -isnot [ValueType] -or $entry.Version -is [bool] -or $entry.Version -ne 1 -or $entry.ComputerName -isnot [string] -or $entry.ComputerName -ine $hostState.Computer -or
$entry.Id -isnot [string] -or -not $entry.Id -or $byId.ContainsKey($entry.Id)) {throw 'Duplicate, wrong-host or invalid journal entry.'}
$time=[datetimeoffset]::MinValue
if ($entry.RecordedUtc -isnot [string] -or $entry.RecordedUtc -notmatch '(Z|\+00:00)$' -or -not [datetimeoffset]::TryParse($entry.RecordedUtc,[ref]$time) -or $time -gt [datetimeoffset]::UtcNow.AddMinutes(1)) {throw 'Journal timestamp must be valid UTC.'}
$byId[$entry.Id]=$entry
}
$catalog=@{}; foreach ($item in (Import-WelaAuditProfiles).catalog) {$catalog['AuditPolicy/'+$item.id]=$item}
$precedenceId='Registry/HKLM:\SYSTEM\CurrentControlSet\Control\Lsa/SCENoApplyLegacyAuditPolicy'
$rows=New-Object 'System.Collections.Generic.List[object]'
$targets=@{}
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]
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)) {
if ($catalog[$id].id -cne $id.Substring(12) -or $entry.Target.Guid -isnot [string] -or $entry.Target.Guid -ine $catalog[$id].guid -or $targets.ContainsKey($entry.Target.Guid)) {throw 'Canonical audit name/GUID mismatch or duplicate target.'}
$targets[$entry.Target.Guid]=$true
$target=Get-WelaRecoveryMaskTarget $entry.Before $entry.Desired $last.After
} elseif ($entry.Kind -ceq 'Registry' -and $id -ceq $precedenceId) {
if ($entry.Target.Path -cne 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -or $entry.Target.Name -cne 'SCENoApplyLegacyAuditPolicy' -or $entry.Desired.Type -cne 'DWord' -or $entry.Desired.Value -ne 1) {throw 'Unsupported registry recovery target.'}
Assert-WelaRecoveryPrecedenceValue $entry.Before; Assert-WelaRecoveryPrecedenceValue $last.After
if (-not $last.After.ValueExists -or $last.After.Value -ne 1) {throw 'Final precedence is not enabled.'}
# 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
} 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})
}
[pscustomobject][ordered]@{
Kind='WelaAuditRecoveryPlan';SchemaVersion=1
Host=$hostState;Journal=[pscustomobject]@{Path=$journal.Path;Sha256=$journal.Sha256}
OriginalResults=[pscustomobject]@{Path=$resultFile.Path;Sha256=$resultFile.Sha256}
CatalogSha256=(Get-FileHash -LiteralPath (Join-Path $PSScriptRoot '../config/audit_profiles.json')).Hash.ToLowerInvariant()
ControlIds=@($ControlId | Sort-Object);Controls=@($rows.ToArray() | Sort-Object Kind,Id)
HistoricalIdentity='Version-1 journals record only ComputerName. MachineGuid/context bind this reviewed recovery plan, not the historical configuration. Hashes establish consistency, not authenticity.'
UnsupportedJournalControls=@($entries | Where-Object {$_.Id -notin $ControlId} | Select-Object Id,Kind)
ReadyRuleCredit=0
}
}
function Get-WelaRecoveryOutputDriveType {
param([string]$Root)
if ([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT) {return ([IO.DriveInfo]::new($Root)).DriveType}
# Offline fixture execution has no Windows drive classifications.
return [IO.DriveType]::Fixed
}
function New-WelaRecoveryOutput {
param([string]$Path)
if (-not $Path) {throw 'A new output directory is required.'}
$provider=$null;$drive=$null
$resolved=$ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path,[ref]$provider,[ref]$drive)
if ($provider.Name -ne 'FileSystem' -or $resolved -match '^[\\/]{2}' -or $resolved.Substring([IO.Path]::GetPathRoot($resolved).Length).Contains(':')) {throw 'Output must be an ordinary local filesystem path without remote/device paths or alternate data streams.'}
# Check the drive before probing the destination or its parent: a mapped
# network drive can have an ordinary drive-letter path without a UNC prefix.
if ((Get-WelaRecoveryOutputDriveType ([IO.Path]::GetPathRoot($resolved))) -ne [IO.DriveType]::Fixed) {throw 'Recovery output requires a local fixed drive.'}
if (Test-Path -LiteralPath $resolved) {throw 'Output must be a new local filesystem directory.'}
$parent=Get-Item -LiteralPath ([IO.Path]::GetDirectoryName($resolved)) -ErrorAction Stop
for ($node=$parent;$null -ne $node;$node=$node.Parent) {if ($node.Attributes -band [IO.FileAttributes]::ReparsePoint) {throw 'Output ancestors must not be reparse points.'}}
$null=New-Item -ItemType Directory -Path $resolved -ErrorAction Stop
if ($env:OS -eq 'Windows_NT') {
$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) {
$rule=New-Object Security.AccessControl.FileSystemAccessRule((New-Object Security.Principal.SecurityIdentifier($sid)), 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow')
$acl.AddAccessRule($rule)
}
Set-Acl -LiteralPath $resolved -AclObject $acl -ErrorAction Stop
}
return $resolved
}
function Write-WelaRecoveryArtifact {
param([string]$Path,$Value)
$bytes=(New-Object Text.UTF8Encoding($false)).GetBytes((ConvertTo-Json -InputObject $Value -Depth 24))
$stream=New-Object IO.FileStream($Path,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None)
try {$stream.Write($bytes,0,$bytes.Length);$stream.Flush($true)} finally {$stream.Dispose()}
}
function Get-WelaRecoveryCurrent {
param($Control)
if ($Control.Kind -eq 'AuditPolicy') {return Get-WelaAuditPolicyMask $Control.Target.Guid}
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}
$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)
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.'}
}
function Invoke-WelaAuditRecovery {
param([ValidateSet('Plan','Restore')][string]$Action='Plan',[string]$JournalPath,[string]$OriginalResultsPath,[string[]]$ControlId,[string]$PlanPath,[string]$OutputPath,[switch]$Auto,[switch]$DryRun)
$ErrorActionPreference='Stop'
if ($Action -eq 'Plan') {
if ($PlanPath -or $Auto -or $DryRun) {throw 'Plan uses original journal/results and selected IDs; Auto/DryRun are Restore-only.'}
$plan=New-WelaRecoveryPlan $JournalPath $OriginalResultsPath $ControlId
$observed=@(foreach ($row in $plan.Controls) {[pscustomobject]@{Id=$row.Id;Current=(Get-WelaRecoveryCurrent $row);Expected=$row.Expected;RecoverTo=$row.RecoverTo}})
Assert-WelaRecoverySources $plan
$output=New-WelaRecoveryOutput $OutputPath
Write-WelaRecoveryArtifact (Join-Path $output 'plan.json') $plan
Write-WelaRecoveryArtifact (Join-Path $output 'observed.json') $observed
return [pscustomobject]@{Status='Planned';ExitCode=0;OutputPath=$output;Controls=$observed;ReadyRuleCredit=0}
}
if ($JournalPath -or $OriginalResultsPath -or $ControlId -or -not $PlanPath) {throw 'Restore consumes only a reviewed PlanPath and a new OutputPath.'}
$source=Get-WelaRecoveryFile $PlanPath; $plan=ConvertFrom-WelaRecoveryJson $source.Text
if ($plan.Kind -cne 'WelaAuditRecoveryPlan' -or $plan.SchemaVersion -ne 1) {throw 'Unsupported recovery plan.'}
Assert-WelaRecoverySources $plan
$rebuilt=New-WelaRecoveryPlan $plan.Journal.Path $plan.OriginalResults.Path $plan.ControlIds
if ((Get-WelaRecoveryKey $rebuilt) -cne (Get-WelaRecoveryKey $plan)) {throw 'Recovery plan differs from independently rebuilt source evidence.'}
$output=if (-not $DryRun) {New-WelaRecoveryOutput $OutputPath} elseif ($OutputPath) {throw 'DryRun produces no directory; omit OutputPath.'} else {$null}
$results=New-Object 'System.Collections.Generic.List[object]';$blocked=$false;$sequence=0
foreach ($control in $plan.Controls) {
$sequence++
$row=[pscustomobject]@{Id=$control.Id;Status='Failed';Before=$null;After=$null;RecoverTo=$control.RecoverTo;Diagnostic=''}
try {
Assert-WelaRecoverySources $plan
if ((Get-WelaRecoveryFile $source.Path).Sha256 -cne $source.Sha256) {throw 'Reviewed recovery plan changed.'}
$row.Before=Get-WelaRecoveryCurrent $control
if ((Get-WelaRecoveryKey $row.Before) -ceq (Get-WelaRecoveryKey $control.RecoverTo)) {$row.Status='AlreadyRecovered';$row.After=$row.Before}
else {
if ($blocked -and $control.Kind -eq 'Registry') {throw 'Precedence recovery blocked by incomplete audit recovery.'}
if ((Get-WelaRecoveryKey $row.Before) -cne (Get-WelaRecoveryKey $control.Expected)) {throw 'Current state drifted from recorded post-configuration state.'}
if ($control.Kind -eq 'AuditPolicy') {
$precedence=Get-WelaRegistryState 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' SCENoApplyLegacyAuditPolicy
if (-not $precedence.ValueExists -or $precedence.Type -ne 'DWord' -or $precedence.Value -ne 1) {throw 'Enabled DWORD audit precedence is required before subcategory recovery.'}
}
if ($DryRun) {$row.Status='WouldRestore'}
elseif (-not $Auto -and (Read-Host "Restore $($control.Id) to reviewed state? (y/N)") -cnotin @('y','Y')) {$row.Status='Declined';$blocked=$true}
else {
Write-WelaRecoveryArtifact (Join-Path $output (('{0:d3}-before.json' -f $sequence))) ([pscustomobject]@{Host=$plan.Host;RecordedUtc=[datetime]::UtcNow.ToString('o');PlanSha256=$source.Sha256;Control=$control;Before=$row.Before})
Assert-WelaRecoverySources $plan
if ((Get-WelaRecoveryFile $source.Path).Sha256 -cne $source.Sha256 -or (Get-WelaRecoveryKey (Get-WelaRecoveryCurrent $control)) -cne (Get-WelaRecoveryKey $row.Before)) {throw 'State or reviewed plan changed before recovery write.'}
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 {
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
$row.After=Get-WelaRecoveryCurrent $control
if ((Get-WelaRecoveryKey $row.After) -cne (Get-WelaRecoveryKey $control.RecoverTo)) {throw 'Recovery readback did not match.'}
$row.Status='Restored'
}
}
} catch {$row.Status='Failed';$row.Diagnostic=$_.Exception.Message;$blocked=$true}
$results.Add($row)
}
foreach ($row in $results | Where-Object {$_.Status -in @('Restored','AlreadyRecovered')}) {
try {
Assert-WelaRecoverySources $plan
$control=$plan.Controls | Where-Object Id -eq $row.Id
$row.After=Get-WelaRecoveryCurrent $control
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.'}
if (-not $DryRun) {Write-WelaRecoveryArtifact (Join-Path $output 'results.json') $report}
return $report
}
+118
View File
@@ -0,0 +1,118 @@
$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
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=$script:hostKey}}
$script:hostKey='test-context'
$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-recovery-'+[guid]::NewGuid().ToString('N'))
$null=New-Item -ItemType Directory -Path $root
$journal=Join-Path $root 'before.jsonl';$original=Join-Path $root 'original.json'
$id='AuditPolicy/Process Creation';$prec='Registry/HKLM:\SYSTEM\CurrentControlSet\Control\Lsa/SCENoApplyLegacyAuditPolicy'
$script:state=3;$script:writes=0;$script:failWrite=$false;$script:badReadback=$false
$script:precedence=[pscustomobject]@{KeyExists=$true;ValueExists=$true;Value=1;Type='DWord'}
function Get-WelaRegistryState {$script:precedence}
function Get-WelaRecoveryCurrent {param($Control) if ($Control.Kind -eq 'AuditPolicy') {$script:state} else {$script:precedence}}
function Set-WelaRecoveryCurrent {param($Control) if ($script:failWrite) {throw 'Injected write failure'};$script:writes++;if ($script:badReadback) {return};if ($Control.Kind -eq 'AuditPolicy') {$script:state=$Control.RecoverTo} else {$script:precedence=$Control.RecoverTo}}
function Save-Fixture {
param([switch]$WithPrecedence)
$script:entry=[pscustomobject][ordered]@{Version=1;ComputerName='TEST';RecordedUtc=[datetime]::UtcNow.ToString('o');Id=$id;Kind='AuditPolicy';Target=[pscustomobject]@{Guid='0cce922b-69ae-11d9-bed3-505054503030'};Before=0;Desired=[pscustomobject]@{Mask=1;Mode='minimum'}}
$script:final=[pscustomobject]@{Id=$entry.Id;Kind=$entry.Kind;Target=$entry.Target;Before=$entry.Before;Desired=$entry.Desired;After=3;Status='Applied';Diagnostic=''}
$entries=@($entry);$rows=@($final)
if ($WithPrecedence) {
$prior=[pscustomobject]@{KeyExists=$true;ValueExists=$false;Value=$null;Type=$null}
$pe=[pscustomobject]@{Version=1;ComputerName='TEST';RecordedUtc=[datetime]::UtcNow.ToString('o');Id=$prec;Kind='Registry';Target=[pscustomobject]@{Path='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa';Name='SCENoApplyLegacyAuditPolicy'};Before=$prior;Desired=[pscustomobject]@{Value=1;Type='DWord'}}
$entries=@($pe)+$entries
$rows=@([pscustomobject]@{Id=$pe.Id;Kind=$pe.Kind;Target=$pe.Target;Before=$pe.Before;Desired=$pe.Desired;After=$script:precedence;Status='Applied'})+$rows
}
($entries | ForEach-Object {$_ | ConvertTo-Json -Depth 20 -Compress}) | Set-Content -LiteralPath $journal -Encoding UTF8
[pscustomobject]@{DryRun=$false;ExitCode=0;Results=$rows} | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $original -Encoding UTF8
}
function New-PlanFile {
param([string[]]$Ids=@($id))
$destination=Join-Path $root ([guid]::NewGuid().ToString('N'))
$result=Invoke-WelaAuditRecovery -JournalPath $journal -OriginalResultsPath $original -ControlId $Ids -OutputPath $destination
Assert ($result.Status -eq 'Planned') 'Plan is read only.'
Join-Path $destination 'plan.json'
}
try {
for ($before=0;$before -le 3;$before++) {for ($desired=0;$desired -le 3;$desired++) {for ($after=0;$after -le 3;$after++) {
if (($after -band $before) -eq $before -and ($after -band $desired) -eq $desired) {
$target=Get-WelaRecoveryMaskTarget $before ([pscustomobject]@{Mask=$desired;Mode='minimum'}) $after
Assert (($target -band $before) -eq $before) 'Minimum undo preserves every original bit.'
$unrequested=$after -band (3 -bxor $desired)
Assert (($target -band $unrequested) -eq $unrequested) 'Minimum undo preserves independent added bits.'
Assert (($target -band ($desired -band (3 -bxor $before))) -eq 0) 'Minimum undo removes only requested additions.'
} else {Throws {Get-WelaRecoveryMaskTarget $before ([pscustomobject]@{Mask=$desired;Mode='minimum'}) $after} 'inconsistent'}
Assert ((Get-WelaRecoveryMaskTarget $before ([pscustomobject]@{Mask=$desired;Mode='exact'}) $desired) -eq $before) 'Exact undo returns original mask.'
}}}
Throws {Get-WelaRecoveryMaskTarget '0' ([pscustomobject]@{Mask=1;Mode='exact'}) 1} 'integer'
Throws {ConvertFrom-WelaRecoveryJson '{"Id":1,"i\u0064":2}'} 'Duplicate'
Throws {ConvertFrom-WelaRecoveryJson '{"Id":1,}'} 'strict JSON'
$rejectedOutput=Join-Path $root 'rejected-network-output'
$driveReader=(Get-Command Get-WelaRecoveryOutputDriveType).ScriptBlock
try {
function Get-WelaRecoveryOutputDriveType {[IO.DriveType]::Network}
Throws {New-WelaRecoveryOutput $rejectedOutput} 'local fixed drive'
Assert (-not (Test-Path -LiteralPath $rejectedOutput)) 'Mapped network drive observation blocks output before directory creation.'
function Get-WelaRecoveryOutputDriveType {[IO.DriveType]::Unknown}
Throws {New-WelaRecoveryOutput $rejectedOutput} 'local fixed drive'
Assert (-not (Test-Path -LiteralPath $rejectedOutput)) 'Unknown drive type cannot create recovery output.'
} finally {Set-Item Function:Get-WelaRecoveryOutputDriveType $driveReader}
Throws {New-WelaRecoveryOutput (Join-Path $root 'rejected:stream')} '.'
Assert (-not (Test-Path -LiteralPath (Join-Path $root 'rejected'))) 'Stream destination is rejected before creating a base object.'
Save-Fixture
$planFile=New-PlanFile
$dry=Invoke-WelaAuditRecovery -Action Restore -PlanPath $planFile -DryRun -Auto
Assert ($dry.Status -eq 'DryRun' -and $writes -eq 0 -and $dry.Results[0].Status -eq 'WouldRestore') 'DryRun writes nothing.'
$script:state=1
$drift=Invoke-WelaAuditRecovery -Action Restore -PlanPath $planFile -OutputPath (Join-Path $root 'drift') -Auto
Assert ($drift.ExitCode -eq 1 -and $writes -eq 0 -and $drift.Results[0].Diagnostic -match 'drift') 'Intervening change refuses restoration.'
$script:state=3
$ok=Invoke-WelaAuditRecovery -Action Restore -PlanPath $planFile -OutputPath (Join-Path $root 'restored') -Auto
Assert ($ok.ExitCode -eq 0 -and $state -eq 2 -and $writes -eq 1) 'Actual restore preserves independent Failure auditing.'
Assert (Test-Path -LiteralPath (Join-Path $root 'restored/001-before.json')) 'Durable pre-write receipt exists.'
$again=Invoke-WelaAuditRecovery -Action Restore -PlanPath $planFile -OutputPath (Join-Path $root 'again') -Auto
Assert ($again.Results[0].Status -eq 'AlreadyRecovered' -and $writes -eq 1) 'Recovery is idempotent.'
Throws {Invoke-WelaAuditRecovery -Action Restore -PlanPath $planFile -OutputPath (Join-Path $root 'again') -Auto} 'new local'
$script:hostKey='drift';Throws {Invoke-WelaAuditRecovery -Action Restore -PlanPath $planFile -DryRun} 'host changed';$script:hostKey='test-context'
Add-Content -LiteralPath $journal -Value ' '
Throws {Invoke-WelaAuditRecovery -Action Restore -PlanPath $planFile -DryRun} 'evidence changed'
Save-Fixture
$entry.ComputerName='OTHER';$entry | ConvertTo-Json -Depth 20 -Compress | Set-Content $journal
Throws {New-WelaRecoveryPlan $journal $original @($id)} 'wrong-host'
Save-Fixture
$final.Status='Failed';[pscustomobject]@{DryRun=$false;Results=@($final)} | ConvertTo-Json -Depth 20 | Set-Content $original
Throws {New-WelaRecoveryPlan $journal $original @($id)} 'Applied'
Save-Fixture
$entry.Target.Guid='0cce922c-69ae-11d9-bed3-505054503030';$entry | ConvertTo-Json -Depth 20 -Compress | Set-Content $journal
Throws {New-WelaRecoveryPlan $journal $original @($id)} 'mismatch'
Save-Fixture -WithPrecedence
Throws {New-WelaRecoveryPlan $journal $original @($prec)} 'every journaled'
$planFile=New-PlanFile @($id,$prec)
$script:state=3;$script:failWrite=$true
$failed=Invoke-WelaAuditRecovery -Action Restore -PlanPath $planFile -OutputPath (Join-Path $root 'failed') -Auto
Assert ($failed.ExitCode -eq 1 -and $failed.Results[1].Diagnostic -match 'blocked' -and $precedence.Value -eq 1) 'Failed mask recovery blocks precedence restoration.'
$script:failWrite=$false;$script:badReadback=$true
$failed=Invoke-WelaAuditRecovery -Action Restore -PlanPath $planFile -OutputPath (Join-Path $root 'readback') -Auto
Assert ($failed.ExitCode -eq 1 -and $failed.Results[0].Diagnostic -match 'readback') 'Ineffective write is a failure.'
$script:badReadback=$false
$ok=Invoke-WelaAuditRecovery -Action Restore -PlanPath $planFile -OutputPath (Join-Path $root 'ordered') -Auto
Assert ($ok.ExitCode -eq 0 -and $state -eq 2 -and -not $precedence.ValueExists) 'Precedence absence restores only after masks.'
$payload=Get-WelaRecoveryFile $planFile
$tampered=ConvertFrom-WelaRecoveryJson $payload.Text;$tampered.Controls[0].RecoverTo=0
$tampered | ConvertTo-Json -Depth 24 | Set-Content $planFile
Throws {Invoke-WelaAuditRecovery -Action Restore -PlanPath $planFile -DryRun} 'independently rebuilt'
$ps=(Get-Process -Id $PID).Path
$old=$ErrorActionPreference;$ErrorActionPreference='Continue'
$output=& $ps -NoProfile -File (Join-Path $repo 'WELA.ps1') configure -RecoveryAction Restore 2>&1;$code=$LASTEXITCODE
$ErrorActionPreference=$old
Assert ($code -ne 0 -and ($output -join ' ') -match 'require audit-recovery') 'Recovery options cannot dispatch unrelated configuration.'
Push-Location $root
try {Save-Fixture;$script:precedence=[pscustomobject]@{KeyExists=$true;ValueExists=$true;Value=1;Type='DWord'};$script:state=3;$null=Invoke-WelaAuditRecovery -JournalPath ./before.jsonl -OriginalResultsPath ./original.json -ControlId $id -OutputPath ./relative;Assert (Test-Path ./relative/plan.json) 'Relative paths follow PowerShell location.'} finally {Pop-Location}
} finally {Remove-Item -LiteralPath $root -Recurse -Force}
$global:LASTEXITCODE=0
Write-Host "Audit recovery: $script:n assertions passed."
+41
View File
@@ -0,0 +1,41 @@
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')
$guid='0cce922b-69ae-11d9-bed3-505054503030';$path='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa';$name='SCENoApplyLegacyAuditPolicy'
$mask=Get-WelaAuditPolicyMask $guid;$precedence=Get-WelaRegistryState $path $name
$temp=Join-Path ([IO.Path]::GetTempPath()) ('wela-native-recovery-'+[guid]::NewGuid().ToString('N'))
$null=New-Item -ItemType Directory -Path $temp
$restored=$false
Write-WelaRecoveryArtifact (Join-Path $temp 'safety-before.json') ([pscustomobject]@{Mask=$mask;Precedence=$precedence})
try {
# The test deliberately changes only this subcategory and local precedence.
$null=New-ItemProperty -LiteralPath $path -Name $name -Value 1 -PropertyType DWord -Force
$context=New-WelaConfigurationContext -Auto -BackupPath (Join-Path $temp 'original-backup')
Set-WelaAuditPolicyControl -Context $context -Policy @{GUID=$guid;Name='Process Creation'} -Mask (3 -bxor $mask) -Mode exact -RequirePrecedence
$original=Join-Path $temp 'original-results.json'
$report=Complete-WelaConfiguration -Context $context -ResultsPath $original
if ($report.ExitCode -ne 0 -or $report.Results[0].Status -ne 'Applied') {throw 'Test configuration did not create a completed native journal.'}
$plan=Invoke-WelaAuditRecovery -JournalPath (Join-Path $context.BackupPath 'before.jsonl') -OriginalResultsPath $original -ControlId 'AuditPolicy/Process Creation' -OutputPath (Join-Path $temp 'plan')
$result=Invoke-WelaAuditRecovery -Action Restore -PlanPath (Join-Path $plan.OutputPath 'plan.json') -OutputPath (Join-Path $temp 'recovery') -Auto
if ($result.ExitCode -ne 0 -or $result.Results[0].Status -ne 'Restored' -or (Get-WelaAuditPolicyMask $guid) -ne $mask) {throw ($result | ConvertTo-Json -Depth 20)}
$again=Invoke-WelaAuditRecovery -Action Restore -PlanPath (Join-Path $plan.OutputPath 'plan.json') -OutputPath (Join-Path $temp 'again') -Auto
if ($again.ExitCode -ne 0 -or $again.Results[0].Status -ne 'AlreadyRecovered') {throw 'Native recovery is not idempotent.'}
Write-Host 'Native completed-journal planning, exact audit restoration and idempotence passed.'
} finally {
$errors=@()
try {Set-WelaEffectiveAuditPolicy -Guid $guid -Mask $mask -Mode exact;if ((Get-WelaAuditPolicyMask $guid) -ne $mask) {throw 'Audit safety restoration differs.'}} catch {$errors+=$_.Exception.Message}
try {
if ($precedence.ValueExists) {$null=New-ItemProperty -LiteralPath $path -Name $name -Value $precedence.Value -PropertyType $precedence.Type -Force}
else {Remove-ItemProperty -LiteralPath $path -Name $name -ErrorAction SilentlyContinue}
if ((Get-WelaRecoveryKey (Get-WelaRegistryState $path $name)) -cne (Get-WelaRecoveryKey $precedence)) {throw 'Precedence safety restoration differs.'}
} catch {$errors+=$_.Exception.Message}
if ($errors.Count) {throw "Native safety restoration failed; evidence retained at $temp : $($errors -join '; ')"}
Remove-Item -LiteralPath $temp -Recurse -Force
}
$global:LASTEXITCODE=0
+2
View File
@@ -7,6 +7,8 @@
**改善:**
- `audit-recovery` を追加し、完了した監査サブカテゴリと優先設定の変更を明示選択して計画・復元できるようにしました。元の記録と結果、ホストと入力の再検証、復元前の記録、最終確認でドリフトを検出し、最小設定の独立した追加ビットを保持します。優先設定は最後に復元します。使い捨て Windows 環境で実際の復元を検証し、過去のホスト同一性、GPO 永続性、Sigma 対応の証明とは区別します。 出力先はローカル固定ドライブに限定し、ネットワークドライブと代替データストリームを拒否します。 (#419) (@Shirofune-Security)
- 読み取り専用の`wef-arrival`を追加し、完了したWindows標準4688プローブの資料を検証して、ローカル収集サーバーに元イベントが1件だけ一致するか確認できるようにしました。ハッシュ・形式・環境の厳密な確認、件数を制限したネイティブ検索、実際の読み取りユーザーと変更検出、保護された生XMLの保存により、不完全・曖昧な結果は未検証として保持します。イベントの存在を、配信サブスクリプション・遅延・時刻同期・Sigma利用可能性の証明とは扱いません。ホスト間の正常到着は別途ラボ検証が必要です。 (#418) (@Shirofune-Security)
- 読み取り専用の `score` JSON・自己完結型 HTML レポートを追加し、高度な監査プロファイルへの適合率と重大度別のネイティブルール検証率を分けて表示します。バージョン付き重み、分子・分母、未確認・除外項目、ソースのハッシュ、記録時の証拠コンテキストを保持します。オフラインでは現在の設定を未確認とし、設定の有効化だけで Ready を加算しません。総合的なセキュリティ評価や Sysmon の検知範囲は示しません。 (#417) (@Shirofune-Security)
- 共有する詳細監査プロファイルと優先設定のセキュリティテンプレートについて、オフラインで計画・出力・検証する`gpo-package`を追加しました。省略項目を保持し、成功または失敗だけの最低要件を両方へ拡張する場合は明示指定を求め、未検証のゼロ値による配備は拒否します。出典・申告対象・全項目レビュー・検証済みハッシュを含む配備用ファイルであり、GPOバックアップではありません。正規のGPMC/LGPO準備と未リンクGPO作成のレビュー手順を文書化しました。ドメイン配備と実イベントの検証は別途ラボで必要となり、Sigma検知範囲には加算しません。 (#415) (@Shirofune-Security)
+2
View File
@@ -7,6 +7,8 @@
**Improvements:**
- 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)
- Added read-only `score` JSON and self-contained HTML reports with separate advanced audit-profile compliance and severity-weighted native rule readiness. Versioned weights, explicit numerators/denominators, unknowns, exclusions, source fingerprints and recorded evidence contexts make each result reviewable. Offline scenarios keep current settings Unknown; enabled settings grant no Ready credit, and no overall security grade or Sysmon coverage is implied. (#417) (@Shirofune-Security)
- Added offline `gpo-package` plan, export and verification for shared advanced audit profiles and the precedence security template. Packages preserve omissions, require explicit expansion of one-sided minimum masks, reject unvalidated zero-mask deployment, and include source/target context, full reviews and verified file hashes. These are deployment components, not GPO backups; genuine GPMC/LGPO preparation and reviewed create-unlinked procedures are documented. Native domain application and event evidence remain separate lab acceptance with no Sigma credit. (#415) (@Shirofune-Security)