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

405 lines
14 KiB
Go

package viewer
import (
"fmt"
"math"
"net"
"strings"
"time"
"github.com/activecm/rita/v5/config"
"github.com/activecm/rita/v5/database"
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/lipgloss"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
type MixtapeResult struct {
Src net.IP `ch:"src" json:"src"`
Dst net.IP `ch:"dst" json:"dst"`
FQDN string `ch:"fqdn"`
FinalScore float64 `ch:"final_score"`
Count uint64 `ch:"count"`
ProxyCount uint64 `ch:"proxy_count"`
BeaconScore float64 `ch:"beacon_score"`
StrobeScore float64 `ch:"strobe_score"`
BeaconThreatScore float64 `ch:"beacon_threat_score"`
TotalDuration float64 `ch:"total_duration"`
LongConnScore float64 `ch:"long_conn_score"`
FirstSeen time.Time `ch:"first_seen_historical"`
FirstSeenScore float64 `ch:"first_seen_score"`
Prevalence float64 `ch:"prevalence"`
PrevalenceScore float64 `ch:"prevalence_score"`
PrevalenceTotal uint64 `ch:"prevalence_total"`
NetworkSize uint64 `ch:"network_size"`
Subdomains uint64 `ch:"subdomains"`
PortProtoService []string `ch:"port_proto_service"`
C2OverDNSScore float64 `ch:"c2_over_dns_score"`
C2OverDNSDirectConnScore float64 `ch:"c2_over_dns_direct_conn_score"`
ThreatIntelScore float64 `ch:"threat_intel_score"`
ThreatIntelDataSizeScore float64 `ch:"threat_intel_data_size_score"`
TotalBytes uint64 `ch:"total_bytes"`
TotalBytesFormatted string `ch:"total_bytes_formatted"`
MissingHostHeaderScore float64 `ch:"missing_host_header_score"`
MissingHostCount uint64 `ch:"missing_host_count"`
ProxyIPs []net.IP `ch:"proxy_ips"`
Modifiers []map[string]string `ch:"modifiers"`
TotalModifierScore float64 `ch:"total_modifier_score"`
}
type Item MixtapeResult
func (i *Item) GetSrc() string {
if i.Src.String() == "::" && i.Dst.String() == "::" && len(i.FQDN) > 0 {
return ""
}
return i.Src.String()
}
func (i *Item) GetDst() string {
if i.Dst.String() == "::" && len(i.FQDN) > 0 {
return i.FQDN
}
return i.Dst.String()
}
func (i *Item) GetBeacon() string {
// if connection is a strobe, set beacon score to 100%
if i.StrobeScore > 0 {
return renderIndicator(i.StrobeScore, "100%")
}
return renderIndicator(i.BeaconThreatScore, fmt.Sprintf("%1.2f%%", i.BeaconScore*100))
}
func (i *Item) GetFirstSeen(relativeTimestamp time.Time) string {
timeAgo := relativeTimestamp.Sub(i.FirstSeen)
switch {
case timeAgo.Hours() >= 8760:
months := int(math.Floor(timeAgo.Hours() / 8760))
text := "years"
if months == 1 {
text = "year"
}
return fmt.Sprintf("%d %s ago", months, text)
case timeAgo.Hours() >= 720:
months := int(math.Floor(timeAgo.Hours() / 720))
text := "months"
if months == 1 {
text = "month"
}
return fmt.Sprintf("%d %s ago", months, text)
case timeAgo.Hours() >= 24:
days := int(math.Floor(timeAgo.Hours() / 24))
text := "days"
if days == 1 {
text = "day"
}
return fmt.Sprintf("%d %s ago", days, text)
case timeAgo.Hours() < 1:
minutes := int(math.Floor(timeAgo.Minutes()))
text := "minutes"
if minutes == 1 {
text = "minute"
}
return fmt.Sprintf("%d %s ago", minutes, text)
}
text := "hours"
if math.Floor(timeAgo.Hours()) == 1 {
text = "hour"
}
return fmt.Sprintf("%d %s ago", int(math.Floor(timeAgo.Hours())), text)
}
func (i *Item) GetTotalDuration() string {
return renderIndicator(i.LongConnScore, time.Duration(i.TotalDuration*float64(time.Second)).Truncate(time.Second).String())
}
func (i *Item) GetPrevalence() string {
// prevalence = (prevalence_total / network_size)
// network_size = prevalence_total / prevalence
// format prevalence as a percentage
prevalence := fmt.Sprintf("%1.0f%%", i.Prevalence*100)
// show two decimal points if the prevalence is less than 1% to avoid displaying 0%
if i.Prevalence < 0.01 {
prevalence = fmt.Sprintf("%1.2f%%", i.Prevalence*100)
}
return renderIndicator(i.PrevalenceScore, fmt.Sprintf("%d/%d (%s)", i.PrevalenceTotal, i.NetworkSize, prevalence))
}
func (i *Item) GetSubdomains() string {
return renderIndicator(i.C2OverDNSScore, fmt.Sprintf("%d", i.Subdomains))
}
func (i *Item) GetPortProtoService() []string { return i.PortProtoService }
func (i *Item) GetThreatIntel() string {
if i.ThreatIntelScore > 0 {
return "⛔"
}
return ""
}
//nolint:gocritic // filtervalue cannot be a pointer method
func (i Item) FilterValue() string { return i.GetSrc() } // no-op, bubble tea requires this method to be implemented but it is unused
// GetSeverity returns the severity of the mixtape result based on the final score and
// adds a color based on the severity level if color is set to true
func (i *Item) GetSeverity(color bool) string {
caser := cases.Title(language.English)
var severity config.ImpactCategory
if i.FinalScore > config.HIGH_CATEGORY_SCORE {
severity = config.CriticalThreat
if DebugMode {
return lipgloss.NewStyle().Foreground(red).Render(fmt.Sprintf("%1.2f%%", i.FinalScore*100))
}
if color {
return lipgloss.NewStyle().Foreground(red).Render(caser.String(string(severity)))
}
} else {
severity = config.GetImpactCategoryFromScore(i.FinalScore)
if DebugMode {
return renderIndicator(i.FinalScore, fmt.Sprintf("%1.2f%%", i.FinalScore*100))
}
if color {
return renderIndicator(i.FinalScore, caser.String(string(severity)))
}
}
return caser.String(string(severity))
}
// GetResults queries the database for mixtape results based on the filter and pagination parameters
func GetResults(db *database.DB, filter *Filter, currentPage, pageSize int, minTimestamp time.Time) ([]list.Item, bool, error) {
// build query
query, params, appliedFilter := BuildResultsQuery(filter, currentPage, pageSize, minTimestamp)
// set context
ctx := clickhouse.Context(db.GetContext(), clickhouse.WithParameters(params))
// query database for results
rows, err := db.Conn.Query(ctx, query)
if err != nil {
return nil, false, err
}
var items []list.Item
for rows.Next() {
var res Item
if err := rows.ScanStruct(&res); err != nil {
return nil, false, fmt.Errorf("could not read mixtape result for viewer: %w", err)
}
items = append(items, list.Item(&res))
}
rows.Close()
return items, appliedFilter, nil
}
// BuildResultsQuery builds a query for fetching mixtape results based on the filter and pagination parameters
func BuildResultsQuery(filter *Filter, currentPage, pageSize int, minTimestamp time.Time) (string, clickhouse.Parameters, bool) {
params := clickhouse.Parameters{}
query := `--sql
SELECT src, dst, fqdn,
count,
proxy_count,
proxy_ips,
total_bytes,
total_bytes_formatted,
subdomains,
-- arrayDistinct(flatten(port_proto_service)) as port_proto_service,
port_proto_service,
beacon_score as beacon_score,
beacon_threat_score,
c2_over_dns_score,
strobe_score,
total_duration,
long_conn_score,
prevalence,
prevalence_score,
prevalence_total,
network_size,
first_seen_historical,
first_seen_score,
threat_intel_score,
threat_intel_data_size_score,
missing_host_count,
missing_host_header_score,
c2_over_dns_direct_conn_score,
modifiers,
total_modifier_score,
base_score + total_modifier_score + prevalence_score + first_seen_score + missing_host_header_score + threat_intel_data_size_score + c2_over_dns_direct_conn_score as final_score
-- base_score
-- total_modifier_score
FROM (
SELECT hash, src, dst, fqdn,
groupUniqArrayArray(proxy_ips) as proxy_ips,
max(proxy_count) as proxy_count,
max(count) as count,
sum(total_bytes) as total_bytes,
formatReadableSize(total_bytes) as total_bytes_formatted,
sum(subdomain_count) as subdomains,
flatten(groupArray(port_proto_service)) as port_proto_service,
sum(beacon_score) as beacon_score,
sum(beacon_threat_score) as beacon_threat_score,
sum(c2_over_dns_score) as c2_over_dns_score,
sum(strobe_score) as strobe_score,
sum(total_duration) as total_duration,
sum(long_conn_score) as long_conn_score,
sum(prevalence) as prevalence,
sum(prevalence_score) as prevalence_score,
sum(prevalence_total) as prevalence_total,
sum(network_size) as network_size,
max(first_seen_historical) as first_seen_historical,
sum(first_seen_score) as first_seen_score,
sum(threat_intel_score) as threat_intel_score,
sum(threat_intel_data_size_score) as threat_intel_data_size_score,
sum(missing_host_count) as missing_host_count,
sum(missing_host_header_score) as missing_host_header_score,
sum(c2_over_dns_direct_conn_score) as c2_over_dns_direct_conn_score,
arraySort(groupUniqArrayIf(map('modifier_name', modifier_name, 'modifier_value', modifier_value), modifier_name != '')) as modifiers,
sum(modifier_score) as total_modifier_score,
greatest(beacon_threat_score, long_conn_score, strobe_score, c2_over_dns_score, threat_intel_score) as base_score
FROM threat_mixtape t
INNER JOIN (SELECT hash, argMax(import_id, last_seen) as import_id, max(last_seen) as max_last_seen FROM threat_mixtape GROUP BY hash) x
ON t.hash = x.hash and t.last_seen = x.max_last_seen and t.import_id = x.import_id
WHERE toStartOfHour(t.last_seen) >= toStartOfHour(fromUnixTimestamp({min_ts:Int64}))
`
// build conditions for query based on filter
// set where conditions for src and dst filters
whereConditions := []string{}
if filter != nil {
if filter.Src != "" {
whereConditions = append(whereConditions, "src={src:String}")
params["src"] = filter.Src
}
if filter.Dst != "" {
whereConditions = append(whereConditions, "dst={dst:String}")
params["dst"] = filter.Dst
}
if filter.Fqdn != "" {
whereConditions = append(whereConditions, "fqdn={fqdn:String}")
params["fqdn"] = filter.Fqdn
}
if filter.ThreatIntel != "" {
whereConditions = append(whereConditions, "threat_intel={threat_intel:Bool}")
params["threat_intel"] = filter.ThreatIntel
}
if !filter.LastSeen.IsZero() {
whereConditions = append(whereConditions, "toStartOfHour(last_seen) >= {last_seen:Int64}")
params["last_seen"] = fmt.Sprintf("%d", filter.LastSeen.UTC().Unix())
}
}
// set where conditions for src and dst filters to query if any were specified
if len(whereConditions) > 0 {
query += "AND " + strings.Join(whereConditions, " AND ")
}
// set group by
query += `--sql
GROUP BY hash, src, dst, fqdn
`
// set having conditions for numerical filters
havingConditions := []string{}
if filter != nil {
if filter.Count.Value != "" && filter.Count.Operator != "" {
havingConditions = append(havingConditions, "count "+filter.Count.Operator+" {count:Int64}")
params["count"] = filter.Count.Value
}
if filter.Beacon.Value != "" && filter.Beacon.Operator != "" {
havingConditions = append(havingConditions, "beacon_score "+filter.Beacon.Operator+" {beacon:Float64}")
params["beacon"] = filter.Beacon.Value
}
if filter.Subdomains.Value != "" && filter.Subdomains.Operator != "" {
havingConditions = append(havingConditions, "subdomain_count "+filter.Subdomains.Operator+" {subdomains:Int64}")
params["subdomains"] = filter.Subdomains.Value
}
if filter.Duration.Value != "" && filter.Duration.Operator != "" {
if filter.Duration.Operator == "=" {
// round column down to the nearest integer if the operator is equ
havingConditions = append(havingConditions, "floor(total_duration) "+filter.Duration.Operator+" {duration:Float64}")
} else {
havingConditions = append(havingConditions, "total_duration "+filter.Duration.Operator+" {duration:Float64}")
}
params["duration"] = filter.Duration.Value
}
}
// add having conditions to query if any were specified
if len(havingConditions) > 0 {
query += "HAVING " + strings.Join(havingConditions, " AND ")
}
// add parentheses to close subquery
query += `--sql
)`
// add where conditions to the outer part of the query if any were specified
outerWhereConditions := []string{}
if filter != nil {
// add conditions for severity filter to query
if len(filter.Severity) > 0 {
for i, op := range filter.Severity {
paramName := fmt.Sprintf("final_score_%d", i)
outerWhereConditions = append(outerWhereConditions, "final_score "+op.Operator+fmt.Sprintf("{%s:Float64}", paramName))
params[paramName] = op.Value
}
query += "WHERE " + strings.Join(outerWhereConditions, " AND ")
}
}
// set sorting conditions if any were specified
sortingConditions := []string{}
if filter != nil {
if filter.SortSeverity != "" {
sortingConditions = append(sortingConditions, "final_score "+filter.SortSeverity)
}
if filter.SortBeacon != "" {
sortingConditions = append(sortingConditions, "beacon_score "+filter.SortBeacon)
}
if filter.SortDuration != "" {
sortingConditions = append(sortingConditions, "total_duration "+filter.SortDuration)
}
if filter.SortSubdomains != "" {
sortingConditions = append(sortingConditions, "subdomains "+filter.SortSubdomains)
}
}
// add sorting conditions to query if any were specified
if len(sortingConditions) > 0 {
query += "ORDER BY " + strings.Join(sortingConditions, ",")
} else {
query += `--sql
ORDER BY final_score DESC, strobe_score DESC, beacon_score DESC
`
}
// set offset ; fetch if the offset is greater than 0, otherwise set limit
offset := currentPage * pageSize
if offset > 0 {
query += `--sql
OFFSET {skip:Int32} ROWS FETCH NEXT {page_size:Int32} ROWS ONLY
`
params["skip"] = fmt.Sprintf("%d", offset)
} else {
query += `--sql
LIMIT {page_size:Int32}
`
}
params["page_size"] = fmt.Sprint(pageSize)
params["min_ts"] = fmt.Sprintf("%d", minTimestamp.UTC().Unix())
appliedFilter := len(whereConditions) > 0 || len(havingConditions) > 0 || len(outerWhereConditions) > 0 || len(sortingConditions) > 0
return query, params, appliedFilter
}