Public/Invoke-TripwireAnalysis.ps1
|
<# .SYNOPSIS Correlate a set of touches into actors, then score every touch. .DESCRIPTION Fan-out is the only signal that cannot be computed from a single touch, so it is computed here, over the whole set, before anything is scored. The correlation is per actor and per sliding window: for each touch, count the DISTINCT decoys the same actor touched within CorrelationWindowMinutes either side. Distinct decoys, never events -- an attacker retrying one decoy fifty times must not look like a sweep across fifty decoys. Touches whose actor cannot be identified at all (no directory object id, no source address) are never correlated with anything. Bucketing anonymous events together would manufacture fan-out out of unrelated noise, and fan-out is what carries a trip into the band where containment becomes eligible. A touch whose timestamp cannot be resolved is in the same position: it cannot be placed in any window, so it is scored alone at fan-out 1 and the signals that need a clock withhold themselves. It is NOT dropped and it does not stop the batch. Before this was explicit, one record with no createdDateTime raised a terminating error here and took every other scored trip in the run with it -- a run that found intrusions returned nothing, and nothing is exactly what a quiet tenant returns. .PARAMETER Touch Normalised touches from Watch-TripwireActivity. .PARAMETER Decoy The decoy inventory. .PARAMETER Baseline Tenant baseline. Optional; without it the affected signals are withheld. .PARAMETER Policy Scoring policy. Defaults to Get-TripwirePolicy. .EXAMPLE Invoke-TripwireAnalysis -Touch $touches -Decoy (Get-TripwireDecoy) -Baseline (Get-TripwireBaseline) | Sort-Object Confidence -Descending | Format-Table Confidence, Band, DecoyName, ActorLabel #> function Invoke-TripwireAnalysis { [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $Touch, [AllowEmptyCollection()] [object[]] $Decoy = @(), [AllowNull()] $Baseline, $Policy ) $Policy = Resolve-TripwirePolicy -Policy $Policy $decoyById = @{} foreach ($d in $Decoy) { $decoyById[[string]$d.id] = $d } $windowTicks = [TimeSpan]::FromMinutes($Policy.CorrelationWindowMinutes).Ticks # One pass to resolve each touch's identity and its time. Both were previously # recomputed inside the inner loop, which made the parse cost quadratic: 2,000 touches # over 20 actors took 10.3 s, almost all of it re-parsing the same timestamps. $entries = [System.Collections.Generic.List[object]]::new() $byActor = @{} foreach ($t in $Touch) { $key = Get-TripwireActorKey -Touch $t $when = ConvertTo-TripwireUtcOrNull $t.timestamp $entry = [pscustomobject]@{ Touch = $t Ticks = if ($null -ne $when) { $when.Ticks } else { $null } DecoyId = [string]$t.decoyId Count = 1 } $entries.Add($entry) # Correlation needs BOTH an identifiable actor and a placeable time. Missing # either, the touch is scored alone rather than folded into somebody else's window. if ($null -ne $key -and $null -ne $when) { if (-not $byActor.ContainsKey($key)) { $byActor[$key] = [System.Collections.Generic.List[object]]::new() } $byActor[$key].Add($entry) } } # Per actor: sort once, then sweep the window with two pointers. The distinct count is # over decoy IDS, never events -- an attacker retrying one decoy fifty times must not # look like a sweep across fifty decoys. foreach ($bucket in $byActor.Values) { $sorted = @($bucket | Sort-Object -Property Ticks) $lo = 0 $hi = -1 for ($i = 0; $i -lt $sorted.Count; $i++) { $centre = $sorted[$i].Ticks while ($lo -lt $sorted.Count -and ($centre - $sorted[$lo].Ticks) -gt $windowTicks) { $lo++ } if ($hi -lt $i) { $hi = $i } while (($hi + 1) -lt $sorted.Count -and ($sorted[$hi + 1].Ticks - $centre) -le $windowTicks) { $hi++ } $distinct = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) for ($j = $lo; $j -le $hi; $j++) { [void] $distinct.Add($sorted[$j].DecoyId) } $sorted[$i].Count = [Math]::Max(1, $distinct.Count) } } # One advisory for the batch rather than one per touch. The signals withhold themselves # quietly and carry their reason on the trip, which is right for a report and useless # at a prompt: this is the line that explains why a whole run came back degraded. $newest = $null foreach ($entry in $entries) { if ($null -ne $entry.Ticks -and ($null -eq $newest -or $entry.Ticks -gt $newest)) { $newest = $entry.Ticks } } $newestAt = if ($null -ne $newest) { [DateTime]::new([long]$newest, [DateTimeKind]::Utc) } else { $null } $freshness = Test-TripwireBaselineFreshness -Policy $Policy -Baseline $Baseline -AsOf $newestAt if ($freshness.Stale) { Write-TripwireLog -Level 'Warn' -Message "The tenant baseline is being refused for this batch: $($freshness.Reason) The origin and off-hours signals are WITHHELD on every trip below, so each score is a floor rather than a measurement." } elseif ($freshness.Warn) { Write-TripwireLog -Level 'Warn' -Message ("The tenant baseline was generated {0:N1} day(s) before the newest touch in this batch. It is still being used; rebuild it with Get-TripwireBaseline -Build -Save." -f $freshness.AgeDays) } foreach ($entry in $entries) { Measure-TripwireConfidence -Touch $entry.Touch -Decoy $decoyById[$entry.DecoyId] ` -Baseline $Baseline -DistinctDecoyCount $entry.Count -Policy $Policy } } |