mirror of
https://github.com/Yamato-Security/WELA.git
synced 2026-10-08 07:15:25 +02:00
Add reviewed removal of one proven parent-only WMI audit ACE
This commit is contained in:
1 parent
7ef29df61f
commit
ab45d03f15
13 files changed
+600
-1
No files matched your search
@@ -110,3 +110,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/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 -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 -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
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
**改善:**
|
||||
|
||||
- 追加が証明された親名前空間のみの明示的な監査ACEを1つ削除する `wmi-sacl-recovery` の Plan/Recover を追加しました。完了済み設定の整合する記録、現在の完全な記述子、レビュー済みハッシュ、監査縮小への明示的同意を必須とし、他の記述子情報・ACE・トークン権限を保持します。部分書き込みの証跡と所有する実機名前空間の後始末を検証し、過去の名前空間・操作者の同一性やイベント・Sigmaの準備完了は保証しません。 (関連 #372, #365) (@Shirofune-Security)
|
||||
|
||||
- Server2022/2025 と PowerShell5.1/7 で、Token Right Adjusted の正規GUIDに対する Security4703 の実機検証を追加しました。所有する子プロセスの既存権限を固定手順で無効化・復元し、候補となる2つの監査マスクを比較して、実イベント・実行条件・ハッシュとポリシー/トークンの復元を記録します。過去の候補は条件付きのまま維持し、製品用プローブ・全OS共通の対応関係・Sigma加点は追加しません。(関連 #380) (@Shirofune-Security)
|
||||
|
||||
- OneSettingsポリシーと明示的なPrivacyチャネル設定の公開CLIを実機検証します。Server 2022で実際の適用、型付き復元記録と再読取、冪等性、不正値の拒否を確認し、Server 2025の既存の拒否を維持します。両PowerShellで厳密な後処理を検証し、イベント生成やSigma対応は主張しません。通知コントロール省略時の引数渡しを修正し、既定のAuditは両項目を読み取り、項目未選択のConfigureは非ゼロで失敗します。(関連 #378) (@Shirofune-Security)
|
||||
|
||||
@@ -4,6 +4,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 disposable native Security4703 attribution for the canonical Token Right Adjusted GUID on Server2022/2025 and PowerShell5.1/7. A fixed owned-child privilege disable/restore compares the two historical audit-mask candidates, retains exact event/context/hash evidence and verifies policy/token cleanup. Historical candidates remain conditional; no production probe, universal mapping or Sigma credit. (Related #380) (@Shirofune-Security)
|
||||
|
||||
- Add native public OneSettings policy and explicit Privacy-channel configuration acceptance: actual apply, typed journals/readback, idempotence and invalid-value refusals on Server 2022; preserve the existing Server 2025 refusal. Both PowerShell engines verify exact fixture cleanup without event or Sigma claims. Fix omitted notification-control dispatch so default Audit reads both controls and Configure without a selection fails with a nonzero exit. (Related #378) (@Shirofune-Security)
|
||||
|
||||
@@ -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]$Capi2ProbeAction = 'Plan',
|
||||
[string]$Capi2ProbeOutputPath,
|
||||
[ValidateRange(1,30)][int]$Capi2ProbeTimeoutSeconds = 15,
|
||||
@@ -261,6 +269,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/FileAccessProbe.ps1")
|
||||
. (Join-Path $ScriptRoot "scripts/Capi2Probe.ps1")
|
||||
@@ -2030,6 +2039,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
|
||||
@@ -2222,6 +2232,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.'}
|
||||
@@ -2537,6 +2549,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"
|
||||
@@ -7,6 +7,8 @@
|
||||
|
||||
**改善:**
|
||||
|
||||
- 追加が証明された親名前空間のみの明示的な監査ACEを1つ削除する `wmi-sacl-recovery` の Plan/Recover を追加しました。完了済み設定の整合する記録、現在の完全な記述子、レビュー済みハッシュ、監査縮小への明示的同意を必須とし、他の記述子情報・ACE・トークン権限を保持します。部分書き込みの証跡と所有する実機名前空間の後始末を検証し、過去の名前空間・操作者の同一性やイベント・Sigmaの準備完了は保証しません。 (関連 #372, #365) (@Shirofune-Security)
|
||||
|
||||
- Server2022/2025 と PowerShell5.1/7 で、Token Right Adjusted の正規GUIDに対する Security4703 の実機検証を追加しました。所有する子プロセスの既存権限を固定手順で無効化・復元し、候補となる2つの監査マスクを比較して、実イベント・実行条件・ハッシュとポリシー/トークンの復元を記録します。過去の候補は条件付きのまま維持し、製品用プローブ・全OS共通の対応関係・Sigma加点は追加しません。(関連 #380) (@Shirofune-Security)
|
||||
|
||||
- OneSettingsポリシーと明示的なPrivacyチャネル設定の公開CLIを実機検証します。Server 2022で実際の適用、型付き復元記録と再読取、冪等性、不正値の拒否を確認し、Server 2025の既存の拒否を維持します。両PowerShellで厳密な後処理を検証し、イベント生成やSigma対応は主張しません。通知コントロール省略時の引数渡しを修正し、既定のAuditは両項目を読み取り、項目未選択のConfigureは非ゼロで失敗します。(関連 #378) (@Shirofune-Security)
|
||||
|
||||
@@ -7,6 +7,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 disposable native Security4703 attribution for the canonical Token Right Adjusted GUID on Server2022/2025 and PowerShell5.1/7. A fixed owned-child privilege disable/restore compares the two historical audit-mask candidates, retains exact event/context/hash evidence and verifies policy/token cleanup. Historical candidates remain conditional; no production probe, universal mapping or Sigma credit. (Related #380) (@Shirofune-Security)
|
||||
|
||||
- Add native public OneSettings policy and explicit Privacy-channel configuration acceptance: actual apply, typed journals/readback, idempotence and invalid-value refusals on Server 2022; preserve the existing Server 2025 refusal. Both PowerShell engines verify exact fixture cleanup without event or Sigma claims. Fix omitted notification-control dispatch so default Audit reads both controls and Configure without a selection fails with a nonzero exit. (Related #378) (@Shirofune-Security)
|
||||
|
||||
Reference in new issue
Block a user