Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion cmd/ja4monitor/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -801,7 +801,8 @@ func runTUI(src capture.PacketSource, eng *engine.Engine, firstSeen *engine.Firs
evaluator.SuppressedCount()
}

router := tui.NewRouter(connChan, evaluator.Subscribe(128), statsFn, engineFn, store, name)
router := tui.NewRouter(connChan, evaluator.Subscribe(128), statsFn, engineFn, store, name).
WithRuleStatsFn(evaluator.CustomRuleStats)
evaluator.Start()
p := tea.NewProgram(router, tea.WithAltScreen())

Expand Down
22 changes: 22 additions & 0 deletions internal/anomaly/evaluator.go
Original file line number Diff line number Diff line change
Expand Up @@ -297,3 +297,25 @@ func (e *Evaluator) RuleCount() int {
}
return count
}

// RuleStat is a snapshot of one custom rule's activity for the summary dashboard.
type RuleStat struct {
Name string
FireCount int64
}

// CustomRuleStats returns a fire-count snapshot for every active custom rule.
// Returns nil when no custom rules are loaded. Safe to call from any goroutine.
func (e *Evaluator) CustomRuleStats() []RuleStat {
cr := e.customRules.Load()
if cr == nil || len(*cr) == 0 {
return nil
}
stats := make([]RuleStat, 0, len(*cr))
for _, rule := range *cr {
if tr, ok := rule.(*ThresholdRule); ok {
stats = append(stats, RuleStat{Name: rule.Name(), FireCount: tr.FireCount()})
}
}
return stats
}
168 changes: 168 additions & 0 deletions internal/anomaly/evaluator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -312,3 +312,171 @@ func TestReloadConfigSwapsAllowlist(t *testing.T) {
// correct
}
}

// ── ReloadCustomRules ────────────────────────────────────────────────────────

func TestReloadCustomRules_LoadsAndEvaluates(t *testing.T) {
ev := newTestEvaluator()
sub := ev.Subscribe(16)
ev.Start()

cfgs := []config.CustomRuleConfig{{
Name: "rapid_conns",
Enabled: true,
Severity: "high",
Type: "threshold",
Aggregation: "count",
Field: "connection",
GroupBy: "src_ip",
Threshold: 1,
Window: "60s",
Condition: "gt",
}}
if err := ev.ReloadCustomRules(cfgs); err != nil {
t.Fatalf("ReloadCustomRules: %v", err)
}

// Two evaluations exceed the gt-1 threshold → alert should fire.
conn := tracker.NewConnection("10.0.0.1", 1234, "1.1.1.1", 443, "tcp", time.Now())
ev.Evaluate(conn) // count = 1
ev.Evaluate(conn) // count = 2 > 1 → fire
time.Sleep(20 * time.Millisecond)

select {
case a := <-sub:
if a.Rule != "rapid_conns" {
t.Errorf("unexpected rule name: %q", a.Rule)
}
default:
t.Error("expected custom rule alert, got none")
}
}

func TestReloadCustomRules_InvalidConfigKeepsOldRules(t *testing.T) {
ev := newTestEvaluator()

// Load a valid rule first.
valid := []config.CustomRuleConfig{{
Name: "valid",
Enabled: true,
Severity: "high",
Type: "threshold",
Aggregation: "count",
Field: "connection",
GroupBy: "src_ip",
Threshold: 1,
Window: "60s",
Condition: "gt",
}}
if err := ev.ReloadCustomRules(valid); err != nil {
t.Fatalf("initial load failed: %v", err)
}
stats := ev.CustomRuleStats()
if len(stats) != 1 || stats[0].Name != "valid" {
t.Fatalf("expected 1 rule 'valid', got %v", stats)
}

// Attempt reload with an invalid window duration — must fail without
// replacing the current rule set.
bad := []config.CustomRuleConfig{{
Name: "bad",
Enabled: true,
Severity: "high",
Type: "threshold",
Aggregation: "count",
Field: "connection",
GroupBy: "src_ip",
Threshold: 1,
Window: "not-a-duration", // invalid
Condition: "gt",
}}
if err := ev.ReloadCustomRules(bad); err == nil {
t.Fatal("expected error for invalid window, got nil")
}

// Old rule set must still be intact.
stats = ev.CustomRuleStats()
if len(stats) != 1 || stats[0].Name != "valid" {
t.Errorf("expected old rule 'valid' preserved after failed reload, got %v", stats)
}
}

func TestReloadCustomRules_ClearRules(t *testing.T) {
ev := newTestEvaluator()

cfgs := []config.CustomRuleConfig{{
Name: "r",
Enabled: true,
Severity: "high",
Type: "threshold",
Aggregation: "count",
Field: "connection",
GroupBy: "src_ip",
Threshold: 1,
Window: "60s",
Condition: "gt",
}}
if err := ev.ReloadCustomRules(cfgs); err != nil {
t.Fatalf("load: %v", err)
}

// Passing nil clears all custom rules.
if err := ev.ReloadCustomRules(nil); err != nil {
t.Fatalf("clear: %v", err)
}

if stats := ev.CustomRuleStats(); stats != nil {
t.Errorf("after clear, expected nil stats, got %v", stats)
}
}

// ── CustomRuleStats ──────────────────────────────────────────────────────────

func TestCustomRuleStats_NilWhenNoRules(t *testing.T) {
ev := newTestEvaluator()
if stats := ev.CustomRuleStats(); stats != nil {
t.Errorf("expected nil with no custom rules, got %v", stats)
}
}

