diff --git a/.github/workflows/wmi-descendants.yml b/.github/workflows/wmi-descendants.yml new file mode 100644 index 00000000..013e999f --- /dev/null +++ b/.github/workflows/wmi-descendants.yml @@ -0,0 +1,39 @@ +name: Native WMI descendant safeguards +on: + push: + branches: ['**'] + paths: + - 'scripts/WmiNamespaceAuditing.ps1' + - 'scripts/WmiNamespaceDescendants.ps1' + - 'tests/WmiNamespaceDescendants*' + - '.github/workflows/wmi-descendants.yml' + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + wmi-descendants: + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + shell: [powershell, pwsh] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Existing WMI regression contracts + shell: ${{ matrix.shell }} + run: ./tests/WmiNamespaceAuditing.Tests.ps1 + - name: Actual owned namespace tree and cleanup + shell: ${{ matrix.shell }} + run: ./tests/WmiNamespaceDescendants.Windows.Tests.ps1 -AllowDisposableNamespaceWrite -EvidencePath wmi-descendants-native.json + - name: Retain complete native observations + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: wmi-descendants-${{ matrix.os }}-${{ matrix.shell }} + path: | + wmi-descendants-native.json + wmi-descendants-native.json.journals + if-no-files-found: error diff --git a/scripts/WmiNamespaceAuditing.ps1 b/scripts/WmiNamespaceAuditing.ps1 index 1c25a37b..dca97a5b 100644 --- a/scripts/WmiNamespaceAuditing.ps1 +++ b/scripts/WmiNamespaceAuditing.ps1 @@ -1,4 +1,5 @@ # Opt-in local namespace SACLs. No namespace DACL, audit policy, or remote-access changes. +. (Join-Path $PSScriptRoot 'WmiNamespaceDescendants.ps1') function Get-WelaWmiAuditDefinitions { param([string[]]$Namespace, [switch]$IncludeChildren) $source = 'https://github.com/AustralianCyberSecurityCentre/windows_event_logging/blob/59041b5d4586789a751171fb752be1624ad5e3b4/events/wmi_auditing/wmi_auditing.ps1' @@ -256,10 +257,14 @@ function Get-WelaWmiAuditPlan { foreach ($name in @($definitions | Select-Object -ExpandProperty Namespace -Unique)) { $selected = @($definitions | Where-Object Namespace -eq $name) try { - $snapshot = Get-WelaWmiNamespaceSnapshot $name + $tree=$null + if(@($selected|Where-Object {($_.AceFlags -band 2) -ne 0}).Count){ + $tree=Get-WelaWmiStableDescendants $name + $snapshot=$tree.Root + } else {$snapshot = Get-WelaWmiNamespaceSnapshot $name} $descriptor = $snapshot.DescriptorJson | ConvertFrom-Json $missing = @(Get-WelaWmiMissingAces $descriptor $selected) - [pscustomobject]@{ Namespace = $name; Status = $(if ($missing.Count) { 'ChangeRequired' } else { 'AlreadyCompliant' }); Before = $snapshot; Definitions = $selected; Missing = $missing; Diagnostic = '' } + [pscustomobject]@{ Namespace = $name; Status = $(if ($missing.Count) { 'ChangeRequired' } else { 'AlreadyCompliant' }); Before = $snapshot; Definitions = $selected; Missing = $missing; Descendants=$tree; Diagnostic = '' } } catch { [pscustomobject]@{ Namespace = $name; Status = 'Unknown'; Before = $null; Definitions = $selected; Missing = @(); Diagnostic = $_.Exception.Message } } } } @@ -267,16 +272,34 @@ function Get-WelaWmiAuditPlan { function Set-WelaWmiAuditControls { param($Context, [array]$Plan) foreach ($entry in $Plan) { - $callback = @{ Namespace = $entry.Namespace; Definitions = $entry.Definitions; Original = $null; ExpectedJson = $null; Applied = $false; VerifiedJson = $null } + $inherit=@($entry.Definitions|Where-Object {($_.AceFlags -band 2) -ne 0}).Count -gt 0 + $callback = @{ Namespace = $entry.Namespace; Definitions = $entry.Definitions; Original = $null; ExpectedJson = $null; Applied = $false; VerifiedJson = $null; Inherit=$inherit; PlannedTree=$entry.Descendants; OriginalTree=$null; VerifiedTree=$null; DescendantVerification=[pscustomobject]@{Observation=$null} } $read = { param($state) - $snapshot = Get-WelaWmiNamespaceSnapshot $state.Namespace + if($state.Inherit){ + $tree=Get-WelaWmiStableDescendants $state.Namespace + if($null -eq $state.OriginalTree){ + if((Get-WelaWmiDescendantKey $tree) -cne (Get-WelaWmiDescendantKey $state.PlannedTree)){throw 'WMI descendant tree changed after planning; no SACL was written.'} + $state.OriginalTree=$tree + } + $snapshot=$tree.Root|Select-Object * + $snapshot|Add-Member NoteProperty Descendants $tree -Force + } else {$snapshot = Get-WelaWmiNamespaceSnapshot $state.Namespace} if ($null -eq $state.Original) { $state.Original = $snapshot.DescriptorJson | ConvertFrom-Json; $state.ExpectedJson = $snapshot.DescriptorJson } return $snapshot } $test = { param($snapshot, $state) $descriptor = $snapshot.DescriptorJson | ConvertFrom-Json + if($state.Inherit){ + $state.DescendantVerification.Observation=Test-WelaWmiDescendantOutcomes $state.OriginalTree $snapshot.Descendants $state.Definitions + if($state.Applied -or -not @(Get-WelaWmiMissingAces $descriptor $state.Definitions).Count){ + if($state.DescendantVerification.Observation.Status -cne 'Observed'){throw ('WMI descendant outcome is unverified: '+($state.DescendantVerification.Observation.Diagnostics -join '; '))} + $key=Get-WelaWmiDescendantKey $snapshot.Descendants + if($null -eq $state.VerifiedTree){$state.VerifiedTree=$key} + if($key -cne $state.VerifiedTree){throw 'WMI descendant descriptor changed after verification.'} + } + } if (@(Get-WelaWmiMissingAces $descriptor $state.Definitions).Count) { return $false } if ($state.Applied) { if (-not (Test-WelaWmiDescriptorPreserved $state.Original $descriptor)) { return $false } @@ -288,13 +311,18 @@ function Set-WelaWmiAuditControls { } $apply = { param($state) + if($state.Inherit){ + $fresh=Get-WelaWmiStableDescendants $state.Namespace + if((Get-WelaWmiDescendantKey $fresh) -cne (Get-WelaWmiDescendantKey $state.OriginalTree)){throw 'WMI descendant topology or descriptor changed before the parent setter; no SACL was written.'} + } Set-WelaWmiNamespaceDescriptor -Namespace $state.Namespace -ExpectedJson $state.ExpectedJson -Definitions $state.Definitions $state.Applied = $true } Invoke-WelaConfigurationControl -Context $Context -Id "WmiNamespace/$($entry.Namespace)/SACL" -Kind WmiNamespaceSacl ` -Target @{ Namespace = $entry.Namespace; Computer = 'Local'; Operation = 'Append audit ACEs only' } -Desired $entry.Definitions ` -Read $read -Compliant $test -Apply $apply -CallbackState $callback ` - -Description ('Append missing success audit ACEs. Scope: ' + (($entry.Definitions.Scope | Select-Object -Unique) -join ', ')) + -Description ('Append missing success audit ACEs. Scope: ' + (($entry.Definitions.Scope | Select-Object -Unique) -join ', ') + $(if($inherit){'; reviewed existing descendants: '+@($entry.Descendants.Entries).Count+'. Only the parent is written; unsupported propagation fails verification.'}else{''})) + if($inherit){$Context.Results[$Context.Results.Count-1]|Add-Member NoteProperty DescendantVerification $callback.DescendantVerification} } } diff --git a/scripts/WmiNamespaceDescendants.ps1 b/scripts/WmiNamespaceDescendants.ps1 new file mode 100644 index 00000000..233926da --- /dev/null +++ b/scripts/WmiNamespaceDescendants.ps1 @@ -0,0 +1,137 @@ +# Read-only bounded observations. A descendant is never passed to a setter. +function Get-WelaWmiDescendantContext { + if(-not (Get-Command Initialize-WelaWmiProbeNative -ErrorAction SilentlyContinue)){. (Join-Path $PSScriptRoot 'WmiProbe.ps1')} + Initialize-WelaWmiProbeNative + if((Get-Service Winmgmt -ErrorAction Stop).Status -ne 'Running'){throw 'Winmgmt must already be running before descendant observation.'} + $sources=[ordered]@{} + foreach($name in @('WmiNamespaceAuditing.ps1','WmiNamespaceDescendants.ps1','WmiProbeNative.cs')){$sources[$name]=(Get-FileHash -LiteralPath (Join-Path $PSScriptRoot $name) -Algorithm SHA256 -ErrorAction Stop).Hash} + [ordered]@{Computer=[Environment]::MachineName;Version=[Environment]::OSVersion.VersionString;Token=(Get-WelaWmiProbeTokenKey ([Wela.WmiProbe.Native]::Snapshot()));Sources=$sources}|ConvertTo-Json -Compress -Depth 5 +} +function Get-WelaWmiChildNames { + param([string]$Namespace,[int]$Maximum) + Initialize-WelaWmiInterop + $options=New-Object System.Management.ConnectionOptions + $options.EnablePrivileges=$false;$options.Impersonation=[System.Management.ImpersonationLevel]::Impersonate + $scope=New-Object System.Management.ManagementScope -ArgumentList "\\.\$Namespace",$options + $query=New-Object System.Management.ObjectQuery -ArgumentList 'SELECT Name FROM __Namespace' + $enumeration=New-Object System.Management.EnumerationOptions + $enumeration.ReturnImmediately=$true;$enumeration.Rewindable=$false;$enumeration.BlockSize=1 + $enumeration.Timeout=[TimeSpan]::FromSeconds(10) + $searcher=New-Object System.Management.ManagementObjectSearcher -ArgumentList $scope,$query,$enumeration + $collection=$null;$names=New-Object 'System.Collections.Generic.List[string]' + try { + $collection=$searcher.Get() + foreach($item in $collection){ + try { + if($names.Count -ge $Maximum){throw 'WMI descendant count exceeds the reviewed maximum of 64.'} + $name=$item.Name + if($name -isnot [string] -or $name -cnotmatch '^[A-Za-z_][A-Za-z0-9_]{0,63}$'){throw 'Unsupported or ambiguous native child namespace name.'} + $names.Add($name) + } finally {$item.Dispose()} + } + @($names.ToArray()|Sort-Object) + } finally {if($collection){$collection.Dispose()};$searcher.Dispose()} +} +function Get-WelaWmiDescendantKey { + param($Tree) + if($null -eq $Tree -or $Tree.Status -cne 'Complete' -or $Tree.Maximum -ne 64 -or $Tree.MaximumDepth -ne 8 -or $Tree.Entries -isnot [array] -or $Tree.Entries.Count -gt 64){throw 'Complete bounded WMI descendant evidence is required.'} + $parts=@($Tree.Context,$Tree.Root.Namespace,$Tree.Root.DescriptorJson) + foreach($entry in $Tree.Entries){ + if($entry.Namespace -cne $entry.Snapshot.Namespace -or $entry.Depth -lt 1 -or $entry.Depth -gt 8 -or $entry.ProtectedBarrier -isnot [bool]){throw 'Malformed WMI descendant evidence.'} + $parts+=@($entry.Namespace,$entry.Parent,[string]$entry.Depth,[string]$entry.ProtectedBarrier,$entry.Snapshot.DescriptorJson) + } + ConvertTo-Json -InputObject $parts -Compress -Depth 4 +} +function Get-WelaWmiDescendants { + param([string]$Namespace) + $entries=New-Object 'System.Collections.Generic.List[object]' + $diagnostics=New-Object 'System.Collections.Generic.List[string]' + $queue=New-Object 'System.Collections.Generic.Queue[object]' + $seen=New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase) + $root=$null;$started=[DateTime]::UtcNow;$bytes=0 + try { + if($Namespace -cnotmatch '^root(\\[A-Za-z_][A-Za-z0-9_]{0,63}){1,8}$'){throw 'An exact local WMI namespace is required.'} + $root=Get-WelaWmiNamespaceSnapshot $Namespace + $queue.Enqueue([pscustomobject]@{Namespace=$Namespace;Depth=0;ProtectedBarrier=$false}) + $null=$seen.Add($Namespace) + while($queue.Count){ + if(([DateTime]::UtcNow-$started).TotalSeconds -gt 30){throw 'WMI tree scan time budget exceeded; individual provider calls are not forcibly cancellable.'} + $parent=$queue.Dequeue() + $children=@(Get-WelaWmiChildNames $parent.Namespace (64-$entries.Count)) + if($entries.Count+$children.Count -gt 64){throw 'WMI descendant count exceeds 64.'} + if($parent.Depth -ge 8 -and $children.Count){throw 'WMI descendant depth exceeds eight.'} + foreach($name in $children){ + if($name -isnot [string] -or $name -cnotmatch '^[A-Za-z_][A-Za-z0-9_]{0,63}$'){throw 'Unsupported native child namespace name.'} + $path=$parent.Namespace+'\'+$name + if(-not $seen.Add($path)){throw 'Duplicate or ambiguous WMI namespace during enumeration.'} + $snapshot=Get-WelaWmiNamespaceSnapshot $path + if($snapshot.Namespace -cne $path -or -not $snapshot.DescriptorMof){throw 'Incomplete or mismatched namespace descriptor.'} + $bytes+=[Text.Encoding]::UTF8.GetByteCount($snapshot.DescriptorJson+$snapshot.DescriptorMof) + if($bytes -gt 2097152){throw 'WMI descendant evidence exceeds two MiB.'} + $descriptor=$snapshot.DescriptorJson|ConvertFrom-Json -ErrorAction Stop + if($null -eq $descriptor.ControlFlags){throw 'Incomplete child descriptor controls.'} + $barrier=$parent.ProtectedBarrier -or (([uint32]$descriptor.ControlFlags -band 8192) -ne 0) + $entry=[pscustomobject]@{Namespace=$path;Parent=$parent.Namespace;Depth=$parent.Depth+1;ProtectedBarrier=[bool]$barrier;Snapshot=$snapshot} + $entries.Add($entry);$queue.Enqueue($entry) + } + } + } catch {$diagnostics.Add($_.Exception.Message)} + [pscustomobject]@{Status=$(if($diagnostics.Count){'Incomplete'}else{'Complete'});Maximum=64;MaximumDepth=8;StartedUtc=$started.ToString('o');CompletedUtc=[DateTime]::UtcNow.ToString('o');Root=$root;Entries=@($entries.ToArray());Diagnostics=@($diagnostics.ToArray())} +} +function Get-WelaWmiStableDescendants { + param([string]$Namespace) + $context=Get-WelaWmiDescendantContext + try { + $first=Get-WelaWmiDescendants $Namespace + if($first.Status -cne 'Complete'){throw ('Incomplete WMI descendant inventory: '+($first.Diagnostics -join '; '))} + $second=Get-WelaWmiDescendants $Namespace + if((Get-WelaWmiDescendantKey $first) -cne (Get-WelaWmiDescendantKey $second)){throw 'WMI descendant topology or full descriptor changed between observations.'} + $second|Add-Member NoteProperty Context $context + $second + } finally { + if((Get-WelaWmiDescendantContext) -cne $context){throw 'Full caller token, host, source or Winmgmt state changed while observing WMI descendants.'} + } +} +function Test-WelaWmiDescendantOutcomes { + param($Before,$After,[array]$Definitions) + $outcomes=New-Object 'System.Collections.Generic.List[object]' + $diagnostics=New-Object 'System.Collections.Generic.List[string]' + if($After.Status -cne 'Complete'){$diagnostics.Add('Post-write descendant inventory is incomplete: '+($After.Diagnostics -join '; '))} + $map=@{};foreach($entry in $After.Entries){$map[$entry.Namespace]=$entry} + foreach($entry in $Before.Entries){ + $status='Unverified';$actual=$null;$message='' + try { + if(-not $map.ContainsKey($entry.Namespace)){throw 'Reviewed namespace disappeared or could not be observed.'} + $actual=$map[$entry.Namespace];$map.Remove($entry.Namespace) + if($actual.Parent -cne $entry.Parent -or $actual.Depth -ne $entry.Depth -or $actual.ProtectedBarrier -ne $entry.ProtectedBarrier){throw 'Descendant topology or observed protection changed.'} + $a=$entry.Snapshot.DescriptorJson|ConvertFrom-Json;$b=$actual.Snapshot.DescriptorJson|ConvertFrom-Json + if($entry.ProtectedBarrier){ + if($entry.Snapshot.DescriptorJson -cne $actual.Snapshot.DescriptorJson){throw 'Protected namespace or its subtree changed.'} + $status='ProtectedUnchanged' + } else { + # Allow only SACL_PRESENT to appear. Every other control and full + # owner/group/DACL/unknown descriptor property remains identical. + foreach($property in $a.PSObject.Properties){ + if($property.Name -eq 'SACL'){continue} + if($property.Name -eq 'ControlFlags'){ + if(([uint32]$a.ControlFlags -band (-bnot 16)) -ne ([uint32]$b.ControlFlags -band (-bnot 16)) -or (([uint32]$a.ControlFlags -band 16) -ne 0 -and ([uint32]$b.ControlFlags -band 16) -eq 0)){throw 'Child descriptor controls changed.'} + } elseif((ConvertTo-WelaWmiJson $property.Value) -cne (ConvertTo-WelaWmiJson $b.($property.Name))){throw 'Child access or unknown descriptor field changed.'} + } + $remaining=New-Object 'System.Collections.Generic.List[object]' + foreach($ace in @($b.SACL)){if($null -ne $ace){$remaining.Add($ace)}} + foreach($ace in @($a.SACL)){ + if($null -eq $ace){continue};$found=-1 + for($i=0;$i -lt $remaining.Count;$i++){if((ConvertTo-WelaWmiJson $remaining[$i]) -ceq (ConvertTo-WelaWmiJson $ace)){$found=$i;break}} + if($found -lt 0){throw 'Original child audit/unknown ACE changed or disappeared.'};$remaining.RemoveAt($found) + } + $expected=@($Definitions|Where-Object {($_.AceFlags -band 2) -ne 0}|ForEach-Object {[pscustomobject]@{Sid=$_.Sid;AccessMask=$_.AccessMask;AceFlags=([uint32]$_.AceFlags -bor 16)}}) + foreach($ace in $remaining){if(-not @($expected|Where-Object {Test-WelaWmiAceMatch $ace $_}).Count){throw 'Unexplained child audit entry appeared.'}} + foreach($definition in $expected){if(-not @($b.SACL|Where-Object {Test-WelaWmiAceMatch $_ $definition}).Count){throw 'Requested inherited ACE was not observed; existing-child propagation is unverified.'}} + $status='InheritedAceObserved' + } + } catch {$message=$_.Exception.Message;$diagnostics.Add($entry.Namespace+': '+$message)} + $outcomes.Add([pscustomobject]@{Namespace=$entry.Namespace;Status=$status;Diagnostic=$message;Before=$entry.Snapshot;After=$(if($actual){$actual.Snapshot}else{$null})}) + } + foreach($entry in $map.Values){$diagnostics.Add('New unreviewed descendant: '+$entry.Namespace);$outcomes.Add([pscustomobject]@{Namespace=$entry.Namespace;Status='NewUnreviewedNamespace';Diagnostic='No pre-write snapshot or ownership established.';Before=$null;After=$entry.Snapshot})} + [pscustomobject]@{Status=$(if($diagnostics.Count){'Unverified'}else{'Observed'});Outcomes=@($outcomes.ToArray());Diagnostics=@($diagnostics.ToArray());Scope='Observed existing namespaces only. No atomic tree, namespace recreation identity, future-child, event, recovery ownership or Sigma claim.'} +} diff --git a/tests/WmiNamespaceAuditing.DisposableNamespace.Tests.ps1 b/tests/WmiNamespaceAuditing.DisposableNamespace.Tests.ps1 index 2ecabed1..86620775 100644 --- a/tests/WmiNamespaceAuditing.DisposableNamespace.Tests.ps1 +++ b/tests/WmiNamespaceAuditing.DisposableNamespace.Tests.ps1 @@ -45,6 +45,7 @@ try { $definitions = @(Get-WelaWmiAuditDefinitions -Namespace 'root\default' -IncludeChildren) $definitions[0].Namespace = $namespace; $definitions[0].AceFlags = [uint32]$flags $entry = [pscustomobject]@{ Namespace=$namespace; Definitions=$definitions } + if($flags -eq 66){$entry|Add-Member NoteProperty Descendants (Get-WelaWmiStableDescendants $namespace)} $context = New-WelaConfigurationContext -Auto -BackupPath (Join-Path $backup ('first-' + $flags)) Set-WelaWmiAuditControls -Context $context -Plan @($entry) $case.Result = Complete-WelaConfiguration -Context $context -Scope 'wmi-namespace-sacl-only' @@ -58,6 +59,7 @@ try { Assert (Test-WelaWmiDescriptorPreserved $beforeData $afterData) 'Original access fields and existing ACEs survive the real SACL-only write' Assert (@(Get-WelaWmiMissingAces $afterData $definitions).Count -eq 0) 'Native provider stores the requested SID/mask/outcome/inheritance' $repeat = New-WelaConfigurationContext -Auto -BackupPath (Join-Path $backup ('repeat-' + $flags)) + if($flags -eq 66){$entry.Descendants=Get-WelaWmiStableDescendants $namespace} Set-WelaWmiAuditControls -Context $repeat -Plan @($entry) $case.RepeatResult = Complete-WelaConfiguration -Context $repeat -Scope 'wmi-namespace-sacl-only' Assert ($case.RepeatResult.ExitCode -eq 0 -and $case.RepeatResult.Results[0].Status -eq 'AlreadyCompliant') 'Repeated real configuration is idempotent' diff --git a/tests/WmiNamespaceAuditing.Tests.ps1 b/tests/WmiNamespaceAuditing.Tests.ps1 index 0ccc0164..2ee0aebe 100644 --- a/tests/WmiNamespaceAuditing.Tests.ps1 +++ b/tests/WmiNamespaceAuditing.Tests.ps1 @@ -41,6 +41,8 @@ function Set-WelaWmiNamespaceDescriptor { if ($script:dropUnknown) { $script:descriptor.SACL = @($script:descriptor.SACL | Where-Object AceType -ne 19) } } function Read-Host { param($Prompt) if ($script:promptCallback) { & $script:promptCallback }; if ($script:decline) { 'n' } else { 'Y' } } +function Get-WelaWmiChildNames {param($Namespace,$Maximum) @()} +function Get-WelaWmiDescendantContext {'Mock unchanged caller/host/source context'} function New-TestContext([switch]$DryRun, [switch]$Prompt) { $script:context = New-WelaConfigurationContext -Auto:(-not $Prompt) -DryRun:$DryRun -BackupPath (Join-Path $root ([guid]::NewGuid().ToString('N'))) return $script:context diff --git a/tests/WmiNamespaceDescendants.Windows.Tests.ps1 b/tests/WmiNamespaceDescendants.Windows.Tests.ps1 new file mode 100644 index 00000000..025c6f0e --- /dev/null +++ b/tests/WmiNamespaceDescendants.Windows.Tests.ps1 @@ -0,0 +1,100 @@ +param([switch]$AllowDisposableNamespaceWrite,[string]$EvidencePath='wmi-descendants-native.json') +$ErrorActionPreference='Stop' +if(-not $AllowDisposableNamespaceWrite -or $env:OS -ne 'Windows_NT'){throw 'Requires disposable Windows and explicit namespace-write consent.'} +$repo=Split-Path $PSScriptRoot -Parent +. (Join-Path $repo 'scripts/Configuration.ps1') +. (Join-Path $repo 'scripts/WmiNamespaceAuditing.ps1') +. (Join-Path $repo 'scripts/WmiProbe.ps1') +Initialize-WelaWmiInterop;Initialize-WelaWmiProbeNative +$script:assertions=0 +function Assert($v,[string]$m){if(-not $v){throw $m};$script:assertions++} +function Safety { + $masks=[ordered]@{};foreach($p in (Get-Content (Join-Path $repo 'config/audit_profiles.json') -Raw|ConvertFrom-Json).catalog){$masks[$p.guid]=Get-WelaAuditPolicyMask $p.guid} + if($masks.Count -ne 59){throw 'Complete 59-subcategory inventory required.'} + [pscustomobject]@{Token=(Get-WelaWmiProbeTokenKey ([Wela.WmiProbe.Native]::Snapshot()));Masks=$masks;Precedence=(Get-WelaRegistryState 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' SCENoApplyLegacyAuditPolicy);Services=@(Get-Service Winmgmt,EventLog|Sort-Object Name|Select-Object Name,@{n='Status';e={[string]$_.Status}})} +} +$owned=New-Object 'System.Collections.Generic.List[object]' +function New-OwnedNamespace([string]$Parent,[string]$Name){ + $factory=New-Object System.Management.ManagementClass -ArgumentList ('\\.\'+$Parent+':__Namespace');$instance=$null + try{ + $instance=$factory.CreateInstance();$instance.Name=$Name;$o=New-Object System.Management.PutOptions;$o.Type=[System.Management.PutType]::CreateOnly + $path=$instance.Put($o);$owned.Add([pscustomobject]@{Parent=$Parent;Name=$Name;Path=$Parent+'\'+$Name;Instance=$instance;Removed=$false});$instance=$null + Assert ($path.RelativePath -ieq ('__NAMESPACE.Name="'+$Name+'"')) 'Created identity differs from owned request.' + $Parent+'\'+$Name + }finally{if($instance){$instance.Dispose()};$factory.Dispose()} +} +function Entry([string]$Namespace){ + $d=@(Get-WelaWmiAuditDefinitions -Namespace 'root\default' -IncludeChildren);$d[0].Namespace=$Namespace + [pscustomobject]@{Namespace=$Namespace;Definitions=$d;Descendants=(Get-WelaWmiStableDescendants $Namespace)} +} +function Configure($Entry,[string]$Name,[switch]$DryRun){ + $c=New-WelaConfigurationContext -Auto -DryRun:$DryRun -BackupPath (Join-Path $backup $Name) + Set-WelaWmiAuditControls -Context $c -Plan @($Entry) + Complete-WelaConfiguration -Context $c -Scope wmi-namespace-sacl-only +} +$backup=Join-Path ([IO.Path]::GetTempPath()) ('wela-wmi-tree-'+[guid]::NewGuid().ToString('N')) +$e=[ordered]@{SchemaVersion=1;Host=$env:COMPUTERNAME;Version=[Environment]::OSVersion.VersionString;PowerShell=$PSVersionTable.PSVersion.ToString();Head=$env:GITHUB_SHA;Cases=@();Before=$null;After=$null;Sources=@();Cleanup=@();Complete=$false;Failure=$null} +$failure=$null +try{ + $e.Before=Safety + $root=New-OwnedNamespace root ('WelaInheritance_'+[guid]::NewGuid().ToString('N')) + $child=New-OwnedNamespace $root Existing + $grand=New-OwnedNamespace $child Grandchild + $special=New-OwnedNamespace $root Explicit + # Owned child with a distinct explicit failure ACE; access descriptors remain intact. + $s=Get-WelaWmiNamespaceSnapshot $special + $specialDef=[pscustomobject]@{Sid='S-1-5-18';AccessMask=[uint32]1;AceFlags=[uint32]128} + $null=Set-WelaWmiNamespaceDescriptor $special $s.DescriptorJson @($specialDef) + $p=Entry $root + Assert ($p.Descendants.Entries.Count -eq 3) 'All existing children and the grandchild are captured.' + $dry=Configure $p dry -DryRun + Assert ($dry.ExitCode -eq 0 -and $dry.Results[0].Status -eq 'Skipped' -and -not (Test-Path $backup)) 'Tree dry-run wrote state or backup.' + Assert ((Get-WelaWmiDescendantKey (Get-WelaWmiStableDescendants $root)) -ceq (Get-WelaWmiDescendantKey $p.Descendants)) 'Dry-run changed tree.' + $extra=New-OwnedNamespace $root Stale + $stale=Configure $p stale + Assert ($stale.ExitCode -eq 1 -and $stale.Results[0].Diagnostic -match 'changed after planning') 'New child failed to invalidate plan.' + Assert ((Get-WelaWmiNamespaceSnapshot $root).DescriptorJson -ceq $p.Descendants.Root.DescriptorJson) 'Stale topology allowed a parent setter.' + $p=Entry $root + $result=Configure $p apply + $after=Get-WelaWmiStableDescendants $root + $parentBefore=$p.Descendants.Root.DescriptorJson|ConvertFrom-Json;$parentAfter=$after.Root.DescriptorJson|ConvertFrom-Json + Assert (Test-WelaWmiDescriptorPreserved $parentBefore $parentAfter) 'Parent access or existing ACE preservation failed.' + Assert (@(Get-WelaWmiMissingAces $parentAfter $p.Definitions).Count -eq 0) 'Parent setter failed to apply requested inheritance ACE.' + $outcome=Test-WelaWmiDescendantOutcomes $p.Descendants $after $p.Definitions + Assert (($outcome.Status -eq 'Observed' -and $result.ExitCode -eq 0) -or ($outcome.Status -eq 'Unverified' -and $result.ExitCode -eq 1)) 'Configuration status misrepresents actual child observations.' + $journal=@(Get-Content (Join-Path $backup 'apply/before.jsonl')|ConvertFrom-Json) + Assert ($journal.Count -eq 1 -and $journal[0].Before.Descendants.Entries.Count -eq 4) 'Original complete subtree missing from journal.' + Assert ((Get-WelaWmiDescendantKey $journal[0].Before.Descendants) -ceq (Get-WelaWmiDescendantKey $p.Descendants)) 'Journal tree differs from pre-write snapshots.' + $e.Cases+=@{Name='ExistingTree';Plan=$p;Result=$result;After=$after;Outcomes=$outcome;Journal=$journal} + # A genuinely newly created namespace independently demonstrates provider inheritance. + $future=New-OwnedNamespace $root Future + $futureSnapshot=Get-WelaWmiNamespaceSnapshot $future + $futureDescriptor=$futureSnapshot.DescriptorJson|ConvertFrom-Json + $expected=[pscustomobject]@{Sid=$p.Definitions[0].Sid;AccessMask=$p.Definitions[0].AccessMask;AceFlags=[uint32]82} + Assert (@($futureDescriptor.SACL|Where-Object {Test-WelaWmiAceMatch $_ $expected}).Count -eq 1) 'New child did not expose the exact native inherited ACE.' + $e.Cases+=@{Name='NewChildInheritance';Snapshot=$futureSnapshot;Expected=$expected} + $repeat=Configure (Entry $root) repeat + Assert ($repeat.ExitCode -eq $result.ExitCode) 'Repeat no longer reflects observed existing descendant outcomes.' + Assert ((Get-WelaWmiNamespaceSnapshot $root).DescriptorJson -ceq $after.Root.DescriptorJson) 'Idempotent parent repeat changed descriptor.' + $e.Cases+=@{Name='Repeat';Result=$repeat} + # A reviewed empty descendant set can complete, and a later child observes inheritance. + $empty=New-OwnedNamespace root ('WelaInheritance_'+[guid]::NewGuid().ToString('N')) + $emptyResult=Configure (Entry $empty) empty + Assert ($emptyResult.ExitCode -eq 0 -and $emptyResult.Results[0].Status -eq 'Applied') 'Reviewed empty tree did not apply.' + $emptyRepeat=Configure (Entry $empty) emptyrepeat + Assert ($emptyRepeat.ExitCode -eq 0 -and $emptyRepeat.Results[0].Status -eq 'AlreadyCompliant') 'Empty-tree repeat is not idempotent.' + $e.Cases+=@{Name='EmptyTree';Result=$emptyResult;Repeat=$emptyRepeat} + $e.Assertions=$script:assertions +}catch{$failure=$_;$e.Failure=$_.Exception.ToString()} +finally{ + $cleanupErrors=@() + for($i=$owned.Count-1;$i -ge 0;$i--){$item=$owned[$i];try{$item.Instance.Delete();$names=@(Get-WelaWmiChildNames $item.Parent 64);if($item.Name -in $names){throw 'Owned namespace still exists after deletion.'};$item.Removed=$true}catch{$cleanupErrors+=$_.Exception.ToString()}finally{$item.Instance.Dispose()}} + $e.Cleanup=@($owned|Select-Object Parent,Name,Path,Removed);$e.CleanupErrors=$cleanupErrors + try{$e.After=Safety;Assert (($e.Before|ConvertTo-Json -Depth 12 -Compress) -ceq ($e.After|ConvertTo-Json -Depth 12 -Compress)) 'Full token/audit/precedence/services changed.'}catch{$cleanupErrors+=$_.Exception.ToString();$e.CleanupErrors=$cleanupErrors} + foreach($f in @('scripts/WmiNamespaceAuditing.ps1','scripts/WmiNamespaceDescendants.ps1','scripts/WmiProbe.ps1','scripts/WmiProbeNative.cs','scripts/Configuration.ps1','tests/WmiNamespaceDescendants.Windows.Tests.ps1')){$e.Sources+=@{Path=$f;Sha256=(Get-FileHash (Join-Path $repo $f) -Algorithm SHA256).Hash.ToLowerInvariant()}} + $e.Complete=($null -eq $failure -and $cleanupErrors.Count -eq 0) + $e|ConvertTo-Json -Depth 25|Set-Content -LiteralPath $EvidencePath -Encoding UTF8 + if(Test-Path $backup){Copy-Item $backup -Destination ($EvidencePath+'.journals') -Recurse;Remove-Item $backup -Recurse -Force} +} +if($failure){throw $failure};if(-not $e.Complete){throw 'Native WMI descendant cleanup or safety verification failed.'} +Write-Host "PASS: $script:assertions native WMI descendant assertions, complete owned-tree cleanup."