Merge pull request #468 from Shirofune-Security/feat/372-reviewed-wmi-recovery

Recover one proven parent-only WMI namespace audit ACE
This commit is contained in:
田中ザック Isaac Mathis authored and GitHub committed 2026-09-22 18:12:25 +09:00
commit 46b88daa3c
13 files changed
+600 -1

No files matched your search

+4
View File
@@ -119,3 +119,7 @@ tests/NativeProviderConfigure.Windows.Tests.ps1 text eol=lf
# Public filesystem-SACL disposable lifecycle evidence.
tests/FileSaclProfileFixture.cs text eol=lf
tests/FileSaclLifecycle.Windows.Tests.ps1 text eol=lf
# Reviewed WMI namespace recovery binds exact source bytes.
/scripts/WmiSaclRecovery.ps1 text eol=lf
/tests/WmiSaclRecovery* text eol=lf
+1 -1
View File
@@ -41,7 +41,7 @@ jobs:
Copy-Item -Recurse -Path ./scripts -Destination release-binaries/
Copy-Item -Recurse -Path ./modules -Destination release-binaries/
New-Item -ItemType Directory -Path release-binaries/docs -Force | Out-Null
Copy-Item -Path ./docs/gpo-audit-packages.md, ./docs/gpo-package-deployment.md, ./docs/gpo-creation.md, ./docs/wmi-probe.md, ./docs/transcript-probe.md, ./docs/powershell-transcription.md, ./docs/firewall-logging.md, ./docs/firewall-logging-recovery.md, ./docs/ipsec-prerequisites.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-ingress.md, ./docs/capi2-probe.md, ./docs/transcription-recovery.md, ./docs/eventlog-recovery.md, ./docs/failed-logon-probe.md, ./docs/wec-listener.md, ./docs/file-access-probe.md, ./docs/applocker-script-probe.md, ./docs/applocker-probe.md, ./docs/selected-sacl-configuration.md, ./docs/registry-sacl-recovery.md, ./docs/targeted-sacl-planning.md, ./docs/native-registry-sacl-validation.md, ./docs/wec-authorization.md, ./docs/channel-recovery.md, ./docs/outgoing-ntlm.md, ./docs/wec-collector-observation.md, ./docs/native-provider-packs.md, ./docs/native-provider-acceptance.md, ./docs/audit-catalog-mappings.md, ./docs/native-token-right-attribution.md, ./docs/audit-notifications.md, ./docs/native-onesettings-acceptance.md, ./docs/ntlm-auditing.md, ./docs/wef-query.md, ./docs/native-filesystem-sacl-validation.md, ./docs/file-sacl-recovery.md, ./docs/dns-client-probe.md -Destination release-binaries/docs/
Copy-Item -Path ./docs/gpo-audit-packages.md, ./docs/gpo-package-deployment.md, ./docs/gpo-creation.md, ./docs/wmi-probe.md, ./docs/firewall-logging.md, ./docs/firewall-logging-recovery.md, ./docs/ipsec-prerequisites.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-ingress.md, ./docs/capi2-probe.md, ./docs/powershell-transcription.md, ./docs/transcription-recovery.md, ./docs/eventlog-recovery.md, ./docs/failed-logon-probe.md, ./docs/wec-listener.md, ./docs/file-access-probe.md, ./docs/applocker-script-probe.md, ./docs/applocker-probe.md, ./docs/selected-sacl-configuration.md, ./docs/registry-sacl-recovery.md, ./docs/targeted-sacl-planning.md, ./docs/native-registry-sacl-validation.md, ./docs/wec-authorization.md, ./docs/channel-recovery.md, ./docs/outgoing-ntlm.md, ./docs/wec-collector-observation.md, ./docs/native-provider-packs.md, ./docs/native-provider-acceptance.md, ./docs/audit-catalog-mappings.md, ./docs/native-token-right-attribution.md, ./docs/audit-notifications.md, ./docs/native-onesettings-acceptance.md, ./docs/ntlm-auditing.md, ./docs/wef-query.md, ./docs/native-filesystem-sacl-validation.md, ./docs/wmi-sacl-recovery.md, ./docs/transcript-probe.md, ./docs/file-sacl-recovery.md, ./docs/dns-client-probe.md -Destination release-binaries/docs/
- name: Set Artifact Name
if: contains(matrix.info.os, 'windows') == true
+55
View File
@@ -0,0 +1,55 @@
name: Reviewed native WMI SACL recovery
on:
push:
branches: ['**']
paths:
- 'WELA.ps1'
- 'scripts/WmiSaclRecovery*'
- 'scripts/WmiNamespace*'
- 'scripts/WmiProbe*'
- 'scripts/Configuration.ps1'
- 'tests/WmiSaclRecovery*'
- '.github/workflows/wmi-sacl-recovery.yml'
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
wmi-sacl-recovery:
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
os: [windows-2022, windows-2025]
engine: [powershell, pwsh]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
- name: Recovery proof and public CLI guards in Windows PowerShell5.1
if: matrix.engine == 'powershell'
shell: powershell
run: |
./tests/WmiSaclRecovery.Tests.ps1
./tests/WmiSaclRecovery.Cli.Tests.ps1
- name: Recovery proof and public CLI guards in PowerShell7
if: matrix.engine == 'pwsh'
shell: pwsh
run: |
./tests/WmiSaclRecovery.Tests.ps1
./tests/WmiSaclRecovery.Cli.Tests.ps1
- name: Public Configure and Recover on owned namespaces in Windows PowerShell5.1
if: matrix.engine == 'powershell'
shell: powershell
run: ./tests/WmiSaclRecovery.Windows.Tests.ps1 -AllowDisposableNamespaceWrite
- name: Public Configure and Recover on owned namespaces in PowerShell7
if: matrix.engine == 'pwsh'
shell: pwsh
run: ./tests/WmiSaclRecovery.Windows.Tests.ps1 -AllowDisposableNamespaceWrite
- name: Preserve native evidence and independent cleanup
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: wmi-sacl-recovery-${{ matrix.os }}-${{ matrix.engine }}
path: ${{ runner.temp }}/wela-wmi-recovery-*/
retention-days: 14
if-no-files-found: error
+2
View File
@@ -6,6 +6,8 @@
**改善:**
- 追加が証明された親名前空間のみの明示的な監査ACEを1つ削除する `wmi-sacl-recovery` の Plan/Recover を追加しました。完了済み設定の整合する記録、現在の完全な記述子、レビュー済みハッシュ、監査縮小への明示的同意を必須とし、他の記述子情報・ACE・トークン権限を保持します。部分書き込みの証跡と所有する実機名前空間の後始末を検証し、過去の名前空間・操作者の同一性やイベント・Sigmaの準備完了は保証しません。 (関連 #372, #365) (@Shirofune-Security)
- 既に有効なローカル保存先で、現在の実行ユーザーによる固定 Windows PowerShell 5.1 子プロセスの完了した自動トランスクリプトを検証する `transcript-probe` を追加しました。実トークン・ログオン、標準ヘッダー/フッター、nonce・PID・時刻、件数を制限したファイル識別と出典確認により不明な結果を保持し、ポリシーやACLの変更・明示的トランスクリプト開始・Sigma/EVTX加算は行いません。Server 2022/2025と両WELA実行エンジン向けに、使い捨て標準ユーザーの成功・拒否テストを追加しました。 (#436) (@Shirofune-Security)
- `file-sacl-recovery` を追加しました。元の計画、変更前後のレシート、成功結果、ソースとホストのハッシュ、実際のファイル識別情報を照合し、変更されていない選択済み単一ファイルに追加した監査 ACE だけを明示的に削除できます。書き込み前の永続記録と SACL 限定の変更・再読込により、無関係な ACE のバイト列と個数、観測した他の記述子要素を保持します。空または null の SACL が存在フラグ付きで残る場合を区別し、元の記述子との完全一致、ポリシー・ディレクトリ・レジストリの復旧、Sigma の利用可能性は保証しません。(#437) (@Shirofune-Security)
+2
View File
@@ -6,6 +6,8 @@
**Improvements:**
- Add reviewed `wmi-sacl-recovery` Plan/Recover for one proven explicit parent-only namespace audit ACE. Require matching completed configuration evidence, current full descriptor, reviewed hash and explicit audit-reduction consent; preserve all other descriptor fields, remaining ACEs and token privileges, retain partial-write evidence, and test owned native namespace cleanup. Historical namespace/operator identity and event/Sigma readiness remain unclaimed. (Related #372, #365) (@Shirofune-Security)
- Added opt-in `transcript-probe` to verify a fixed current-account Windows PowerShell 5.1 child produces its own completed automatic transcript in an already enabled local destination. Actual token/logon, native header/footer, nonce/PID/time, bounded file identity and source checks preserve unverified outcomes without changing policy or ACLs, invoking explicit transcription or granting Sigma/EVTX credit. Added disposable standard-writer success/denial tests for Server 2022/2025 under both WELA host engines. (#436) (@Shirofune-Security)
- Added opt-in `file-sacl-recovery` to review and remove one proven explicit audit ACE from an unchanged selected leaf file. Original plans, paired receipts, successful results, source/host hashes and held file identity bind the operation; durable pre-write evidence and SACL-only readback preserve unrelated ACE bytes/counts and observed descriptor components. Empty or null present SACLs are reported without claiming exact historical descriptor restoration; policy, directory/registry recovery and Sigma readiness remain outside this scope. (#437) (@Shirofune-Security)
+18
View File
@@ -58,6 +58,14 @@
[ValidateSet('Audit', 'Plan', 'Import')][string]$AppLockerAction = 'Audit',
[string]$AppLockerPolicyPath,
[ValidateSet('List', 'Audit', 'Plan', 'Configure')][string]$WmiAction = 'List',
[ValidateSet('Plan','Recover')][string]$WmiRecoveryAction = 'Plan',
[string]$WmiRecoveryNamespace,
[string]$WmiRecoveryJournalPath,
[string]$WmiRecoveryOriginalResultsPath,
[string]$WmiRecoveryPlanPath,
[string]$WmiRecoveryPlanHash,
[string]$WmiRecoveryOutputPath,
[switch]$WmiRecoveryAllowAuditReduction,
[ValidateSet('Plan','Run')][string]$DnsClientProbeAction = 'Plan',
[string]$DnsClientProbeResolver,
[string]$DnsClientProbeOutputPath,
@@ -276,6 +284,7 @@ $SaclTargetsPath = Join-Path $ScriptRoot "config/audit_sacl_targets.json"
. (Join-Path $ScriptRoot "scripts/AppLockerProbe.ps1")
. (Join-Path $ScriptRoot "scripts/AppLockerScriptProbe.ps1")
. (Join-Path $ScriptRoot "scripts/WmiNamespaceAuditing.ps1")
. (Join-Path $ScriptRoot "scripts/WmiSaclRecovery.ps1")
. (Join-Path $ScriptRoot "scripts/WmiProbe.ps1")
. (Join-Path $ScriptRoot "scripts/DnsClientProbe.ps1")
. (Join-Path $ScriptRoot "scripts/FileAccessProbe.ps1")
@@ -2048,6 +2057,7 @@ Usage:
./WELA.ps1 retention-health -ResultsPath source-retention.json
./WELA.ps1 retention-health -RetentionConfigPath collector-health.json -HtmlPath retention.html
# Native channels only; ACL changes require -GrantEventLogReaders. Forwarding identity access needs a separate test.
./WELA.ps1 wmi-sacl-recovery -Help # Review removal of one proven parent-only WMI audit ACE
./WELA.ps1 wmi-auditing -WmiAction List
./WELA.ps1 wmi-auditing -WmiAction Plan -WmiNamespace root\cimv2 -ResultsPath wmi-plan.json
./WELA.ps1 wmi-auditing -WmiAction Configure -WmiNamespace root\cimv2 -DryRun
@@ -2249,6 +2259,8 @@ if ($Cmd -ne 'capi2-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -li
if ($Cmd -eq 'capi2-probe' -and ($args.Count -or @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','Capi2ProbeAction','Capi2ProbeOutputPath','Capi2ProbeTimeoutSeconds','Help')}).Count)) {throw 'capi2-probe accepts only dedicated probe options.'}
if ($Cmd -ne 'failed-logon-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'FailedLogon*'}).Count) {throw 'FailedLogon options require failed-logon-probe.'}
if ($Cmd -eq 'failed-logon-probe' -and ($args.Count -or @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','FailedLogonAction','FailedLogonOutputPath','FailedLogonTimeoutSeconds','Help')}).Count)) {throw 'failed-logon-probe accepts only dedicated probe options.'}
if ($Cmd -ne 'wmi-sacl-recovery' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'WmiRecovery*'}).Count) {throw 'WmiRecovery options require wmi-sacl-recovery.'}
if ($Cmd -eq 'wmi-sacl-recovery' -and ($args.Count -or @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','WmiRecoveryAction','WmiRecoveryNamespace','WmiRecoveryJournalPath','WmiRecoveryOriginalResultsPath','WmiRecoveryPlanPath','WmiRecoveryPlanHash','WmiRecoveryOutputPath','WmiRecoveryAllowAuditReduction','Help')}).Count)) {throw 'wmi-sacl-recovery accepts only dedicated recovery options.'}
if ($Cmd -ne 'wmi-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'WmiProbe*'}).Count) {throw 'WmiProbe options require wmi-probe.'}
if ($Cmd -eq 'wmi-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','WmiProbeAction','WmiProbeNamespace','WmiProbeOutputPath','WmiProbeTimeoutSeconds','Help')}).Count) {throw 'wmi-probe accepts only dedicated probe options.'}
if ($Cmd -ne 'applocker-script-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'AppLockerScript*'}).Count) {throw 'AppLockerScript options require applocker-script-probe.'}
@@ -2576,6 +2588,12 @@ switch ($Cmd.ToLower()) {
$report
if($report.ExitCode){exit $report.ExitCode}
}
'wmi-sacl-recovery' {
if ($Help) {Write-Host 'Usage: wmi-sacl-recovery -WmiRecoveryAction Plan|Recover [-WmiRecoveryNamespace exact-local-namespace -WmiRecoveryJournalPath before.jsonl -WmiRecoveryOriginalResultsPath completed.json] [-WmiRecoveryPlanPath reviewed-plan.json -WmiRecoveryPlanHash SHA256 -WmiRecoveryAllowAuditReduction] -WmiRecoveryOutputPath new-private-directory. Removes one proven parent-only success audit ACE; no whole descriptor rollback or Sigma credit. See docs/wmi-sacl-recovery.md.';return}
$report=Invoke-WelaWmiSaclRecovery -Action $WmiRecoveryAction -Namespace $WmiRecoveryNamespace -JournalPath $WmiRecoveryJournalPath -OriginalResultsPath $WmiRecoveryOriginalResultsPath -PlanPath $WmiRecoveryPlanPath -PlanHash $WmiRecoveryPlanHash -OutputPath $WmiRecoveryOutputPath -AllowAuditReduction:$WmiRecoveryAllowAuditReduction
$report
if($report.ExitCode){exit $report.ExitCode}
}
'wmi-probe' {
if ($Help) {Write-Host 'Usage: wmi-probe [-WmiProbeAction Plan|Run] -WmiProbeNamespace root\default [-WmiProbeOutputPath new-private-directory] [-WmiProbeTimeoutSeconds 1..30]. Fixed local read only; requires existing matching SACL and auditing. No policy changes, remote access or Sigma credit. See docs/wmi-probe.md.';return}
$report=Invoke-WelaWmiProbe -Action $WmiProbeAction -Namespace $WmiProbeNamespace -OutputPath $WmiProbeOutputPath -TimeoutSeconds $WmiProbeTimeoutSeconds
+43
View File
@@ -0,0 +1,43 @@
# Reviewed WMI namespace SACL recovery
`wmi-sacl-recovery` removes one explicit, parent-only success audit ACE proven to have been added by a completed `wmi-auditing Configure` operation. It advances #372 and #365 without closing their broader auditing and recovery acceptance work. Sysmon is excluded.
## Review and recover
Keep the trusted original backup `before.jsonl` and successful result JSON. Select the exact canonical namespace used by that operation. There must have been exactly one missing ordinary parent-only success ACE; inherited/inheritable additions, multiple additions, failed/partial operations, source-profile changes and a changed current descriptor require manual assessment.
```powershell
./WELA.ps1 wmi-sacl-recovery `
-WmiRecoveryNamespace 'root\default' `
-WmiRecoveryJournalPath C:\Evidence\original\before.jsonl `
-WmiRecoveryOriginalResultsPath C:\Evidence\completed.json `
-WmiRecoveryOutputPath C:\Evidence\recovery-review
# Review plan.json and obtain its SHA-256 independently before authorizing recovery.
$reviewedHash = (Get-FileHash C:\Evidence\recovery-review\plan.json -Algorithm SHA256).Hash.ToLowerInvariant()
./WELA.ps1 wmi-sacl-recovery -WmiRecoveryAction Recover `
-WmiRecoveryPlanPath C:\Evidence\recovery-review\plan.json `
-WmiRecoveryPlanHash $reviewedHash `
-WmiRecoveryAllowAuditReduction `
-WmiRecoveryOutputPath C:\Evidence\recovery-result
```
Both output directories must be new, local fixed-drive directories. `Plan` reads native state and writes evidence only. `Recover` reconstructs the plan from the original records, verifies its hash and requires explicit audit-reduction consent. It rejects unrelated CLI options, including `Auto`, `DryRun`, arbitrary registry settings and namespace inheritance switches. It never enables audit policy or starts services.
## Evidence and preservation
The result must contain one unique Applied namespace control whose typed target, before snapshot and desired definitions exactly match the journal. The current complete native descriptor must match its recorded After state. The proof checks all original descriptor properties and SACL-entry multiplicities, permits only the original SACL-present transition, and identifies one previously absent explicit success ACE. Unknown added-ACE fields, duplicate matching additions and propagation-request control flags are refused; unrelated existing entries remain opaque and preserved.
Review plans bind the actual computer/build/role/MachineGuid, current logon, group attributes, full privilege inventory, all 59 audit masks, typed precedence, running services, PowerShell executable and installed implementation hashes. Recovery checks these values and the original records again before and after writing. Only the selected ACE is removed from the held native descriptor. The provider request omits owner, group and DACL updates; every retained field and remaining ACE order must match native readback and an independent reopened observation. The temporary `SeSecurityPrivilege` adjustment must restore the original token state.
The new SACL array is explicitly non-null, including when it is empty: Microsoft's [SetSecurityDescriptor contract](https://learn.microsoft.com/en-us/windows/win32/wmisdk/setsecuritydescriptor-method-in-class---systemsecurity) says a null SACL leaves the existing SACL unchanged. The same contract specifies how SACL-only requests preserve owner/group/DACL fields. An empty present SACL may be represented differently from the original absent SACL; `HistoricalDescriptorMatches` reports observed equality separately from successful removal. See also the [security descriptor control definitions](https://learn.microsoft.com/en-us/previous-versions/windows/desktop/secrcw32prov/win32-securitydescriptor).
`pending.json` is flushed before the native call. `after.json`, `confirmed.json` and `manifest.json` retain the observed outcome and artifact hashes. A possible write followed by an error or drift is `WriteAttemptedUnverified`, never a claim that nothing changed. Replaying a completed old plan is refused. Preserve partial evidence and investigate the current native descriptor before taking further action.
## Limits and native validation
Version 1 configuration journals record the historical computer name, not a durable namespace identifier, operator authentication or implementation fingerprint. Current source hashes cannot retroactively prove those missing historical facts. The original records must be trusted: a matching hash does not authenticate their author. Windows WMI provides no atomic compare-and-swap for the full security descriptor; an identical namespace recreation or competing ACL writer cannot be excluded. Quiesce competing namespace ACL writers. This command neither restores a whole historical descriptor nor owns descendant ACEs.
The disposable Windows workflow exercises Server 2022/2025 and Windows PowerShell 5.1/PowerShell 7. It creates fresh owned namespaces, redirects only the canonical namespace entry in an owned copied checkout, and runs the actual public Configure/Plan/Recover commands. It verifies sole-ACE and unrelated-ACE recovery, missing-consent refusal, replay refusal, source/artifact hashes and independent cleanup. The production CLI has no arbitrary namespace or fixture bypass. The original root/default namespaces, root namespace inventory, service settings, audit masks, precedence and full parent token are checked independently. Exact current-head native results are recorded in the PR; portable tests alone do not establish Windows behavior.
Windows 11, domain-controller/AD CS deployments, descendant changes, cross-host recovery, event generation, forwarding and Sigma readiness are separate acceptance work. Recovery always grants zero rule-readiness credit.
+199
View File
@@ -0,0 +1,199 @@
# Reviewed removal of one proven, parent-only WMI success audit ACE.
function Get-WelaWmiRecoveryKey {param($Value) ConvertTo-Json -InputObject $Value -Depth 40 -Compress}
function Assert-WelaWmiRecoveryText {param($Value,[string[]]$Fields) foreach($field in $Fields){if($Value.$field -isnot [string]){throw "Missing or mistyped WMI recovery text: $field"}}}
function Assert-WelaWmiRecoveryInteger {param($Value) if($Value -isnot [int] -and $Value -isnot [long] -and $Value -isnot [uint32]){throw 'WMI recovery requires an integer.'}}
function Get-WelaWmiRecoverySources {
$sources=[ordered]@{}
foreach($path in @('WELA.ps1','scripts/WmiSaclRecovery.ps1','scripts/WmiNamespaceAuditing.ps1','scripts/WmiProbe.ps1','scripts/WmiProbeNative.cs','scripts/Configuration.ps1','scripts/ChannelRead.ps1','scripts/ChannelReadNative.cs','scripts/WefArrival.ps1','scripts/WecUpdate.ps1','scripts/ControlApplicability.ps1','modules/AuditProfiles.psm1','scripts/CustomAuditProfiles.ps1','config/audit_profiles.json')){
$sources[$path]=(Get-FileHash -LiteralPath (Join-Path $script:ScriptRoot $path) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()
}
if(Test-Path -LiteralPath (Join-Path $script:ScriptRoot 'scripts/WmiNamespaceDescendants.ps1')){$sources['scripts/WmiNamespaceDescendants.ps1']=(Get-FileHash -LiteralPath (Join-Path $script:ScriptRoot 'scripts/WmiNamespaceDescendants.ps1') -Algorithm SHA256).Hash.ToLowerInvariant()}
[pscustomobject]$sources
}
function Get-WelaWmiRecoveryTokenKey {Initialize-WelaWmiProbeNative;Get-WelaWmiProbeTokenKey ([Wela.WmiProbe.Native]::Snapshot())}
function Get-WelaWmiRecoveryContext {
if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'WMI SACL recovery requires native 64-bit Windows.'}
$services=[ordered]@{}
foreach($name in @('Winmgmt','EventLog')){if((Get-Service $name -ErrorAction Stop).Status -ne 'Running'){throw 'Winmgmt and EventLog must already be running; recovery starts no services.'};$services[$name]='Running'}
$hostState=Get-WelaChannelReadHost
$machine=Get-WelaRegistryState 'HKLM:\SOFTWARE\Microsoft\Cryptography' MachineGuid;$guid=[guid]::Empty
if(-not $machine.ValueExists -or $machine.Type -cne 'String' -or -not [guid]::TryParse([string]$machine.Value,[ref]$guid) -or $guid -eq [guid]::Empty){throw 'Actual machine identity is unavailable.'}
$identity=[Security.Principal.WindowsIdentity]::GetCurrent()
try{if(-not ([Security.Principal.WindowsPrincipal]::new($identity)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){throw 'WMI SACL recovery requires an elevated operator.'}}finally{$identity.Dispose()}
$masks=Get-WelaEffectiveAuditPolicy;$orderedMasks=[ordered]@{};foreach($id in @($masks.Keys|Sort-Object)){$orderedMasks[$id]=$masks[$id]}
if($orderedMasks.Count -ne 59){throw 'Complete 59-subcategory audit policy observation is required.'}
$engine=(Get-Process -Id $PID -ErrorAction Stop).Path
[pscustomobject][ordered]@{Host=$hostState;MachineGuid=$guid.ToString();Services=[pscustomobject]$services;AuditMasks=[pscustomobject]$orderedMasks;Precedence=(Get-WelaRegistryState 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' SCENoApplyLegacyAuditPolicy);Engine=$engine;EngineHash=(Get-FileHash -LiteralPath $engine -Algorithm SHA256).Hash.ToLowerInvariant();TokenKey=(Get-WelaWmiRecoveryTokenKey)}
}
function ConvertFrom-WelaWmiRecoveryDescriptor {
param($Snapshot,[string]$Namespace)
Assert-WelaArrivalObject $Snapshot @('Namespace','DescriptorJson','DescriptorMof','SaclReadPrivilege')
Assert-WelaWmiRecoveryText $Snapshot @('Namespace','DescriptorJson','DescriptorMof','SaclReadPrivilege')
if($Snapshot.Namespace -cne $Namespace -or -not $Snapshot.DescriptorMof -or $Snapshot.DescriptorMof.Length -gt 1048576 -or -not $Snapshot.DescriptorJson -or $Snapshot.DescriptorJson.Length -gt 1048576 -or $Snapshot.SaclReadPrivilege -cne 'SeSecurityPrivilege enabled'){throw 'A complete original privileged namespace snapshot is required.'}
$value=ConvertFrom-WelaArrivalJson $Snapshot.DescriptorJson
if($value -isnot [pscustomobject] -or @($value.PSObject.Properties).Count -gt 32){throw 'Invalid namespace descriptor.'}
foreach($name in @('ControlFlags','Owner','Group','DACL','SACL')){if(-not $value.PSObject.Properties[$name]){throw 'Incomplete namespace descriptor fields.'}}
Assert-WelaWmiRecoveryInteger $value.ControlFlags
if($value.ControlFlags -lt 0 -or $value.ControlFlags -gt 65535 -or ($value.ControlFlags -band 768) -ne 0){throw 'Unknown or propagation-request descriptor controls require manual recovery.'}
foreach($name in @('DACL','SACL')){if($null -ne $value.$name -and ($value.$name -isnot [array] -or $value.$name.Count -gt 1024)){throw 'Descriptor ACL must be a bounded array or null.'}}
if($null -ne $value.SACL){foreach($ace in $value.SACL){if($null -eq $ace -or $ace -isnot [pscustomobject]){throw 'Null or mistyped SACL entries require manual recovery.'}}}
$value
}
function Get-WelaWmiRecoveryDescriptorOutsideKey {
param($Descriptor,[switch]$Addition)
$outside=[ordered]@{}
foreach($p in $Descriptor.PSObject.Properties){if($p.Name -ceq 'SACL'){continue};$outside[$p.Name]=if($p.Name -ceq 'ControlFlags' -and $Addition){[uint32]$p.Value -bor 16}else{$p.Value}}
Get-WelaWmiRecoveryKey $outside
}
function Get-WelaWmiRecoveryAddition {
param($Before,$After,[array]$Definitions)
if((Get-WelaWmiRecoveryDescriptorOutsideKey $Before -Addition) -cne (Get-WelaWmiRecoveryDescriptorOutsideKey $After)){throw 'Original operation changed descriptor fields outside the permitted SACL addition.'}
$missing=@(Get-WelaWmiMissingAces $Before $Definitions)
if($missing.Count -ne 1 -or $missing[0].AceFlags -ne 64 -or $missing[0].AceType -ne 2){throw 'Exactly one missing parent-only ordinary success audit ACE is recoverable.'}
if(@(Get-WelaWmiMissingAces $After $Definitions).Count){throw 'Completed descriptor lacks a requested audit ACE.'}
$remaining=New-Object 'System.Collections.Generic.List[string]'
foreach($ace in @($After.SACL)){$remaining.Add((Get-WelaWmiRecoveryKey $ace))}
foreach($ace in @($Before.SACL|Where-Object {$null -ne $_})){if(-not $remaining.Remove((Get-WelaWmiRecoveryKey $ace))){throw 'An original SACL entry was removed or modified.'}}
if($remaining.Count -ne 1){throw 'Completed operation must add exactly one unchanged explicit audit ACE.'}
$added=ConvertFrom-WelaArrivalJson $remaining[0]
if(-not (Test-WelaWmiAceMatch $added $missing[0]) -or @($Before.SACL|Where-Object {(Get-WelaWmiRecoveryKey $_) -ceq $remaining[0]}).Count -ne 0 -or @($After.SACL|Where-Object {(Get-WelaWmiRecoveryKey $_) -ceq $remaining[0]}).Count -ne 1){throw 'Added ACE identity or multiplicity is ambiguous.'}
foreach($name in @('AceType','AceFlags','AccessMask')){Assert-WelaWmiRecoveryInteger $added.$name}
foreach($p in $added.PSObject.Properties){if($p.Name -cnotin @('AccessMask','AceFlags','AceType','GuidObjectType','GuidInheritedObjectType','Trustee','TIME_CREATED')){throw 'Unknown added ACE fields require manual recovery.'}}
if($added.Trustee -isnot [pscustomobject] -or $added.AceFlags -ne 64 -or $added.AceType -ne 2 -or $added.AccessMask -lt 1 -or $added.GuidObjectType -or $added.GuidInheritedObjectType -or $added.TIME_CREATED){throw 'Only one ordinary, explicit, parent-only audit addition is supported.'}
$added
}
function Get-WelaWmiRecoveryExpectedDescriptor {
param($Current,[string]$AddedAceJson)
$target=ConvertFrom-WelaArrivalJson (Get-WelaWmiRecoveryKey $Current);$indices=@()
for($i=0;$i -lt @($Current.SACL).Count;$i++){if((Get-WelaWmiRecoveryKey $Current.SACL[$i]) -ceq $AddedAceJson){$indices+=,$i}}
if($indices.Count -ne 1){throw 'Current SACL must contain the exact added ACE once.'}
$target.SACL=@(for($i=0;$i -lt $Current.SACL.Count;$i++){if($i -ne $indices[0]){$Current.SACL[$i]}})
$target
}
function Assert-WelaWmiRecoveryRemoved {
param($Expected,$Actual)
if((Get-WelaWmiRecoveryDescriptorOutsideKey $Actual) -cne (Get-WelaWmiRecoveryDescriptorOutsideKey $Expected)){throw 'WMI recovery changed a preserved descriptor property.'}
# A provider may represent a newly empty present SACL as null. Do not claim
# historical descriptor equality; null sent to SetSecurityDescriptor is never used.
if(@($Expected.SACL).Count -eq 0 -and $null -eq $Actual.SACL){return}
if((Get-WelaWmiRecoveryKey $Actual.SACL) -cne (Get-WelaWmiRecoveryKey $Expected.SACL)){throw 'WMI recovery failed to remove only the proven ACE while preserving all remaining ACEs and their order.'}
}
function New-WelaWmiRecoveryPlan {
param([string]$JournalPath,[string]$OriginalResultsPath,[string]$Namespace)
Assert-WelaWmiProbeNamespace $Namespace
$definitions=@(Get-WelaWmiAuditDefinitions -Namespace @($Namespace))
if(-not $definitions.Count -or @($definitions|Where-Object {$_.AceFlags -ne 64 -or $_.Namespace -cne $Namespace}).Count){throw 'Select one exact canonical parent-only namespace.'}
$context=Get-WelaWmiRecoveryContext;$sources=Get-WelaWmiRecoverySources
$journal=Read-WelaWecUpdateFile $JournalPath;$file=Read-WelaWecUpdateFile $OriginalResultsPath
if($journal.Path -ieq $file.Path){throw 'Original journal and completed results must be distinct files.'}
$lines=@($journal.Text -split '\r?\n'|Where-Object {$_ -match '\S'})
if($lines.Count -lt 1 -or $lines.Count -gt 128){throw 'Expected a bounded original configuration journal.'}
$entries=@($lines|ForEach-Object {ConvertFrom-WelaArrivalJson $_});$result=ConvertFrom-WelaArrivalJson $file.Text
Assert-WelaWmiRecoveryText $result @('Scope','BackupPath')
foreach($field in @('ExitCode','Failed','Skipped')){Assert-WelaWmiRecoveryInteger $result.$field}
if($result.Scope -cne 'wmi-namespace-sacl-only' -or $result.ExitCode -ne 0 -or $result.Failed -ne 0 -or $result.Skipped -ne 0 -or $result.DryRun -isnot [bool] -or $result.DryRun -or $result.Results -isnot [array] -or $result.Results.Count -lt 1 -or $result.Results.Count -gt 5){throw 'Require successful completed, non-dry-run WMI-only configuration results.'}
if((Resolve-WelaArrivalPath (Join-Path $result.BackupPath 'before.jsonl')) -ine $journal.Path){throw 'Journal path differs from the recorded backup directory.'}
$id='WmiNamespace/'+$Namespace+'/SACL';$seen=@{}
foreach($row in $result.Results){Assert-WelaWmiRecoveryText $row @('Id');if($seen.ContainsKey($row.Id)){throw 'Duplicate original result ID.'};$seen[$row.Id]=$true}
$rows=@($result.Results|Where-Object Id -ceq $id);$matching=@($entries|Where-Object Id -ceq $id)
if($rows.Count -ne 1 -or $matching.Count -ne 1){throw 'Exactly one completed Applied namespace result and original journal entry are required.'}
$row=$rows[0];$entry=$matching[0]
Assert-WelaArrivalObject $row @('Id','Kind','Target','Desired','Before','After','Status','Diagnostic')
Assert-WelaArrivalObject $entry @('Version','ComputerName','RecordedUtc','Id','Kind','Target','Before','Desired')
Assert-WelaWmiRecoveryText $row @('Id','Kind','Status','Diagnostic');Assert-WelaWmiRecoveryText $entry @('ComputerName','RecordedUtc','Id','Kind');Assert-WelaWmiRecoveryInteger $entry.Version
if($row.Kind -cne 'WmiNamespaceSacl' -or $row.Status -cne 'Applied' -or $entry.Kind -cne 'WmiNamespaceSacl' -or $entry.Version -ne 1 -or $entry.ComputerName -ine $context.Host.Computer -or (ConvertTo-WelaArrivalUtc $entry.RecordedUtc) -gt [DateTimeOffset]::UtcNow.AddMinutes(1)){throw 'Original operation is not a completed Applied namespace addition on this host.'}
foreach($field in @('Before','Desired','Target')){if((Get-WelaWmiRecoveryKey $entry.$field) -cne (Get-WelaWmiRecoveryKey $row.$field)){throw 'Original journal and result evidence disagree.'}}
Assert-WelaArrivalObject $row.Target @('Namespace','Computer','Operation');Assert-WelaWmiRecoveryText $row.Target @('Namespace','Computer','Operation')
if($row.Target.Namespace -cne $Namespace -or $row.Target.Computer -cne 'Local' -or $row.Target.Operation -cne 'Append audit ACEs only' -or $row.Desired -isnot [array] -or (Get-WelaWmiRecoveryKey $row.Desired) -cne (Get-WelaWmiRecoveryKey $definitions)){throw 'Original target/definitions differ from the current canonical parent-only profile.'}
$before=ConvertFrom-WelaWmiRecoveryDescriptor $row.Before $Namespace;$after=ConvertFrom-WelaWmiRecoveryDescriptor $row.After $Namespace
$added=Get-WelaWmiRecoveryAddition $before $after $definitions
$current=Get-WelaWmiNamespaceSnapshot $Namespace;$currentData=ConvertFrom-WelaWmiRecoveryDescriptor $current $Namespace
if((Get-WelaWmiRecoveryKey $currentData) -cne (Get-WelaWmiRecoveryKey $after)){throw 'Current full namespace descriptor differs from completed After; manual assessment is required.'}
$plan=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaWmiSaclRecoveryPlan';Namespace=$Namespace;Context=$context;Sources=$sources;Journal=[pscustomobject]@{Path=$journal.Path;Sha256=$journal.Hash};OriginalResults=[pscustomobject]@{Path=$file.Path;Sha256=$file.Hash};BeforeAddition=$row.Before;Expected=$current;AddedAce=$added;RequiresAuditReductionConsent=$true;HistoricalBinding='Version1 journals record ComputerName, not durable namespace identity, historical operator or source hashes. Hashes do not authenticate an untrusted receipt author. Identical namespace recreation and concurrent descriptor writes are not excluded.';Outcome='Remove one proven explicit parent-only success audit ACE. Preserve every remaining descriptor property and ACE; empty present SACL representation can differ from the pre-addition descriptor.';ReadyRuleCredit=0}
Assert-WelaWmiRecoveryBindings $plan
$plan
}
function Assert-WelaWmiRecoveryBindings {
param($Plan)
if((Get-WelaWmiRecoveryKey (Get-WelaWmiRecoveryContext)) -cne (Get-WelaWmiRecoveryKey $Plan.Context) -or (Get-WelaWmiRecoveryKey (Get-WelaWmiRecoverySources)) -cne (Get-WelaWmiRecoveryKey $Plan.Sources)){throw 'Current host, logon, token, policy, service or installed source changed.'}
foreach($inputFile in @($Plan.Journal,$Plan.OriginalResults)){if((Read-WelaWecUpdateFile $inputFile.Path).Hash -cne $inputFile.Sha256){throw 'Original recovery evidence changed.'}}
}
function Remove-WelaWmiRecoveryAce {
param($Plan,$State)
Initialize-WelaWmiInterop
$token=Get-WelaWmiRecoveryTokenKey;$privilege=$null;$connection=$null;$updated=$null
try {
$privilege=New-Object Wela.WmiSecurityPrivilege;$connection=New-WelaWmiConnection $Plan.Namespace
$descriptor=Get-WelaWmiNativeDescriptor $connection;$data=ConvertTo-WelaWmiData $descriptor
if((Get-WelaWmiRecoveryKey $data) -cne (Get-WelaWmiRecoveryKey (ConvertFrom-WelaArrivalJson $Plan.Expected.DescriptorJson))){throw 'Held native namespace descriptor changed immediately before removal.'}
$aceKey=Get-WelaWmiRecoveryKey $Plan.AddedAce;$expected=Get-WelaWmiRecoveryExpectedDescriptor $data $aceKey
$remaining=@($descriptor.SACL|Where-Object {(Get-WelaWmiRecoveryKey (ConvertTo-WelaWmiData $_)) -cne $aceKey})
$updated=$descriptor.Clone();$updated.SACL=[System.Management.ManagementBaseObject[]]$remaining
if($null -eq $updated.SACL){throw 'Native provider did not retain the explicit empty SACL array; null cannot remove an ACE.'}
$updated.DACL=$null;$updated.Owner=$null;$updated.Group=$null
$updated.ControlFlags=([uint32]$descriptor.ControlFlags -band [uint32]4294967291) -bor [uint32]16
$parameters=$connection.GetMethodParameters('SetSecurityDescriptor');$parameters.Descriptor=$updated
$State.WriteAttempted=$true
$response=$connection.InvokeMethod('SetSecurityDescriptor',$parameters,$null)
Assert-WelaWmiReturnCode $response 'SetSecurityDescriptor'
$readback=Get-WelaWmiNativeDescriptor $connection;$after=ConvertTo-WelaWmiData $readback
$State.After=[pscustomobject]@{Namespace=$Plan.Namespace;DescriptorJson=(ConvertTo-WelaWmiJson $after);DescriptorMof=$readback.GetText([System.Management.TextFormat]::Mof);SaclReadPrivilege='SeSecurityPrivilege enabled'}
Assert-WelaWmiRecoveryRemoved $expected $after
}finally{
try{if($updated){$updated.Dispose()};if($connection){$connection.Dispose()}}finally{if($privilege){$privilege.Dispose()}}
if((Get-WelaWmiRecoveryTokenKey) -cne $token){throw 'Native recovery did not preserve the full original token authorization and privileges.'}
}
}
function Invoke-WelaWmiSaclRecovery {
param([ValidateSet('Plan','Recover')][string]$Action='Plan',[string]$JournalPath,[string]$OriginalResultsPath,[string]$Namespace,[string]$PlanPath,[string]$PlanHash,[string]$OutputPath,[switch]$AllowAuditReduction)
$ErrorActionPreference='Stop'
if($Action -eq 'Plan'){
if(-not $JournalPath -or -not $OriginalResultsPath -or -not $Namespace -or -not $OutputPath -or $PlanPath -or $PlanHash -or $AllowAuditReduction){throw 'Plan requires original journal/results, exact namespace and a new output directory only.'}
}elseif(-not $PlanPath -or $PlanHash -cnotmatch '^[a-f0-9]{64}$' -or -not $OutputPath -or $JournalPath -or $OriginalResultsPath -or $Namespace){throw 'Recover requires only reviewed plan/hash, a new output directory and explicit audit-reduction consent.'}
$output=New-WelaArrivalOutput $OutputPath $script:ScriptRoot
$report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaWmiSaclRecovery';Action=$Action;Status='Refused';ExitCode=1;PlanHash=$null;WriteAttempted=$false;Before=$null;After=$null;HistoricalDescriptorMatches=$false;Artifacts=@();OutputPath=$output;Diagnostic='';ReadyRuleCredit=0;PolicyChanges=0;Scope='One proven explicit parent-only WMI success audit ACE. No whole-descriptor rollback, durable historical namespace identity, descendant propagation, event or Sigma claim.'}
$state=[pscustomobject]@{WriteAttempted=$false;After=$null}
try {
if($Action -eq 'Plan'){
$plan=New-WelaWmiRecoveryPlan $JournalPath $OriginalResultsPath $Namespace
$artifact=Write-WelaWecUpdateArtifact $output 'plan.json' (Get-WelaWmiRecoveryKey $plan);$report.Artifacts+=$artifact;$report.PlanHash=$artifact.Sha256
Assert-WelaWmiRecoveryBindings $plan
if((Get-WelaWmiNamespaceSnapshot $plan.Namespace).DescriptorJson -cne $plan.Expected.DescriptorJson){throw 'Namespace descriptor changed while saving the review plan.'}
$report.Status='ReviewRequired';$report.ExitCode=0
}else{
$inputFile=Read-WelaWecUpdateFile $PlanPath
if($inputFile.Hash -cne $PlanHash){throw 'Reviewed plan hash differs.'}
$plan=ConvertFrom-WelaArrivalJson $inputFile.Text
Assert-WelaWmiRecoveryText $plan @('Kind','Namespace')
if($plan.Kind -cne 'WelaWmiSaclRecoveryPlan'){throw 'Unsupported WMI recovery plan kind.'}
$rebuilt=New-WelaWmiRecoveryPlan $plan.Journal.Path $plan.OriginalResults.Path $plan.Namespace
if((Get-WelaWmiRecoveryKey $plan) -cne (Get-WelaWmiRecoveryKey $rebuilt)){throw 'Reviewed recovery plan is stale or modified.'}
if(-not $AllowAuditReduction){throw 'Recover requires explicit AllowAuditReduction; the proven audit ACE will be removed.'}
$report.PlanHash=$PlanHash;$report.Before=$plan.Expected
$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'reviewed-plan.json' $inputFile.Text
$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'pending.json' (Get-WelaWmiRecoveryKey ([pscustomobject]@{Kind='WelaWmiSaclRecoveryIntent';State='Pending';PlanHash=$PlanHash;Namespace=$plan.Namespace;Expected=$plan.Expected;RemoveAce=$plan.AddedAce;AllowAuditReduction=[bool]$AllowAuditReduction;RecordedUtc=[DateTime]::UtcNow.ToString('o')}))
Assert-WelaWmiRecoveryBindings $plan
if((Read-WelaWecUpdateFile $PlanPath).Hash -cne $PlanHash){throw 'Reviewed plan changed before native removal.'}
foreach($artifact in $report.Artifacts){if((Read-WelaWecUpdateFile (Join-Path $output $artifact.Name)).Hash -cne $artifact.Sha256){throw 'Saved recovery evidence changed before native removal.'}}
Remove-WelaWmiRecoveryAce $plan $state
$report.After=$state.After
$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'after.json' (Get-WelaWmiRecoveryKey $state.After)
Assert-WelaWmiRecoveryBindings $plan
$current=Get-WelaWmiNamespaceSnapshot $plan.Namespace
if($current.DescriptorJson -cne $state.After.DescriptorJson){throw 'Full namespace descriptor changed after native readback.'}
$expected=Get-WelaWmiRecoveryExpectedDescriptor (ConvertFrom-WelaArrivalJson $plan.Expected.DescriptorJson) (Get-WelaWmiRecoveryKey $plan.AddedAce)
Assert-WelaWmiRecoveryRemoved $expected (ConvertFrom-WelaWmiRecoveryDescriptor $current $plan.Namespace)
if((Read-WelaWecUpdateFile $PlanPath).Hash -cne $PlanHash){throw 'Reviewed plan changed after native removal.'}
foreach($artifact in $report.Artifacts){if((Read-WelaWecUpdateFile (Join-Path $output $artifact.Name)).Hash -cne $artifact.Sha256){throw 'Saved recovery evidence changed after native removal.'}}
$report.HistoricalDescriptorMatches=$current.DescriptorJson -ceq $plan.BeforeAddition.DescriptorJson
$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'confirmed.json' (Get-WelaWmiRecoveryKey ([pscustomobject]@{Kind='WelaWmiSaclRecoveryConfirmation';State='Confirmed';PlanHash=$PlanHash;After=$current;RecordedUtc=[DateTime]::UtcNow.ToString('o')}))
$report.Status='AddedAceRemoved';$report.ExitCode=0
}
}catch{
$report.Status=if($state.WriteAttempted){'WriteAttemptedUnverified'}else{'Refused'};$report.Diagnostic=$_.Exception.Message
if($state.WriteAttempted){try{$state.After=Get-WelaWmiNamespaceSnapshot $plan.Namespace;$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'failure-state.json' (Get-WelaWmiRecoveryKey $state.After)}catch{$report.Diagnostic+=' Final failure-state read also failed: '+$_.Exception.Message}}
}
$report.WriteAttempted=$state.WriteAttempted;if($null -ne $state.After){$report.After=$state.After}
$null=Write-WelaWecUpdateArtifact $output 'manifest.json' (Get-WelaWmiRecoveryKey $report)
$report
}
+15
View File
@@ -0,0 +1,15 @@
$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent;$engine=(Get-Process -Id $PID).Path;$count=0
$cases=@(
@{Args=@('wmi-sacl-recovery','-Help');Code=0;Pattern='one proven parent-only'},
@{Args=@('configure','-WmiRecoveryAction','Recover','-Auto');Code=1;Pattern='require wmi-sacl-recovery'},
@{Args=@('wmi-sacl-recovery','-Help','-Auto');Code=1;Pattern='only dedicated'},
@{Args=@('wmi-sacl-recovery','-Help','-DryRun');Code=1;Pattern='only dedicated'},
@{Args=@('wmi-sacl-recovery','-Help','-WmiAction','Configure');Code=1;Pattern='only dedicated'},
@{Args=@('wmi-sacl-recovery','-Help','-WmiIncludeChildren');Code=1;Pattern='only dedicated'},
@{Args=@('wmi-sacl-recovery','-Help','-Profile','wela-2.2.0');Code=1;Pattern='only dedicated'},
@{Args=@('wmi-sacl-recovery','-Help','-UnknownOption');Code=1;Pattern='Unsupported|Unexpected|unbound|only dedicated'},
@{Args=@('wmi-sacl-recovery','extra');Code=1;Pattern='Unsupported|Unexpected|unbound|only dedicated'},
@{Args=@('wmi-sacl-recovery');Code=1;Pattern='Plan requires'},
@{Args=@('wmi-sacl-recovery','-WmiRecoveryAction','Recover','-WmiRecoveryPlanPath','absent','-WmiRecoveryPlanHash','bad');Code=1;Pattern='Recover requires'})
foreach($case in $cases){$ErrorActionPreference='Continue';$text=& $engine -NoLogo -NoProfile -NonInteractive -File "$repo/WELA.ps1" @($case.Args) 2>&1|Out-String;$code=$LASTEXITCODE;$ErrorActionPreference='Stop';if($code -ne $case.Code -or $text -notmatch $case.Pattern){throw "CLI boundary failed: $($case.Args -join ' ') [$code] $text"};$count++}
Write-Host "PASS: $count WMI SACL recovery public CLI guards.";$global:LASTEXITCODE=0
+124
View File
@@ -0,0 +1,124 @@
$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo
Import-Module "$repo/modules/AuditProfiles.psm1" -Force
. "$repo/scripts/Configuration.ps1"
. "$repo/scripts/WmiNamespaceAuditing.ps1"
. "$repo/scripts/WmiProbe.ps1"
. "$repo/scripts/WefArrival.ps1"
. "$repo/scripts/WecUpdate.ps1"
. "$repo/scripts/WmiSaclRecovery.ps1"
$script:checks=0
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:checks++}
function Clone($Value){ConvertFrom-WelaArrivalJson (Get-WelaWmiRecoveryKey $Value)}
function Save($Path,$Value){[IO.File]::WriteAllText($Path,(Get-WelaWmiRecoveryKey $Value),[Text.UTF8Encoding]::new($false))}
function Reject([scriptblock]$Action){$failed=$false;try{&$Action|Out-Null}catch{$failed=$true};Assert $failed 'Unsupported or malformed evidence must be refused.'}
function Get-WelaWmiRecoveryContext {[pscustomobject][ordered]@{Host=[pscustomobject]@{Computer='TEST';Build=26100};TokenKey=$script:token;Policies='unchanged'}}
function Get-WelaWmiNamespaceSnapshot {
param($Namespace)
if($Namespace -cne 'root\default'){throw 'Unexpected fixture namespace'}
[pscustomobject]@{Namespace=$Namespace;DescriptorJson=(ConvertTo-WelaWmiJson $script:descriptor);DescriptorMof='complete-native-fixture';SaclReadPrivilege='SeSecurityPrivilege enabled'}
}
function Set-WelaWmiNamespaceDescriptor {
param($Namespace,$ExpectedJson,$Definitions)
Assert ($ExpectedJson -ceq (ConvertTo-WelaWmiJson $script:descriptor)) 'Original production configuration supplies the exact current descriptor.'
foreach($definition in $Definitions){
$ace=[pscustomobject][ordered]@{AccessMask=[uint32]$definition.AccessMask;AceFlags=[uint32]$definition.AceFlags;AceType=2;GuidInheritedObjectType=$null;GuidObjectType=$null;Trustee=[pscustomobject]@{SIDString=$definition.Sid};TIME_CREATED=$null}
$script:descriptor.SACL=@($script:descriptor.SACL|Where-Object {$null -ne $_})+@($ace)
}
$script:descriptor.ControlFlags=[uint32]$script:descriptor.ControlFlags -bor 16
'Original fixture append succeeded.'
}
function Remove-WelaWmiRecoveryAce {
param($Plan,$State)
Assert (Test-Path (Join-Path $script:output 'pending.json')) 'Durable pending receipt precedes native removal.'
$script:writes++;$State.WriteAttempted=$true
if($script:scenario -eq 'native-failure'){throw 'Injected native failure'}
if($script:scenario -ne 'false-success'){$script:descriptor=Get-WelaWmiRecoveryExpectedDescriptor $script:descriptor (Get-WelaWmiRecoveryKey $Plan.AddedAce)}
if($script:scenario -eq 'empty-null'){$script:descriptor.SACL=$null}
if($script:scenario -eq 'preservation'){$script:descriptor.Owner.SIDString='S-1-5-19'}
if($script:scenario -eq 'token'){$script:token='changed'}
if($script:scenario -eq 'last-history'){[IO.File]::AppendAllText($Plan.OriginalResults.Path,' ')}
if($script:scenario -eq 'last-artifact'){[IO.File]::AppendAllText((Join-Path $script:output 'pending.json'),' ')}
$State.After=Get-WelaWmiNamespaceSnapshot $Plan.Namespace
}
$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-wmi-recovery-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root
$oldComputer=$env:COMPUTERNAME;$env:COMPUTERNAME='TEST'
function Original([string]$Directory,[switch]$Empty){
$script:descriptor=Get-Content "$repo/tests/fixtures/wmi-namespace-descriptor.json" -Raw|ConvertFrom-Json
if($Empty){$script:descriptor.SACL=$null}
$script:token='original';$script:writes=0;$script:scenario=''
$definitions=@(Get-WelaWmiAuditDefinitions -Namespace 'root\default')
$entry=[pscustomobject]@{Namespace='root\default';Definitions=$definitions}
$context=New-WelaConfigurationContext -Auto -BackupPath "$Directory/journal"
Set-WelaWmiAuditControls $context @($entry)
$r=Complete-WelaConfiguration $context -Scope 'wmi-namespace-sacl-only'
Save "$Directory/original.json" $r
Assert ($r.ExitCode -eq 0 -and $r.Results[0].Status -ceq 'Applied') 'Original journal and completed result come from actual shared configuration callbacks.'
}
try {
foreach($case in @('ok','empty','empty-null','missing-consent','hash','tamper','duplicate-json','source','descriptor-drift','native-failure','false-success','preservation','token','last-history','last-artifact')){
$dir=Join-Path $root $case;$null=New-Item -ItemType Directory $dir;Original $dir -Empty:($case -in @('empty','empty-null'))
$plan=Invoke-WelaWmiSaclRecovery -Namespace 'root\default' -JournalPath "$dir/journal/before.jsonl" -OriginalResultsPath "$dir/original.json" -OutputPath "$dir/plan"
Assert ($plan.Status -ceq 'ReviewRequired' -and $plan.ExitCode -eq 0) "Plan $case : $($plan.Diagnostic)"
Assert ($script:writes -eq 0 -and -not $plan.WriteAttempted) 'Plan performs no native mutation.'
$path="$dir/plan/plan.json";$hash=$plan.PlanHash
if($case -eq 'hash'){$hash='f'*64}
if($case -in @('tamper','duplicate-json')){
$text=[IO.File]::ReadAllText($path)
$text=if($case -eq 'tamper'){$text.Replace('audit ACE','unexpected ACE')}else{$text.Replace('"SchemaVersion":1,','"SchemaVersion":1,"SchemaVersion":1,')}
[IO.File]::WriteAllText($path,$text);$hash=(Get-FileHash $path).Hash.ToLowerInvariant()
}
if($case -eq 'source'){[IO.File]::AppendAllText("$dir/original.json",' ')}
if($case -eq 'descriptor-drift'){$script:descriptor.Group.SIDString='S-1-5-19'}
$script:scenario=$case;$script:output="$dir/recover"
$result=Invoke-WelaWmiSaclRecovery Recover -PlanPath $path -PlanHash $hash -OutputPath $script:output -AllowAuditReduction:($case -ne 'missing-consent')
Assert (($result.ExitCode -eq 0) -eq ($case -in @('ok','empty','empty-null'))) "Recover $case : $($result.Diagnostic)"
Assert ($result.ReadyRuleCredit -eq 0 -and $result.PolicyChanges -eq 0 -and (Test-Path "$dir/recover/manifest.json")) 'Recovery reports no event/policy/Sigma credit and retains outcome evidence.'
if($case -in @('ok','empty','empty-null')){
Assert ($result.Status -ceq 'AddedAceRemoved' -and $result.WriteAttempted -and $script:writes -eq 1) 'Successful recovery removes one proven ACE once.'
Assert (@(Get-WelaWmiMissingAces $script:descriptor @(Get-WelaWmiAuditDefinitions -Namespace 'root\default')).Count -eq 1) 'The original proven addition is absent after recovery.'
$again=Invoke-WelaWmiSaclRecovery Recover -PlanPath $path -PlanHash $hash -OutputPath "$dir/replay" -AllowAuditReduction
Assert ($again.Status -ceq 'Refused' -and $script:writes -eq 1) 'Completed recovery cannot be replayed against an already changed descriptor.'
}elseif($case -in @('native-failure','false-success','preservation','token','last-history','last-artifact')){
Assert ($result.Status -ceq 'WriteAttemptedUnverified' -and $result.WriteAttempted -and $script:writes -eq 1) 'Possible mutation is never reported as a pre-write refusal or verified recovery.'
}else{Assert ($result.Status -ceq 'Refused' -and -not $result.WriteAttempted -and $script:writes -eq 0) 'Stale, malformed, unconsented or drifted input cannot write.'}
foreach($artifact in $result.Artifacts){$valid=(Get-FileHash (Join-Path $result.OutputPath $artifact.Name)).Hash.ToLowerInvariant() -ceq $artifact.Sha256;Assert ($valid -eq (-not ($case -eq 'last-artifact' -and $artifact.Name -ceq 'pending.json'))) 'Artifact hashes reflect actual saved bytes, including deliberate tampering.'}
}
$dir=Join-Path $root 'history';$null=New-Item -ItemType Directory $dir;Original $dir
$savedResult=[IO.File]::ReadAllText("$dir/original.json");$savedJournal=[IO.File]::ReadAllText("$dir/journal/before.jsonl")
foreach($bad in @('failed','dryrun','wrong-host','future','duplicate-row','duplicate-journal','wrong-kind','desired-inheritance','target','incomplete','changed-owner','removed-other','extra-addition','ambiguous-addition','unknown-added-field','false-privilege','string-flags','null-ace','propagation-control')){
$r=ConvertFrom-WelaArrivalJson $savedResult;$e=ConvertFrom-WelaArrivalJson $savedJournal
switch($bad){
'failed' {$r.Results[0].Status='Failed'}
'dryrun' {$r.DryRun=$true}
'wrong-host' {$e.ComputerName='OTHER'}
'future' {$e.RecordedUtc=[DateTime]::UtcNow.AddDays(1).ToString('o')}
'duplicate-row' {$r.Results+=,$r.Results[0]}
'wrong-kind' {$r.Results[0].Kind='Other'}
'desired-inheritance' {$r.Results[0].Desired[0].AceFlags=66;$e.Desired=Clone $r.Results[0].Desired}
'target' {$r.Results[0].Target.Operation='Replace descriptor';$e.Target=Clone $r.Results[0].Target}
'incomplete' {$r.Results[0].Before.PSObject.Properties.Remove('DescriptorMof');$e.Before=Clone $r.Results[0].Before}
'false-privilege' {$r.Results[0].Before.SaclReadPrivilege='Not enabled';$e.Before=Clone $r.Results[0].Before}
default {
$d=ConvertFrom-WelaArrivalJson $r.Results[0].After.DescriptorJson
switch($bad){
'changed-owner' {$d.Owner.SIDString='S-1-5-19'}
'removed-other' {$d.SACL=@($d.SACL|Select-Object -Skip 1)}
'extra-addition' {$d.SACL+=,(Clone $d.SACL[0])}
'ambiguous-addition' {$d.SACL+=,(Clone $d.SACL[-1])}
'unknown-added-field' {$d.SACL[-1]|Add-Member NoteProperty Unknown 'unsafe'}
'string-flags' {$d.ControlFlags=[string]$d.ControlFlags}
'null-ace' {$d.SACL+=,$null}
'propagation-control' {$d.ControlFlags=[int]$d.ControlFlags -bor 512}
}
$r.Results[0].After.DescriptorJson=Get-WelaWmiRecoveryKey $d
}
}
Save "$dir/original.json" $r;$text=Get-WelaWmiRecoveryKey $e;if($bad -eq 'duplicate-journal'){$text+="`n"+$text};[IO.File]::WriteAllText("$dir/journal/before.jsonl",$text)
$p=Invoke-WelaWmiSaclRecovery -Namespace 'root\default' -JournalPath "$dir/journal/before.jsonl" -OriginalResultsPath "$dir/original.json" -OutputPath "$dir/reject-$bad"
Assert ($p.Status -ceq 'Refused' -and -not $p.WriteAttempted) "Original $bad refused: $($p.Diagnostic)"
}
foreach($args in @(@{Namespace='root\*'},@{Namespace='\\remote\root\default'},@{Namespace='root\default';AllowAuditReduction=$true},@{Action='Recover';PlanHash='bad';PlanPath='absent'})){
Reject {Invoke-WelaWmiSaclRecovery @args -OutputPath "$dir/unused"}
}
}finally{$env:COMPUTERNAME=$oldComputer;Remove-Item -LiteralPath $root -Recurse -Force}
Write-Host "PASS: $script:checks WMI recovery proof, consent, preservation, replay and partial-outcome assertions. Native behavior is tested separately."
+133
View File
@@ -0,0 +1,133 @@
param([switch]$AllowDisposableNamespaceWrite)
$ErrorActionPreference='Stop'
if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not $AllowDisposableNamespaceWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit disposable hosted Windows namespace-write opt-in is required.'}
$repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo
Import-Module "$repo/modules/AuditProfiles.psm1" -Force
. "$repo/scripts/Configuration.ps1"
. "$repo/scripts/WmiNamespaceAuditing.ps1"
. "$repo/scripts/WmiProbe.ps1"
. "$repo/scripts/WefArrival.ps1"
. "$repo/scripts/WecUpdate.ps1"
. "$repo/scripts/WmiSaclRecovery.ps1"
$script:checks=0;$errors=@();$primary=$null;$owned=@();$engine=(Get-Process -Id $PID).Path
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:checks++}
function Key($Value){ConvertTo-Json -InputObject $Value -Depth 40 -Compress}
$root=Join-Path $env:RUNNER_TEMP ('wela-wmi-recovery-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root
function Save($Name,$Value){[IO.File]::WriteAllText((Join-Path $root $Name),(Key $Value),[Text.UTF8Encoding]::new($false))}
function Inventory {@(Get-CimInstance -Namespace root -ClassName __Namespace -ErrorAction Stop|ForEach-Object Name|Sort-Object)}
function Services {@(foreach($name in @('Winmgmt','EventLog','WinRM')){$s=Get-CimInstance Win32_Service -Filter "Name='$name'" -ErrorAction Stop;[pscustomobject][ordered]@{Name=$s.Name;State=$s.State;StartMode=$s.StartMode}})}
function Masks {$m=Get-WelaEffectiveAuditPolicy;$o=[ordered]@{};foreach($id in @($m.Keys|Sort-Object)){$o[$id]=$m[$id]};[pscustomobject]$o}
Add-Type -TypeDefinition @'
using System; using System.IO; using System.Text; using System.Threading.Tasks;
public static class WelaWmiRecoveryFixturePipe {
public static async Task<string> Read(TextReader reader) {
var text=new StringBuilder();var buffer=new char[1024];
while(true){int n=await reader.ReadAsync(buffer,0,buffer.Length).ConfigureAwait(false);if(n==0)return text.ToString();
if(n>1048576-text.Length)throw new InvalidDataException("Fixture output exceeds one Mi characters.");text.Append(buffer,0,n);}
}
}
'@
function Public([string]$Name,[string[]]$Arguments,[int]$Expected=0){
$all=@('-NoLogo','-NoProfile','-NonInteractive','-File',"$script:checkout/WELA.ps1")+$Arguments
foreach($a in $all){if($a.Contains('"') -or $a.EndsWith('\') -or $a -match '[\x00-\x1f]'){throw 'Unsupported fixture argument.'}}
$info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$engine;$info.Arguments=(@($all|ForEach-Object {'"'+$_+'"'}) -join ' ');$info.UseShellExecute=$false;$info.CreateNoWindow=$true;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true
$process=[Diagnostics.Process]::new();$process.StartInfo=$info;$started=$false
try{
if(-not $process.Start()){throw 'Owned public child did not start.'};$started=$true
$stdout=[WelaWmiRecoveryFixturePipe]::Read($process.StandardOutput);$stderr=[WelaWmiRecoveryFixturePipe]::Read($process.StandardError)
if(-not $process.WaitForExit(120000)){throw 'Public command exceeded two minutes.'}
if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),5000)){throw 'Owned public child output drain did not complete.'}
$text=$stdout.Result+"`n"+$stderr.Result;[IO.File]::WriteAllText((Join-Path $root ($Name+'.txt')),$text)
Assert ($process.ExitCode -eq $Expected) "Public $Name exit $($process.ExitCode) expected $Expected : $text"
}finally{
if($started){$exited=$false;try{$exited=$process.HasExited}catch{$script:errors+=$_.Exception.Message};if(-not $exited){try{$process.Kill()}catch{$script:errors+=$_.Exception.Message};try{$exited=$process.WaitForExit(5000)}catch{$script:errors+=$_.Exception.Message}};if(-not $exited){$script:errors+='Owned public child termination unconfirmed'}}
$process.Dispose()
}
}
Initialize-WelaWmiInterop;Initialize-WelaWmiProbeNative
$beforeInventory=Inventory;$beforeServices=Services;$beforeMasks=Masks;$beforePrecedence=Get-WelaRegistryState 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' SCENoApplyLegacyAuditPolicy
$originalParent=Get-WelaWmiNamespaceSnapshot 'root';$originalDefault=Get-WelaWmiNamespaceSnapshot 'root\default';$beforeToken=Get-WelaWmiRecoveryTokenKey
Save 'original-safety.json' ([ordered]@{Inventory=$beforeInventory;Services=$beforeServices;AuditMasks=$beforeMasks;Precedence=$beforePrecedence;Root=$originalParent;Default=$originalDefault;TokenKey=$beforeToken;Engine=$PSVersionTable.PSVersion.ToString()})
try{
$os=Get-CimInstance Win32_OperatingSystem
Assert ($os.ProductType -eq 3 -and $os.BuildNumber -in @('20348','26100')) 'Disposable Server2022/2025 required.'
Assert (@($beforeMasks.PSObject.Properties).Count -eq 59) 'All59 original audit masks are present.'
foreach($case in @('empty','unrelated')){
$name='WelaRecovery_'+[guid]::NewGuid().ToString('N');$namespace='root\'+$name
$factory=New-Object System.Management.ManagementClass -ArgumentList '\\.\root:__Namespace';$instance=$factory.CreateInstance();$instance.Name=$name
$options=New-Object System.Management.PutOptions;$options.Type=[System.Management.PutType]::CreateOnly
$createdPath=$instance.Put($options)
$entry=[pscustomobject]@{Name=$name;Namespace=$namespace;Instance=$instance;Factory=$factory;Removed=$false};$owned+=,$entry
Assert ($createdPath.RelativePath -ceq ('__NAMESPACE.Name="'+$name+'"')) 'Only the exclusively created namespace receives writes.'
$prepared=Get-WelaWmiNamespaceSnapshot $namespace;$data=ConvertFrom-WelaArrivalJson $prepared.DescriptorJson
Assert (@($data.SACL|Where-Object {$null -ne $_}).Count -eq 0) 'Owned namespace begins without existing SACL entries.'
if($case -ceq 'unrelated'){
$other=[pscustomobject]@{Namespace=$namespace;AccessMask=[uint32]2;AceType=2;AceFlags=[uint32]128;Sid='S-1-5-18'}
$null=Set-WelaWmiNamespaceDescriptor $namespace $prepared.DescriptorJson @($other)
$prepared=Get-WelaWmiNamespaceSnapshot $namespace
}
Save ($case+'-prepared.json') $prepared
$script:checkout=Join-Path $root ($case+'-checkout');$null=New-Item -ItemType Directory $script:checkout
foreach($directory in @('config','modules','scripts')){Copy-Item -LiteralPath (Join-Path $repo $directory) -Destination $script:checkout -Recurse}
Copy-Item -LiteralPath "$repo/WELA.ps1" -Destination $script:checkout
# Production retains canonical namespace selection. Only the owned disposable
# copied catalog is redirected, preserving all real public Configure/Recover code.
$catalogPath=Join-Path $script:checkout 'scripts/WmiNamespaceAuditing.ps1';$catalog=[IO.File]::ReadAllText($catalogPath)
Assert ($catalog.Contains("'root\default'")) 'Expected canonical namespace entry exists in owned copied checkout.'
[IO.File]::WriteAllText($catalogPath,$catalog.Replace("'root\default'","'$namespace'"),[Text.UTF8Encoding]::new($false))
Copy-Item -LiteralPath $catalogPath -Destination (Join-Path $root ($case+'-redirected-WmiNamespaceAuditing.ps1'))
$journal=Join-Path $root ($case+'-journal');$results=Join-Path $root ($case+'-original.json')
Public ($case+'-dryrun') @('wmi-auditing','-WmiAction','Configure','-WmiNamespace',$namespace,'-Auto','-DryRun','-BackupPath',($journal+'-dryrun'),'-ResultsPath',($results+'-dryrun'))
Assert (-not(Test-Path ($journal+'-dryrun')) -and (Get-WelaWmiNamespaceSnapshot $namespace).DescriptorJson -ceq $prepared.DescriptorJson) 'Public dry-run writes no namespace SACL or journal.'
Public ($case+'-configure') @('wmi-auditing','-WmiAction','Configure','-WmiNamespace',$namespace,'-Auto','-BackupPath',$journal,'-ResultsPath',$results)
$configured=Get-WelaWmiNamespaceSnapshot $namespace;$original=ConvertFrom-WelaArrivalJson ([IO.File]::ReadAllText($results))
Assert ($original.Results.Count -eq 1 -and $original.Results[0].Status -ceq 'Applied') 'Genuine public Configure journal/result proves the original addition.'
Assert (Test-WelaWmiDescriptorPreserved (ConvertFrom-WelaArrivalJson $prepared.DescriptorJson) (ConvertFrom-WelaArrivalJson $configured.DescriptorJson)) 'Original public append preserves all unrelated descriptor properties and ACEs.'
$planDir=Join-Path $root ($case+'-plan')
Public ($case+'-plan') @('wmi-sacl-recovery','-WmiRecoveryNamespace',$namespace,'-WmiRecoveryJournalPath',"$journal/before.jsonl",'-WmiRecoveryOriginalResultsPath',$results,'-WmiRecoveryOutputPath',$planDir)
$plan=ConvertFrom-WelaArrivalJson ([IO.File]::ReadAllText("$planDir/manifest.json"));$reviewed=ConvertFrom-WelaArrivalJson ([IO.File]::ReadAllText("$planDir/plan.json"))
Assert ($plan.Status -ceq 'ReviewRequired' -and -not $plan.WriteAttempted -and (Get-FileHash "$planDir/plan.json").Hash.ToLowerInvariant() -ceq $plan.PlanHash) 'Native public review is read-only and has an independently checked hash.'
Assert ((Get-WelaWmiNamespaceSnapshot $namespace).DescriptorJson -ceq $configured.DescriptorJson) 'Public Plan leaves the full descriptor unchanged.'
foreach($p in $reviewed.Sources.PSObject.Properties){Assert ((Get-FileHash -LiteralPath (Join-Path $script:checkout $p.Name)).Hash.ToLowerInvariant() -ceq $p.Value) 'Plan binds exact installed copied sources.'}
$recover=@('wmi-sacl-recovery','-WmiRecoveryAction','Recover','-WmiRecoveryPlanPath',"$planDir/plan.json",'-WmiRecoveryPlanHash',$plan.PlanHash)
Public ($case+'-missing-consent') ($recover+@('-WmiRecoveryOutputPath',(Join-Path $root ($case+'-missing-consent')))) 1
Assert ((Get-WelaWmiNamespaceSnapshot $namespace).DescriptorJson -ceq $configured.DescriptorJson) 'Missing explicit audit-reduction consent preserves the native descriptor.'
$recoverDir=Join-Path $root ($case+'-recover')
Public ($case+'-recover') ($recover+@('-WmiRecoveryAllowAuditReduction','-WmiRecoveryOutputPath',$recoverDir))
$recovered=ConvertFrom-WelaArrivalJson ([IO.File]::ReadAllText("$recoverDir/manifest.json"));$after=Get-WelaWmiNamespaceSnapshot $namespace
Assert ($recovered.Status -ceq 'AddedAceRemoved' -and $recovered.WriteAttempted -and $recovered.ReadyRuleCredit -eq 0 -and $recovered.PolicyChanges -eq 0) 'Public recovery confirms one proven removal without policy/event/Sigma credit.'
$expected=Get-WelaWmiRecoveryExpectedDescriptor (ConvertFrom-WelaArrivalJson $configured.DescriptorJson) (Get-WelaWmiRecoveryKey $reviewed.AddedAce)
Assert-WelaWmiRecoveryRemoved $expected (ConvertFrom-WelaArrivalJson $after.DescriptorJson)
Assert ($after.DescriptorJson -ceq $recovered.After.DescriptorJson) 'Independent reopened descriptor matches confirmed native readback.'
if($case -ceq 'unrelated'){Assert (@((ConvertFrom-WelaArrivalJson $after.DescriptorJson).SACL).Count -eq 1) 'Unrelated original audit ACE remains after recovery.'}
else{Assert (@((ConvertFrom-WelaArrivalJson $after.DescriptorJson).SACL|Where-Object {$null -ne $_}).Count -eq 0) 'Sole added audit ACE is actually absent.'}
Public ($case+'-replay') ($recover+@('-WmiRecoveryAllowAuditReduction','-WmiRecoveryOutputPath',(Join-Path $root ($case+'-replay')))) 1
Assert ((Get-WelaWmiNamespaceSnapshot $namespace).DescriptorJson -ceq $after.DescriptorJson) 'Stale completed plan replay makes no descriptor change.'
foreach($manifest in @($plan,$recovered)){foreach($artifact in $manifest.Artifacts){Assert ((Get-FileHash -LiteralPath (Join-Path $manifest.OutputPath $artifact.Name)).Hash.ToLowerInvariant() -ceq $artifact.Sha256) 'Saved plan/recovery artifacts match actual hashes.'}}
Save ($case+'-after.json') $after
Remove-Item -LiteralPath $script:checkout -Recurse -Force
}
}catch{$primary=$_;Write-Host ('Primary WMI recovery fixture failure: '+$_.Exception.Message)}
finally{
foreach($entry in $owned){
try{
if($entry.Name -cnotmatch '^WelaRecovery_[a-f0-9]{32}$' -or $entry.Instance.Name -cne $entry.Name -or $entry.Instance.Path.RelativePath -cne ('__NAMESPACE.Name="'+$entry.Name+'"')){throw 'Owned namespace identity changed; refusing deletion.'}
$children=@(Get-CimInstance -Namespace $entry.Namespace -ClassName __Namespace -ErrorAction Stop)
if($children.Count){throw 'Owned namespace acquired unexpected children; refusing recursive deletion.'}
$entry.Instance.Delete();$entry.Removed=$true
}catch{$errors+=$_.Exception.Message}
finally{try{$entry.Instance.Dispose();$entry.Factory.Dispose()}catch{$errors+=$_.Exception.Message}}
}
$inventoryOk=$false;$rootOk=$false;$defaultOk=$false;$servicesOk=$false;$masksOk=$false;$precedenceOk=$false;$tokenOk=$false
try{$afterInventory=Inventory;$inventoryOk=(Key $afterInventory) -ceq (Key $beforeInventory)}catch{$errors+=$_.Exception.Message}
try{$rootOk=(Get-WelaWmiNamespaceSnapshot 'root').DescriptorJson -ceq $originalParent.DescriptorJson;$defaultOk=(Get-WelaWmiNamespaceSnapshot 'root\default').DescriptorJson -ceq $originalDefault.DescriptorJson}catch{$errors+=$_.Exception.Message}
try{$afterServices=Services;$servicesOk=(Key $afterServices) -ceq (Key $beforeServices)}catch{$errors+=$_.Exception.Message}
try{$afterMasks=Masks;$masksOk=(Key $afterMasks) -ceq (Key $beforeMasks)}catch{$errors+=$_.Exception.Message}
try{$afterPrecedence=Get-WelaRegistryState 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' SCENoApplyLegacyAuditPolicy;$precedenceOk=(Key $afterPrecedence) -ceq (Key $beforePrecedence)}catch{$errors+=$_.Exception.Message}
try{$afterToken=Get-WelaWmiRecoveryTokenKey;$tokenOk=$afterToken -ceq $beforeToken}catch{$errors+=$_.Exception.Message}
foreach($dir in @((Join-Path $root 'empty-checkout'),(Join-Path $root 'unrelated-checkout'))){try{if(Test-Path -LiteralPath $dir){Remove-Item -LiteralPath $dir -Recurse -Force}}catch{$errors+=$_.Exception.Message}}
$complete=$inventoryOk -and $rootOk -and $defaultOk -and $servicesOk -and $masksOk -and $precedenceOk -and $tokenOk -and @($owned|Where-Object {-not $_.Removed}).Count -eq 0 -and $errors.Count -eq 0
Save 'cleanup.json' ([ordered]@{Complete=[bool]$complete;Assertions=$script:checks;OwnedNamespaces=@($owned|Select-Object Namespace,Removed);RootInventoryRestored=$inventoryOk;RootDescriptorUnchanged=$rootOk;RealDefaultDescriptorUnchanged=$defaultOk;ServicesUnchanged=$servicesOk;AuditMasksCompared=59;AuditMasksUnchanged=$masksOk;PrecedenceUnchanged=$precedenceOk;TokenRestored=$tokenOk;AfterInventory=$afterInventory;AfterServices=$afterServices;AfterMasks=$afterMasks;AfterPrecedence=$afterPrecedence;AfterToken=$afterToken;Errors=$errors;PrimaryFailure=$(if($primary){$primary.Exception.Message}else{$null})})
}
if($primary){throw $primary};Assert $complete 'Independent exact cleanup failed; retain evidence and discard disposable VM.'
Write-Host "PASS: $script:checks native public WMI recovery assertions and exact cleanup. Evidence: $root"
+2
View File
@@ -9,6 +9,8 @@
**改善:**
- 追加が証明された親名前空間のみの明示的な監査ACEを1つ削除する `wmi-sacl-recovery` の Plan/Recover を追加しました。完了済み設定の整合する記録、現在の完全な記述子、レビュー済みハッシュ、監査縮小への明示的同意を必須とし、他の記述子情報・ACE・トークン権限を保持します。部分書き込みの証跡と所有する実機名前空間の後始末を検証し、過去の名前空間・操作者の同一性やイベント・Sigmaの準備完了は保証しません。 (関連 #372, #365) (@Shirofune-Security)
- 既に有効なローカル保存先で、現在の実行ユーザーによる固定 Windows PowerShell 5.1 子プロセスの完了した自動トランスクリプトを検証する `transcript-probe` を追加しました。実トークン・ログオン、標準ヘッダー/フッター、nonce・PID・時刻、件数を制限したファイル識別と出典確認により不明な結果を保持し、ポリシーやACLの変更・明示的トランスクリプト開始・Sigma/EVTX加算は行いません。Server 2022/2025と両WELA実行エンジン向けに、使い捨て標準ユーザーの成功・拒否テストを追加しました。 (#436) (@Shirofune-Security)
- `file-sacl-recovery` を追加しました。元の計画、変更前後のレシート、成功結果、ソースとホストのハッシュ、実際のファイル識別情報を照合し、変更されていない選択済み単一ファイルに追加した監査 ACE だけを明示的に削除できます。書き込み前の永続記録と SACL 限定の変更・再読込により、無関係な ACE のバイト列と個数、観測した他の記述子要素を保持します。空または null の SACL が存在フラグ付きで残る場合を区別し、元の記述子との完全一致、ポリシー・ディレクトリ・レジストリの復旧、Sigma の利用可能性は保証しません。(#437) (@Shirofune-Security)
+2
View File
@@ -9,6 +9,8 @@
**Improvements:**
- Add reviewed `wmi-sacl-recovery` Plan/Recover for one proven explicit parent-only namespace audit ACE. Require matching completed configuration evidence, current full descriptor, reviewed hash and explicit audit-reduction consent; preserve all other descriptor fields, remaining ACEs and token privileges, retain partial-write evidence, and test owned native namespace cleanup. Historical namespace/operator identity and event/Sigma readiness remain unclaimed. (Related #372, #365) (@Shirofune-Security)
- Added opt-in `transcript-probe` to verify a fixed current-account Windows PowerShell 5.1 child produces its own completed automatic transcript in an already enabled local destination. Actual token/logon, native header/footer, nonce/PID/time, bounded file identity and source checks preserve unverified outcomes without changing policy or ACLs, invoking explicit transcription or granting Sigma/EVTX credit. Added disposable standard-writer success/denial tests for Server 2022/2025 under both WELA host engines. (#436) (@Shirofune-Security)
- Added opt-in `file-sacl-recovery` to review and remove one proven explicit audit ACE from an unchanged selected leaf file. Original plans, paired receipts, successful results, source/host hashes and held file identity bind the operation; durable pre-write evidence and SACL-only readback preserve unrelated ACE bytes/counts and observed descriptor components. Empty or null present SACLs are reported without claiming exact historical descriptor restoration; policy, directory/registry recovery and Sigma readiness remain outside this scope. (#437) (@Shirofune-Security)