Files
WELA/modules/WefSubscriptions.psm1

233 lines
20 KiB
PowerShell

# Bounded native, domain source-initiated WEF input model. No Windows mutations.
function Read-WelaWefXml {
param([string]$Xml)
$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); return ,$doc }
finally { $reader.Dispose() }
}
function Test-WelaWefContainerText {
param($Node)
foreach ($child in $Node.ChildNodes) {
if ($child.NodeType -eq 'ProcessingInstruction' -or ($child.NodeType -in @('Text','CDATA') -and -not [string]::IsNullOrWhiteSpace($child.Value))) { throw 'Unexpected text or processing instruction in XML container.' }
}
}
function Get-WelaWefXmlKey {
param($Node)
$attributes = @($Node.Attributes | Where-Object { $_.Name -ne 'xmlns' } | Sort-Object Name | ForEach-Object { @($_.Name, $_.Value) -join '=' })
$children = @($Node.ChildNodes | Where-Object NodeType -eq Element | ForEach-Object { Get-WelaWefXmlKey $_ })
# Text/CDATA boundaries have no XML semantic meaning. Trimming individual
# fragments would erase significant whitespace inside XPath string literals.
$text = (@($Node.ChildNodes | Where-Object { $_.NodeType -in @('Text','CDATA') } | ForEach-Object { $_.Value }) -join '').Trim()
ConvertTo-Json -InputObject @($Node.LocalName, $Node.NamespaceURI, $attributes, $text, $children) -Depth 30 -Compress
}
function ConvertFrom-WelaWefQuery {
param([string]$Xml)
$doc = Read-WelaWefXml $Xml
if ($doc.DocumentElement.Name -cne 'QueryList' -or $doc.DocumentElement.NamespaceURI -or $doc.DocumentElement.Attributes.Count) { throw 'Query must contain an unqualified QueryList without attributes.' }
Test-WelaWefContainerText $doc
Test-WelaWefContainerText $doc.DocumentElement
if ($Xml -match '(?i)Sysmon|\bEMET\b') { throw 'Sysmon and EMET subscriptions are outside native-only scope.' }
$rows = @(); $ids = @{}
foreach ($query in @($doc.DocumentElement.ChildNodes | Where-Object NodeType -eq Element)) {
if ($query.Name -cne 'Query' -or $query.NamespaceURI -or @($query.Attributes | Where-Object Name -cnotin @('Id','Path')).Count) { throw 'Unsupported Query element/attribute.' }
$id = $query.GetAttribute('Id')
Test-WelaWefContainerText $query
if ($id -notmatch '^\d+$' -or $ids.ContainsKey($id)) { throw 'Query IDs must be explicit and unique integers.' }
$ids[$id] = $true
$selectCount = 0
foreach ($filter in @($query.ChildNodes | Where-Object NodeType -eq Element)) {
if ($filter.Name -cnotin @('Select','Suppress') -or $filter.NamespaceURI -or @($filter.Attributes | Where-Object Name -cne 'Path').Count -or @($filter.ChildNodes | Where-Object NodeType -eq Element).Count) { throw 'Unsupported query filter structure.' }
$channel = $filter.GetAttribute('Path'); if (-not $channel) { $channel = $query.GetAttribute('Path') }
if ($channel -match '(?i)Sysmon|\bEMET\b') { throw 'Decoded Sysmon/EMET channel names are outside native-only scope.' }
if (@($filter.ChildNodes | Where-Object NodeType -eq ProcessingInstruction).Count) { throw 'Processing instructions in XPath filters are unsupported.' }
if ($channel -notin @('Security','System','Application','Windows PowerShell') -and $channel -notmatch '^Microsoft-Windows-[A-Za-z0-9 -]+/[A-Za-z0-9 -]+$') { throw "Unsupported/non-native or wildcard channel: $channel" }
if ([string]::IsNullOrWhiteSpace($filter.InnerText)) { throw 'Empty XPath filter is not accepted.' }
if ($filter.Name -eq 'Select') { $selectCount++ }
$rows += [pscustomobject]@{ QueryId=$id; Channel=$channel; Operation=$filter.Name; XPath=$filter.InnerText.Trim() }
}
if (-not $selectCount) { throw 'Each query must contain at least one Select.' }
}
if (-not $rows.Count) { throw 'Empty QueryList is not accepted.' }
[pscustomobject]@{ Xml=$doc.OuterXml; Key=(Get-WelaWefXmlKey $doc.DocumentElement); Filters=$rows; Channels=@($rows.Channel | Sort-Object -Unique) }
}
function Get-WelaWefAuthorization {
param([string[]]$SourceSids)
if (-not $SourceSids.Count) { throw 'Explicit source computer/group domain SIDs are required; no default broad authorization is used.' }
foreach ($sid in $SourceSids) { if ($sid -notmatch '^S-1-5-21-\d+-\d+-\d+-\d+$') { throw "Expected an explicit domain computer/group SID: $sid" } }
return 'O:NSG:NSD:' + ((@($SourceSids | Sort-Object -Unique) | ForEach-Object { '(A;;GA;;;' + $_ + ')' }) -join '')
}
function ConvertFrom-WelaWefSubscription {
param([string]$Xml, [string[]]$SourceSids, [switch]$Observed)
$doc = Read-WelaWefXml $Xml
$ns = 'http://schemas.microsoft.com/2006/03/windows/events/subscription'
if ($doc.DocumentElement.LocalName -cne 'Subscription' -or $doc.DocumentElement.NamespaceURI -cne $ns) { throw 'Expected the native Windows Subscription XML namespace.' }
Test-WelaWefContainerText $doc
Test-WelaWefContainerText $doc.DocumentElement
if (@($doc.DocumentElement.Attributes | Where-Object Name -cne 'xmlns').Count) { throw 'Unknown subscription root attributes.' }
$allowed = @('SubscriptionId','SubscriptionType','Description','Enabled','Uri','ConfigurationMode','Query','ReadExistingEvents','TransportName','ContentFormat','Locale','LogFile','PublisherName','AllowedSourceDomainComputers','AllowedSourceNonDomainComputers')
if ($Observed) { $allowed += @('Delivery','EventSources','CredentialsType','TransportPort') }
$elements = @{}
foreach ($node in @($doc.DocumentElement.ChildNodes | Where-Object NodeType -eq Element)) {
if ($node.NamespaceURI -cne $ns -or $node.LocalName -cnotin $allowed -or $elements.ContainsKey($node.LocalName)) { throw "Unknown/duplicate subscription field: $($node.Name)" }
$elements[$node.LocalName] = $node
}
foreach ($required in @('SubscriptionId','SubscriptionType','Enabled','Uri','ConfigurationMode','Query','ReadExistingEvents','TransportName','ContentFormat','Locale','LogFile','AllowedSourceDomainComputers')) {
if (-not $elements.ContainsKey($required)) { throw "Subscription requires explicit $required." }
}
$id = $elements.SubscriptionId.InnerText
if ($id -notmatch '^[A-Za-z0-9][A-Za-z0-9 ._-]{0,127}$') { throw 'Unsupported subscription ID.' }
if ($elements.SubscriptionType.InnerText -cne 'SourceInitiated' -or $elements.TransportName.InnerText -ine 'HTTP' -or
$elements.Uri.InnerText -cne 'http://schemas.microsoft.com/wbem/wsman/1/windows/EventLog' -or $elements.LogFile.InnerText -cne 'ForwardedEvents') { throw 'Only HTTP source-initiated native EventLog subscriptions to ForwardedEvents are supported.' }
if ($elements.ConfigurationMode.InnerText -cnotin @('Normal','MinLatency','MinBandwidth')) { throw 'Use a native Normal, MinLatency or MinBandwidth preset; Custom delivery is outside this initial scope.' }
foreach ($field in @('Enabled','ReadExistingEvents')) { if ($elements[$field].InnerText -cnotin @('true','false')) { throw "$field must be explicit true or false." } }
if ($elements.ContentFormat.InnerText -cnotin @('Events','RenderedText')) { throw 'Unsupported content format.' }
if ($elements.Locale.GetAttribute('Language') -notmatch '^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$') { throw 'Explicit locale language is required.' }
if ($elements.PublisherName -and $elements.PublisherName.InnerText -cne 'Microsoft-Windows-EventCollector') { throw 'Only the native EventCollector publisher is supported.' }
if ($elements.AllowedSourceNonDomainComputers) {
$nonDomain = $elements.AllowedSourceNonDomainComputers
if ($nonDomain.Attributes.Count -or -not [string]::IsNullOrWhiteSpace($nonDomain.InnerText)) { throw 'Non-domain/certificate sources require a separately designed topology.' }
foreach ($child in @($nonDomain.ChildNodes | Where-Object NodeType -eq Element)) {
if ($child.LocalName -cne 'AllowedIssuerCAList' -or $child.NamespaceURI -cne $ns -or $child.Attributes.Count -or $child.ChildNodes.Count) { throw 'Unknown non-domain authorization structure is not accepted.' }
}
}
if ($elements.CredentialsType -and $elements.CredentialsType.InnerText -cne 'Default') { throw 'Explicit credentials are not accepted.' }
if ($elements.TransportPort -and $elements.TransportPort.InnerText -ne '5985') { throw 'Only the standard HTTP transport port is supported.' }
foreach ($node in $elements.Values) {
if ($node.LocalName -in @('AllowedSourceNonDomainComputers','Delivery','EventSources')) { continue }
if (@($node.ChildNodes | Where-Object NodeType -eq ProcessingInstruction).Count) { throw 'Processing instructions in subscription settings are unsupported.' }
if (@($node.ChildNodes | Where-Object NodeType -eq Element).Count -or @($node.Attributes | Where-Object { -not ($node.LocalName -eq 'Locale' -and $_.Name -ceq 'Language') }).Count) { throw "Unexpected nested/attributed subscription setting: $($node.Name)" }
}
$authorization = Get-WelaWefAuthorization $SourceSids
$currentAuthorization = $elements.AllowedSourceDomainComputers.InnerText.Trim()
if ($currentAuthorization -and $currentAuthorization -cne $authorization) { throw 'Subscription source authorization does not exactly match the explicitly configured source SIDs.' }
if ($Observed -and -not $currentAuthorization) { throw 'Observed subscription has missing/default source authorization.' }
$elements.AllowedSourceDomainComputers.InnerText = $authorization
$query = ConvertFrom-WelaWefQuery $elements.Query.InnerText
$definition = [ordered]@{
Id=$id; Enabled=($elements.Enabled.InnerText -eq 'true'); ConfigurationMode=$elements.ConfigurationMode.InnerText
ReadExistingEvents=($elements.ReadExistingEvents.InnerText -eq 'true'); ContentFormat=$elements.ContentFormat.InnerText
Locale=$elements.Locale.GetAttribute('Language'); Description=$(if ($elements.Description) { $elements.Description.InnerText } else { '' })
LogFile='ForwardedEvents'; SourceAuthorization=$authorization; QueryKey=$query.Key
}
[pscustomobject]@{ Id=$id; Xml=$doc.OuterXml; Definition=[pscustomobject]$definition; Key=($definition | ConvertTo-Json -Depth 30 -Compress); Query=$query; SourceSids=@($SourceSids | Sort-Object -Unique) }
}
# Compare explicit firewall address scopes by network identity, retaining family
# and IPv6 scope ID. Windows may report IPv4 CIDR as a dotted netmask.
function ConvertTo-WelaWefFirewallAddressKey {
param([string]$Value,[switch]$Observed)
$parts=$Value -split '/';$address=$null
if($parts.Count -gt 2 -or -not [Net.IPAddress]::TryParse($parts[0],[ref]$address)){throw 'Expected an explicit firewall IP address or CIDR network.'}
$bytes=$address.GetAddressBytes();$bits=$bytes.Length*8;$prefix=$bits
if($parts.Count -eq 2){
if($Observed -and $bytes.Length -eq 4 -and $parts[1].Contains('.')){
if($parts[1] -notmatch '^[0-9]{1,3}(\.[0-9]{1,3}){3}$'){throw 'Invalid observed IPv4 netmask.'}
$mask=@($parts[1].Split('.')|ForEach-Object {if([int]$_ -gt 255){throw 'Invalid observed IPv4 netmask.'};[int]$_})
$prefix=0;$zeroSeen=$false
foreach($octet in $mask){for($bit=7;$bit -ge 0;$bit--){if(($octet -band (1 -shl $bit)) -ne 0){if($zeroSeen){throw 'Observed IPv4 netmask is not contiguous.'};$prefix++}else{$zeroSeen=$true}}}
}else{
if($parts[1] -notmatch '^\d+$'){throw 'Expected a numeric firewall CIDR prefix.'}
$prefix=[int]$parts[1]
}
if($prefix -lt 1 -or $prefix -gt $bits){throw 'Zero or out-of-range firewall CIDR prefix is unsupported.'}
}
# Network host bits are immaterial to an explicit CIDR scope.
for($i=0;$i -lt $bytes.Length;$i++){
$remaining=$prefix-8*$i
if($remaining -le 0){$bytes[$i]=0}elseif($remaining -lt 8){$bytes[$i]=[byte]($bytes[$i] -band (256-(1 -shl (8-$remaining))))}
}
$scope=if($bits -eq 128){'%'+$address.ScopeId}else{''}
[string]$bits+':'+([BitConverter]::ToString($bytes)).Replace('-','')+$scope+'/'+$prefix
}
function Test-WelaWefFirewallAddressSet {
param([object[]]$Expected,[object[]]$Observed)
if(-not $Expected.Count -or -not $Observed.Count){return $false}
$expectedKeys=@(foreach($value in $Expected){if($value -isnot [string]){throw 'Expected firewall address must be a string.'};ConvertTo-WelaWefFirewallAddressKey $value})
$observedKeys=@(foreach($value in $Observed){if($value -isnot [string]){throw 'Observed firewall address must be a string.'};ConvertTo-WelaWefFirewallAddressKey $value -Observed})
return @((Compare-Object @($expectedKeys|Sort-Object -Unique) @($observedKeys|Sort-Object -Unique))).Count -eq 0
}
function Import-WelaWefConfig {
param([string]$Path, [ValidateSet('Source','Collector')][string]$Role, [scriptblock]$ReadText)
$full = (Resolve-Path -LiteralPath $Path -ErrorAction Stop).Path
$configText=if($ReadText){ & $ReadText $full }else{Get-Content -LiteralPath $full -Raw -Encoding UTF8 -ErrorAction Stop}
$config = $configText | ConvertFrom-Json -ErrorAction Stop
$known = @('SchemaVersion','Role','CollectorFqdn','CollectorUri','Authentication','SourceSids','SubscriptionFiles','Hardening','SubscriptionManagerSlot','RefreshSeconds','GrantNetworkServiceRead','ApplyChannelProfile','GrantCapi2Read','ListenerAddress','IngressRuleName','IngressLocalAddresses','IngressRemoteAddresses')
foreach ($property in $config.PSObject.Properties) { if ($property.Name -cnotin $known) { throw "Unknown WEF config field: $($property.Name)" } }
if ($config.SchemaVersion -ne 1 -or $config.Role -cne $Role) { throw "Expected schema 1 $Role configuration." }
if ($config.CollectorFqdn -notmatch '^(?=.{1,253}$)[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)+$') { throw 'An actual collector FQDN is required.' }
$ipLiteral=$null
if ([Net.IPAddress]::TryParse([string]$config.CollectorFqdn,[ref]$ipLiteral)) { throw 'CollectorFqdn must be a DNS identity, not an IP literal.' }
$uri = $null
if (-not [Uri]::TryCreate([string]$config.CollectorUri,[UriKind]::Absolute,[ref]$uri) -or $uri.Scheme -ne 'http' -or $uri.Port -ne 5985 -or $uri.DnsSafeHost -ine $config.CollectorFqdn -or
$uri.AbsolutePath -cne '/wsman/SubscriptionManager/WEC' -or $uri.Query -or $uri.Fragment -or $uri.UserInfo) { throw 'CollectorUri must match CollectorFqdn and http://FQDN:5985/wsman/SubscriptionManager/WEC exactly, without credentials/query/fragment.' }
if ($config.Authentication -cne 'Kerberos' -or $config.Hardening -cnotin @('AssessOnly','ApplyASD')) { throw 'Explicit Kerberos authentication and AssessOnly/ApplyASD hardening selection are required.' }
$null = Get-WelaWefAuthorization @($config.SourceSids)
if (@($config.SubscriptionFiles).Count -lt 1 -or @($config.SubscriptionFiles).Count -gt 32) { throw 'Select 1 to 32 explicit native subscription XML files.' }
if ($Role -eq 'Source') {
foreach ($field in @('ListenerAddress','IngressRuleName','IngressLocalAddresses','IngressRemoteAddresses')) { if ($config.PSObject.Properties[$field]) { throw "Collector-only field is not accepted in Source config: $field" } }
if ([string]$config.SubscriptionManagerSlot -notmatch '^[1-9]\d{0,3}$' -or ($config.RefreshSeconds -isnot [int] -and $config.RefreshSeconds -isnot [long]) -or $config.RefreshSeconds -lt 10 -or $config.RefreshSeconds -gt 86400) { throw 'Source config requires a numeric SubscriptionManagerSlot (1..9999) and integer RefreshSeconds (10..86400).' }
foreach ($field in @('GrantNetworkServiceRead','ApplyChannelProfile','GrantCapi2Read')) { if ($config.$field -isnot [bool]) { throw "Explicit boolean $field is required." } }
if ($config.GrantCapi2Read -and -not $config.ApplyChannelProfile) { throw 'GrantCapi2Read requires explicit ApplyChannelProfile.' }
} else {
foreach ($field in @('SubscriptionManagerSlot','RefreshSeconds','GrantNetworkServiceRead','ApplyChannelProfile','GrantCapi2Read')) { if ($config.PSObject.Properties[$field]) { throw "Source-only field is not accepted in Collector config: $field" } }
if (-not $config.ListenerAddress -or -not $config.IngressRuleName -or @($config.IngressLocalAddresses).Count -eq 0 -or @($config.IngressRemoteAddresses).Count -eq 0) { throw 'Collector requires an existing listener address, firewall rule name and explicit local/remote address scopes.' }
foreach ($range in @($config.IngressLocalAddresses) + @($config.IngressRemoteAddresses)) {
$parts = [string]$range -split '/'; $address = $null
if ($parts.Count -gt 2 -or -not [Net.IPAddress]::TryParse($parts[0],[ref]$address) -or ($parts.Count -eq 2 -and ($parts[1] -notmatch '^\d+$' -or [int]$parts[1] -lt 1 -or [int]$parts[1] -gt $(if ($address.AddressFamily -eq 'InterNetwork') { 32 } else { 128 })))) { throw "Use explicit IP/CIDR ingress addresses, not Any or zero-prefix ranges: $range" }
}
}
$subscriptions = @(); $ids = @{}
foreach ($file in $config.SubscriptionFiles) {
$target = if ([IO.Path]::IsPathRooted($file)) { $file } else { Join-Path (Split-Path $full -Parent) $file }
$xml = if($ReadText){ & $ReadText $target }else{Get-Content -LiteralPath $target -Raw -Encoding UTF8 -ErrorAction Stop}
$subscription = ConvertFrom-WelaWefSubscription -Xml $xml -SourceSids @($config.SourceSids)
if ($ids.ContainsKey($subscription.Id)) { throw 'Duplicate subscription ID in selected files.' }
$ids[$subscription.Id] = $true; $subscriptions += $subscription
}
[pscustomobject]@{ Config=$config; Path=$full; Subscriptions=$subscriptions }
}
function Initialize-WelaWecSubscriptionInventory {
$path=Join-Path $PSScriptRoot 'WecSubscriptionInventory.cs'
$bytes=[IO.File]::ReadAllBytes($path);if($bytes.Length -gt 65536){throw 'Native inventory source exceeds its bound.'}
$sha=[Security.Cryptography.SHA256]::Create();try{$hash=([BitConverter]::ToString($sha.ComputeHash($bytes))).Replace('-','').ToLowerInvariant()}finally{$sha.Dispose()}
if(-not ('Wela.WecInventory.Reader' -as [type])){
$source=[Text.UTF8Encoding]::new($false,$true).GetString($bytes).TrimStart([char]0xfeff)
if([regex]::Matches($source,'__WELA_SOURCE_SHA256__').Count -ne 1){throw 'Native inventory source binding marker is missing or ambiguous.'}
$compile=@{TypeDefinition=$source.Replace('__WELA_SOURCE_SHA256__',$hash);ErrorAction='Stop'}
if($PSVersionTable.PSEdition -eq 'Desktop'){$compile.ReferencedAssemblies=@('System.dll','System.Core.dll')}
Add-Type @compile
}
if([Wela.WecInventory.Reader]::SourceSha256 -cne $hash){throw 'Loaded native inventory differs from its source; start a fresh process.'}
}
function Get-WelaWecSubscriptionIds {
if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'Native WEC inventory requires 64-bit Windows.'}
Initialize-WelaWecSubscriptionInventory
# The native method returns nothing until enumeration has completed successfully.
[Wela.WecInventory.Reader]::ReadNames()
}
function Read-WelaWecSubscriptionXml {
param([Parameter(Mandatory)][string]$Id)
if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'Native WEC XML reads require 64-bit Windows.'}
$path=Join-Path $PSScriptRoot 'WecSubscriptionXml.cs';$hash=(Get-FileHash -LiteralPath $path -Algorithm SHA256 -ErrorAction Stop).Hash
if(-not ('Wela.WecXml.Reader' -as [type])){
$compile=@{Path=$path;ErrorAction='Stop'}
if($PSVersionTable.PSEdition -eq 'Desktop'){$compile.ReferencedAssemblies=@('System.dll','System.Core.dll','System.Xml.dll')}
Add-Type @compile;$script:WelaWecXmlSourceHash=$hash
}
if($script:WelaWecXmlSourceHash -cne $hash){throw 'Loaded native WEC XML reader differs from its source; start a fresh session.'}
[Wela.WecXml.Reader]::ReadXml($Id)
}
Export-ModuleMember -Function Get-WelaWecSubscriptionIds, ConvertTo-WelaWefFirewallAddressKey, Test-WelaWefFirewallAddressSet, Read-WelaWecSubscriptionXml, Read-WelaWefXml, Get-WelaWefXmlKey, ConvertFrom-WelaWefQuery, Get-WelaWefAuthorization, ConvertFrom-WelaWefSubscription, Import-WelaWefConfig