Merge branch 'feat/367-native-channel-access' into feat/371-ad-object-sacl

# Conflicts:
#	CHANGELOG-Japanese.md
#	CHANGELOG.md
#	WELA.ps1
#	scripts/Configuration.ps1
#	website/docs/resources/changelog.ja.md
#	website/docs/resources/changelog.md
This commit is contained in:
Shirofune-Security committed 2026-09-19 06:54:08 +09:00
commit 9d993a2a7e
26 files changed
+2286 -4

No files matched your search

+255
View File
@@ -0,0 +1,255 @@
# Native AppLocker observations and a deliberately narrow local audit-only import.
function ConvertFrom-WelaAppLockerXml {
param([Parameter(Mandatory)][string]$Xml, [switch]$ForImport)
$settings = New-Object Xml.XmlReaderSettings
$settings.DtdProcessing = [Xml.DtdProcessing]::Prohibit; $settings.XmlResolver = $null
$settings.MaxCharactersInDocument = 10485760
$reader = [Xml.XmlReader]::Create((New-Object IO.StringReader($Xml)), $settings)
try {
$doc = New-Object Xml.XmlDocument; $doc.XmlResolver = $null
$doc.Load($reader)
} finally { $reader.Dispose() }
if ($doc.DocumentElement.LocalName -cne 'AppLockerPolicy' -or $doc.DocumentElement.NamespaceURI -or $doc.DocumentElement.GetAttribute('Version') -ne '1') { throw 'Expected unqualified AppLockerPolicy Version=1.' }
$unknownPolicyData = @($doc.DocumentElement.Attributes | Where-Object { $_.NamespaceURI -or $_.Name -cne 'Version' }).Count -gt 0 -or
@($doc.DocumentElement.ChildNodes | Where-Object { $_.NodeType -notin @('Element', 'Whitespace', 'SignificantWhitespace', 'Comment') }).Count -gt 0
if ($ForImport -and $unknownPolicyData) { throw 'Unknown policy attributes/content are not accepted for import.' }
$collections = New-Object 'System.Collections.Generic.List[object]'
$types = @{}; $ids = @{}
foreach ($node in @($doc.DocumentElement.ChildNodes | Where-Object NodeType -eq Element)) {
if ($node.LocalName -ne 'RuleCollection') { throw "Unsupported AppLocker policy element: $($node.LocalName)" }
$type = $node.GetAttribute('Type'); $mode = $node.GetAttribute('EnforcementMode')
if ($type -notin @('Exe', 'Dll', 'Msi', 'Script', 'Appx') -or $types.ContainsKey($type)) { throw "Unknown/duplicate rule collection: $type" }
if ($mode -notin @('Enabled', 'AuditOnly', 'NotConfigured')) { throw "Unknown enforcement mode: $mode" }
$types[$type] = $true
$rules = @($node.ChildNodes | Where-Object { $_.NodeType -eq 'Element' -and $_.LocalName -in @('FilePathRule', 'FilePublisherRule', 'FileHashRule') })
if ($ForImport) {
if ($mode -ne 'AuditOnly' -or -not $rules.Count) { throw 'Every imported collection must explicitly be AuditOnly and contain rules.' }
if (@($node.ChildNodes | Where-Object { $_.NodeType -eq 'Element' -and $_.LocalName -notin @('FilePathRule', 'FilePublisherRule', 'FileHashRule') }).Count) { throw 'Policy extensions/unknown rule elements are not accepted for import.' }
foreach ($rule in $rules) {
$guid = [guid]::Empty
if (-not [guid]::TryParse($rule.GetAttribute('Id'), [ref]$guid) -or $ids.ContainsKey($guid.ToString())) { throw 'Rule IDs must be valid and globally unique.' }
$ids[$guid.ToString()] = $true
if ($rule.GetAttribute('Action') -notin @('Allow', 'Deny') -or $rule.GetAttribute('UserOrGroupSid') -notmatch '^S-1-\d+(-\d+)+$' -or -not $rule.GetAttribute('Name')) { throw 'Invalid rule action, SID or name.' }
if (@($rule.SelectNodes('./Conditions')).Count -ne 1 -or -not $rule.SelectSingleNode('./Conditions/*')) { throw 'Each rule must have conditions.' }
# Reject hidden extension nodes and namespaces; Windows validates the
# complete native rule schema before applying the prepared snapshot.
if (@($rule.ChildNodes | Where-Object { $_.NodeType -eq 'Element' -and $_.LocalName -notin @('Conditions', 'Exceptions') }).Count) { throw 'Unknown rule child element.' }
}
}
# Some serializers can include empty NotConfigured collection shells.
# Only this exact shape is ignorable; zero rules alone is insufficient.
$placeholder = $node.LocalName -ceq 'RuleCollection' -and -not $node.NamespaceURI -and
$type -cin @('Exe', 'Dll', 'Msi', 'Script', 'Appx') -and $mode -ceq 'NotConfigured' -and
$node.Attributes.Count -eq 2 -and
@($node.Attributes | Where-Object { $_.NamespaceURI -or $_.Name -cnotin @('Type', 'EnforcementMode') }).Count -eq 0 -and
@($node.ChildNodes | Where-Object { $_.NodeType -notin @('Whitespace', 'SignificantWhitespace', 'Comment') }).Count -eq 0
$collections.Add([pscustomobject]@{ Type=$type; EnforcementMode=$mode; RuleCount=$rules.Count; IsEmptyPlaceholder=[bool]$placeholder; PotentialEnforcement=($mode -eq 'Enabled' -or ($mode -eq 'NotConfigured' -and $rules.Count -gt 0)); Xml=$node.OuterXml })
}
if ($ForImport -and -not $collections.Count) { throw 'An empty policy cannot supply AppLocker generation prerequisites.' }
if ($ForImport -and @($doc.SelectNodes('//*') | Where-Object { $_.NamespaceURI -or @($_.Attributes | Where-Object { $_.NamespaceURI }).Count }).Count) { throw 'Namespaced policy elements/attributes are not accepted for import.' }
[pscustomobject]@{ Xml=$doc.OuterXml; Collections=@($collections.ToArray()); EmptyPlaceholderCount=@($collections.ToArray() | Where-Object IsEmptyPlaceholder).Count; HasUnknownPolicyData=[bool]$unknownPolicyData; TotalRules=(@($collections.ToArray() | Measure-Object RuleCount -Sum)[0].Sum); HasEnforcement=(@($collections.ToArray() | Where-Object PotentialEnforcement).Count -gt 0) }
}
function Get-WelaAppLockerHost {
try {
$os = Get-CimInstance -ClassName Win32_OperatingSystem -Property BuildNumber, ProductType, Caption -ErrorAction Stop
$computer = Get-CimInstance -ClassName Win32_ComputerSystem -Property PartOfDomain -ErrorAction Stop
if (-not $os -or $os.BuildNumber -notmatch '^\d+$' -or $null -eq $computer -or $computer.PartOfDomain -isnot [bool]) { throw 'Host applicability or management state is unknown.' }
$eligible = ($os.ProductType -eq 1 -and [int]$os.BuildNumber -ge 22000) -or ($os.ProductType -eq 3 -and [int]$os.BuildNumber -ge 14393)
$state = if ($eligible) { 'Candidate' } else { 'NotApplicable' }
[pscustomobject]@{ Status=$state; Build=[int]$os.BuildNumber; ProductType=[int]$os.ProductType; Caption=[string]$os.Caption; PartOfDomain=$computer.PartOfDomain; Is64BitProcess=[Environment]::Is64BitProcess; Diagnostic='Native cmdlet/service observations determine capability; no edition-only inference. Import scope is local client/member server.' }
} catch { [pscustomobject]@{ Status='Unknown'; Diagnostic=$_.Exception.Message } }
}
function Get-WelaAppLockerPolicySnapshot {
param([ValidateSet('Local', 'Effective')][string]$Scope)
try {
if (-not (Get-Command Get-AppLockerPolicy -ErrorAction SilentlyContinue)) { return [pscustomobject]@{ Status='CmdletUnavailable'; Policy=$null; Diagnostic='Get-AppLockerPolicy is unavailable in this PowerShell session; capability is unverified.' } }
$arguments = @{ Xml=$true; ErrorAction='Stop' }; $arguments[$Scope] = $true
$xml = [string](Get-AppLockerPolicy @arguments)
[pscustomobject]@{ Status='Observed'; Policy=(ConvertFrom-WelaAppLockerXml -Xml $xml); Diagnostic='GP policy only. AppLocker CSP policy is not visible to this cmdlet.' }
} catch { [pscustomobject]@{ Status='Unknown'; Policy=$null; Diagnostic=$_.Exception.Message } }
}
function Get-WelaAppLockerService {
try {
$service = Get-CimInstance -ClassName Win32_Service -Filter "Name='AppIDSvc'" -ErrorAction Stop
if (-not $service) { return [pscustomobject]@{ Status='NotInstalled'; State=$null; StartMode=$null; Diagnostic='Application Identity service was not found.' } }
[pscustomobject]@{ Status='Observed'; State=[string]$service.State; StartMode=[string]$service.StartMode; Diagnostic='Service state observed; no service changes were made.' }
} catch { [pscustomobject]@{ Status='Unknown'; State=$null; StartMode=$null; Diagnostic=$_.Exception.Message } }
}
function Get-WelaAppLockerChannels {
foreach ($name in @('EXE and DLL', 'MSI and Script', 'Packaged app-Execution', 'Packaged app-Deployment')) {
$channel = "Microsoft-Windows-AppLocker/$name"
try {
$log = Get-WinEvent -ListLog $channel -ErrorAction Stop
if (-not $log -or $log.LogName -ne $channel) { throw 'Channel read did not return the requested channel.' }
[pscustomobject]@{ Channel=$channel; Status='Observed'; Enabled=[bool]$log.IsEnabled; Diagnostic='Channel enablement is not proof of event generation.' }
} catch {
$state = if ($_.FullyQualifiedErrorId -like 'NoMatchingLogsFound*') { 'NotInstalled' } else { 'Unknown' }
[pscustomobject]@{ Channel=$channel; Status=$state; Enabled=$null; Diagnostic=$_.Exception.Message }
}
}
}
function Get-WelaAppLockerManagement {
# These are blockers, not an assertion that CSP policy is absent. The native
# cmdlets cannot read CSP; import is confined to apparently unmanaged hosts.
try {
$present = @()
foreach ($path in @('HKLM:\SOFTWARE\Microsoft\Enrollments', 'HKLM:\SOFTWARE\Microsoft\PolicyManager\Providers')) {
if (Test-Path -LiteralPath $path -ErrorAction Stop) {
$present += @(Get-ChildItem -LiteralPath $path -ErrorAction Stop | Where-Object { $_.PSChildName -match '^\{?[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}\}?$' } | ForEach-Object { $_.Name })
}
}
[pscustomobject]@{ Status='Observed'; ManagementEntries=$present; CspPolicyState='Unknown'; Diagnostic='No CSP policy completeness claim. Any observed enrollment/provider blocks local import.' }
} catch { [pscustomobject]@{ Status='Unknown'; ManagementEntries=@(); CspPolicyState='Unknown'; Diagnostic=$_.Exception.Message } }
}
function Get-WelaAppLockerReadiness {
$hostState = Get-WelaAppLockerHost
$local = Get-WelaAppLockerPolicySnapshot Local; $effective = Get-WelaAppLockerPolicySnapshot Effective
$service = Get-WelaAppLockerService; $channels = @(Get-WelaAppLockerChannels)
$rows = foreach ($type in @('Exe', 'Dll', 'Msi', 'Script', 'Appx')) {
$collection = @($effective.Policy.Collections | Where-Object Type -eq $type) | Select-Object -First 1
$names = switch ($type) { 'Exe' { 'EXE and DLL' } 'Dll' { 'EXE and DLL' } 'Msi' { 'MSI and Script' } 'Script' { 'MSI and Script' } 'Appx' { 'Packaged app-Execution'; 'Packaged app-Deployment' } }
$logs = @($channels | Where-Object { $_.Channel.Substring('Microsoft-Windows-AppLocker/'.Length) -in $names })
$state = if ($hostState.Status -eq 'NotApplicable') { 'NotApplicable' }
elseif ($hostState.Status -ne 'Candidate' -or $effective.Status -ne 'Observed' -or $service.Status -eq 'Unknown') { 'Unknown' }
elseif (-not $collection -or $collection.RuleCount -eq 0) { 'MissingGpPolicy' }
elseif ($service.Status -eq 'NotInstalled') { 'NotInstalled' }
elseif ($service.StartMode -eq 'Disabled' -or $service.State -ne 'Running') { 'ServiceNotRunning' }
elseif (@($logs | Where-Object Status -ne 'Observed').Count) { 'ChannelUnknown' }
elseif (@($logs | Where-Object { -not $_.Enabled }).Count) { 'ChannelDisabled' }
else { 'Conditional' }
[pscustomobject]@{ Type=$type; EnforcementMode=if ($collection) {$collection.EnforcementMode} else {$null}; RuleCount=if ($collection) {$collection.RuleCount} else {0}; PotentialEnforcement=if ($collection) {$collection.PotentialEnforcement} else {$false}; PrerequisiteState=$state; Channels=$logs; GenerationReadiness='Unverified'; Diagnostic='Local/GP observations only; CSP policies and actual executable/script event XML require separate verification.' }
}
[pscustomobject]@{ Scope='native-applocker-readiness'; Host=$hostState; LocalPolicy=$local; EffectiveGpPolicy=$effective; Service=$service; Collections=@($rows); Management=(Get-WelaAppLockerManagement); CspPolicyState='Unknown'; UsableRuleCredit=0; GenerationReadiness='Unverified' }
}
function Get-WelaAppLockerXmlKey {
param([string]$Xml)
# Compare policy meaning without treating native XML formatting/attribute
# ordering as a failed write. Rule IDs are unique, so rule order is immaterial.
$document = New-Object Xml.XmlDocument; $document.XmlResolver=$null; $document.LoadXml($Xml)
function Convert-WelaAppLockerNodeKey($Node) {
$attributes = @($Node.Attributes | Where-Object { -not ($_.LocalName -eq 'Description' -and $_.Value -eq '') } | Sort-Object Name | ForEach-Object { @($_.Name, $_.Value) -join '=' })
$children = @($Node.ChildNodes | Where-Object NodeType -eq Element | ForEach-Object { Convert-WelaAppLockerNodeKey $_ } | Sort-Object)
# JSON arrays delimit values so attribute/condition text cannot collide.
return ConvertTo-Json -InputObject @($Node.LocalName, $attributes, $children) -Depth 20 -Compress
}
Convert-WelaAppLockerNodeKey $document.DocumentElement
}
function Test-WelaAppLockerPolicyMatch {
param($Snapshot, $Desired)
if ($Snapshot.LocalPolicy.Status -ne 'Observed') { return $false }
$current = $Snapshot.LocalPolicy.Policy
$currentCollections = @($current.Collections | Where-Object { -not $_.IsEmptyPlaceholder })
if ($currentCollections.Count -ne $Desired.Collections.Count -or $current.HasEnforcement -or $current.HasUnknownPolicyData) { return $false }
foreach ($wanted in $Desired.Collections) {
$actual = @($currentCollections | Where-Object Type -eq $wanted.Type)
if ($actual.Count -ne 1 -or (Get-WelaAppLockerXmlKey $actual[0].Xml) -cne (Get-WelaAppLockerXmlKey $wanted.Xml)) { return $false }
}
return $true
}
function Assert-WelaAppLockerImportSafe {
param($Snapshot, $Desired)
if ($Snapshot.Host.Status -ne 'Candidate' -or -not $Snapshot.Host.Is64BitProcess) { throw 'Local import requires a supported 64-bit Windows client/member-server session.' }
if ($Snapshot.Host.PartOfDomain -or $Snapshot.Management.Status -ne 'Observed' -or @($Snapshot.Management.ManagementEntries).Count) { throw 'Local import is blocked on domain-joined, managed or unknown-management hosts. Deploy through the existing policy authority.' }
if ($Snapshot.LocalPolicy.Status -ne 'Observed' -or $Snapshot.EffectiveGpPolicy.Status -ne 'Observed') { throw 'Both local and GP effective policies must be readable.' }
if ($Snapshot.LocalPolicy.Policy.HasEnforcement -or $Snapshot.EffectiveGpPolicy.Policy.HasEnforcement) { throw 'Existing enforcement (including NotConfigured collections with rules) is preserved; audit-only import is blocked.' }
if ($Snapshot.LocalPolicy.Policy.HasUnknownPolicyData -or $Snapshot.EffectiveGpPolicy.Policy.HasUnknownPolicyData) { throw 'Unknown policy attributes/content are preserved; audit-only import is blocked.' }
if (Test-WelaAppLockerPolicyMatch -Snapshot $Snapshot -Desired $Desired) { return }
# -Merge preserves target enforcement settings. A currently empty
# NotConfigured target can enforce the new rules once merged. Only unused
# placeholders are safe to ignore before an import; do not remove/change them.
$targetPlaceholders = @(@($Snapshot.LocalPolicy.Policy.Collections) + @($Snapshot.EffectiveGpPolicy.Policy.Collections) |
Where-Object { $_.IsEmptyPlaceholder -and $_.Type -in $Desired.Collections.Type })
if ($targetPlaceholders.Count) { throw ('Empty NotConfigured collection(s) targeted by this import are preserved: ' + (($targetPlaceholders.Type | Select-Object -Unique) -join ', ') + '. A merge may retain NotConfigured and enforce newly added rules; review these collections through the existing policy authority before importing.') }
if (@($Snapshot.LocalPolicy.Policy.Collections | Where-Object { -not $_.IsEmptyPlaceholder }).Count -or
@($Snapshot.EffectiveGpPolicy.Policy.Collections | Where-Object { -not $_.IsEmptyPlaceholder }).Count) { throw 'Existing policy is preserved. Import only initializes an empty local/GP policy; it never replaces a configured policy.' }
}
function New-WelaAppLockerImportReadLock {
param([string]$Path, [string]$Xml)
# CreateNew refuses a pre-existing file/link in the backup directory. Native
# readers generally require that the writer handle has already been closed.
$writer = [IO.File]::Open($Path, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
try {
$bytes = [Text.Encoding]::UTF8.GetBytes($Xml)
$writer.Write($bytes, 0, $bytes.Length)
$writer.Flush()
} finally { $writer.Dispose() }
return [IO.File]::Open($Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read)
}
function Set-WelaAppLockerAuditPolicy {
param($Context, $Desired)
$state = @{ Desired=$Desired; Before=$null; Context=$Context }
$read = { param($state) $snapshot = Get-WelaAppLockerReadiness; Assert-WelaAppLockerImportSafe $snapshot $state.Desired; $state.Before=$snapshot; return $snapshot }
$test = { param($snapshot, $state) Test-WelaAppLockerPolicyMatch $snapshot $state.Desired }
$apply = {
param($state)
$fresh = Get-WelaAppLockerReadiness
Assert-WelaAppLockerImportSafe $fresh $state.Desired
if ($fresh.LocalPolicy.Policy.Xml -cne $state.Before.LocalPolicy.Policy.Xml -or $fresh.EffectiveGpPolicy.Policy.Xml -cne $state.Before.EffectiveGpPolicy.Policy.Xml) { throw 'AppLocker policy changed after the recovery snapshot; no policy was imported.' }
if (-not (Get-Command Set-AppLockerPolicy -ErrorAction SilentlyContinue)) { throw 'Set-AppLockerPolicy is unavailable in this session.' }
# Import the validated in-memory snapshot, not a mutable operator source file.
$path = Join-Path $state.Context.BackupPath 'appLocker-audit-import.xml'
if (-not (Get-Command Test-AppLockerPolicy -ErrorAction SilentlyContinue)) { throw 'Test-AppLockerPolicy is unavailable; native schema validation is required before import.' }
# Deny concurrent modification/deletion of the prepared XML while both
# native cmdlets consume it; they need only read access.
$lock = New-WelaAppLockerImportReadLock -Path $path -Xml $state.Desired.Xml
try {
# The file can be replaced between writer-close and read-lock-open.
# Validate the locked bytes against the already reviewed snapshot,
# since native schema validation alone also accepts enforcing XML.
$expectedBytes = [Text.Encoding]::UTF8.GetBytes($state.Desired.Xml)
if ($lock.Length -ne $expectedBytes.Length) { throw 'Prepared AppLocker XML changed before its read lock; no policy was imported.' }
$hasher = [Security.Cryptography.SHA256]::Create()
try {
$expectedHash = [Convert]::ToBase64String($hasher.ComputeHash($expectedBytes))
$actualHash = [Convert]::ToBase64String($hasher.ComputeHash($lock))
if ($actualHash -cne $expectedHash) { throw 'Prepared AppLocker XML changed before its read lock; no policy was imported.' }
} finally { $hasher.Dispose() }
$validation = @(Test-AppLockerPolicy -XmlPolicy $path -Path "$env:SystemRoot\System32\cmd.exe" -User 'S-1-1-0' -ErrorAction Stop)
if (-not $validation.Count) { throw 'Native policy validation returned no result; no policy was imported.' }
$immediate = Get-WelaAppLockerReadiness
Assert-WelaAppLockerImportSafe $immediate $state.Desired
if ($immediate.LocalPolicy.Policy.Xml -cne $state.Before.LocalPolicy.Policy.Xml -or $immediate.EffectiveGpPolicy.Policy.Xml -cne $state.Before.EffectiveGpPolicy.Policy.Xml) { throw 'Policy changed during native validation; no policy was imported.' }
Set-AppLockerPolicy -XmlPolicy $path -Merge -ErrorAction Stop
} finally { $lock.Dispose() }
'Audit-only local policy merged. Service, event generation, CSP state and future policy refresh are not configured or verified.'
}
Invoke-WelaConfigurationControl -Context $Context -Id 'AppLocker/LocalAuditOnlyPolicy' -Kind AppLocker -Target 'Local GPO' -Desired $Desired `
-Read $read -Compliant $test -Apply $apply -CallbackState $state -Description 'Initialize empty local AppLocker policy from this operator-supplied audit-only XML; preserve existing policies.'
}
function Invoke-WelaAppLockerCommand {
param([ValidateSet('Audit','Plan','Import')][string]$Action='Audit', [string]$PolicyPath, [switch]$Auto, [switch]$DryRun, [string]$BackupPath, [string]$ResultsPath)
if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { throw 'AppLocker readiness requires Windows.' }
if ($DryRun -and $Action -ne 'Import') { throw '-DryRun applies only to AppLockerAction Import.' }
if ($Action -eq 'Import' -and -not $PolicyPath) { throw '-AppLockerPolicyPath is required for Import.' }
$desired = $null
if ($PolicyPath) { $desired = ConvertFrom-WelaAppLockerXml -Xml (Get-Content -LiteralPath $PolicyPath -Raw -ErrorAction Stop) -ForImport }
if ($Action -eq 'Import') {
$context = New-WelaConfigurationContext -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath
Set-WelaAppLockerAuditPolicy -Context $context -Desired $desired
$report = Complete-WelaConfiguration -Context $context -Scope 'native-windows-configuration' -SuccessMessage 'Requested local audit-only policy verified; AppLocker event generation remains unverified.'
$report | Add-Member NoteProperty VerificationScope 'Local audit-only policy readback only; no service changes, CSP assessment, event-generation or forwarding verification.'
} else {
$assessment = Get-WelaAppLockerReadiness
$blocker = $null
if ($desired) { try { Assert-WelaAppLockerImportSafe $assessment $desired } catch { $blocker=$_.Exception.Message } }
$report = [pscustomobject]@{ Scope='native-applocker-readiness'; Action=$Action; Assessment=$assessment; ProposedAuditPolicy=$desired; ImportBlocker=$blocker; ExitCode=0 }
if ($assessment.Host.Status -eq 'Unknown' -or $assessment.LocalPolicy.Status -in @('Unknown','CmdletUnavailable') -or $assessment.EffectiveGpPolicy.Status -in @('Unknown','CmdletUnavailable')) { $report.ExitCode=1 }
}
if ($ResultsPath) {
try { $report | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $ResultsPath -Encoding UTF8 -ErrorAction Stop }
catch { $report.ExitCode=1; Write-Host "[Failed] Writing AppLocker results: $_" -ForegroundColor Red }
}
return $report
}
+1 -1
View File
@@ -94,7 +94,7 @@ function Invoke-WelaConfigurationControl {
function Complete-WelaConfiguration {
param($Context, [string]$ResultsPath, $Plan,
[ValidateSet("native-windows-configuration", "advanced-audit-policy-only", "advanced-audit-policy-and-precedence", "firewall-text-logging-only", "event-log-size-and-mode-only", "smb-audit-policies-only", "ad-object-sacl-only")]
[ValidateSet("native-windows-configuration", "advanced-audit-policy-only", "advanced-audit-policy-and-precedence", "firewall-text-logging-only", "event-log-size-and-mode-only", "smb-audit-policies-only", "native-channel-settings-only", "wmi-namespace-sacl-only", "ad-object-sacl-only")]
[string]$Scope = "native-windows-configuration",
[string]$SuccessMessage = 'Configuration completed; all requested controls verified.')
# A second read detects a value that was compliant earlier but changed during
+142
View File
@@ -0,0 +1,142 @@
# Uses the shared configuration runner; no live writes occur in Audit or Plan.
function Test-WelaNativeChannelSnapshot {
param($Snapshot)
return $Snapshot.State -in @('Enabled', 'Disabled') -and $Snapshot.IsEnabled -is [bool] -and
$null -ne $Snapshot.MaximumSizeInBytes -and $Snapshot.MaximumSizeInBytes -gt 0 -and
$Snapshot.LogMode -in @('Circular', 'AutoBackup', 'Retain') -and
-not [string]::IsNullOrWhiteSpace($Snapshot.SecurityDescriptor)
}
function Test-WelaNativeChannelSnapshotEqual {
param($First, $Second)
if (-not (Test-WelaNativeChannelSnapshot $First) -or -not (Test-WelaNativeChannelSnapshot $Second)) { return $false }
return $First.Name -eq $Second.Name -and $First.IsEnabled -eq $Second.IsEnabled -and
$First.MaximumSizeInBytes -eq $Second.MaximumSizeInBytes -and $First.LogMode -eq $Second.LogMode -and
(Test-WelaChannelDescriptorEqual $First.SecurityDescriptor $Second.SecurityDescriptor)
}
function Get-WelaNativeChannelPlan {
param($Profile, [switch]$GrantEventLogReaders)
foreach ($control in $Profile.controls) {
$before = Get-WelaNativeChannel -Name $control.channel
$access = if ($control.readerSid) { Get-WelaChannelAccessPlan -SecurityDescriptor $before.SecurityDescriptor } else { $null }
$minimum = ConvertTo-WelaEventLogBytes $control.sourceExampleBytes
$valid = Test-WelaNativeChannelSnapshot $before
$desiredAcl = $before.SecurityDescriptor
if ($GrantEventLogReaders -and $control.readerSid -and $access.State -eq 'GrantRequired') { $desiredAcl = $access.ProposedDescriptor }
$status = if (-not $valid) { if ($before.State -eq 'Not installed') { 'NotInstalled' } else { 'Unknown' } }
elseif ($GrantEventLogReaders -and $access -and $access.State -notin @('GrantPresent', 'GrantRequired')) { 'ManualReview' }
elseif (($null -ne $control.enabled -and $before.IsEnabled -ne $control.enabled) -or $before.MaximumSizeInBytes -lt $minimum -or
($GrantEventLogReaders -and $access -and $access.State -eq 'GrantRequired')) { 'ChangeRequired' } else { 'RequestedSettingsMatch' }
[pscustomobject][ordered]@{
Definition = $control; Before = $before; Status = $status; Access = $access
Desired = [pscustomobject]@{
IsEnabled = $(if ($null -eq $control.enabled) { $before.IsEnabled } else { $control.enabled })
SourceExampleBytes = [long]$control.sourceExampleBytes; RoundedMinimumBytes = $minimum
MaximumSizeInBytes = $(if ($valid) { [math]::Max([long]$before.MaximumSizeInBytes, $minimum) } else { $null })
LogMode = $before.LogMode; SecurityDescriptor = $desiredAcl
AccessChangeRequested = [bool]($GrantEventLogReaders -and $control.readerSid)
}
Prerequisites = @($(if ($access -and $access.State -ne 'GrantPresent') { "Event Log Readers read ACE: $($access.State). Use -GrantEventLogReaders only after reviewing the proposed descriptor; manual-review states cannot be changed automatically." }),
'Effective forwarding identity read access and actual event/forwarding evidence remain unverified.') | Where-Object { $_ }
}
}
}
function Set-WelaNativeChannelControls {
param($Context, [array]$Plan, [string]$Profile)
foreach ($entry in $Plan) {
$channel = $entry.Definition.channel
$id = "NativeChannel/$channel/Settings"
if ($entry.Status -in @('NotInstalled', 'Unknown', 'ManualReview')) {
$Context.Results.Add([pscustomobject]@{
Id = $id; Kind = 'NativeChannel'; Target = @{ Channel = $channel; Profile = $Profile }
Desired = $entry.Desired; Before = $entry.Before; After = $null; Status = 'Failed'
Diagnostic = "$($entry.Status): channel metadata/ACL cannot safely be configured. $($entry.Access.Diagnostic)"
})
continue
}
$state = @{ Entry = $entry; InitialRead = $true; Snapshot = $null }
$read = {
param($state)
$current = Get-WelaNativeChannel -Name $state.Entry.Definition.channel
if (-not (Test-WelaNativeChannelSnapshot $current)) { throw 'Channel settings became unreadable; no assumed defaults are used.' }
if ($state.InitialRead) {
# The plan may outlive another writer. Never apply an ACL based on
# an old descriptor, even before the shared runner's first read.
if (-not (Test-WelaNativeChannelSnapshotEqual $state.Entry.Before $current)) { throw 'Channel settings changed after planning; review a fresh plan before retrying.' }
$state.Snapshot = $current; $state.InitialRead = $false
}
return $current
}
$test = {
param($current, $state)
$desired = $state.Entry.Desired
return $current.IsEnabled -eq $desired.IsEnabled -and $current.MaximumSizeInBytes -eq $desired.MaximumSizeInBytes -and
$current.LogMode -eq $desired.LogMode -and (Test-WelaChannelDescriptorEqual $current.SecurityDescriptor $desired.SecurityDescriptor)
}
$apply = {
param($state)
$entry = $state.Entry
$fresh = Get-WelaNativeChannel -Name $entry.Definition.channel
if (-not (Test-WelaNativeChannelSnapshotEqual $state.Snapshot $fresh)) { throw 'Channel settings changed after the recovery snapshot; no channel write was attempted.' }
$arguments = @('sl', $entry.Definition.channel)
if ($fresh.IsEnabled -ne $entry.Desired.IsEnabled) { $arguments += '/e:true' }
if ($fresh.MaximumSizeInBytes -ne $entry.Desired.MaximumSizeInBytes) { $arguments += "/ms:$($entry.Desired.MaximumSizeInBytes)" }
if (-not (Test-WelaChannelDescriptorEqual $fresh.SecurityDescriptor $entry.Desired.SecurityDescriptor)) {
if (-not $entry.Desired.AccessChangeRequested -or $entry.Access.State -ne 'GrantRequired') { throw 'An ACL difference has no explicit, validated read-grant request.' }
$arguments += "/ca:$($entry.Desired.SecurityDescriptor)"
}
if ($arguments.Count -gt 2) { Invoke-WelaNative -FilePath 'wevtutil.exe' -Arguments $arguments }
}
Invoke-WelaConfigurationControl -Context $Context -Id $id -Kind NativeChannel -Target @{ Channel = $channel; Profile = $Profile } `
-Desired $entry.Desired -Read $read -Compliant $test -Apply $apply -CallbackState $state `
-Description "Apply declared enable/minimum-size settings; preserve larger buffers, retention and existing ACEs. Add only the Event Log Readers read ACE when explicitly requested."
}
}
function Invoke-WelaNativeChannelCommand {
param([ValidateSet('Audit', 'Plan', 'Configure')][string]$Action = 'Audit',
[string]$Profile = 'microsoft-wef-appendix-c',
[ValidateSet('Baseline', 'Suspect', 'Both')][string]$QuerySet = 'Both',
[switch]$GrantEventLogReaders, [switch]$Auto, [switch]$DryRun, [string]$BackupPath, [string]$ResultsPath)
if ($env:OS -ne 'Windows_NT') { throw 'Native channel settings require Windows.' }
if ($DryRun -and $Action -ne 'Configure') { throw '-DryRun requires ChannelAction Configure; Audit and Plan are read-only.' }
$selected = Get-WelaNativeChannelProfile -Id $Profile
$plan = @(Get-WelaNativeChannelPlan -Profile $selected -GrantEventLogReaders:$GrantEventLogReaders)
if ($Action -eq 'Configure') {
$context = New-WelaConfigurationContext -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath
Set-WelaNativeChannelControls -Context $context -Plan $plan -Profile $selected.id
$report = Complete-WelaConfiguration -Context $context -Scope 'native-channel-settings-only' `
-SuccessMessage 'Requested channel settings verified. Forwarding identity read access and event/ingestion evidence remain unverified.'
} else {
$report = [pscustomobject]@{ Scope = 'native-channel-settings-only'; ExitCode = $(if (@($plan | Where-Object Status -in @('Unknown', 'NotInstalled', 'ManualReview')).Count) { 1 } else { 0 }) }
}
# Read inventory after configuration so exports do not show only stale pre-state.
$inventory = @(Get-WelaNativeChannelInventory -Profile $selected -QuerySet $QuerySet)
$current = if ($Action -eq 'Configure') { @(Get-WelaNativeChannelPlan -Profile $selected -GrantEventLogReaders:$GrantEventLogReaders) } else { $plan }
$excluded = @()
foreach ($set in @('Baseline', 'Suspect')) {
if ($QuerySet -eq 'Both' -or $QuerySet -eq $set) {
foreach ($query in $selected.querySets.$set.excludedQueries) { $excluded += [pscustomobject]@{ QuerySet = $set; QueryId = $query.queryId; Reason = $query.reason } }
}
}
$report | Add-Member NoteProperty Action $Action
$report | Add-Member NoteProperty ChannelProfile $selected.id
$report | Add-Member NoteProperty Source $selected.source
$report | Add-Member NoteProperty WefQuerySet $QuerySet
$report | Add-Member NoteProperty GrantEventLogReadersRequested ([bool]$GrantEventLogReaders)
$report | Add-Member NoteProperty Controls $current
$report | Add-Member NoteProperty QueryInventory $inventory
$report | Add-Member NoteProperty ExcludedQueries $excluded
$report | Add-Member NoteProperty ForwardingReadiness 'Not verified'
$report | Add-Member NoteProperty UnverifiedPrerequisites @('Forwarding token/group membership (including Network Service where applicable)', 'WinRM and collector/subscription configuration', 'Representative native events, identity read access and collector ingestion')
Write-Host 'Native query inventory and channel settings are observations only. Forwarding access, event generation and ingestion are not verified; no Sigma coverage increase is claimed.' -ForegroundColor Yellow
$current | Select-Object @{n='Channel';e={$_.Definition.channel}}, Status, @{n='ReaderAce';e={$_.Access.State}}, @{n='SourceBytes';e={$_.Desired.SourceExampleBytes}}, @{n='MinimumBytes';e={$_.Desired.RoundedMinimumBytes}} | Format-Table -AutoSize | Out-Host
$inventory | Select-Object @{n='RequiredChannel';e={$_.Channel.Name}}, @{n='State';e={$_.Channel.State}}, EffectiveReadAccess | Format-Table -AutoSize | Out-Host
if ($ResultsPath) {
try { $report | ConvertTo-Json -Depth 16 | Set-Content -LiteralPath $ResultsPath -Encoding UTF8 -ErrorAction Stop }
catch { $report.ExitCode = 1; Write-Host "[Failed] Writing channel results: $_" -ForegroundColor Red }
}
return $report
}
+324
View File
@@ -0,0 +1,324 @@
# Opt-in local namespace SACLs. No namespace DACL, audit policy, or remote-access changes.
function Get-WelaWmiAuditDefinitions {
param([string[]]$Namespace, [switch]$IncludeChildren)
$source = 'https://github.com/AustralianCyberSecurityCentre/windows_event_logging/blob/59041b5d4586789a751171fb752be1624ad5e3b4/events/wmi_auditing/wmi_auditing.ps1'
$rows = @(
@('root\cimv2', 262146, 64, 'S-1-1-0'),
@('root\cimv2', 1, 64, 'S-1-5-4'),
@('root\cimv2', 1, 64, 'S-1-5-2'),
@('root\cimv2', 1, 64, 'S-1-5-3'),
@('root\SecurityCenter', 262145, 66, 'S-1-1-0'),
@('root\SecurityCenter2', 262145, 66, 'S-1-1-0'),
@('root\subscription', 262174, 66, 'S-1-1-0'),
@('root\default', 262175, 66, 'S-1-1-0')
)
foreach ($selected in $Namespace) {
if ($selected -notin @($rows | ForEach-Object { $_[0] })) { throw "Unsupported namespace '$selected'. Select exact local namespaces listed by wmi-auditing -WmiAction List; wildcards and remote paths are not accepted." }
}
foreach ($row in $rows) {
if ($Namespace -and $row[0] -notin $Namespace) { continue }
[pscustomobject][ordered]@{ Namespace = $row[0]; AccessMask = [uint32]$row[1]; AceType = 2
AceFlags = $(if ($IncludeChildren) { [uint32]$row[2] } else { [uint32]64 }); Sid = $row[3]
SourceAceFlags = $row[2]; Source = $source; AuditOutcome = 'Success'
Scope = $(if ($IncludeChildren -and $row[2] -eq 66) { 'Selected namespace and inheriting descendants' } else { 'Selected namespace only' }) }
}
}
function Initialize-WelaWmiInterop {
if ($env:OS -ne 'Windows_NT') { throw 'WMI namespace security requires Windows.' }
Add-Type -AssemblyName System.Management -ErrorAction Stop
if ('Wela.WmiSecurityPrivilege' -as [type]) { return }
Add-Type -TypeDefinition @'
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
namespace Wela {
public sealed class WmiSecurityPrivilege : IDisposable {
[StructLayout(LayoutKind.Sequential)] struct Luid { public uint Low; public int High; }
[StructLayout(LayoutKind.Sequential)] struct TokenPrivileges { public uint Count; public Luid Luid; public uint Attributes; }
[DllImport("kernel32.dll")] static extern IntPtr GetCurrentProcess();
[DllImport("kernel32.dll", SetLastError=true)] static extern bool CloseHandle(IntPtr handle);
[DllImport("advapi32.dll", SetLastError=true)] static extern bool OpenProcessToken(IntPtr process, uint access, out IntPtr token);
[DllImport("advapi32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern bool LookupPrivilegeValue(string system, string name, out Luid luid);
[DllImport("advapi32.dll", SetLastError=true)] static extern bool AdjustTokenPrivileges(IntPtr token, bool disable, ref TokenPrivileges current, uint size, out TokenPrivileges previous, out uint required);
IntPtr token; TokenPrivileges previous; bool changed;
public WmiSecurityPrivilege() {
if (!OpenProcessToken(GetCurrentProcess(), 0x28, out token)) throw new Win32Exception(Marshal.GetLastWin32Error());
try {
Luid luid;
if (!LookupPrivilegeValue(null, "SeSecurityPrivilege", out luid)) throw new Win32Exception(Marshal.GetLastWin32Error());
TokenPrivileges requested = new TokenPrivileges { Count=1, Luid=luid, Attributes=2 };
uint required;
bool ok = AdjustTokenPrivileges(token, false, ref requested, (uint)Marshal.SizeOf(typeof(TokenPrivileges)), out previous, out required);
int error = Marshal.GetLastWin32Error();
if (!ok || error != 0) throw new Win32Exception(error, "SeSecurityPrivilege must be assigned and enabled; refusing a potentially incomplete SACL read.");
changed=true;
} catch { CloseHandle(token); token=IntPtr.Zero; throw; }
}
public void Dispose() {
if (token==IntPtr.Zero) return;
try {
if (changed) {
TokenPrivileges ignored; uint required;
bool ok = AdjustTokenPrivileges(token, false, ref previous, (uint)Marshal.SizeOf(typeof(TokenPrivileges)), out ignored, out required);
int error = Marshal.GetLastWin32Error();
if (!ok || error != 0) throw new Win32Exception(error, "Restoring SeSecurityPrivilege failed; the previous token state could not be verified.");
}
} finally { CloseHandle(token); token=IntPtr.Zero; }
}
}
}
'@ -ErrorAction Stop
}
function Assert-WelaWmiReturnCode {
param($Response, [string]$Method)
if ($null -eq $Response -or $null -eq $Response.ReturnValue -or
$Response.ReturnValue -is [bool] -or [string]$Response.ReturnValue -notmatch '^\d+$' -or
[uint64]$Response.ReturnValue -ne 0) {
throw "$Method failed (ReturnValue=$($Response.ReturnValue)); success requires an explicit numeric zero."
}
}
function ConvertTo-WelaWmiData {
param($Value)
if ($null -eq $Value) { return $null }
if ($Value -is [System.Management.ManagementBaseObject]) {
$properties = [ordered]@{}
foreach ($property in @($Value.Properties | Sort-Object Name)) { $properties[$property.Name] = ConvertTo-WelaWmiData $property.Value }
return [pscustomobject]$properties
}
if ($Value -is [array]) {
$items = @(); foreach ($item in $Value) { $items += ,(ConvertTo-WelaWmiData $item) }
return ,$items
}
return $Value
}
function ConvertTo-WelaWmiJson { param($Value) ConvertTo-Json -InputObject $Value -Depth 40 -Compress }
function Get-WelaWmiSid {
param($Trustee)
if ($Trustee.SIDString) { return [string]$Trustee.SIDString }
$bytes = [byte[]]$Trustee.SID
if (-not $bytes -or $bytes.Length -lt 8 -or $bytes.Length -ne (8 + 4 * $bytes[1])) { return '' }
[uint64]$authority = 0
for ($i = 2; $i -lt 8; $i++) { $authority = ($authority * 256) + $bytes[$i] }
$sid = "S-$($bytes[0])-$authority"
for ($i = 0; $i -lt $bytes[1]; $i++) { $sid += '-' + [BitConverter]::ToUInt32($bytes, 8 + 4 * $i) }
return $sid
}
function Test-WelaWmiAceMatch {
param($Ace, $Definition)
# Only an exact, explicit, ordinary success ACE satisfies a requested entry.
# Unknown/object/inherited ACEs are retained without interpreting them.
return $null -ne $Ace -and $Ace.AceType -eq 2 -and $Ace.AceFlags -eq $Definition.AceFlags -and
$Ace.AccessMask -eq $Definition.AccessMask -and -not $Ace.GuidObjectType -and -not $Ace.GuidInheritedObjectType -and
(Get-WelaWmiSid $Ace.Trustee) -eq $Definition.Sid
}
function Get-WelaWmiMissingAces {
param($Descriptor, [array]$Definitions)
foreach ($definition in $Definitions) {
$matches = @($Descriptor.SACL | Where-Object { Test-WelaWmiAceMatch $_ $definition })
if ($matches.Count -eq 0) { $definition }
}
}
function New-WelaWmiConnection {
param([string]$Namespace)
$options = New-Object System.Management.ConnectionOptions
$options.EnablePrivileges = $true
$options.Impersonation = [System.Management.ImpersonationLevel]::Impersonate
$scope = New-Object System.Management.ManagementScope -ArgumentList "\\.\$Namespace", $options
$scope.Connect()
$path = New-Object System.Management.ManagementPath -ArgumentList '__SystemSecurity=@'
return New-Object System.Management.ManagementObject -ArgumentList $scope, $path, $null
}
function Get-WelaWmiNativeDescriptor {
param($Connection)
$result = $Connection.InvokeMethod('GetSecurityDescriptor', $null, $null)
Assert-WelaWmiReturnCode $result 'GetSecurityDescriptor'
if ($null -eq $result.Descriptor -or $null -eq $result.Descriptor.ControlFlags) { throw 'GetSecurityDescriptor returned no complete descriptor.' }
return $result.Descriptor
}
function Get-WelaWmiNamespaceSnapshot {
param([string]$Namespace)
Initialize-WelaWmiInterop
$privilege = New-Object Wela.WmiSecurityPrivilege
$connection = $null
try {
$connection = New-WelaWmiConnection $Namespace
$descriptor = Get-WelaWmiNativeDescriptor $connection
$data = ConvertTo-WelaWmiData $descriptor
# Strings prevent JSON journal depth truncation of nested, unfamiliar ACEs.
[pscustomobject]@{ Namespace = $Namespace; DescriptorJson = ConvertTo-WelaWmiJson $data
DescriptorMof = $descriptor.GetText([System.Management.TextFormat]::Mof); SaclReadPrivilege = 'SeSecurityPrivilege enabled' }
} finally {
try { if ($connection) { $connection.Dispose() } }
finally { $privilege.Dispose() }
}
}
function Set-WelaWmiNamespaceDescriptor {
param([string]$Namespace, [string]$ExpectedJson, [array]$Definitions)
Initialize-WelaWmiInterop
$privilege = New-Object Wela.WmiSecurityPrivilege
$connection = $null
try {
$connection = New-WelaWmiConnection $Namespace
$descriptor = Get-WelaWmiNativeDescriptor $connection
$data = ConvertTo-WelaWmiData $descriptor
if ((ConvertTo-WelaWmiJson $data) -cne $ExpectedJson) { throw 'Namespace descriptor changed after its recovery snapshot; no SACL was written. Review and retry.' }
$missing = @(Get-WelaWmiMissingAces $data $Definitions)
if (-not $missing.Count) { return 'Requested audit ACEs already present at the immediate pre-write read.' }
# Clone the full native descriptor; existing native ACE objects are not
# reconstructed from selected fields, merged, reordered, or removed.
$updated = $descriptor.Clone()
$aces = @($descriptor.SACL | Where-Object { $null -ne $_ })
foreach ($definition in $missing) {
$aceClass = New-Object System.Management.ManagementClass -ArgumentList '\\.\root\cimv2:Win32_ACE'
$trusteeClass = New-Object System.Management.ManagementClass -ArgumentList '\\.\root\cimv2:Win32_Trustee'
try {
$ace = $aceClass.CreateInstance(); $trustee = $trusteeClass.CreateInstance()
$sid = New-Object System.Security.Principal.SecurityIdentifier -ArgumentList $definition.Sid
$sidBytes = New-Object byte[] $sid.BinaryLength; $sid.GetBinaryForm($sidBytes, 0)
$trustee.SID = $sidBytes
$ace.Trustee = $trustee; $ace.AccessMask = [uint32]$definition.AccessMask
$ace.AceFlags = [uint32]$definition.AceFlags; $ace.AceType = [uint32]2
$aces += $ace
} finally { $aceClass.Dispose(); $trusteeClass.Dispose() }
}
$updated.SACL = [System.Management.ManagementBaseObject[]]$aces
# SetSecurityDescriptor treats SE_DACL_PRESENT and non-null Owner/Group
# as requests to rewrite access permissions. Omit those fields explicitly
# so the provider preserves them, even if another writer races this call.
# Complete original fields remain in the journal and read-back comparison.
$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
$response = $connection.InvokeMethod('SetSecurityDescriptor', $parameters, $null)
Assert-WelaWmiReturnCode $response 'SetSecurityDescriptor'
'SACL update accepted; full descriptor preservation and audit entries require read-back verification. Event generation is unverified.'
} finally {
try { if ($connection) { $connection.Dispose() } }
finally { $privilege.Dispose() }
}
}
function Test-WelaWmiDescriptorPreserved {
param($Before, $After)
foreach ($property in $Before.PSObject.Properties) {
if ($property.Name -eq 'SACL') { continue }
if ($property.Name -eq 'ControlFlags') {
if ([uint32]$After.ControlFlags -ne ([uint32]$Before.ControlFlags -bor 16)) { return $false }
} elseif ((ConvertTo-WelaWmiJson $property.Value) -cne (ConvertTo-WelaWmiJson $After.($property.Name))) { return $false }
}
# Compare a multiset: providers can reorder a SACL, but cannot remove/change
# any original entry, including unknown types, trustee details or extra fields.
$remaining = New-Object 'System.Collections.Generic.List[string]'
foreach ($ace in @($After.SACL)) { if ($null -ne $ace) { $remaining.Add((ConvertTo-WelaWmiJson $ace)) } }
foreach ($ace in @($Before.SACL)) {
if ($null -eq $ace) { continue }
if (-not $remaining.Remove((ConvertTo-WelaWmiJson $ace))) { return $false }
}
return $true
}
function Get-WelaWmiNamespaceInventory {
$namespaces = @(Get-WelaWmiAuditDefinitions | Select-Object -ExpandProperty Namespace -Unique)
try {
$children = @(Get-CimInstance -Namespace root -ClassName __Namespace -ErrorAction Stop | ForEach-Object { 'root\' + $_.Name })
foreach ($namespace in $namespaces) { [pscustomobject]@{ Namespace = $namespace; State = $(if ($namespace -in $children) { 'Present' } else { 'NotInstalled' }) } }
} catch {
foreach ($namespace in $namespaces) { [pscustomobject]@{ Namespace = $namespace; State = 'Unknown'; Diagnostic = $_.Exception.Message } }
}
}
function Get-WelaWmiAuditPrerequisite {
try {
$mask = Get-WelaNativeAuditPolicy -Guid '0CCE9227-69AE-11D9-BED3-505054503030'
[pscustomobject]@{ Policy = 'Other Object Access Events'; Mask = $mask; SuccessEnabled = (($mask -band 1) -eq 1); State = 'Observed' }
} catch { [pscustomobject]@{ Policy = 'Other Object Access Events'; Mask = $null; SuccessEnabled = $null; State = 'Unknown'; Diagnostic = $_.Exception.Message } }
}
function Get-WelaWmiAuditPlan {
param([string[]]$Namespace, [switch]$IncludeChildren)
if (-not $Namespace.Count) { throw 'Select at least one exact namespace with -WmiNamespace; there is no implicit all-namespaces configuration.' }
$definitions = @(Get-WelaWmiAuditDefinitions -Namespace $Namespace -IncludeChildren:$IncludeChildren)
foreach ($name in @($definitions | Select-Object -ExpandProperty Namespace -Unique)) {
$selected = @($definitions | Where-Object Namespace -eq $name)
try {
$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 = '' }
} catch { [pscustomobject]@{ Namespace = $name; Status = 'Unknown'; Before = $null; Definitions = $selected; Missing = @(); Diagnostic = $_.Exception.Message } }
}
}
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 }
$read = {
param($state)
$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 (@(Get-WelaWmiMissingAces $descriptor $state.Definitions).Count) { return $false }
if ($state.Applied) {
if (-not (Test-WelaWmiDescriptorPreserved $state.Original $descriptor)) { return $false }
if ($null -eq $state.VerifiedJson) { $state.VerifiedJson = $snapshot.DescriptorJson }
return $snapshot.DescriptorJson -ceq $state.VerifiedJson
}
# An already compliant descriptor still gets a full final drift check.
return $snapshot.DescriptorJson -ceq $state.ExpectedJson
}
$apply = {
param($state)
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 ', '))
}
}
function Invoke-WelaWmiAuditCommand {
param([ValidateSet('List', 'Audit', 'Plan', 'Configure')][string]$Action = 'List', [string[]]$Namespace,
[switch]$IncludeChildren, [switch]$Auto, [switch]$DryRun, [string]$BackupPath, [string]$ResultsPath)
if ($env:OS -ne 'Windows_NT') { throw 'WMI namespace auditing requires Windows.' }
if ($DryRun -and $Action -ne 'Configure') { throw '-DryRun requires -WmiAction Configure.' }
if ($Action -eq 'List') {
if ($Namespace -or $IncludeChildren) { throw 'List does not accept namespace or inheritance selections. Use Audit, Plan or Configure.' }
$inventory = @(Get-WelaWmiNamespaceInventory)
$report = [pscustomobject]@{ Scope = 'wmi-namespace-sacl-only'; Action = $Action; Namespaces = $inventory; ExitCode = $(if (@($inventory | Where-Object State -eq Unknown).Count) { 1 } else { 0 }) }
} else {
$plan = @(Get-WelaWmiAuditPlan -Namespace $Namespace -IncludeChildren:$IncludeChildren)
$prerequisite = Get-WelaWmiAuditPrerequisite
if ($Action -eq 'Configure') {
$context = New-WelaConfigurationContext -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath
Set-WelaWmiAuditControls -Context $context -Plan $plan
$report = Complete-WelaConfiguration -Context $context -Scope 'wmi-namespace-sacl-only' `
-SuccessMessage 'Selected WMI namespace SACLs verified; namespace access events and collection remain unverified.'
$report | Add-Member NoteProperty Prerequisite $prerequisite
} else { $report = [pscustomobject]@{ Scope = 'wmi-namespace-sacl-only'; Action = $Action; Controls = $plan; Prerequisite = $prerequisite; ExitCode = $(if (@($plan | Where-Object Status -eq Unknown).Count) { 1 } else { 0 }) } }
$report | Add-Member NoteProperty EventValidation 'Not performed. Namespace access auditing (Security 4662) is distinct from provider-operation success and local/remote WMI-Activity telemetry. Audit-policy readiness is observed separately; no usable-rule credit.'
}
if ($ResultsPath) {
try { $report | ConvertTo-Json -Depth 16 | Set-Content -LiteralPath $ResultsPath -Encoding UTF8 -ErrorAction Stop }
catch { $report.ExitCode = 1; Write-Host "[Failed] Writing WMI results: $_" -ForegroundColor Red }
}
return $report
}