Private/Threat.Telemetry.ps1
|
# Telemetry correlation: after a provider scan, determine what Windows telemetry the # action generated - whether an alert was raised, with what context, and which sources # were blind. Encodes the "Assume Visibility, Then Validate It" principle. Windows-only. # See docs/TELEMETRY-CORRELATION-DESIGN.md. # Sources correlated against, in priority order. Defender is primary and readable # non-admin; PowerShell script-block logging is secondary; Sysmon/Security are # environment-dependent and reported as visibility gaps when absent or inaccessible. $script:OITelemetrySources = @( [pscustomobject]@{ Key = 'Defender'; LogName = 'Microsoft-Windows-Windows Defender/Operational'; Ids = @(1116, 1117) } [pscustomobject]@{ Key = 'PowerShell'; LogName = 'Microsoft-Windows-PowerShell/Operational'; Ids = @(4104) } [pscustomobject]@{ Key = 'Sysmon'; LogName = 'Microsoft-Windows-Sysmon/Operational'; Ids = @(1) } [pscustomobject]@{ Key = 'Security'; LogName = 'Security'; Ids = @(4688) } ) function ConvertFrom-OIWinEventDataField { <# Pure: parse a Windows event's XML (from EventRecord.ToXml()) into a hashtable of its named EventData/Data fields. Unit-tested directly against fixture XML - no live provider. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string]$Xml ) $fields = @{} try { $doc = [xml]$Xml } catch { return $fields } # Windows event XML carries a default namespace, so match Data nodes by local name. # Use GetAttribute/InnerText (methods, not property access) so an empty <Data/> element # is safe under Set-StrictMode instead of throwing a missing-'#text' error. foreach ($d in $doc.SelectNodes('//*[local-name()="Data"]')) { $name = $d.GetAttribute('Name') if (-not [string]::IsNullOrEmpty($name)) { $fields[$name] = $d.InnerText } } return $fields } function ConvertFrom-OIDefenderDetectionEvent { <# Pure: shape a parsed Defender 1116/1117 event into a structured detection record. Field names follow the Microsoft Defender Operational schema. Unit-tested with fixtures. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Xml, [Parameter(Mandatory = $true)] [int]$EventId, [int64]$RecordId = 0, [datetime]$TimeCreated = [datetime]::MinValue ) $f = ConvertFrom-OIWinEventDataField -Xml $Xml [pscustomobject]@{ EventId = $EventId RecordId = $RecordId TimeCreated = $TimeCreated ThreatName = $f['Threat Name'] ThreatId = $f['Threat ID'] SeverityName = $f['Severity Name'] CategoryName = $f['Category Name'] SourceName = $f['Source Name'] ProcessName = $f['Process Name'] DetectionUser = $f['Detection User'] Path = $f['Path'] ActionName = $f['Action Name'] FwLink = $f['FWLink'] } } function Get-OITelemetryEventConfidence { <# Pure: score how confidently a Defender detection is attributable to this scan. High requires both the detection Source matching the provider (AMSI) AND the process matching the scanning host, so a coincidental concurrent detection on the box is never claimed as High. Medium is source-only; Low is neither. Unit-tested directly. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] $Event, [AllowNull()] [string]$ExpectedSource, [AllowNull()] [string]$HostProcessPath ) $sourceMatch = [string]::IsNullOrEmpty($ExpectedSource) -or ($Event.SourceName -eq $ExpectedSource) $procMatch = $false if (-not [string]::IsNullOrEmpty($HostProcessPath) -and -not [string]::IsNullOrEmpty($Event.ProcessName)) { $ep = $Event.ProcessName.TrimEnd([char]0).ToLowerInvariant() $hp = $HostProcessPath.ToLowerInvariant() $procMatch = ($ep -eq $hp) -or $ep.Contains($hp) -or $hp.Contains($ep) } if ($sourceMatch -and $procMatch) { 'High' } elseif ($sourceMatch) { 'Medium' } else { 'Low' } } function Get-OITelemetrySnapshot { <# Capture, immediately before a scan, which telemetry logs are accessible and the highest RecordId currently in each - so correlation can find only events the scan produced. Inaccessible/absent logs are recorded as visibility gaps, not failures. Runs non-admin (Defender + PowerShell are readable; Security typically needs elevation). #> [CmdletBinding()] param() $startUtc = [datetime]::UtcNow $accessible = New-Object 'System.Collections.Generic.List[object]' $unavailable = New-Object 'System.Collections.Generic.List[object]' foreach ($src in $script:OITelemetrySources) { try { $log = Get-WinEvent -ListLog $src.LogName -ErrorAction Stop $lastId = [int64]0 try { $newest = Get-WinEvent -LogName $src.LogName -MaxEvents 1 -ErrorAction Stop if ($null -ne $newest) { $lastId = [int64]$newest.RecordId } } catch { # An empty (but present) log: no records yet. Snapshot at 0. $lastId = [int64]0 } $accessible.Add([pscustomobject]@{ Key = $src.Key LogName = $src.LogName Ids = $src.Ids LastRecordId = $lastId Enabled = [bool]$log.IsEnabled }) } catch { $msg = $_.Exception.Message $reason = if ($msg -match 'access is denied|elevat|administrator') { 'requires elevation' } elseif ($msg -match 'no events|could not be found|does not exist|Attempted to perform an unauthorized') { 'not present' } else { 'not present' } $unavailable.Add([pscustomobject]@{ Key = $src.Key; LogName = $src.LogName; Reason = $reason }) } } [pscustomobject]@{ StartTimeUtc = $startUtc Accessible = $accessible.ToArray() Unavailable = $unavailable.ToArray() } } function Get-OITelemetryCorrelation { <# After a scan, query each accessible log for events produced since the snapshot and correlate them to the action. Confidence is High only when a Defender detection's process matches the scanning host (so a coincidental concurrent detection is never claimed), Medium on source+window, Low on window alone. Returns an OffsetInspect.TelemetryCorrelation object. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [pscustomobject]$Snapshot, [ValidateSet('AMSI', 'Defender')] [string]$Engine = 'AMSI', [string]$HostProcessPath, [ValidateRange(0, 60)] [int]$PollSeconds = 5 ) $watch = [System.Diagnostics.Stopwatch]::StartNew() $defenderEvents = New-Object 'System.Collections.Generic.List[object]' $defenderSrc = $Snapshot.Accessible | Where-Object Key -eq 'Defender' | Select-Object -First 1 # Correlate by RecordId (monotonic, timezone-proof) rather than time: the Get-WinEvent # StartTime filter is interpreted in LOCAL time, so a UTC snapshot would silently exclude # events. Query the newest detections and keep only those past the pre-scan high-water. # Poll because Defender writes its events asynchronously; break once a detection appears. $deadline = [datetime]::UtcNow.AddSeconds($PollSeconds) do { if ($null -ne $defenderSrc -and $defenderEvents.Count -eq 0) { try { $candidates = Get-WinEvent -FilterHashtable @{ LogName = $defenderSrc.LogName Id = 1116, 1117 } -MaxEvents 100 -ErrorAction Stop foreach ($evt in @($candidates)) { if ([int64]$evt.RecordId -le [int64]$defenderSrc.LastRecordId) { continue } $defenderEvents.Add( (ConvertFrom-OIDefenderDetectionEvent -Xml $evt.ToXml() -EventId ([int]$evt.Id) -RecordId ([int64]$evt.RecordId) -TimeCreated $evt.TimeCreated) ) } } catch { # Get-WinEvent throws when a filter matches nothing yet; keep polling. Write-Verbose "No matching Defender events yet: $($_.Exception.Message)" } } if ($defenderEvents.Count -gt 0) { break } if ([datetime]::UtcNow -lt $deadline) { Start-Sleep -Milliseconds 400 } } while ([datetime]::UtcNow -lt $deadline) # Correlate: pick the strongest-matching detection and score confidence. $alertEvent = $null $confidence = 'None' $expectedSource = if ($Engine -eq 'AMSI') { 'AMSI' } else { $null } foreach ($evt in $defenderEvents) { $score = Get-OITelemetryEventConfidence -Event $evt -ExpectedSource $expectedSource -HostProcessPath $HostProcessPath # Keep the highest-confidence event. if ($null -eq $alertEvent -or @{ High = 3; Medium = 2; Low = 1; None = 0 }[$score] -gt @{ High = 3; Medium = 2; Low = 1; None = 0 }[$confidence]) { $alertEvent = $evt $confidence = $score } } $alertGenerated = $defenderEvents.Count -gt 0 $findings = New-Object 'System.Collections.Generic.List[string]' if ($alertGenerated) { if ([string]::IsNullOrWhiteSpace($alertEvent.ThreatName)) { $findings.Add('A detection was logged, but without a threat name (alert lacks actionable context).') } else { $findings.Add("Alert generated: $($alertEvent.ThreatName) [$($alertEvent.SeverityName)] via $($alertEvent.SourceName).") } } else { $findings.Add('No defender telemetry was generated for this scan action (absence of an alert is a finding).') } foreach ($gap in @($Snapshot.Unavailable)) { $findings.Add("Visibility gap: $($gap.Key) ($($gap.LogName)) $($gap.Reason).") } $watch.Stop() $result = [pscustomobject]@{ SourcesChecked = @($script:OITelemetrySources | ForEach-Object { $_.Key }) SourcesAccessible = @($Snapshot.Accessible | ForEach-Object { $_.Key }) SourcesUnavailable = @($Snapshot.Unavailable) AlertGenerated = $alertGenerated Alert = $alertEvent DefenderEvents = $defenderEvents.ToArray() CorrelationConfidence = $confidence Findings = $findings.ToArray() PollDurationMs = [Math]::Round($watch.Elapsed.TotalMilliseconds, 3) } $result.PSObject.TypeNames.Insert(0, 'OffsetInspect.TelemetryCorrelation') return $result } |