mirror of
https://github.com/Yamato-Security/WELA.git
synced 2026-10-08 07:15:25 +02:00
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:
commit
9d993a2a7e
26 files changed
+2286
-4
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user