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)))