mirror of
https://github.com/Yamato-Security/WELA.git
synced 2026-10-08 07:15:25 +02:00
Merge latest dev before PowerShell logging integration
This commit is contained in:
commit
dee12fa965
13 files changed
+600
-1
No files matched your search
@@ -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
|
||||
@@ -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-logging.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/transcript-probe.md, ./docs/powershell-logging.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, ./docs/wmi-sacl-recovery.md -Destination release-binaries/docs/
|
||||
|
||||
- name: Set Artifact Name
|
||||
if: contains(matrix.info.os, 'windows') == true
|
||||
|
||||
@@ -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
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
- Windows PowerShell 5.1 のモジュール/スクリプトブロック監査を明示的に選択する `powershell-logging` Audit/Plan/Configure を追加しました。モジュール名、事前の記録容量、型付き変更前記録、観測したポリシーの変化と再読取を確認し、呼出しログ・転写・Core設定・既存モジュール一覧を保持します。Server 2022/2025 の破棄可能なテストで正確な 4103/4104 と復元を要求し、PowerShell 7 のフォールバック、管理ポリシーの継続性、転送、Sigma 対応は別途検証します。(関連 #364, #366, #387) (@Shirofune-Security)
|
||||
|
||||
- 追加が証明された親名前空間のみの明示的な監査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)
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
- Added explicit `powershell-logging` Audit/Plan/Configure for selected Windows PowerShell 5.1 module and script-block logging. Reviewed module names, preflight inventory-capacity checks, typed original journals, full observed-policy drift guards and native readback preserve invocation/transcription/Core settings and other module entries. Disposable Server 2022/2025 tests require exact local 4103/4104 evidence and policy cleanup; PowerShell 7 fallback, managed-host persistence, forwarding and Sigma readiness remain separate. (Related #364, #366, #387) (@Shirofune-Security)
|
||||
|
||||
- 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)
|
||||
|
||||
@@ -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,
|
||||
@@ -279,6 +287,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")
|
||||
@@ -2052,6 +2061,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
|
||||
@@ -2260,6 +2270,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.'}
|
||||
@@ -2587,6 +2599,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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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."
|
||||
@@ -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"
|
||||
@@ -11,6 +11,8 @@
|
||||
|
||||
- Windows PowerShell 5.1 のモジュール/スクリプトブロック監査を明示的に選択する `powershell-logging` Audit/Plan/Configure を追加しました。モジュール名、事前の記録容量、型付き変更前記録、観測したポリシーの変化と再読取を確認し、呼出しログ・転写・Core設定・既存モジュール一覧を保持します。Server 2022/2025 の破棄可能なテストで正確な 4103/4104 と復元を要求し、PowerShell 7 のフォールバック、管理ポリシーの継続性、転送、Sigma 対応は別途検証します。(関連 #364, #366, #387) (@Shirofune-Security)
|
||||
|
||||
- 追加が証明された親名前空間のみの明示的な監査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)
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
|
||||
- Added explicit `powershell-logging` Audit/Plan/Configure for selected Windows PowerShell 5.1 module and script-block logging. Reviewed module names, preflight inventory-capacity checks, typed original journals, full observed-policy drift guards and native readback preserve invocation/transcription/Core settings and other module entries. Disposable Server 2022/2025 tests require exact local 4103/4104 evidence and policy cleanup; PowerShell 7 fallback, managed-host persistence, forwarding and Sigma readiness remain separate. (Related #364, #366, #387) (@Shirofune-Security)
|
||||
|
||||
- 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)
|
||||
|
||||
Reference in new issue
Block a user