Files
Naomi Kramer b923c39e2d Update configuration (#78)
* 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>
2025-09-22 16:21:05 -04:00

801 lines
30 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}