mirror of
https://github.com/activecm/rita
synced 2026-06-08 13:02:45 +00:00
b923c39e2d
* Update on config structure, functionality, and tests Co-Authored-By: Naomi Kramer <naomiagoddard@gmail.com> * Extend subnet type to read/write from db, update tests Co-Authored-By: Liza Tsibur <liza@activecountermeasures.com> * updated read file config test and subnet tests * fixed config and util tests, updated subnet related functions Co-Authored-By: Naomi Kramer <naomiagoddard@gmail.com> * Remove error return from GetDefaultConfig Co-Authored-By: Liza Tsibur <liza@activecountermeasures.com> * added json tags to database struct * Updated beacon weights validation for config * updates to score thresholds validation tags * changes to config subnet validation and testing * Update subnet.go * Write missing host entries to http to populate http_proto * Updating some fields to uint64 * WIP update some field types * Update zeek count types and fix tests * Add clickhouse credentials * Misc fixes * Update pointer * Add ability to mark datasets as sample datasets * fix column name * Fix datasets exiting import if hour is empty * Fix zeek count parsing from TSV files * Remove storing dns conns in arrays, Fix historical first seen dns lag * Remove unused columns * Update config.hjson * Update config.hjson * updated impact category score functions to use float64 Co-Authored-By: Naomi Kramer <naomiagoddard@gmail.com> * Update subnet.go * Store import version in imports table * Fix duplicated SNI/IP long connections * Update subnet_test.go * Cleanup output * Rolling files updates (#39) * Limit number of days to import for rolling datasets * Fix breaking imports when import was interrupted * Remove debug output --------- Co-authored-by: Naomi Kramer <naomi@activecountermeasures.com> * Omit parts of env from output * Set max for threat intel datasize * Remove SELinux neutering for QA * Add network size column * Fix http_proto for missing host, update tests for missing host fixes * Add online feeds to default config * Update sshprep (#45) * Update sshprep Co-Authored-By: William Stearns <3538265+william-stearns@users.noreply.github.com> * Update sshprep Add Bradley's suggestion of using head -1 to limit to a single address. --------- Co-authored-by: Naomi Kramer <naomi@activecountermeasures.com> Co-authored-by: William Stearns <3538265+william-stearns@users.noreply.github.com> * Installer Behavior Tweaks (#41) * Add --yes flag to add-apt-repository command * Add missing sudo flags, make sure we're using the SUDO variable instead * Add ability to perform zone transfers (#48) * Store zone transfer records Co-Authored-By: moth <25512187+0x6d6f7468@users.noreply.github.com> * Update config * Add tests * Tests, connectivity test * Update tests --------- Co-authored-by: moth <25512187+0x6d6f7468@users.noreply.github.com> * Support RedHat/RHEL as a valid target (#47) * Update sshprep Co-Authored-By: William Stearns <3538265+william-stearns@users.noreply.github.com> * Supporrt RedHat/RHEL as a valid target --------- Co-authored-by: Naomi Kramer <naomi@activecountermeasures.com> Co-authored-by: William Stearns <3538265+william-stearns@users.noreply.github.com> Co-authored-by: moth <moth@blackhillsinfosec.com> * Fix tests (#49) * Fix tests * Update WalkFiles to use UTC * fixed issue with rolling datasets over 24hours old not getting historical first seen timestamp set (#52) * Change values from float32 to float64 (#50) * Switch float32 to float64 * Update threat category calculation to match CalculateBucketedScore (#51) --------- Co-authored-by: Liza Tsibur <liza@activecountermeasures.com> * Bump max query execution time default value * Use string instead of error for ZoneTransferConnectivityErrors struct fields (#61) * Upgrade Golang to version 1.24 (#59) (#60) * Replace get_url with shell and curl (#58) * Update sshprep Co-Authored-By: William Stearns <3538265+william-stearns@users.noreply.github.com> * Replace get_url with shell and curl * Use get_url by default, fall back to curl if it fails --------- Co-authored-by: Naomi Kramer <naomi@activecountermeasures.com> Co-authored-by: William Stearns <3538265+william-stearns@users.noreply.github.com> Co-authored-by: moth <moth@blackhillsinfosec.com> * add automated log transfer, AC-Hunter issue 135 (#62) * Update sshprep Co-Authored-By: William Stearns <3538265+william-stearns@users.noreply.github.com> * add automated log transfer, PR135 * cron requires non-executable permission * Specify suggested YAML plugin and config in VSCode workspace * Linting and light cleanup * Update generate_installer.sh Download zeek_log_transport.sh to send to the sensor. * Create cron file if remote zeek installation * Only run zeek log import steps for remote sensor installations --------- Co-authored-by: Naomi Kramer <naomi@activecountermeasures.com> Co-authored-by: William Stearns <3538265+william-stearns@users.noreply.github.com> Co-authored-by: moth <moth@blackhillsinfosec.com> * Temporarily disable RITA/Zeek log transport until installer is modular (#66) * Uniform -y flag usage for repo management/package installation; Uniform SUDO variable usage (#68) * Resolve Installer Side Effects and Formalize RHEL Support (#73) * Add missing necessary wildcards for RHEL versions * Remove Ansible task replacing python3-requests to avoid RHEL distro installation side effects * Update supported distros in README * Update scoring defaults * Resolve Ansible Reboot Errors (#75) * Clean up conditionals; Fix reboot step for Ubuntu * Suppress erroneous error output on RPM systems, ignore errors on reboot necessity checks * Ignore missing host rows for openhttp (#76) * Fix integration tests due to prevalence (#77) --------- Co-authored-by: Liza Tsibur <liza@activecountermeasures.com> Co-authored-by: moth <moth@blackhillsinfosec.com> Co-authored-by: William Stearns <william.l.stearns@gmail.com> Co-authored-by: William Stearns <3538265+william-stearns@users.noreply.github.com> Co-authored-by: moth <25512187+0x6d6f7468@users.noreply.github.com>
801 lines
30 KiB
Go
801 lines
30 KiB
Go
package analysis
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"math"
|
||
"slices"
|
||
"sort"
|
||
|
||
zlog "github.com/activecm/rita/v5/logger"
|
||
"github.com/activecm/rita/v5/util"
|
||
|
||
"github.com/montanaflynn/stats"
|
||
)
|
||
|
||
var ErrInvalidDatasetTimeRange = errors.New("invalid dataset timerange: min ts is greater than or equal to max ts")
|
||
var ErrInputSliceEmpty = errors.New("input slice must not be empty")
|
||
|
||
type Beacon struct {
|
||
BeaconType string `ch:"beacon_type"` // (sni, ip)
|
||
Score float64 `ch:"beacon_score"`
|
||
TimestampScore float64 `ch:"ts_score"`
|
||
DataSizeScore float64 `ch:"ds_score"`
|
||
HistogramScore float64 `ch:"hist_score"`
|
||
DurationScore float64 `ch:"dur_score"`
|
||
|
||
TSIntervals []int64 `ch:"ts_intervals"`
|
||
TSIntervalCounts []int64 `ch:"ts_interval_counts"`
|
||
DSSizes []int64 `ch:"ds_sizes"`
|
||
DSCounts []int64 `ch:"ds_size_counts"`
|
||
}
|
||
|
||
func (analyzer *Analyzer) analyzeBeacon(entry *AnalysisResult) (Beacon, error) {
|
||
logger := zlog.GetLogger()
|
||
var beacon Beacon
|
||
|
||
// verify that minTSBeacon < maxTSBeacon
|
||
if analyzer.minTSBeacon.After(analyzer.maxTSBeacon) || analyzer.minTSBeacon.Equal(analyzer.maxTSBeacon) {
|
||
logger.Err(ErrInvalidDatasetTimeRange).Caller().Str("src", entry.Src.String()).Str("dst", entry.Dst.String()).Str("fqdn", entry.FQDN).Send()
|
||
return beacon, ErrInvalidDatasetTimeRange
|
||
}
|
||
|
||
// calculate timestamp scores and metrics (unused fields are used by the test functions)
|
||
tsScore, _, _, intervals, intervalCounts, _, _, err := getTimestampScore(entry.TSList)
|
||
if err != nil {
|
||
logger.Err(err).Caller().Str("src", entry.Src.String()).Str("dst", entry.Dst.String()).Str("fqdn", entry.FQDN).Send()
|
||
return beacon, err
|
||
}
|
||
|
||
// calculate data size scores and metrics
|
||
dsScore, _, _, dsSizes, dsCounts, _, _, err := getDataSizeScore(entry.BytesList)
|
||
if err != nil {
|
||
logger.Err(err).Caller().Str("src", entry.Src.String()).Str("dst", entry.Dst.String()).Str("fqdn", entry.FQDN).Send()
|
||
return beacon, err
|
||
}
|
||
|
||
// calculate histogram score (note: we currently look at a 24 hour period)
|
||
_, _, totalBars, longestRun, histScore, err := getHistogramScore(
|
||
analyzer.minTSBeacon.Unix(), analyzer.maxTSBeacon.Unix(), entry.TSList, analyzer.Config.Scoring.Beacon.HistogramModeSensitivity,
|
||
analyzer.Config.Scoring.Beacon.HistogramBimodalOutlierRemoval, analyzer.Config.Scoring.Beacon.HistogramBimodalMinHoursSeen, 24,
|
||
)
|
||
if err != nil {
|
||
logger.Err(err).Caller().Str("src", entry.Src.String()).Str("dst", entry.Dst.String()).Str("fqdn", entry.FQDN).Send()
|
||
return beacon, err
|
||
}
|
||
|
||
// calculate duration score
|
||
_, _, durScore, err := getDurationScore(
|
||
analyzer.minTSBeacon.Unix(), analyzer.maxTSBeacon.Unix(), int64(entry.TSList[0]), int64(entry.TSList[len(entry.TSList)-1]),
|
||
totalBars, longestRun, analyzer.Config.Scoring.Beacon.DurationMinHoursSeen, analyzer.Config.Scoring.Beacon.DurationConsistencyIdealHoursSeen,
|
||
)
|
||
if err != nil {
|
||
logger.Err(err).Caller().Str("src", entry.Src.String()).Str("dst", entry.Dst.String()).Str("fqdn", entry.FQDN).Send()
|
||
return beacon, err
|
||
}
|
||
|
||
// calculate overall beacon score
|
||
score, err := getBeaconScore(tsScore, analyzer.Config.Scoring.Beacon.TimestampScoreWeight,
|
||
dsScore, analyzer.Config.Scoring.Beacon.DatasizeScoreWeight,
|
||
durScore, analyzer.Config.Scoring.Beacon.DurationScoreWeight,
|
||
histScore, analyzer.Config.Scoring.Beacon.HistogramScoreWeight)
|
||
if err != nil {
|
||
logger.Err(err).Caller().Str("src", entry.Src.String()).Str("dst", entry.Dst.String()).Str("fqdn", entry.FQDN).Send()
|
||
return beacon, err
|
||
}
|
||
|
||
// create beacon
|
||
beacon = Beacon{
|
||
// score fields
|
||
BeaconType: entry.BeaconType,
|
||
Score: score,
|
||
TimestampScore: tsScore,
|
||
DataSizeScore: dsScore,
|
||
HistogramScore: histScore,
|
||
DurationScore: durScore,
|
||
|
||
// graphing fields
|
||
TSIntervals: intervals,
|
||
TSIntervalCounts: intervalCounts,
|
||
DSSizes: dsSizes,
|
||
DSCounts: dsCounts,
|
||
}
|
||
return beacon, nil
|
||
}
|
||
|
||
// getBeaconScore calculates the overall beacon score from the weighted subscores
|
||
func getBeaconScore(tsScore, tsWeight, dsScore, dsWeight, durScore, durWeight, histScore, histWeight float64) (float64, error) {
|
||
// ensure that the calculated subscores are between 0 and 1
|
||
scores := []float64{tsScore, dsScore, durScore, histScore}
|
||
for _, score := range scores {
|
||
if score < 0 || score > 1 {
|
||
return 0, errors.New("scores must be between 0 and 1")
|
||
}
|
||
}
|
||
|
||
// ensure that the weights are between 0 and 1 and sum to 1
|
||
weights := []float64{tsWeight, dsWeight, durWeight, histWeight}
|
||
weightSum := 0.0
|
||
for _, weight := range weights {
|
||
if weight < 0 || weight > 1 {
|
||
return 0, errors.New("weights must be between 0 and 1")
|
||
}
|
||
weightSum += weight
|
||
}
|
||
if weightSum != 1 {
|
||
return 0, errors.New("weights must sum to 1")
|
||
}
|
||
|
||
// calculate the final score
|
||
score := math.Round(((tsScore*tsWeight)+(dsScore*dsWeight)+(durScore*durWeight)+(histScore*histWeight))*1000) / 1000
|
||
|
||
return score, nil
|
||
}
|
||
|
||
// getTimestampScore calculates the timestamp score for a given list of timestamps. This score is based on the
|
||
// statistical properties of the intervals between timestamps, utilizing skewness and median absolute deviation
|
||
// to calculate a score that reflects the consistency of the intervals. This function returns the ts score, skew,
|
||
// median absolute deviation, intervals between timestamps, their counts, the most frequent interval, and its count.
|
||
func getTimestampScore(tsList []uint32) (float64, float64, float64, []int64, []int64, int64, int64, error) {
|
||
// ensure that the input slice has at least 4 elements (need at least 3 intervals, which requires at least 4 timestamps)
|
||
if len(tsList) < 4 {
|
||
return 0, 0, 0, nil, nil, 0, 0, fmt.Errorf("timestamp slice must contain at least 4 elements")
|
||
}
|
||
|
||
// find the delta times between the full, non-unique timestamp list and sort
|
||
// this will be used for the user/ graph reference variables returned by createCountMap
|
||
// the slice size is tsLength - 1 since we are looking at the deltas between timestamps
|
||
deltaTimesFull := make([]float64, len(tsList)-1)
|
||
nonZeroCounter := 0
|
||
for i := 0; i < len(tsList)-1; i++ {
|
||
interval := tsList[i+1] - tsList[i]
|
||
if interval > 0 {
|
||
nonZeroCounter++
|
||
}
|
||
deltaTimesFull[i] = float64(interval)
|
||
}
|
||
|
||
// ensure that there are at least 3 non-zero intervals
|
||
if nonZeroCounter < 3 {
|
||
return 0, 0, 0, nil, nil, 0, 0, fmt.Errorf("timestamp slice must contain at least 3 non-zero intervals")
|
||
}
|
||
|
||
// sort the delta times
|
||
slices.Sort(deltaTimesFull)
|
||
|
||
// get a list of the intervals found in the data, the number of times the interval was found, and the most occurring interval
|
||
intervals, intervalCounts, tsMode, tsModeCount, err := calculateDistinctCounts(deltaTimesFull)
|
||
if err != nil {
|
||
return 0, 0, 0, nil, nil, 0, 0, err
|
||
}
|
||
|
||
// deltas from the unique timestamp list are used for the scoring calculations. These can be
|
||
// calculated by taking the slice of the sorted deltaTimesFull from the first non-zero index
|
||
nonZeroIndex := 0
|
||
|
||
for i := 0; i < len(deltaTimesFull); i++ {
|
||
if deltaTimesFull[i] > 0 {
|
||
nonZeroIndex = i
|
||
break
|
||
}
|
||
}
|
||
deltaTimes := deltaTimesFull[nonZeroIndex:]
|
||
|
||
// calculate ts score, skew, and median absolute deviation
|
||
tsScore, tsSkew, tsMadm, err := calculateStatisticalScore(deltaTimes, 1)
|
||
if err != nil {
|
||
return 0, 0, 0, nil, nil, 0, 0, err
|
||
}
|
||
|
||
return tsScore, tsSkew, tsMadm, intervals, intervalCounts, tsMode, tsModeCount, nil
|
||
|
||
}
|
||
|
||
// getDataSizeScore calculates the data size score for a given list of data sizes. This score is based on the
|
||
// statistical properties of the data sizes, utilizing skewness and median absolute deviation to calculate a
|
||
// score that reflects the consistency of the data sizes. This function returns the ds score, skew,
|
||
// median absolute deviation, unique data sizes, their counts, the most frequent data size, and its count.
|
||
func getDataSizeScore(bytesList []float64) (float64, float64, float64, []int64, []int64, int64, int64, error) {
|
||
// ensure that the input slice has at least 3 elements
|
||
if len(bytesList) < 3 {
|
||
return 0, 0, 0, nil, nil, 0, 0, fmt.Errorf("bytes slice must contain at least 3 elements")
|
||
}
|
||
|
||
// sort the data sizes
|
||
slices.Sort(bytesList)
|
||
|
||
// find distinct data sizes and their counts
|
||
dsSizes, dsCounts, dsMode, dsModeCount, err := calculateDistinctCounts(bytesList)
|
||
if err != nil {
|
||
return 0, 0, 0, nil, nil, 0, 0, err
|
||
}
|
||
|
||
// calculate datasize score, skew, and median absolute deviation
|
||
dsScore, dsSkew, dsMadm, err := calculateStatisticalScore(bytesList, 0)
|
||
if err != nil {
|
||
return 0, 0, 0, nil, nil, 0, 0, err
|
||
}
|
||
|
||
return dsScore, dsSkew, dsMadm, dsSizes, dsCounts, dsMode, dsModeCount, nil
|
||
|
||
}
|
||
|
||
// calculateStatisticalScore calculates the statistical score, skew, and median absolute deviation for a given list of float64 values
|
||
func calculateStatisticalScore(values []float64, defaultMadScore float64) (float64, float64, float64, error) {
|
||
// ensure that the input slice is not empty
|
||
if len(values) == 0 {
|
||
return 0, 0, 0, ErrInputSliceEmpty
|
||
}
|
||
|
||
// calculate the skewness of the values
|
||
skew, skewScore, err := calculateBowleySkewness(values)
|
||
if err != nil {
|
||
return 0, 0, 0, err
|
||
}
|
||
|
||
// calculate the median absolute deviation of the values
|
||
mad, madScore, err := calculateMedianAbsoluteDeviation(values, defaultMadScore)
|
||
if err != nil {
|
||
return 0, 0, 0, err
|
||
}
|
||
|
||
// calculate final statistical score
|
||
score := math.Round(((skewScore+madScore)/2.0)*1000) / 1000
|
||
|
||
return score, skew, mad, nil
|
||
}
|
||
|
||
// getHistogramScore calculates a score based on the histogram of timestamps of a host pair over a specified period of time
|
||
func getHistogramScore(datasetMin int64, datasetMax int64, tsList []uint32, modeSensitivity float64, bimodalOutlierRemoval int32, bimodalMinHoursSeen int32, beaconTimeSpan int32) ([]int, map[int32]int32, int32, int32, float64, error) {
|
||
// ensure that the input slice is not empty
|
||
if len(tsList) == 0 {
|
||
return nil, nil, 0, 0, 0, ErrInputSliceEmpty
|
||
}
|
||
|
||
// ensure that the dataset time range is valid
|
||
if datasetMax <= datasetMin {
|
||
return nil, nil, 0, 0, 0, ErrInvalidDatasetTimeRange
|
||
}
|
||
|
||
// get histogram bin eges (note: we currently look at a 24 hour period)
|
||
binEdges, err := computeHistogramBins(datasetMin, datasetMax, beaconTimeSpan)
|
||
if err != nil {
|
||
return nil, nil, 0, 0, 0, err
|
||
}
|
||
|
||
// use timestamps to get freqencies for each bin
|
||
freqList, freqCount, totalBars, longestRun, err := createHistogram(binEdges, tsList, modeSensitivity)
|
||
if err != nil {
|
||
return nil, nil, 0, 0, 0, err
|
||
}
|
||
|
||
// calculate first potential score: coefficient of variation
|
||
// coefficient of variation will help score histograms that have jitter in the number of
|
||
// connections but where the overall graph would still look relatively flat and consistent
|
||
// calculate coefficient of variation score
|
||
cvScore, err := calculateCoefficientOfVariationScore(freqList)
|
||
if err != nil {
|
||
return nil, nil, 0, 0, 0, err
|
||
}
|
||
|
||
// calculate second potential score: bimodal fit
|
||
// this will score well for graphs that have 2-3 flat sections in their connection histogram,
|
||
// or a bimodal freqCount histogram.
|
||
bimodalFitScore, err := calculateBimodalFitScore(freqCount, totalBars, bimodalOutlierRemoval, bimodalMinHoursSeen)
|
||
if err != nil {
|
||
return nil, nil, 0, 0, 0, err
|
||
}
|
||
|
||
// calculate final score
|
||
// the final score is the max of the coefficient of variation and bimodal fit scores
|
||
score := math.Max(cvScore, bimodalFitScore)
|
||
|
||
return freqList, freqCount, totalBars, longestRun, score, nil
|
||
}
|
||
|
||
// getDurationScore calculates a duration score based on the provided input parameters, provided that
|
||
// a sufficient amount of hours (default threshold: 6 hours) are represented in the connection frequency histogram.
|
||
// The duration score is derived from two potential subscores: dataset timespan coverage and consistency of connection hours
|
||
func getDurationScore(datasetMin int64, datasetMax int64, histMin int64, histMax int64, totalBars int32, longestConsecutiveRun int32, minHoursThreshold int32, idealNumberConsistentHours int32) (float64, float64, float64, error) {
|
||
|
||
// ensure that the input values are valid
|
||
if minHoursThreshold < 1 || idealNumberConsistentHours < 1 || datasetMax <= datasetMin || histMax <= histMin {
|
||
return 0, 0, 0, fmt.Errorf("invalid input for getDurationScore: check parameter values")
|
||
}
|
||
|
||
// initialize the variables to hold the coverage, consistency, and final score
|
||
coverage, consistency, score := float64(0), float64(0), float64(0)
|
||
|
||
// check if there is enough data to calculate the duration score
|
||
if totalBars >= minHoursThreshold {
|
||
|
||
// calculate the dataset timespan coverage score
|
||
// this score reflects the proportion of time covered by the dataset in relation to the
|
||
// entire specified timeframe. It is calculated as:
|
||
// [ timestamp of last connection - timestamp of first connection ] /
|
||
// [ last timestamp of dataset - first timestamp of dataset ]
|
||
coverage = math.Ceil((float64(histMax-histMin)/float64(datasetMax-datasetMin))*1000) / 1000
|
||
if coverage > 1.0 {
|
||
coverage = 1.0
|
||
}
|
||
|
||
// calculate the consistency score
|
||
// this score measures the continuity of connection hours, considering the longest run
|
||
// of consecutive hours observed. Consecutive hours include wrap-around from the start
|
||
// to the end of the dataset. It is calculated as:
|
||
// [ longest run of consecutive hours seen] / [ Ideal consecutive hours (default: 12) ]
|
||
consistency = math.Ceil((float64(longestConsecutiveRun)/float64(idealNumberConsistentHours))*1000) / 1000
|
||
if consistency > 1.0 {
|
||
consistency = 1.0
|
||
}
|
||
|
||
// take the maximum of the two scores
|
||
score = math.Max(coverage, consistency)
|
||
}
|
||
|
||
return coverage, consistency, score, nil
|
||
}
|
||
|
||
// calculateBowleySkewness calculates a measure of skewness for a distribution.
|
||
// Perfect beacons would have symmetric delta time and size distributions
|
||
func calculateBowleySkewness(data []float64) (float64, float64, error) {
|
||
// ensure that the input slice is not empty, since the minimum number of
|
||
// elements required to calculate skewness is 3
|
||
if len(data) < 3 {
|
||
return 0, 0, fmt.Errorf("input slice must not contain fewer than 3 elements")
|
||
}
|
||
|
||
// calculate the quartiles
|
||
quartiles, err := stats.Quartile(data)
|
||
|
||
// returns an error if array was empty or quartiles could not be calculated
|
||
if err != nil {
|
||
return 0, 0, err
|
||
}
|
||
|
||
// calculate the numerator
|
||
num := quartiles.Q1 + quartiles.Q3 - 2*quartiles.Q2
|
||
|
||
// calculate the denominator
|
||
den := quartiles.Q3 - quartiles.Q1
|
||
|
||
// set the skewness to zero
|
||
skewness := float64(0)
|
||
|
||
// Bowley Skewness = (Q3+Q1 – 2Q2) / (Q3 – Q1)
|
||
// if the denominator less than 10 or the median is equal to the lower or upper quartile, the skewness is zero
|
||
if den >= 10 && quartiles.Q2 != quartiles.Q1 && quartiles.Q2 != quartiles.Q3 {
|
||
skewness = float64(num) / float64(den)
|
||
}
|
||
|
||
// calculate score
|
||
score := 1.0 - math.Abs(skewness)
|
||
|
||
// return the skewness and the score
|
||
return skewness, score, nil
|
||
}
|
||
|
||
// calculateMedianAbsoluteDeviation calculates the Median Absolute Deviation (MAD) about the median,
|
||
// providing a score that measures the dispersion of a distribution. Perfectly consistent data would
|
||
// result in a MAD score close to zero
|
||
func calculateMedianAbsoluteDeviation(data []float64, defaultScore float64) (float64, float64, error) {
|
||
// ensure the the input slice is not empty
|
||
if len(data) == 0 {
|
||
return 0, 0, ErrInputSliceEmpty
|
||
}
|
||
|
||
// ensure that the input is sorted
|
||
if !sort.Float64sAreSorted(data) {
|
||
sort.Float64s(data)
|
||
}
|
||
|
||
// calculate the median of the input data
|
||
median, err := stats.Median(data)
|
||
if err != nil {
|
||
return 0, 0, err
|
||
}
|
||
|
||
mad, err := stats.MedianAbsoluteDeviation(data)
|
||
if err != nil {
|
||
return 0, 0, err
|
||
}
|
||
|
||
// calculate the MAD score, which is a measure of how much the data deviates from its median.
|
||
// The MAD is normalized by dividing it by the median. The resulting score represents how
|
||
// consistent the data is. As the MAD increases, the score decreases, indicating more dispersion
|
||
score := defaultScore
|
||
if median >= 1 {
|
||
score = (median - mad) / median
|
||
}
|
||
|
||
// If the score is less than zero or NaN, return zero
|
||
if score < 0 || math.IsNaN(score) {
|
||
score = 0
|
||
}
|
||
|
||
// Return the MAD and the normalized MAD score
|
||
return mad, score, nil
|
||
}
|
||
|
||
// calculateDistinctCounts takes a sorted slice of numbers as input and returns
|
||
// distinct numbers, their counts, mode, and maximum count
|
||
func calculateDistinctCounts(input []float64) ([]int64, []int64, int64, int64, error) {
|
||
// ensure that the input slice has at least 2 elements
|
||
if len(input) < 2 {
|
||
return nil, nil, 0, 0, fmt.Errorf("input slice must have at least two elements")
|
||
}
|
||
|
||
// ensure that the input is sorted
|
||
if !sort.Float64sAreSorted(input) {
|
||
sort.Float64s(input)
|
||
}
|
||
|
||
// create a slice to store unique elements from the number list,
|
||
// starting with an empty slice (length 0) and a capacity based on
|
||
// the assumption that every element in input is distinct
|
||
distinctNumbers := make([]int64, 0, len(input))
|
||
|
||
// countsMap will map each distinct number to its count
|
||
countsMap := make(map[int64]int64)
|
||
|
||
// initialize with the first element of input
|
||
lastNumber := int64(input[0])
|
||
distinctNumbers = append(distinctNumbers, lastNumber)
|
||
countsMap[lastNumber]++
|
||
|
||
// iterate through input to identify unique elements and count occurrences
|
||
for _, currentNumber := range input[1:] {
|
||
current := int64(currentNumber)
|
||
|
||
// if the current number is different from the last one, add it to distinctNumbers
|
||
if lastNumber != current {
|
||
distinctNumbers = append(distinctNumbers, current)
|
||
}
|
||
|
||
// increment the count for the current number
|
||
countsMap[current]++
|
||
lastNumber = current
|
||
}
|
||
|
||
// prepare the results by calculating countsArray, mode, and maxCount
|
||
countsArray := make([]int64, len(distinctNumbers))
|
||
mode := distinctNumbers[0] // assume the mode is the first distinct number
|
||
maxCount := countsMap[mode] // initialize maxCount with the count of the assumed mode
|
||
|
||
// find the mode and maximum count
|
||
for i, number := range distinctNumbers {
|
||
count := countsMap[number]
|
||
countsArray[i] = count
|
||
|
||
// update mode and maxCount if a higher count is found
|
||
if count > maxCount {
|
||
maxCount = count
|
||
mode = number
|
||
}
|
||
}
|
||
|
||
return distinctNumbers, countsArray, mode, maxCount, nil
|
||
}
|
||
|
||
// computeHistogramBins creates evenly spaced bins for the histogram based on the given timestamp range
|
||
// and the desired number of bins
|
||
func computeHistogramBins(startTime int64, endTime int64, numBins int32) ([]float64, error) {
|
||
// ensure that the number of bins is positive
|
||
if numBins <= 0 {
|
||
return nil, errors.New("number of desired histogram bins must be greater than 0")
|
||
}
|
||
|
||
// ensure that time range is valid
|
||
if endTime <= startTime {
|
||
return nil, errors.New("invalid histogram time range")
|
||
}
|
||
|
||
// set number of bin eges. Since the edges include the endpoints,
|
||
// the number of edges will be one more than the number of desired bins
|
||
edgeCount := numBins + 1
|
||
|
||
// calculate the step size for evenly spaced bins between startTime and endTime
|
||
step := float64(endTime-startTime) / float64(numBins)
|
||
|
||
// create slice to store the bin edges
|
||
binEdges := make([]float64, edgeCount)
|
||
|
||
// explicitly set the first bin edge to startTime
|
||
binEdges[0] = float64(startTime)
|
||
|
||
// create evenly spaced bin edges between startTime and endTime
|
||
for i := int32(1); i < edgeCount-1; i++ {
|
||
binEdges[i] = float64(startTime) + (float64(i) * step)
|
||
}
|
||
|
||
// explicitly set the last edge to endTime
|
||
binEdges[edgeCount-1] = float64(endTime)
|
||
|
||
return binEdges, nil
|
||
}
|
||
|
||
// createHistogram calculates the distribution of timestamps across given bin edges
|
||
// func createHistogram(binEdges []uint32, timestamps []uint32, modeSensitivity float64) ([]int, map[int32]int32, int, int, error) {
|
||
func createHistogram(binEdges []float64, timestamps []uint32, modeSensitivity float64) ([]int, map[int32]int32, int32, int32, error) {
|
||
// validate input
|
||
if len(binEdges) < 2 {
|
||
return nil, nil, 0, 0, errors.New("bin edges must contain at least 2 elements")
|
||
}
|
||
|
||
if len(timestamps) == 0 {
|
||
return nil, nil, 0, 0, ErrInputSliceEmpty
|
||
}
|
||
|
||
// ensure that the bin edges are sorted
|
||
if !sort.Float64sAreSorted(binEdges) {
|
||
sort.Float64s(binEdges)
|
||
}
|
||
|
||
// ensure that the timestamps are sorted
|
||
if !util.UInt32sAreSorted(timestamps) {
|
||
util.SortUInt32s(timestamps)
|
||
}
|
||
|
||
// Initialize nextBinIndex with the second bin edge to start comparisons.
|
||
// This variable represents the upper limit of the current bin, used to determine
|
||
// if a timestamp falls within the current bin or if we need to move to the next bin.
|
||
currentBinIndex := 0
|
||
nextBinEdge := binEdges[currentBinIndex+1]
|
||
|
||
// calculate the number of connections that occurred within the time span represented by each bin
|
||
// this is basically a histogram of the number of connections that occurred within each bin
|
||
// i,e, for a timestamp list of [1, 5, 23, 25, 42, 45] and bin edges [0, 10, 20, 30, 40, 50],
|
||
// the histogram would be [2,0,2,0,2]
|
||
connectionHistogram := make([]int, len(binEdges)-1)
|
||
|
||
// loop over sorted timestamp list
|
||
for _, timestamp := range timestamps {
|
||
|
||
// increment if still in the current bin
|
||
if float64(timestamp) < nextBinEdge {
|
||
connectionHistogram[currentBinIndex]++
|
||
continue
|
||
}
|
||
|
||
// if the timestamp is greater than or equal to the next bin edge, move to the next bin
|
||
for j := currentBinIndex + 1; j < len(binEdges)-1; j++ {
|
||
currentBinIndex = j
|
||
nextBinEdge = binEdges[j+1]
|
||
if float64(timestamp) < binEdges[j+1] {
|
||
break
|
||
}
|
||
|
||
}
|
||
|
||
// increment count
|
||
// this will also capture and increment for a situation where the final timestamp is
|
||
// equal to the final bin
|
||
connectionHistogram[currentBinIndex]++
|
||
}
|
||
|
||
// get histogram frequency counts
|
||
freqCount, totalBars, longestRun, err := getFrequencyCounts(connectionHistogram, modeSensitivity)
|
||
if err != nil {
|
||
return nil, nil, 0, 0, err
|
||
}
|
||
|
||
return connectionHistogram, freqCount, totalBars, longestRun, nil
|
||
|
||
}
|
||
|
||
// getFrequencyCounts calculates the frequency counts for a connection histogram, essentially counting how many
|
||
// bars of a certain height are present in the histogram. The function also calculates the longest consecutive run
|
||
// of hours seen in the connection frequency histogram, including wrap around from start to end of dataset.
|
||
|
||
// alt description:
|
||
// calculateFrequencyCounts analyzes the histogram to calculate the frequency of each count,
|
||
// the total number of non-empty bins, and the longest consecutive sequence of non-empty bins seen in the histogram,
|
||
// including wrap around from start to end of dataset
|
||
func getFrequencyCounts(connectionHistogram []int, modeSensitivity float64) (map[int32]int32, int32, int32, error) {
|
||
// ensure that the input is not empty
|
||
if len(connectionHistogram) == 0 {
|
||
return nil, 0, 0, ErrInputSliceEmpty
|
||
}
|
||
|
||
// count total non-zero histogram entries (total bars) and find the largest histogram entry
|
||
var totalBars int32 = 0
|
||
largestConnCount := 0
|
||
for _, entry := range connectionHistogram {
|
||
if entry > 0 {
|
||
totalBars++
|
||
}
|
||
if entry > largestConnCount {
|
||
largestConnCount = entry
|
||
}
|
||
|
||
}
|
||
|
||
// create a map to store the frequency counts for the connection histogram
|
||
freqCount := make(map[int32]int32)
|
||
|
||
// determine bin size for frequency histogram. This is expressed as a percentage of the
|
||
// largest connection count and controls how forgiving the bimodal analysis is to variation.
|
||
// This keeps us from putting multiple bars of very similar height into different bins and
|
||
// interpreting them as separate modes. For example, if the largest connection count is 1000 and
|
||
// the bimodal sensitivity is 0.05, the bin size will be 50. This means that any bars with a
|
||
// within 50 of each other will be grouped together. This is useful for handling small variations
|
||
// in connection counts that are not significant enough to be considered separate modes.
|
||
// the percentage is set in the rita yaml file (default: 0.05)
|
||
binSize := math.Ceil(float64(largestConnCount) * modeSensitivity)
|
||
|
||
// make variables to track the longest consecutive run of hours seen in the connection
|
||
// frequency histogram, including wrap around from start to end of dataset
|
||
longestRun := int32(0)
|
||
currentRun := int32(0)
|
||
|
||
// make frequency count map
|
||
for i := 0; i < len(connectionHistogram)*2; i++ {
|
||
|
||
// get the bar from the connection histogram, wrapping around if necessary
|
||
frequency := connectionHistogram[i%len(connectionHistogram)]
|
||
|
||
// track the longest run of consecutive bars seen in the connection frequency histogram
|
||
if frequency > 0 {
|
||
currentRun++
|
||
|
||
} else {
|
||
|
||
if currentRun > longestRun {
|
||
longestRun = currentRun
|
||
}
|
||
currentRun = 0
|
||
|
||
}
|
||
|
||
// limit calculation to the first loop through the connection histogram
|
||
if i < len(connectionHistogram) {
|
||
|
||
// if the bar is greater than zero, parse it into the map entry that matches its frequency
|
||
if frequency > 0 {
|
||
|
||
// figure out which bin to parse the frequency bar into
|
||
bin := int(math.Floor(float64(frequency)/binSize) * binSize)
|
||
|
||
// create or increment bin
|
||
if _, ok := freqCount[int32(bin)]; !ok {
|
||
freqCount[int32(bin)] = 1
|
||
} else {
|
||
freqCount[int32(bin)]++
|
||
}
|
||
}
|
||
|
||
}
|
||
|
||
}
|
||
|
||
if currentRun > longestRun {
|
||
longestRun = currentRun
|
||
}
|
||
|
||
// since we could end up with 2*freqListLen for the longest run if
|
||
// every hour has a connection, we will fix it up here.
|
||
if longestRun > int32(len(connectionHistogram)) {
|
||
longestRun = int32(len(connectionHistogram))
|
||
}
|
||
|
||
return freqCount, totalBars, longestRun, nil
|
||
}
|
||
|
||
// calculateCoefficientOfVariationScore calculates the coefficient of variation score for a connection histogram.
|
||
// The score is used to evaluate the level of jitter in the number of connections, providing a measure of how flat
|
||
// or consistent the overall graph appears. A high coefficient of variation implies more jitter, resulting in a lower score.
|
||
// The final score is normalized between 0 and 1, where 1 indicates perfect consistency, and 0 indicates high variation.
|
||
//
|
||
// potential alt description:
|
||
// calculateCoefficientOfVariationScore calculates a score based on the coefficient of variation (CV) for a given frequency list.
|
||
// The CV is a standardized measure of dispersion of a frequency distribution, defined as the ratio of the standard deviation to the mean.
|
||
// This function returns a score inversely related to the CV, aiming to score datasets based on their uniformity or consistency.
|
||
func calculateCoefficientOfVariationScore(freqList []int) (float64, error) {
|
||
// ensure that the input is valid
|
||
|
||
// ensure that the input slice is not empty
|
||
if len(freqList) == 0 {
|
||
return 0, ErrInputSliceEmpty
|
||
}
|
||
|
||
// calculate the total and check for negative values. This will also ensure that the
|
||
// mean cannot be zero, a case for which the CV is unreliable
|
||
total := 0
|
||
for _, entry := range freqList {
|
||
if entry < 0 {
|
||
return 0, errors.New("input slice must not contain negative values")
|
||
}
|
||
total += entry
|
||
}
|
||
if total <= 0 {
|
||
return 0, errors.New("total must be greater than zero")
|
||
}
|
||
|
||
// calculate mean
|
||
freqMean := float64(total) / float64(len(freqList))
|
||
|
||
// calculate standard deviation
|
||
sd := float64(0)
|
||
for j := 0; j < len(freqList); j++ {
|
||
sd += math.Pow(float64(freqList[j])-freqMean, 2)
|
||
}
|
||
sd = math.Sqrt(sd / float64(len(freqList)))
|
||
|
||
// calculate coefficient of variation
|
||
cv := sd / math.Abs(freqMean)
|
||
|
||
// ensures datasets with high variability are not given negative scores
|
||
var cvScore float64
|
||
if cv > 1.0 {
|
||
cvScore = 0.0
|
||
} else {
|
||
cvScore = math.Round((1.0-cv)*1000) / 1000
|
||
}
|
||
|
||
// ensure that the score does not exceed 1
|
||
if cvScore > 1.0 {
|
||
cvScore = 1.0
|
||
}
|
||
|
||
return cvScore, nil
|
||
}
|
||
|
||
// calculateBimodalFitScore calculates the bimodal fit score for a connection histogram.
|
||
// This score is particularly useful for graphs that exhibit 2-3 flat sections in their connection histogram or a
|
||
// bimodal frequency count histogram. It is designed to handle scenarios like a beacon alternating between low and high
|
||
// connection counts per hour. The score is computed only if the number of total bars on the histogram is at least the
|
||
// specified minimum (default: 11). The final score is normalized between 0 and 1, where 1 indicates a perfect fit for
|
||
// bimodal patterns, and 0 indicates a poor fit.
|
||
func calculateBimodalFitScore(freqCount map[int32]int32, totalBars int32, modalOutlierRemoval int32, minHoursForBimodalAnalysis int32) (float64, error) {
|
||
// ensure that the input is valid
|
||
if len(freqCount) == 0 {
|
||
return 0, errors.New("frequency count map must not be empty")
|
||
}
|
||
|
||
// ensure that totalBars is greater than zero
|
||
if totalBars <= 0 {
|
||
return 0, errors.New("total bars must be greater than zero")
|
||
}
|
||
|
||
// override the minimum hours seen back to default (just under half a day) if it is less than 6 (a quarter of a day)
|
||
// this is to ensure that the bimodal fit score is not calculated for histograms with too few bars, as in that case
|
||
// a histogram with 1-2 bars will always be given a high bimoal fit score as it technically has 1-2 modes. This is also
|
||
// vetted when the config is loaded and should in theory not happen outside of tests
|
||
if minHoursForBimodalAnalysis < 6 {
|
||
minHoursForBimodalAnalysis = 11
|
||
}
|
||
|
||
// initialize bimodal fit to zero
|
||
modalFit := float64(0)
|
||
|
||
// check if the histogram has enough non-zero bars to analyze for bimodal patterns
|
||
if totalBars >= minHoursForBimodalAnalysis {
|
||
|
||
largest, secondLargest := int32(0), int32(0)
|
||
|
||
// get the top two frequency mode bars in the histogram
|
||
for _, value := range freqCount {
|
||
if value > largest {
|
||
secondLargest = largest
|
||
largest = value
|
||
} else if value > secondLargest {
|
||
secondLargest = value
|
||
}
|
||
}
|
||
|
||
// calculate the percentage of hour blocks that fit into the top two mode bins.
|
||
// a small buffer for the score is provided by throwing out a yaml-set number of
|
||
// potential outlier bins (default: 1)
|
||
adjustedTotalBars := math.Max(float64(totalBars-modalOutlierRemoval), 1) // ensure that the denominator is not zero
|
||
modalFit = float64(largest+secondLargest) / adjustedTotalBars
|
||
}
|
||
|
||
// calculate final score, ensuring that it does not exceed 1
|
||
modalFitScore := math.Round(float64(modalFit)*1000) / 1000
|
||
if modalFitScore > 1.0 {
|
||
modalFitScore = 1.0
|
||
}
|
||
|
||
return modalFitScore, nil
|
||
}
|