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>
364 lines
12 KiB
Go
364 lines
12 KiB
Go
package analysis
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math"
|
|
"net"
|
|
"runtime"
|
|
"time"
|
|
|
|
"github.com/activecm/rita/v5/config"
|
|
"github.com/activecm/rita/v5/database"
|
|
zlog "github.com/activecm/rita/v5/logger"
|
|
"github.com/activecm/rita/v5/util"
|
|
|
|
"golang.org/x/sync/errgroup"
|
|
"golang.org/x/time/rate"
|
|
)
|
|
|
|
type Analyzer struct {
|
|
Database *database.DB
|
|
ImportID util.FixedString
|
|
Config *config.Config
|
|
AnalysisWorkers int
|
|
WriterWorkers int
|
|
UconnChan chan AnalysisResult
|
|
maxTS time.Time
|
|
minTS time.Time
|
|
maxTSBeacon time.Time
|
|
minTSBeacon time.Time
|
|
networkSize uint64
|
|
useCurrentTime bool
|
|
skipBeaconing bool
|
|
firstSeenMaxTS time.Time
|
|
|
|
writer *database.BulkWriter
|
|
}
|
|
|
|
type ThreatMixtape struct {
|
|
AnalyzedAt time.Time `ch:"analyzed_at"`
|
|
ImportID util.FixedString `ch:"import_id"`
|
|
|
|
// Base connection details
|
|
AnalysisResult
|
|
|
|
FinalScore float64 `ch:"final_score"`
|
|
// BEACONS
|
|
Beacon
|
|
BeaconThreatScore float64 `ch:"beacon_threat_score"` // bucketed beacon score
|
|
BeaconType string `ch:"beacon_type"`
|
|
|
|
// LONG CONNECTIONS
|
|
LongConnScore float64 `ch:"long_conn_score"`
|
|
|
|
// Strobe
|
|
Strobe bool `ch:"strobe"`
|
|
StrobeScore float64 `ch:"strobe_score"`
|
|
|
|
// C2 over DNS
|
|
C2OverDNSScore float64 `ch:"c2_over_dns_score"`
|
|
C2OverDNSDirectConnScore float64 `ch:"c2_over_dns_direct_conn_score"`
|
|
|
|
// Threat Intel
|
|
ThreatIntel bool `ch:"threat_intel"`
|
|
ThreatIntelScore float64 `ch:"threat_intel_score"`
|
|
|
|
// **** MODIFIERS ****
|
|
// for modifiers detected during the modifiers phase
|
|
ModifierName string `ch:"modifier_name"`
|
|
ModifierScore float64 `ch:"modifier_score"`
|
|
ModifierValue string `ch:"modifier_value"`
|
|
|
|
// modifiers that are able to be added to the same row as the threat indicator scores
|
|
// these are detected during the analysis phase (in the spagooper)
|
|
PrevalenceScore float64 `ch:"prevalence_score"`
|
|
NetworkSize uint64 `ch:"network_size"`
|
|
FirstSeenScore float64 `ch:"first_seen_score"`
|
|
ThreatIntelDataSizeScore float64 `ch:"threat_intel_data_size_score"`
|
|
MissingHostHeaderScore float64 `ch:"missing_host_header_score"`
|
|
}
|
|
|
|
// NewAnalyzer returns a new Analyzer object
|
|
func NewAnalyzer(db *database.DB, cfg *config.Config, importID util.FixedString, minTS, maxTS, minTSBeacon, maxTSBeacon time.Time, useCurrentTime bool, skipBeaconing bool) (*Analyzer, error) {
|
|
|
|
// create a rate limiter to control the rate of writing to the database
|
|
limiter := rate.NewLimiter(5, 5)
|
|
networkSize, err := db.GetNetworkSize(minTS) // use true min TS for network size
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var firstSeenMaxTS time.Time
|
|
if !useCurrentTime {
|
|
firstSeenMaxTS = maxTS
|
|
}
|
|
|
|
workers := int(math.Floor(math.Max(4, float64(runtime.NumCPU())/2)))
|
|
return &Analyzer{
|
|
Database: db,
|
|
Config: cfg,
|
|
ImportID: importID,
|
|
AnalysisWorkers: workers,
|
|
WriterWorkers: workers,
|
|
useCurrentTime: useCurrentTime,
|
|
maxTS: maxTS,
|
|
minTS: minTS,
|
|
maxTSBeacon: maxTSBeacon,
|
|
minTSBeacon: minTSBeacon,
|
|
firstSeenMaxTS: firstSeenMaxTS,
|
|
skipBeaconing: skipBeaconing,
|
|
networkSize: networkSize,
|
|
UconnChan: make(chan AnalysisResult),
|
|
writer: database.NewBulkWriter(db, cfg, workers, db.GetSelectedDB(), "threat_mixtape", "INSERT INTO {database:Identifier}.threat_mixtape", limiter, false),
|
|
}, nil
|
|
}
|
|
|
|
func (analyzer *Analyzer) Analyze() error {
|
|
logger := zlog.GetLogger()
|
|
|
|
// log the start time of the analysis
|
|
start := time.Now()
|
|
logger.Debug().Msg("Starting Analysis")
|
|
|
|
// create an error group to manage the analysis threads
|
|
analysisErrGroup, ctx := errgroup.WithContext(context.Background())
|
|
|
|
// create analysis calculation workers
|
|
for i := 0; i < analyzer.AnalysisWorkers; i++ {
|
|
analysisErrGroup.Go(func() error {
|
|
err := analyzer.runAnalysis()
|
|
return err
|
|
})
|
|
}
|
|
|
|
// create analysis writer workers
|
|
for i := 0; i < analyzer.WriterWorkers; i++ {
|
|
analyzer.writer.Start(i)
|
|
}
|
|
|
|
// start spagooper to feed anlysis threads
|
|
err := analyzer.Spagoop(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("could not perform spagoop analysis: %w", err)
|
|
}
|
|
|
|
// wait for all analysis threads to finish
|
|
if err := analysisErrGroup.Wait(); err != nil {
|
|
logger.Fatal().Err(err).Msg("could not perform beacon analysis")
|
|
return err
|
|
}
|
|
|
|
// close the mixtape writer
|
|
analyzer.writer.Close()
|
|
|
|
// log the end time of the analysis
|
|
end := time.Now()
|
|
diff := time.Since(start)
|
|
logger.Info().Str("elapsed_time", diff.String()).Time("analysis_began", start).Time("analysis_finished", end).Msg("Finished Analysis! 🎉")
|
|
|
|
return nil
|
|
}
|
|
|
|
func (analyzer *Analyzer) runAnalysis() error {
|
|
logger := zlog.GetLogger()
|
|
|
|
// loop over the uconn channel to process each entry
|
|
for entry := range analyzer.UconnChan {
|
|
// create a new mixtape entry to store the analysis results
|
|
mixtape := &ThreatMixtape{
|
|
AnalyzedAt: analyzer.Database.ImportStartedAt.Truncate(time.Microsecond),
|
|
ImportID: analyzer.ImportID,
|
|
AnalysisResult: entry,
|
|
BeaconType: entry.BeaconType,
|
|
NetworkSize: analyzer.networkSize,
|
|
}
|
|
|
|
// set the first seen historical value
|
|
firstSeenHistorical, replaced := util.ValidateTimestamp(entry.FirstSeenHistorical)
|
|
if replaced {
|
|
logger.Debug().
|
|
Str("src", entry.Src.String()).
|
|
Str("dst", entry.Dst.String()).
|
|
Str("missing_host_count", fmt.Sprint(entry.MissingHostCount)).
|
|
Str("fqdn", entry.FQDN).Msg("historical first seen timestamp was missing")
|
|
}
|
|
|
|
// if the last seen timestamp was not valid, then this entry cannot be inserted into the mixtape
|
|
// because modifiers require linking up with the last seen date
|
|
// this should log a warning as this is a bugs
|
|
lastSeen, replaced := util.ValidateTimestamp(entry.LastSeen)
|
|
if replaced {
|
|
logger.Debug().
|
|
Str("src", entry.Src.String()).
|
|
Str("dst", entry.Dst.String()).
|
|
Str("missing_host_count", fmt.Sprint(entry.MissingHostCount)).
|
|
Str("fqdn", entry.FQDN).Msg("last seen timestamp was missing")
|
|
}
|
|
|
|
mixtape.FirstSeenHistorical = firstSeenHistorical
|
|
mixtape.LastSeen = lastSeen
|
|
|
|
hasThreatIndicator := false
|
|
|
|
// C2 OVER DNS
|
|
if entry.TLD != "" && entry.SubdomainCount > 0 {
|
|
// run c2 over dns analysis on entry if the TLD is a known c2 domain
|
|
c2OverDNSScore := calculateBucketedScore(float64(entry.SubdomainCount), analyzer.Config.Scoring.C2ScoreThresholds)
|
|
|
|
hash, err := util.NewFixedStringHash(entry.TLD)
|
|
if err != nil {
|
|
logger.Debug().Str("src", entry.Src.String()).Str("fqdn", entry.FQDN).Msg("could not create hash from TLD")
|
|
}
|
|
mixtape.Hash = hash
|
|
mixtape.FQDN = entry.TLD
|
|
if entry.SubdomainCount >= uint64(analyzer.Config.Scoring.C2ScoreThresholds.Base) {
|
|
hasThreatIndicator = true
|
|
mixtape.C2OverDNSScore = c2OverDNSScore
|
|
// run c2 over dns direct connection analysis
|
|
if mixtape.HasC2OverDNSDirectConnectionsModifier {
|
|
mixtape.C2OverDNSDirectConnScore = analyzer.Config.Modifiers.C2OverDNSDirectConnScoreIncrease
|
|
}
|
|
}
|
|
|
|
} else {
|
|
|
|
// ALL OTHER THREAT INDICATORS
|
|
// Run beaconing as long as there are min/max beacon timestamps
|
|
if !analyzer.skipBeaconing {
|
|
// run beacon analysis on entry if there are enough unique connections and the overall connection count is less than a strobe (1 connection per second)
|
|
|
|
if entry.TSUnique >= uint64(analyzer.Config.Scoring.Beacon.UniqueConnectionThreshold) && entry.Count < 86400 {
|
|
beacon, err := analyzer.analyzeBeacon(&entry)
|
|
if err != nil {
|
|
continue // all the errors will get logged in the beacon analyzer so we get a line number
|
|
}
|
|
beaconThreatScore := calculateBucketedScore(float64(beacon.Score*100), analyzer.Config.Scoring.Beacon.ScoreThresholds)
|
|
hasThreatIndicator = true
|
|
mixtape.Beacon = beacon
|
|
mixtape.BeaconThreatScore = beaconThreatScore
|
|
}
|
|
}
|
|
|
|
// run long connection analysis on entry if the total duration is greater than the minimum duration threshold
|
|
if entry.TotalDuration >= float64(analyzer.Config.Scoring.LongConnectionScoreThresholds.Base) {
|
|
longConnScore := calculateBucketedScore(entry.TotalDuration, analyzer.Config.Scoring.LongConnectionScoreThresholds)
|
|
hasThreatIndicator = true
|
|
mixtape.LongConnScore = longConnScore
|
|
}
|
|
|
|
// record entry as a strobe if the overall connection count meets the strobe threshold (1 connection per second)
|
|
if entry.Count >= 86400 {
|
|
hasThreatIndicator = true
|
|
mixtape.Strobe = true
|
|
mixtape.StrobeScore = analyzer.Config.Scoring.StrobeImpact.Score
|
|
}
|
|
|
|
// MODIFIERS
|
|
// due to performance impact, these modifiers are scored here instead of in the modifier package
|
|
// MISSING HOST HEADER MODIFIER
|
|
if entry.MissingHostCount > 0 {
|
|
mixtape.MissingHostHeaderScore = analyzer.Config.Modifiers.MissingHostCountScoreIncrease
|
|
}
|
|
|
|
// Threat Intel Data Size Score
|
|
if entry.OnThreatIntel {
|
|
if entry.TotalBytes >= uint64(analyzer.Config.Modifiers.ThreatIntelDataSizeThreshold) {
|
|
mixtape.ThreatIntelDataSizeScore = analyzer.Config.Modifiers.ThreatIntelScoreIncrease
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
if hasThreatIndicator {
|
|
|
|
// Modifiers that apply to all connection types
|
|
// first seen scoring
|
|
// use the current time to score against unless useCurrentTime is false
|
|
relativeTime := util.GetRelativeFirstSeenTimestamp(analyzer.useCurrentTime, analyzer.firstSeenMaxTS)
|
|
timeSince := relativeTime.Sub(entry.FirstSeenHistorical)
|
|
daysSinceFirstSeen := float64(timeSince.Hours() / 24)
|
|
|
|
// Historical First Seen Scoring
|
|
// only apply to rolling datasets
|
|
if analyzer.Database.Rolling {
|
|
if daysSinceFirstSeen <= analyzer.Config.Modifiers.FirstSeenIncreaseThreshold {
|
|
mixtape.FirstSeenScore = analyzer.Config.Modifiers.FirstSeenScoreIncrease
|
|
} else if daysSinceFirstSeen >= analyzer.Config.Modifiers.FirstSeenDecreaseThreshold {
|
|
mixtape.FirstSeenScore = -1 * analyzer.Config.Modifiers.FirstSeenScoreDecrease
|
|
}
|
|
}
|
|
|
|
// Prevalence Scoring
|
|
if entry.Prevalence <= analyzer.Config.Modifiers.PrevalenceIncreaseThreshold {
|
|
mixtape.PrevalenceScore = analyzer.Config.Modifiers.PrevalenceScoreIncrease
|
|
} else if entry.Prevalence >= analyzer.Config.Modifiers.PrevalenceDecreaseThreshold {
|
|
mixtape.PrevalenceScore = -1 * analyzer.Config.Modifiers.PrevalenceScoreDecrease
|
|
}
|
|
|
|
// record entry as a threat intel if the entry is marked as threat intel
|
|
if entry.OnThreatIntel {
|
|
mixtape.ThreatIntel = true
|
|
mixtape.ThreatIntelScore = analyzer.Config.Scoring.ThreatIntelImpact.Score
|
|
}
|
|
|
|
// check to see if any of the workers cancelled before sending another entry to the writer
|
|
analyzer.writer.WriteChannel <- mixtape
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func calculateBucketedScore(value float64, thresholds config.ScoreThresholds) float64 {
|
|
base := float64(thresholds.Base)
|
|
low := float64(thresholds.Low)
|
|
medium := float64(thresholds.Med)
|
|
high := float64(thresholds.High)
|
|
|
|
// convert category scores to integers for calculation
|
|
noneScore := config.NONE_CATEGORY_SCORE * 100
|
|
lowScore := config.LOW_CATEGORY_SCORE * 100
|
|
mediumScore := config.MEDIUM_CATEGORY_SCORE * 100
|
|
highScore := config.HIGH_CATEGORY_SCORE * 100
|
|
|
|
score := float64(0)
|
|
|
|
// interpolate scores between the threat category bucket thresholds
|
|
switch {
|
|
// (Low) 1-4hrs
|
|
case value < base:
|
|
return 0
|
|
case value < low:
|
|
score = float64(noneScore + (value-base)/(low-base)*(lowScore-noneScore))
|
|
// (Medium) 4-8hrs
|
|
case value >= low && value < medium:
|
|
score = float64(lowScore + (value-low)/(medium-low)*(mediumScore-lowScore))
|
|
// (High) 8-12hrs+
|
|
case value >= medium:
|
|
// cap the maximum duration score value to the High category threshold because we're not scoring any higher than this
|
|
cappedValue := math.Min(value, high)
|
|
score = float64(mediumScore + (cappedValue-medium)/(high-medium)*(highScore-mediumScore))
|
|
}
|
|
return score / 100
|
|
}
|
|
|
|
// shouldHaveC2OverDNSDirectConnModifier returns true if no ips other than the ones in queriedby made connections to this domain
|
|
func shouldHaveC2OverDNSDirectConnModifier(directConns, queriedBy []net.IP) bool {
|
|
if len(queriedBy) > 0 {
|
|
queried := make(map[string]struct{})
|
|
for _, ip := range queriedBy {
|
|
queried[ip.String()] = struct{}{}
|
|
}
|
|
|
|
// check for any ips in direct conns that aren't in queried by
|
|
for _, ip := range directConns {
|
|
if _, ok := queried[ip.String()]; !ok {
|
|
return false
|
|
}
|
|
}
|
|
|
|
}
|
|
// apply direct conn modifier if no ips other than the ones in queried by made connections to this domain
|
|
return true
|
|
}
|