diff --git a/cmd/ja4monitor/main.go b/cmd/ja4monitor/main.go index dcb2320..dcd3c74 100644 --- a/cmd/ja4monitor/main.go +++ b/cmd/ja4monitor/main.go @@ -166,7 +166,8 @@ Examples: } rootCmd.PersistentFlags().StringVar(&configFile, "config", "", "Config file path") - rootCmd.AddCommand(liveCmd, analyzeCmd, daemonCmd, attachCmd, queryCmd, graduateCmd(), installCmd(), validateCmd()) + rootCmd.AddCommand(liveCmd, analyzeCmd, daemonCmd, attachCmd, queryCmd, + graduateCmd(), installCmd(), validateCmd(), seedCmd(), exportProfilesCmd()) if err := rootCmd.Execute(); err != nil { os.Exit(1) @@ -218,7 +219,7 @@ func run(src capture.PacketSource, cfg config.Config, configFile string, jsonOut signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) // Periodic persistence - go persistLoop(store, firstSeen) + go persistLoop(store, firstSeen, evaluator) // Wire alert subscribers (webhook, syslog, CEF). Must happen before // evaluator.Start() which is called inside runTUI/runJSON. @@ -302,7 +303,7 @@ func runDaemon(src capture.PacketSource, cfg config.Config, configFile string) e sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) - go persistLoop(store, firstSeen) + go persistLoop(store, firstSeen, evaluator) if err := wireAlertSinks(cfg, evaluator, stats); err != nil { return err @@ -372,6 +373,9 @@ func runDaemon(src capture.PacketSource, cfg config.Config, configFile string) e } log.Println("ja4monitor daemon shutting down") store.SaveFirstSeen(firstSeen.Snapshot()) + if profiles := evaluator.SerializeBehaviorProfiles(); len(profiles) > 0 { + store.SaveBehaviorProfiles(profiles) //nolint:errcheck + } return nil } @@ -500,6 +504,15 @@ func initPipeline(cfg config.Config, stats *engine.EngineStats) (*storage.Store, return nil, nil, nil, fmt.Errorf("init evaluator: %w", err) } evaluator.SetDropCounter(stats) + + // Seed behavioral profiler from previously-persisted profiles so it + // starts detecting immediately rather than waiting for a full learning + // cycle. No-op when the table is empty or the profiler is disabled. + if profiles, err := store.LoadBehaviorProfiles(); err == nil && len(profiles) > 0 { + evaluator.LoadBehaviorProfiles(profiles) + log.Printf("behavioral profiler: seeded with %d profile entries", len(profiles)) + } + return store, firstSeen, evaluator, nil } @@ -516,12 +529,16 @@ func wireEviction(eng *engine.Engine, store *storage.Store) { } } -func persistLoop(store *storage.Store, firstSeen *engine.FirstSeenMap) { +func persistLoop(store *storage.Store, firstSeen *engine.FirstSeenMap, evaluator *anomaly.Evaluator) { ticker := time.NewTicker(30 * time.Second) defer ticker.Stop() for range ticker.C { store.SaveFirstSeen(firstSeen.Snapshot()) store.Flush() + // Persist behavioral profiles so a sensor restart can seed from them. + if profiles := evaluator.SerializeBehaviorProfiles(); len(profiles) > 0 { + store.SaveBehaviorProfiles(profiles) //nolint:errcheck + } } } diff --git a/cmd/ja4monitor/main_test.go b/cmd/ja4monitor/main_test.go index 4a1d4bf..48b745f 100644 --- a/cmd/ja4monitor/main_test.go +++ b/cmd/ja4monitor/main_test.go @@ -5,9 +5,12 @@ import ( "encoding/json" "os" "testing" + "time" + "github.com/Crank-Git/ja4monitor/internal/anomaly" "github.com/Crank-Git/ja4monitor/internal/capture" "github.com/Crank-Git/ja4monitor/internal/engine" + "github.com/Crank-Git/ja4monitor/internal/storage" ) // Phase 1 regression tests: these must all pass unchanged after the @@ -186,3 +189,83 @@ func TestRunValidate_MissingPCAP(t *testing.T) { t.Error("expected error for missing PCAP file, got nil") } } + +func TestRunSeed_MissingSource(t *testing.T) { + err := runSeed("/nonexistent/backup.db", t.TempDir()+"/dest.db") + if err == nil { + t.Error("expected error for missing source db") + } +} + +func TestRunSeed_SameSourceAndDest(t *testing.T) { + path := t.TempDir() + "/same.db" + err := runSeed(path, path) + if err == nil { + t.Error("expected error when source == destination") + } +} + +func TestRunSeed_RoundTrip(t *testing.T) { + dir := t.TempDir() + srcPath := dir + "/src.db" + dstPath := dir + "/dst.db" + + // Build a source DB with some profiles. + src, err := storage.NewStore(srcPath) + if err != nil { + t.Fatal(err) + } + entries := []anomaly.BehaviorProfileEntry{ + {AnchorKey: "ja4:seed-test", CompanionKey: "ja4h:val", Count: 5, TotalSeen: 10, + LastUpdated: time.Now().UTC()}, + } + if err := src.SaveBehaviorProfiles(entries); err != nil { + t.Fatal(err) + } + src.Close() + + // Seed into dest. + if err := runSeed(srcPath, dstPath); err != nil { + t.Fatalf("runSeed: %v", err) + } + + // Verify dest has the profiles. + dst, err := storage.NewStore(dstPath) + if err != nil { + t.Fatal(err) + } + defer dst.Close() + + got, err := dst.LoadBehaviorProfiles() + if err != nil { + t.Fatal(err) + } + if len(got) != 1 { + t.Fatalf("expected 1 profile entry, got %d", len(got)) + } + if got[0].AnchorKey != "ja4:seed-test" { + t.Errorf("unexpected anchor key: %s", got[0].AnchorKey) + } +} + +func TestRunExportProfiles_MissingDB(t *testing.T) { + err := runExportProfiles("/nonexistent/db.sqlite") + if err == nil { + t.Error("expected error for missing database") + } +} + +func TestRunExportProfiles_EmptyDB(t *testing.T) { + dir := t.TempDir() + dbPath := dir + "/empty.db" + store, err := storage.NewStore(dbPath) + if err != nil { + t.Fatal(err) + } + store.Close() + + // Should succeed with zero entries exported. + if err := runExportProfiles(dbPath); err != nil { + t.Errorf("unexpected error: %v", err) + } +} diff --git a/cmd/ja4monitor/seed.go b/cmd/ja4monitor/seed.go new file mode 100644 index 0000000..ffa590c --- /dev/null +++ b/cmd/ja4monitor/seed.go @@ -0,0 +1,182 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "os" + + "github.com/spf13/cobra" + + "github.com/Crank-Git/ja4monitor/internal/config" + "github.com/Crank-Git/ja4monitor/internal/storage" +) + +// seedCmd returns the cobra command for `ja4monitor seed`. +// +// Seeds the current sensor's behavioral profiles from a backup database, +// eliminating the 24h cold-start blind spot when a sensor is redeployed. +// +// Usage: +// +// ja4monitor seed --from backup.db +// ja4monitor seed --from backup.db --config /etc/ja4monitor/ja4monitor.toml +// +// The command merges (not overwrites) profiles: counts are additive, so +// seeding from multiple sources is safe and idempotent. +func seedCmd() *cobra.Command { + var ( + fromDB string + configFile string + ) + cmd := &cobra.Command{ + Use: "seed", + Short: "Seed behavioral profiles from a backup database", + Long: `Seed the current sensor's behavioral profiles from a previously exported +SQLite database. This eliminates the 24h cold-start blind spot: the behavioral +profiler requires min_seen=50 observations before firing, which can take up to +24h on a new deployment. Seeding from a backup instantly provides that history. + +Profiles are merged additively — counts are summed, not overwritten. Seeding +from multiple sources is safe and idempotent. + +The source database must have been written by ja4monitor v0.8+ (which persists +profiles automatically every 30s and on clean shutdown). To export a snapshot +from a running sensor, use: ja4monitor export-profiles --db /path/to/sensor.db + +Examples: + ja4monitor seed --from backup.db + ja4monitor seed --from old-sensor.db --config /etc/ja4monitor/ja4monitor.toml`, + RunE: func(cmd *cobra.Command, args []string) error { + cfg := loadConfig(configFile) + return runSeed(fromDB, cfg.DBPath) + }, + } + cmd.Flags().StringVar(&fromDB, "from", "", "Source database path to seed from (required)") + cmd.Flags().StringVar(&configFile, "config", "", "Config file for the destination database path") + _ = cmd.MarkFlagRequired("from") + return cmd +} + +// runSeed copies behavioral profiles from srcDB into dstDB. +func runSeed(srcDB, dstDB string) error { + if srcDB == dstDB { + return fmt.Errorf("source and destination databases are the same path: %q", srcDB) + } + if _, err := os.Stat(srcDB); os.IsNotExist(err) { + return fmt.Errorf("source database not found: %q", srcDB) + } + + // Open source (read-only via a regular store; we only call LoadBehaviorProfiles). + src, err := storage.NewStore(srcDB) + if err != nil { + return fmt.Errorf("open source db %q: %w", srcDB, err) + } + defer src.Close() + + profiles, err := src.LoadBehaviorProfiles() + if err != nil { + return fmt.Errorf("load profiles from %q: %w", srcDB, err) + } + if len(profiles) == 0 { + log.Printf("seed: %q has no behavioral profiles — nothing to import", srcDB) + return nil + } + + // Open destination (creates it with full schema migration if new). + if err := os.MkdirAll(dbDir(dstDB), 0755); err != nil { + return fmt.Errorf("create db directory: %w", err) + } + dst, err := storage.NewStore(dstDB) + if err != nil { + return fmt.Errorf("open destination db %q: %w", dstDB, err) + } + defer dst.Close() + + if err := dst.SaveBehaviorProfiles(profiles); err != nil { + return fmt.Errorf("save profiles to %q: %w", dstDB, err) + } + + log.Printf("seed: imported %d behavioral profile entries from %q into %q", + len(profiles), srcDB, dstDB) + return nil +} + +// exportProfilesCmd returns the cobra command for `ja4monitor export-profiles`. +// +// Dumps the current sensor's behavioral profiles as JSON lines to stdout, +// suitable for inspection or transfer to another sensor via `seed`. +func exportProfilesCmd() *cobra.Command { + var ( + dbPath string + configFile string + ) + cmd := &cobra.Command{ + Use: "export-profiles", + Short: "Export behavioral profiles from the sensor database as JSON", + Long: `Dump behavioral profiler state from the sensor's SQLite database as JSON +lines to stdout. Useful for inspecting learned profiles and for transferring +them to a new sensor via: ja4monitor seed --from snapshot.db + +Note: this reads from the database file, not a live daemon. If the daemon is +running, it persists profiles every 30 seconds; exported data may lag slightly. + +Examples: + ja4monitor export-profiles + ja4monitor export-profiles --db /var/lib/ja4monitor/ja4monitor.db + ja4monitor export-profiles | jq 'select(.total_seen > 100)'`, + RunE: func(cmd *cobra.Command, args []string) error { + cfg := loadConfig(configFile) + if dbPath != "" { + cfg.DBPath = dbPath + } + return runExportProfiles(cfg.DBPath) + }, + } + cmd.Flags().StringVar(&dbPath, "db", "", "Database path (default: from config)") + cmd.Flags().StringVar(&configFile, "config", "", "Config file path") + return cmd +} + +// runExportProfiles reads behavioral profiles from dbPath and writes them as +// JSON lines to stdout (one entry per line, no array wrapper). +func runExportProfiles(dbPath string) error { + if _, err := os.Stat(dbPath); os.IsNotExist(err) { + return fmt.Errorf("database not found: %q", dbPath) + } + + store, err := storage.NewStore(dbPath) + if err != nil { + return fmt.Errorf("open db %q: %w", dbPath, err) + } + defer store.Close() + + profiles, err := store.LoadBehaviorProfiles() + if err != nil { + return fmt.Errorf("load profiles: %w", err) + } + + enc := json.NewEncoder(os.Stdout) + for _, p := range profiles { + if err := enc.Encode(p); err != nil { + return err + } + } + + fmt.Fprintf(os.Stderr, "export-profiles: %d entries exported from %q\n", + len(profiles), dbPath) + return nil +} + +// dbDir returns the directory portion of a database path, defaulting to "." +// when the path has no directory component. +func dbDir(dbPath string) string { + cfg := config.DefaultConfig() + _ = cfg // only used for the default path which already has a directory + for i := len(dbPath) - 1; i >= 0; i-- { + if dbPath[i] == '/' || dbPath[i] == '\\' { + return dbPath[:i] + } + } + return "." +} diff --git a/internal/anomaly/behavior.go b/internal/anomaly/behavior.go index 0a495a9..06019fb 100644 --- a/internal/anomaly/behavior.go +++ b/internal/anomaly/behavior.go @@ -216,6 +216,66 @@ func (p *BehaviorProfiler) ProfileCount() int { return n } +// BehaviorProfileEntry is a flat, serializable snapshot of one anchor key's +// companion pairing. Used for SQLite persistence and seed/export operations. +type BehaviorProfileEntry struct { + AnchorKey string `json:"anchor_key"` + CompanionKey string `json:"companion_key"` + Count int `json:"count"` + TotalSeen int `json:"total_seen"` + LastUpdated time.Time `json:"last_updated"` +} + +// Serialize returns all profiler data as a flat slice of entries suitable for +// persistence. Each (anchor, companion) pair becomes one entry. The slice is +// sorted in no particular order. Thread-safe. +func (p *BehaviorProfiler) Serialize() []BehaviorProfileEntry { + p.mu.Lock() + defer p.mu.Unlock() + + out := make([]BehaviorProfileEntry, 0, len(p.profiles)*4) + for ak, prof := range p.profiles { + for ck, count := range prof.companions { + out = append(out, BehaviorProfileEntry{ + AnchorKey: ak, + CompanionKey: ck, + Count: count, + TotalSeen: prof.totalSeen, + LastUpdated: prof.lastUpdated, + }) + } + } + return out +} + +// Merge adds entries into the profiler, summing counts with any existing data. +// Designed for cold-start seeding: call before the profiler handles live traffic. +// Thread-safe; safe to call from any goroutine. +func (p *BehaviorProfiler) Merge(entries []BehaviorProfileEntry) { + if len(entries) == 0 { + return + } + now := time.Now() + p.mu.Lock() + defer p.mu.Unlock() + + for _, e := range entries { + prof, ok := p.profiles[e.AnchorKey] + if !ok { + prof = &pairingProfile{companions: make(map[string]int)} + p.profiles[e.AnchorKey] = prof + } + // Additive merge: sum counts, take max totalSeen, latest lastUpdated. + prof.companions[e.CompanionKey] += e.Count + if e.TotalSeen > prof.totalSeen { + prof.totalSeen = e.TotalSeen + } + if e.LastUpdated.After(prof.lastUpdated) && e.LastUpdated.Before(now) { + prof.lastUpdated = e.LastUpdated + } + } +} + // latestFingerprints returns the most recent fp_value per fp_type for conn. // Only types with at least one event are included. func latestFingerprints(conn *tracker.Connection) map[string]string { diff --git a/internal/anomaly/behavior_test.go b/internal/anomaly/behavior_test.go index 195cc90..f3c7b25 100644 --- a/internal/anomaly/behavior_test.go +++ b/internal/anomaly/behavior_test.go @@ -359,3 +359,77 @@ func TestBehaviorProfiler_AlertMetadataFields(t *testing.T) { t.Error("DedupKey must be non-empty") } } + +func TestBehaviorProfiler_SerializeEmpty(t *testing.T) { + p := DefaultBehaviorProfiler() + entries := p.Serialize() + if entries == nil { + t.Error("Serialize should return non-nil slice even when empty") + } + if len(entries) != 0 { + t.Errorf("expected 0 entries, got %d", len(entries)) + } +} + +func TestBehaviorProfiler_SerializeMergeRoundTrip(t *testing.T) { + // Build a profiler with known state. + p := DefaultBehaviorProfiler() + // Feed enough observations to build a profile (minSeen=50). + for i := 0; i < 60; i++ { + conn := makeBehaviorConn(fmt.Sprintf("c%d", i), map[string]string{ + "ja4": "fp-anchor", + "ja4h": "fp-companion", + }) + p.Analyze(conn) + } + if p.ProfileCount() == 0 { + t.Fatal("expected profiles after 60 observations") + } + + // Serialize and restore into a fresh profiler. + entries := p.Serialize() + if len(entries) == 0 { + t.Fatal("expected non-empty serialized entries") + } + + p2 := DefaultBehaviorProfiler() + p2.Merge(entries) + + if p2.ProfileCount() == 0 { + t.Error("expected profiles after Merge") + } +} + +func TestBehaviorProfiler_MergeAddsToExisting(t *testing.T) { + p := DefaultBehaviorProfiler() + entries := []BehaviorProfileEntry{ + {AnchorKey: "ja4:abc", CompanionKey: "ja4h:xyz", Count: 10, TotalSeen: 10}, + } + p.Merge(entries) + + // Merge again — counts should be additive. + p.Merge(entries) + + out := p.Serialize() + found := false + for _, e := range out { + if e.AnchorKey == "ja4:abc" && e.CompanionKey == "ja4h:xyz" { + if e.Count != 20 { + t.Errorf("expected count 20 after double merge, got %d", e.Count) + } + found = true + } + } + if !found { + t.Error("merged entry not found in Serialize output") + } +} + +func TestBehaviorProfiler_MergeEmptyNoOp(t *testing.T) { + p := DefaultBehaviorProfiler() + p.Merge(nil) + p.Merge([]BehaviorProfileEntry{}) + if p.ProfileCount() != 0 { + t.Error("empty Merge should not add profiles") + } +} diff --git a/internal/anomaly/evaluator.go b/internal/anomaly/evaluator.go index f31c91d..94ae157 100644 --- a/internal/anomaly/evaluator.go +++ b/internal/anomaly/evaluator.go @@ -356,6 +356,28 @@ func (e *Evaluator) processAlerts(conn *tracker.Connection, alerts []Alert, now } } +// SerializeBehaviorProfiles returns a flat snapshot of all behavioral profiler +// data. Returns nil when the profiler is disabled. Used for periodic SQLite +// persistence and the export-profiles subcommand. +func (e *Evaluator) SerializeBehaviorProfiles() []BehaviorProfileEntry { + bp := e.behaviorProfiler.Load() + if bp == nil { + return nil + } + return bp.Serialize() +} + +// LoadBehaviorProfiles seeds the behavioral profiler with previously-persisted +// data. Call at startup, before live traffic is processed, to eliminate the +// cold-start blind spot. No-op when the profiler is disabled. +func (e *Evaluator) LoadBehaviorProfiles(entries []BehaviorProfileEntry) { + bp := e.behaviorProfiler.Load() + if bp == nil || len(entries) == 0 { + return + } + bp.Merge(entries) +} + // RuleCount returns the total number of active rules (built-in + custom). func (e *Evaluator) RuleCount() int { count := len(e.rules) diff --git a/internal/storage/migrate.go b/internal/storage/migrate.go index d5c3ff7..836f2ec 100644 --- a/internal/storage/migrate.go +++ b/internal/storage/migrate.go @@ -18,7 +18,7 @@ import ( // map[string][]string → map[string][]FingerprintEvent. // v3: adds bookmarks table for TUI connection bookmarking (persists // across restarts; B key in table view). -const currentSchemaVersion = 3 +const currentSchemaVersion = 4 // migration is a single schema upgrade step. up() runs once per version // bump. Schema-shape changes happen inside a transaction for atomicity; @@ -172,6 +172,22 @@ var migrations = []migration{ }, backfill: nil, // no data migration needed }, + { + version: 4, + desc: "behavior_profiles for behavioral profiler persistence and cold-start seeding", + schemaTx: func(tx *sql.Tx) error { + _, err := tx.Exec(`CREATE TABLE IF NOT EXISTS behavior_profiles ( + anchor_key TEXT NOT NULL, + companion_key TEXT NOT NULL, + count INTEGER NOT NULL DEFAULT 0, + total_seen INTEGER NOT NULL DEFAULT 0, + last_updated INTEGER NOT NULL, + PRIMARY KEY (anchor_key, companion_key) + )`) + return err + }, + backfill: nil, + }, } // backfillV2 walks existing rows in the `connections` table in chunks, diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index e81b2d7..21d13b4 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -13,6 +13,7 @@ import ( _ "modernc.org/sqlite" + "github.com/Crank-Git/ja4monitor/internal/anomaly" "github.com/Crank-Git/ja4monitor/internal/filter" "github.com/Crank-Git/ja4monitor/internal/tracker" ) @@ -344,6 +345,67 @@ func (s *Store) SaveFirstSeen(entries map[string]time.Time) error { return tx.Commit() } +// SaveBehaviorProfiles persists behavioral profiler data to SQLite. +// Uses INSERT OR REPLACE so re-saves are safe and idempotent. +// Called periodically by persistLoop and on daemon shutdown. +func (s *Store) SaveBehaviorProfiles(entries []anomaly.BehaviorProfileEntry) error { + if len(entries) == 0 { + return nil + } + tx, err := s.db.Begin() + if err != nil { + return err + } + stmt, err := tx.Prepare(`INSERT OR REPLACE INTO behavior_profiles + (anchor_key, companion_key, count, total_seen, last_updated) + VALUES (?, ?, ?, ?, ?)`) + if err != nil { + tx.Rollback() + return err + } + defer stmt.Close() + + for _, e := range entries { + if _, err := stmt.Exec(e.AnchorKey, e.CompanionKey, e.Count, e.TotalSeen, + e.LastUpdated.UTC().Unix()); err != nil { + tx.Rollback() + return err + } + } + return tx.Commit() +} + +// LoadBehaviorProfiles reads all stored behavioral profiler entries from SQLite. +// Returns an empty slice (not nil) when the table is empty. +func (s *Store) LoadBehaviorProfiles() ([]anomaly.BehaviorProfileEntry, error) { + rows, err := s.db.Query( + `SELECT anchor_key, companion_key, count, total_seen, last_updated + FROM behavior_profiles`) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []anomaly.BehaviorProfileEntry + for rows.Next() { + var e anomaly.BehaviorProfileEntry + var lastUpdatedUnix int64 + if err := rows.Scan(&e.AnchorKey, &e.CompanionKey, &e.Count, &e.TotalSeen, &lastUpdatedUnix); err != nil { + s.reporter.IncFlushFailure() + continue + } + e.LastUpdated = time.Unix(lastUpdatedUnix, 0).UTC() + out = append(out, e) + } + if err := rows.Err(); err != nil { + return nil, err + } + if out == nil { + out = []anomaly.BehaviorProfileEntry{} + } + return out, nil +} + // Query runs a filter against the historical connections table and // returns the matching connections, deserialized back into // tracker.Connection values. diff --git a/internal/storage/sqlite_test.go b/internal/storage/sqlite_test.go index 9afc079..0da0545 100644 --- a/internal/storage/sqlite_test.go +++ b/internal/storage/sqlite_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/Crank-Git/ja4monitor/internal/anomaly" "github.com/Crank-Git/ja4monitor/internal/tracker" ) @@ -444,3 +445,102 @@ func TestDiffWindows_DefaultTimeout(t *testing.T) { t.Fatalf("default-timeout DiffWindows returned error: %v", err) } } + +func TestStore_SaveLoadBehaviorProfiles_Empty(t *testing.T) { + store, err := NewStore(tempDB(t)) + if err != nil { + t.Fatal(err) + } + defer store.Close() + + // Save nil — no-op, no error. + if err := store.SaveBehaviorProfiles(nil); err != nil { + t.Errorf("SaveBehaviorProfiles(nil): %v", err) + } + + profiles, err := store.LoadBehaviorProfiles() + if err != nil { + t.Fatalf("LoadBehaviorProfiles: %v", err) + } + if len(profiles) != 0 { + t.Errorf("expected 0 profiles, got %d", len(profiles)) + } +} + +func TestStore_SaveLoadBehaviorProfiles_RoundTrip(t *testing.T) { + store, err := NewStore(tempDB(t)) + if err != nil { + t.Fatal(err) + } + defer store.Close() + + now := time.Now().UTC().Truncate(time.Second) // SQLite stores unix seconds + input := []anomaly.BehaviorProfileEntry{ + {AnchorKey: "ja4:abc", CompanionKey: "ja4h:xyz", Count: 42, TotalSeen: 100, LastUpdated: now}, + {AnchorKey: "ja4:abc", CompanionKey: "ja4h:def", Count: 5, TotalSeen: 100, LastUpdated: now}, + {AnchorKey: "ja4:qrs", CompanionKey: "ja4t:ttt", Count: 1, TotalSeen: 1, LastUpdated: now}, + } + + if err := store.SaveBehaviorProfiles(input); err != nil { + t.Fatalf("SaveBehaviorProfiles: %v", err) + } + + got, err := store.LoadBehaviorProfiles() + if err != nil { + t.Fatalf("LoadBehaviorProfiles: %v", err) + } + if len(got) != len(input) { + t.Fatalf("expected %d entries, got %d", len(input), len(got)) + } + + // Build a map for order-independent comparison. + m := make(map[string]anomaly.BehaviorProfileEntry) + for _, e := range got { + m[e.AnchorKey+"|"+e.CompanionKey] = e + } + for _, want := range input { + key := want.AnchorKey + "|" + want.CompanionKey + got, ok := m[key] + if !ok { + t.Errorf("missing entry %q", key) + continue + } + if got.Count != want.Count { + t.Errorf("%q: Count got %d want %d", key, got.Count, want.Count) + } + if got.TotalSeen != want.TotalSeen { + t.Errorf("%q: TotalSeen got %d want %d", key, got.TotalSeen, want.TotalSeen) + } + if !got.LastUpdated.Equal(want.LastUpdated) { + t.Errorf("%q: LastUpdated got %v want %v", key, got.LastUpdated, want.LastUpdated) + } + } +} + +func TestStore_SaveBehaviorProfiles_Idempotent(t *testing.T) { + store, err := NewStore(tempDB(t)) + if err != nil { + t.Fatal(err) + } + defer store.Close() + + entry := []anomaly.BehaviorProfileEntry{ + {AnchorKey: "ja4:x", CompanionKey: "ja4h:y", Count: 10, TotalSeen: 10, LastUpdated: time.Now().UTC()}, + } + + // Save twice — INSERT OR REPLACE should leave exactly one row. + if err := store.SaveBehaviorProfiles(entry); err != nil { + t.Fatal(err) + } + if err := store.SaveBehaviorProfiles(entry); err != nil { + t.Fatal(err) + } + + got, err := store.LoadBehaviorProfiles() + if err != nil { + t.Fatal(err) + } + if len(got) != 1 { + t.Errorf("expected 1 entry after idempotent save, got %d", len(got)) + } +}