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
25 changes: 21 additions & 4 deletions cmd/ja4monitor/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}

Expand All @@ -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
}
}
}

Expand Down
83 changes: 83 additions & 0 deletions cmd/ja4monitor/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
182 changes: 182 additions & 0 deletions cmd/ja4monitor/seed.go
Original file line number Diff line number Diff line change
@@ -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 "."
}
Loading
Loading