geoip: port secmon's GeoIP/ASN CSV loader + the geoip-gated impossible_travel detector

Jaime Fournier <jaimef@linbsd.org>

fd77e11eb6d5c4c27c717a04b9f37fdca3d7ffc8

diff --git a/Makefile b/Makefile
index c1205bb..8c0183e 100644
--- a/Makefile
+++ b/Makefile
@@ -8,7 +8,7 @@ SCHEME ?= $(JERBOA)/.chez/bin/scheme
 BUILD  ?= build/rust
 TYPED  := $(wildcard typed/*.ss)
 
-.PHONY: rust test ffi-demo kernels-check triage-check triage-store-check analytics-check detect-check storage-check threats-check checks clean
+.PHONY: rust test ffi-demo kernels-check triage-check triage-store-check analytics-check detect-check storage-check threats-check geoip-check checks clean
 # Combined libdir path so sibling libraries `(jsecmon ...)` resolve to ./jsecmon
 # (a second --libdirs would replace, not append, the jerboa one).
 LIBDIRS := "$(JERBOA)/lib:$(CURDIR)"
@@ -82,6 +82,14 @@ threats-check: rust
 	cd $(BUILD) && cargo build --release
 	$(LOADER_ENV) $(SCHEME) --libdirs $(LIBDIRS) --script examples/threats_check.ss
 
+# geoip CSV parsing (secmon src/geoip.rs vectors) plus the geoip-gated
+# impossible_travel detector over a live store. SECMON_GEOIP_CSV points the rule
+# at the fixture, exactly as secmon reads its own CSV from an env-configured path.
+GEOIP_CSV := $(CURDIR)/examples/fixtures/geoip_sample.csv
+geoip-check: rust
+	cd $(BUILD) && cargo build --release
+	$(LOADER_ENV) SECMON_GEOIP_CSV="$(GEOIP_CSV)" $(SCHEME) --libdirs $(LIBDIRS) --script examples/geoip_check.ss
+
 # Everything that runs through the Jerboa side of the bridge, one shot.
 checks: kernels-check
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/triage_check.ss
@@ -90,6 +98,7 @@ checks: kernels-check
 	$(LOADER_ENV) $(SCHEME) --libdirs $(LIBDIRS) --script examples/storage_check.ss
 	$(LOADER_ENV) $(SCHEME) --libdirs $(LIBDIRS) --script examples/threats_check.ss
 	$(LOADER_ENV) $(SCHEME) --libdirs $(LIBDIRS) --script examples/triage_store_check.ss
+	$(LOADER_ENV) SECMON_GEOIP_CSV="$(GEOIP_CSV)" $(SCHEME) --libdirs $(LIBDIRS) --script examples/geoip_check.ss
 
 clean:
 	rm -rf $(BUILD)
diff --git a/README.md b/README.md
index d175bd2..7d58b48 100644
--- a/README.md
+++ b/README.md
@@ -28,6 +28,7 @@ make analytics-check # verify untyped risk-ranking + incident grouping vs vector
 make detect-check    # full pipeline: events -> detect -> analytics (all kernels)
 make storage-check   # SQLite round-trip: store -> query -> detect -> analytics
 make threats-check   # SQL-aggregation threat rules vs secmon detection vectors
+make geoip-check     # geoip CSV vectors + geoip-gated impossible_travel detector
 make checks          # every Jerboa-side check in one shot
 ```
 
@@ -82,5 +83,6 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage).
 | `storage` SQL-aggregation detectors (brute_force, credential_stuffing, dns_tunnel, suspicious_cron, recon_port_scan, data_exfil) | `jsecmon/threats.ss` | ✅ **untyped layer** — secmon's `run_detections` family: the time-bucket GROUP BY/HAVING rules and the two 5-min sliding-window rules, run as SQL (json_extract) over a `(jsecmon storage)` handle. `make threats-check` reproduces secmon's six detection-rule test vectors. Remaining: the sequence/kill-chain rules (priv_escalation_chain, persistence_after_access, log_cover, lateral_after_shell, impossible_travel) + frequency/severity/off-hours aggregates. |
 | `storage` sequence/chain detectors (priv_escalation_chain, persistence_after_access, log_cover, lateral_after_shell) | `jsecmon/threats.ss` | ✅ **untyped layer** — secmon's `detect_sequence_pair` family: event A then event B within a window on the same host (auth-success→priv-esc /5min, reverse-shell/webshell→persistence /1h, any-critical→log-tampering /1h, shell→lateral /1h). Reproduces secmon's chain test vectors incl. the outside-window negative. |
 | `storage` time-window aggregates (frequency_spike, severity_cluster, off_hours, kill_chain) | `jsecmon/threats.ss` | ✅ **untyped layer** — secmon's full `detect_anomalies` family: per-(host,event_type) hour count 3x above its own average, 5+ crit/high on a host /5min, crit/high outside 08:00-18:00 UTC weekday (SQLite `strftime`), and 3+ distinct kill-chain phases /1h. `run-anomaly-detections` is the dispatcher (frequency_spike first, as secmon runs it). `make threats-check` covers each with threshold/negative cases. |
-| `storage` impossible_travel | — | ⏳ needs a geoip CSV loader (IP→country); secmon gates it behind geoip availability |
+| `geoip` (CSV GeoIP/ASN, IPv4+IPv6, binary-search range lookup, is_private) | `jsecmon/geoip.ss` | ✅ **untyped layer** — full port of secmon's `src/geoip.rs`: parse `start,end,country,asn,name` CSV rows (v4 + one-`::`-expanding v6), sort-by-start + binary-search lookup, RFC1918/loopback/link-local/multicast/ULA/CGNAT → synthetic `PRIVATE`. Pure parsing + integer math + file read, so untyped. `make geoip-check` runs secmon's geoip vectors. |
+| `storage` impossible_travel | `jsecmon/threats.ss` | ✅ **untyped layer** — geoip-gated (reads `SECMON_GEOIP_CSV`): pair a user's consecutive successful `auth_event`s, fire `high` when the two source IPs resolve to different countries within `SECMON_TRAVEL_GAP_MIN` (default 30). Private IPs are dropped before pairing. `make geoip-check` proves the fire + the gap/same-country/private/cross-user negatives. |
 | monitors / server / ebpf / dtrace | —  | ⏳ I/O+async+FFI, last           |
diff --git a/examples/fixtures/geoip_sample.csv b/examples/fixtures/geoip_sample.csv
new file mode 100644
index 0000000..5176209
--- /dev/null
+++ b/examples/fixtures/geoip_sample.csv
@@ -0,0 +1,6 @@
+# sample geoip ranges for the jsecmon impossible_travel / geoip check
+# start_ip,end_ip,country,asn,asn_name   (mirrors secmon's geoip.rs sample_db)
+1.0.0.0,1.0.0.255,US,13335,Cloudflare
+8.8.8.0,8.8.8.255,US,15169,Google
+193.0.0.0,193.0.255.255,NL,3333,RIPE NCC
+2001:4860::,2001:4860:ffff:ffff:ffff:ffff:ffff:ffff,US,15169,Google
diff --git a/examples/geoip_check.ss b/examples/geoip_check.ss
new file mode 100644
index 0000000..64d2ef2
--- /dev/null
+++ b/examples/geoip_check.ss
@@ -0,0 +1,106 @@
+;;; Parity check for (jsecmon geoip) and the geoip-gated impossible_travel rule.
+;;;
+;;; First half reproduces secmon's src/geoip.rs #[test] vectors (sample_db
+;;; lookups: v4/v6 hit, private/loopback -> PRIVATE, unknown -> none). Second
+;;; half drives detect-impossible-travel over a live store: same user from two
+;;; countries inside the gap fires; outside the gap, same country, and a private
+;;; source IP do not.
+;;;
+;;; Needs the native sqlite lib on the loader path, the repo on --libdirs, and
+;;; SECMON_GEOIP_CSV pointing at the fixture (the Makefile geoip-check sets all):
+;;;   DYLD_LIBRARY_PATH=$JERBOA/lib SECMON_GEOIP_CSV=examples/fixtures/geoip_sample.csv \
+;;;     scheme --libdirs "$JERBOA/lib:." --script examples/geoip_check.ss
+
+(import (jerboa prelude)
+        (jsecmon storage)
+        (jsecmon geoip)
+        (jsecmon threats))
+
+(def fails 0)
+(def (check name got want)
+  (let ((ok (equal? got want)))
+    (unless ok (set! fails (+ fails 1)))
+    (displayln (if ok "  ok   " "  FAIL ") name
+               (if ok "" (str "   got " got " want " want)))))
+
+;; ── pure geoip (secmon src/geoip.rs vectors) ─────────────────────────────────
+(def sample
+  (string-append
+    "# header\n"
+    "1.0.0.0,1.0.0.255,US,13335,Cloudflare\n"
+    "8.8.8.0,8.8.8.255,US,15169,Google\n"
+    "193.0.0.0,193.0.255.255,NL,3333,RIPE NCC\n"
+    "2001:4860::,2001:4860:ffff:ffff:ffff:ffff:ffff:ffff,US,15169,Google\n"))
+(def gdb (parse-csv sample))
+
+(displayln "geoip parity (secmon src/geoip.rs vectors):")
+(check "loads 4 ranges" (geodb-size gdb) 4)
+(let ((r (geo-lookup gdb "8.8.8.42")))
+  (check "v4 country" (geo-country r) "US")
+  (check "v4 asn" (geo-asn r) 15169)
+  (check "v4 asn-name" (geo-asn-name r) "Google"))
+(let ((r (geo-lookup gdb "2001:4860:4860::8888")))
+  (check "v6 country" (geo-country r) "US")
+  (check "v6 asn" (geo-asn r) 15169))
+(check "192.168.1.1 -> PRIVATE" (geo-country (geo-lookup gdb "192.168.1.1")) "PRIVATE")
+(check "10.0.0.5 -> PRIVATE"    (geo-country (geo-lookup gdb "10.0.0.5")) "PRIVATE")
+(check "127.0.0.1 -> PRIVATE"   (geo-country (geo-lookup gdb "127.0.0.1")) "PRIVATE")
+(check "9.9.9.9 -> none"        (geo-lookup gdb "9.9.9.9") #f)
+;; load-csv reads the fixture from disk too (the path the rule uses)
+(check "load-csv fixture size" (geodb-size (load-csv (getenv "SECMON_GEOIP_CSV"))) 4)
+
+;; ── impossible_travel over a live store ──────────────────────────────────────
+(def (jdata . kvs)
+  (let ((h (make-hash-table)))
+    (let loop ((xs kvs))
+      (if (or (null? xs) (null? (cdr xs))) (json-object->string h)
+          (begin (hash-put! h (car xs) (cadr xs)) (loop (cddr xs)))))))
+(def t0 1000000)
+(def (auth db seq ts user ip)
+  (store-event db seq "h1" "s1" ts "auth_event" "info" #f "sshd" "auth"
+    (jdata "username" user "success" #t "remote_host" ip)))
+(def (fresh) (store-open ":memory:"))
+
+(displayln "impossible_travel (geoip-gated):")
+;; US then NL within 10 min -> fires once.
+(let ((db (fresh)))
+  (auth db 1 t0 "alice" "8.8.8.42")
+  (auth db 2 (+ t0 600000) "alice" "193.0.0.5")
+  (let ((r (detect-impossible-travel db (make-filter))))
+    (check "fires once" (length r) 1)
+    (check "  rule"      (hash-get (car r) "rule") "impossible_travel")
+    (check "  severity"  (hash-get (car r) "severity") "high")
+    (check "  country_a" (hash-get (hash-get (car r) "details") "country_a") "US")
+    (check "  country_b" (hash-get (hash-get (car r) "details") "country_b") "NL")
+    (check "  gap=10min" (hash-get (hash-get (car r) "details") "gap_minutes") 10)
+    (check "  ts=second" (hash-get (car r) "timestamp_ms") (+ t0 600000)))
+  (store-close db))
+;; outside the 30-min gap -> none.
+(let ((db (fresh)))
+  (auth db 1 t0 "alice" "8.8.8.42")
+  (auth db 2 (+ t0 2400000) "alice" "193.0.0.5")          ;; 40 min
+  (check "outside gap -> none" (length (detect-impossible-travel db (make-filter))) 0)
+  (store-close db))
+;; same country (different IP) -> none.
+(let ((db (fresh)))
+  (auth db 1 t0 "alice" "8.8.8.42")
+  (auth db 2 (+ t0 600000) "alice" "1.0.0.5")             ;; both US
+  (check "same country -> none" (length (detect-impossible-travel db (make-filter))) 0)
+  (store-close db))
+;; private source IP is dropped, leaving one point -> none.
+(let ((db (fresh)))
+  (auth db 1 t0 "alice" "8.8.8.42")
+  (auth db 2 (+ t0 600000) "alice" "192.168.1.1")         ;; PRIVATE, skipped
+  (check "private skipped -> none" (length (detect-impossible-travel db (make-filter))) 0)
+  (store-close db))
+;; two different users never pair across the boundary.
+(let ((db (fresh)))
+  (auth db 1 t0 "alice" "8.8.8.42")
+  (auth db 2 (+ t0 60000) "bob" "193.0.0.5")
+  (check "cross-user -> none" (length (detect-impossible-travel db (make-filter))) 0)
+  (store-close db))
+
+(newline)
+(if (= fails 0)
+    (displayln "OK: geoip + impossible_travel match secmon's vectors.")
+    (begin (displayln fails " FAILURES") (exit 1)))
diff --git a/jsecmon/geoip.ss b/jsecmon/geoip.ss
new file mode 100644
index 0000000..691e304
--- /dev/null
+++ b/jsecmon/geoip.ss
@@ -0,0 +1,161 @@
+#!chezscheme
+;;; jsecmon geoip — lightweight GeoIP/ASN enrichment, untyped orchestration.
+;;;
+;;; secmon's src/geoip.rs: instead of a MaxMind MMDB blob (a big dep that
+;;; complicates the static-musl build), a plain CSV with one row per IP range:
+;;;
+;;;   # comment
+;;;   start_ip,end_ip,country,asn,asn_name
+;;;   8.8.8.0,8.8.8.255,US,15169,Google
+;;;
+;;; Both IPv4 and IPv6 are supported; ranges are sorted by start and looked up
+;;; by binary search. Private/RFC1918 (and loopback/link-local/multicast/ULA/
+;;; CGNAT) addresses never consult the table — they get a synthetic
+;;; country=PRIVATE so impossible_travel can ignore them. This is pure parsing +
+;;; integer math + file read, so it stays untyped. Verified against secmon's own
+;;; geoip unit-test vectors in examples/geoip_check.ss.
+
+(library (jsecmon geoip)
+  (export make-geo geo? geo-country geo-asn geo-asn-name
+          parse-csv load-csv geo-lookup geodb? geodb-size)
+  (import (except (chezscheme)
+                  make-hash-table hash-table?
+                  sort sort!
+                  printf fprintf
+                  path-extension path-absolute?
+                  with-input-from-string with-output-to-string
+                  iota 1+ 1-
+                  partition
+                  make-date make-time)
+          (except (jerboa prelude) meta atom?))
+
+  ;; A looked-up record (secmon's GeoRecord).
+  (defstruct geo (country asn asn-name))
+  ;; The in-memory DB: v4/v6 are vectors of #(start end geo), sorted by start.
+  (defstruct geodb (v4 v6))
+  (def (geodb-size db) (+ (vector-length (geodb-v4 db)) (vector-length (geodb-v6 db))))
+
+  ;; ── IP parsing ──────────────────────────────────────────────────────────────
+  ;; "a.b.c.d" -> u32, or #f. Decimal octets 0..255 only.
+  (def (parse-ipv4 s)
+    (let ((parts (string-split s #\.)))
+      (and (= (length parts) 4)
+           (let ((ns (map string->number parts)))
+             (and (every (lambda (x) (and x (integer? x) (exact? x) (<= 0 x 255))) ns)
+                  (+ (* (list-ref ns 0) 16777216)
+                     (* (list-ref ns 1) 65536)
+                     (* (list-ref ns 2) 256)
+                     (list-ref ns 3)))))))
+
+  ;; Colon-separated hex groups (0..ffff each) -> integer, or #f on a bad group.
+  (def (groups->n groups)
+    (let loop ((gs groups) (acc 0))
+      (if (null? gs)
+          acc
+          (let ((g (string->number (car gs) 16)))
+            (and g (integer? g) (exact? g) (<= 0 g 65535)
+                 (loop (cdr gs) (+ (* acc 65536) g)))))))
+
+  ;; "2001:4860:4860::8888" / "::1" / "fe80::1" -> u128, or #f. Handles one "::".
+  (def (parse-ipv6 s)
+    (let ((dc (string-contains s "::")))
+      (if dc
+          (let* ((before (substring s 0 dc))
+                 (after  (substring s (+ dc 2) (string-length s))))
+            (and (not (string-contains after "::"))     ;; only one "::" allowed
+                 (let ((head (if (string=? before "") '() (string-split before #\:)))
+                       (tail (if (string=? after "")  '() (string-split after  #\:))))
+                   (let ((zeros (- 8 (length head) (length tail))))
+                     (and (>= zeros 1)
+                          (groups->n (append head (make-list zeros "0") tail)))))))
+          (let ((groups (string-split s #\:)))
+            (and (= (length groups) 8) (groups->n groups))))))
+
+  ;; ip string -> (cons 'v4 n) | (cons 'v6 n) | #f. v6 is anything with a colon.
+  (def (parse-ip s)
+    (if (string-contains s ":")
+        (let ((n (parse-ipv6 s))) (and n (cons 'v6 n)))
+        (let ((n (parse-ipv4 s))) (and n (cons 'v4 n)))))
+
+  ;; ── private-address tests (mirror Rust's is_private_v4/v6) ──────────────────
+  (def (private-v4? n)
+    (let ((o0 (bitwise-and (ash n -24) 255))
+          (o1 (bitwise-and (ash n -16) 255)))
+      (or (= o0 127)                       ;; loopback 127/8
+          (= n 0)                          ;; unspecified 0.0.0.0
+          (and (= o0 169) (= o1 254))      ;; link-local 169.254/16
+          (<= 224 o0 239)                  ;; multicast 224/4
+          (= o0 10)                        ;; 10/8
+          (and (= o0 172) (<= 16 o1 31))   ;; 172.16/12
+          (and (= o0 192) (= o1 168))      ;; 192.168/16
+          (and (= o0 100) (<= 64 o1 127))))) ;; CGNAT 100.64/10
+
+  (def (private-v6? n)
+    (let ((seg0 (bitwise-and (ash n -112) #xffff)))
+      (or (= n 1) (= n 0)                              ;; ::1 loopback, :: unspecified
+          (= (bitwise-and seg0 #xff00) #xff00)         ;; multicast ff00::/8
+          (= (bitwise-and seg0 #xfe00) #xfc00)         ;; ULA fc00::/7
+          (= (bitwise-and seg0 #xffc0) #xfe80))))      ;; link-local fe80::/10
+
+  ;; ── CSV parsing ─────────────────────────────────────────────────────────────
+  (def (parse-line line acc-v4 acc-v6)   ;; returns (values v4-list v6-list)
+    (let ((trimmed (string-trim line)))
+      (if (or (string=? trimmed "") (string-prefix? "#" trimmed))
+          (values acc-v4 acc-v6)
+          (let ((parts (string-split trimmed #\,)))
+            (if (< (length parts) 5)
+                (values acc-v4 acc-v6)
+                (let* ((s (parse-ip (string-trim (list-ref parts 0))))
+                       (e (parse-ip (string-trim (list-ref parts 1))))
+                       (country (string-trim (list-ref parts 2)))
+                       (asn (let ((a (string->number (string-trim (list-ref parts 3)))))
+                              (if (and a (integer? a) (exact? a) (>= a 0)) a 0)))
+                       (asn-name (string-trim (list-ref parts 4)))
+                       (rec (make-geo country asn asn-name)))
+                  (cond ((and s e (eq? (car s) 'v4) (eq? (car e) 'v4))
+                         (values (cons (vector (cdr s) (cdr e) rec) acc-v4) acc-v6))
+                        ((and s e (eq? (car s) 'v6) (eq? (car e) 'v6))
+                         (values acc-v4 (cons (vector (cdr s) (cdr e) rec) acc-v6)))
+                        (else (values acc-v4 acc-v6)))))))))
+
+  (def (sort-ranges lst)
+    (list->vector (list-sort (lambda (a b) (< (vector-ref a 0) (vector-ref b 0))) lst)))
+
+  (def (parse-csv content)
+    (let loop ((lines (string-split content #\newline)) (v4 '()) (v6 '()))
+      (if (null? lines)
+          (make-geodb (sort-ranges v4) (sort-ranges v6))
+          (let-values (((v4* v6*) (parse-line (car lines) v4 v6)))
+            (loop (cdr lines) v4* v6*)))))
+
+  (def (load-csv path) (parse-csv (read-file-string path)))
+
+  ;; ── lookup ──────────────────────────────────────────────────────────────────
+  ;; Rightmost range whose start <= n, then bounds-check — Rust's binary_search.
+  (def (lookup-range ranges n)
+    (let ((len (vector-length ranges)))
+      (if (= len 0)
+          #f
+          (let loop ((lo 0) (hi (- len 1)) (best -1))
+            (if (> lo hi)
+                (if (< best 0)
+                    #f
+                    (let ((r (vector-ref ranges best)))
+                      (if (and (>= n (vector-ref r 0)) (<= n (vector-ref r 1)))
+                          (vector-ref r 2) #f)))
+                (let* ((mid (quotient (+ lo hi) 2))
+                       (s (vector-ref (vector-ref ranges mid) 0)))
+                  (if (<= s n)
+                      (loop (+ mid 1) hi mid)
+                      (loop lo (- mid 1) best))))))))
+
+  (def the-private (make-geo "PRIVATE" 0 ""))
+
+  ;; ip string -> geo record | #f. Private/loopback/etc. -> synthetic PRIVATE.
+  (def (geo-lookup db ip)
+    (let ((p (parse-ip ip)))
+      (cond ((not p) #f)
+            ((eq? (car p) 'v4)
+             (if (private-v4? (cdr p)) the-private (lookup-range (geodb-v4 db) (cdr p))))
+            (else
+             (if (private-v6? (cdr p)) the-private (lookup-range (geodb-v6 db) (cdr p))))))))
diff --git a/jsecmon/threats.ss b/jsecmon/threats.ss
index 482d9a0..2f2aa02 100644
--- a/jsecmon/threats.ss
+++ b/jsecmon/threats.ss
@@ -27,7 +27,7 @@
           detect-brute-force detect-credential-stuffing detect-dns-tunnel
           detect-suspicious-cron detect-recon-port-scan detect-data-exfil
           detect-priv-escalation-chain detect-persistence-after-access
-          detect-log-cover detect-lateral-after-shell
+          detect-log-cover detect-lateral-after-shell detect-impossible-travel
           detect-frequency-spike
           detect-severity-cluster detect-off-hours detect-kill-chain
           run-anomaly-detections)
@@ -42,7 +42,8 @@
                   make-date make-time)
           (except (jerboa prelude) meta atom?)
           (std db sqlite-native)
-          (only (jsecmon storage) make-filter build-where))
+          (only (jsecmon storage) make-filter build-where)
+          (only (jsecmon geoip) load-csv geo-lookup geo-country geo-asn))
 
   ;; ── anomaly construction (row hash, same shape detect/analytics use) ────────
   (def (details-hash . kvs)
@@ -401,6 +402,63 @@
               (detect-off-hours db filter)
               (detect-kill-chain db filter))))
 
+  ;; ── impossible_travel (geoip-gated) ─────────────────────────────────────────
+  ;; Walk auth_event successes ordered by username/time; flag a consecutive pair
+  ;; by the same user from different countries within SECMON_TRAVEL_GAP_MIN (def
+  ;; 30) minutes. No-ops unless SECMON_GEOIP_CSV points at a loadable geoip CSV
+  ;; (matching secmon). Private/empty-country source IPs are skipped.
+  ;; (SQLite forbids SELECT aliases in WHERE, so the NOT NULL guards use the full
+  ;; json_extract expressions; the alias is only used in ORDER BY, which is legal.)
+  (def (row->travel-point gdb r)
+    (let ((g (geo-lookup gdb (a-str r "remote_host"))))
+      (and g
+           (let ((c (geo-country g)))
+             (and (not (string=? c "PRIVATE")) (not (string=? c ""))
+                  ;; #(host ts user ip country asn)
+                  (vector (a-str r "host") (a-num r "timestamp_ms")
+                          (a-str r "username") (a-str r "remote_host") c (geo-asn g)))))))
+
+  (def (travel-anomaly a b)
+    (let ((gap-min (quotient (- (vector-ref b 1) (vector-ref a 1)) 60000)))
+      (make-anomaly "impossible_travel" (vector-ref b 0) "high" (vector-ref b 1)
+        (details-hash "username" (vector-ref a 2)
+                      "country_a" (vector-ref a 4) "country_b" (vector-ref b 4)
+                      "ip_a" (vector-ref a 3) "ip_b" (vector-ref b 3)
+                      "asn_a" (vector-ref a 5) "asn_b" (vector-ref b 5)
+                      "host_a" (vector-ref a 0) "host_b" (vector-ref b 0)
+                      "gap_minutes" gap-min))))
+
+  (def (detect-impossible-travel db (filter (make-filter)))
+    (let ((geo-path (getenv "SECMON_GEOIP_CSV")))
+      (if (not geo-path)
+          '()
+          (let ((gdb (try (load-csv geo-path) (catch (e) #f))))
+            (if (not gdb)
+                '()
+                (let* ((gap-min (let ((s (getenv "SECMON_TRAVEL_GAP_MIN")))
+                                  (or (and s (string->number s)) 30)))
+                       (gap-ms (* gap-min 60 1000))
+                       (rows (filtered-query db
+                               (str "SELECT host, timestamp_ms,"
+                                    " json_extract(data,'$.username') AS username,"
+                                    " json_extract(data,'$.remote_host') AS remote_host"
+                                    " FROM events WHERE event_type='auth_event'"
+                                    " AND json_extract(data,'$.success')=1"
+                                    " AND json_extract(data,'$.username') IS NOT NULL"
+                                    " AND json_extract(data,'$.remote_host') IS NOT NULL")
+                               " ORDER BY username, timestamp_ms" filter))
+                       (pts (list->vector (filter-map (lambda (r) (row->travel-point gdb r)) rows))))
+                  (let loop ((i 0) (acc '()))
+                    (if (>= (+ i 1) (vector-length pts))
+                        (reverse acc)
+                        (let ((a (vector-ref pts i)) (b (vector-ref pts (+ i 1))))
+                          (loop (+ i 1)
+                            (if (and (string=? (vector-ref a 2) (vector-ref b 2))      ;; same user
+                                     (not (string=? (vector-ref a 4) (vector-ref b 4))) ;; diff country
+                                     (<= (- (vector-ref b 1) (vector-ref a 1)) gap-ms)) ;; within gap
+                                (cons (travel-anomaly a b) acc)
+                                acc)))))))))))
+
   ;; ── dispatch ─────────────────────────────────────────────────────────────────
   (def threat-rules
     (list (cons "brute_force"              detect-brute-force)
@@ -413,6 +471,7 @@
           (cons "persistence_after_access" detect-persistence-after-access)
           (cons "log_cover"                detect-log-cover)
           (cons "lateral_after_shell"      detect-lateral-after-shell)
+          (cons "impossible_travel"        detect-impossible-travel)
           (cons "severity_cluster"         detect-severity-cluster)
           (cons "off_hours"                detect-off-hours)
           (cons "kill_chain"               detect-kill-chain)))