func TestCustomRuleStats_ReturnsFireCounts(t *testing.T) {
ev := newTestEvaluator()
ev.Start()

cfgs := []config.CustomRuleConfig{
{
Name: "rule_a", Enabled: true, Severity: "high", Type: "threshold",
Aggregation: "count", Field: "connection", GroupBy: "src_ip",
Threshold: 1, Window: "60s", Condition: "gt",
},
{
Name: "rule_b", Enabled: true, Severity: "medium", Type: "threshold",
Aggregation: "count", Field: "connection", GroupBy: "src_ip",
Threshold: 5, Window: "60s", Condition: "gt",
},
}
if err := ev.ReloadCustomRules(cfgs); err != nil {
t.Fatalf("ReloadCustomRules: %v", err)
}

// Fire rule_a (threshold 1) but not rule_b (threshold 5).
conn := tracker.NewConnection("10.0.0.1", 1234, "1.1.1.1", 443, "tcp", time.Now())
ev.Evaluate(conn) // count=1
ev.Evaluate(conn) // count=2 > 1 → rule_a fires

stats := ev.CustomRuleStats()
if len(stats) != 2 {
t.Fatalf("expected 2 stats entries, got %d", len(stats))
}

byName := make(map[string]RuleStat, len(stats))
for _, s := range stats {
byName[s.Name] = s
}
if byName["rule_a"].FireCount != 1 {
t.Errorf("rule_a FireCount = %d, want 1", byName["rule_a"].FireCount)
}
if byName["rule_b"].FireCount != 0 {
t.Errorf("rule_b FireCount = %d, want 0", byName["rule_b"].FireCount)
}
}
18 changes: 7 additions & 11 deletions internal/anomaly/threshold.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"time"

"github.com/Crank-Git/ja4monitor/internal/config"
Expand Down Expand Up @@ -60,10 +61,10 @@ type ThresholdRule struct {
mu sync.Mutex
buckets map[string]*windowBucket

// fireCount is incremented without the lock every time this rule fires.
// The summary dashboard reads it with a relaxed load — display staleness
// of a single increment is acceptable.
fireCount int64 // accessed via sync/atomic in FireCount
// fireCount is incremented every time this rule fires. atomic.Int64 because
// the summary dashboard reads it from the bubbletea goroutine concurrently
// with engine shards calling Evaluate (and potentially incrementing it).
fireCount atomic.Int64
}

// newThresholdRule constructs a ThresholdRule from a validated CustomRuleConfig.
Expand Down Expand Up @@ -106,12 +107,7 @@ func (r *ThresholdRule) Name() string { return r.cfg.Name }

// FireCount returns the total number of times this rule has fired.
// Safe to call from any goroutine without holding mu.
func (r *ThresholdRule) FireCount() int64 {
// Direct load is safe on all supported architectures (64-bit aligned field).
// We avoid importing sync/atomic here to keep the method simple; callers
// that need strict ordering should use their own fence.
return r.fireCount
}
func (r *ThresholdRule) FireCount() int64 { return r.fireCount.Load() }

// Evaluate implements Rule. Called on every packet for a connection.
//
Expand Down Expand Up @@ -169,7 +165,7 @@ func (r *ThresholdRule) Evaluate(conn *tracker.Connection) []Alert {
return nil
}

r.fireCount++ // relaxed increment; see FireCount comment
r.fireCount.Add(1)
return []Alert{r.buildAlert(conn, groupKey, count)}
}

Expand Down
34 changes: 34 additions & 0 deletions internal/anomaly/threshold_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,40 @@ func TestThresholdRule_CountEQ_FiresExactly(t *testing.T) {
}
}

func TestThresholdRule_CountLT_FiresWhenBelowThreshold(t *testing.T) {
// lt fires when count < threshold. With threshold=3 and count_distinct
// over a compound group, the simplest test: one evaluation produces
// count=1, which is < 3.
rule := makeRule(t, "r", "count", "connection", "src_ip", "lt", 3, "60s")
conn := makeConn("10.0.0.1", "1.2.3.4", 80)

// First call: count = 1, which is < 3 → should fire
alerts := rule.Evaluate(conn)
if len(alerts) != 1 {
t.Fatalf("lt: expected 1 alert when count(1) < threshold(3), got %d", len(alerts))
}
}

func TestThresholdRule_CountLTE_FiresAtThreshold(t *testing.T) {
// lte fires when count <= threshold. With threshold=1, the first
// evaluation (count=1) should fire; subsequent calls within the window
// must not (once-per-window dedup via lastFired).
rule := makeRule(t, "r", "count", "connection", "src_ip", "lte", 1, "60s")
conn := makeConn("10.0.0.1", "1.2.3.4", 80)

// First call: count = 1 ≤ 1 → should fire
alerts := rule.Evaluate(conn)
if len(alerts) != 1 {
t.Fatalf("lte: expected 1 alert when count(1) <= threshold(1), got %d", len(alerts))
}

// Second call within same window: count = 2 > 1 → must not fire
alerts = rule.Evaluate(conn)
if len(alerts) != 0 {
t.Fatalf("lte: should not fire when count(2) > threshold(1), got %d alerts", len(alerts))
}
}

func TestThresholdRule_OncePerWindow(t *testing.T) {
rule := makeRule(t, "r", "count", "connection", "src_ip", "gt", 1, "60s")
conn := makeConn("10.0.0.1", "1.2.3.4", 80)
Expand Down
Loading
Loading