sync

Jaime Fournier <jaimef@linbsd.org>

e2897fb43298ec45cd96eae21047abf7a993f428

diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..0ac9b61
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,31 @@
+JERBOA_DIR ?= ../jerboa
+GERBIL_LOADPATH := $(JERBOA_DIR)/lib:$(CURDIR)/lib
+export GERBIL_LOADPATH
+
+.PHONY: build clean test keygen agent collector analyze
+
+build:
+	gerbil build
+
+clean:
+	gerbil build clean
+
+keygen:
+	gxi bin/keygen.ss
+
+agent:
+	gxi bin/agent.ss
+
+collector:
+	gxi bin/collector.ss $(ARGS)
+
+analyze:
+	gxi bin/analyze.ss $(ARGS)
+
+test:
+	@echo "Running tests..."
+	@for f in tests/*-test.ss; do \
+		echo "Testing $$f..."; \
+		gxi $$f || exit 1; \
+	done
+	@echo "All tests passed."
diff --git a/bin/agent.ss b/bin/agent.ss
new file mode 100644
index 0000000..1cb7db5
--- /dev/null
+++ b/bin/agent.ss
@@ -0,0 +1,112 @@
+#!/usr/bin/env gxi
+;; secmon-agent: Security monitoring agent
+(import
+  (chezscheme)
+  (jerboa prelude)
+  (secmon config)
+  (secmon crypto ecies)
+  (secmon crypto psk)
+  (secmon buffer ring)
+  (secmon server listener)
+  (secmon monitor events)
+  (secmon platform provider)
+  (secmon platform linux)
+  (secmon monitor process)
+  (secmon monitor network)
+  (secmon monitor files)
+  (secmon monitor auth)
+  (secmon monitor kernel)
+  (secmon monitor cron)
+  (secmon monitor container)
+  (secmon monitor dns)
+  (secmon monitor rootkit)
+  (secmon monitor persistence)
+  (secmon monitor revshell)
+  (secmon monitor lateral)
+  (secmon monitor logtamper)
+  (secmon monitor webshell)
+  (secmon monitor podman)
+  (secmon monitor selinux)
+  (secmon stealth init)
+  (std text hex))
+
+(def (main . args)
+  (let ([debug-mode (and (getenv "SECMON_DEBUG") #t)])
+
+    ;; Stealth init (unless debug mode)
+    (unless debug-mode
+      (stealth-init!))
+
+    ;; Load config
+    (let ([config (load-agent-config)])
+
+      ;; Validate keys
+      (let ([public-key (agent-config-public-key config)]
+            [psk (agent-config-psk config)])
+        (unless (and public-key (= (bytevector-length public-key) 32))
+          (when debug-mode
+            (displayln "ERROR: Missing or invalid public key"))
+          (exit 1))
+        (unless (and psk (= (bytevector-length psk) 32))
+          (when debug-mode
+            (displayln "ERROR: Missing or invalid PSK"))
+          (exit 1))
+
+        (when debug-mode
+          (displayln (string-append "SecMon Agent starting on "
+                       (agent-config-listen-addr config))))
+
+        ;; Create encryption and auth components
+        (let* ([encryptor (make-ecies-encryptor public-key)]
+               [psk-auth (make-psk-auth psk)]
+               [buffer (make-event-buffer encryptor
+                         (agent-config-max-buffer-size config))]
+               [proc-provider (create-linux-process-provider)]
+               [net-provider (create-linux-network-provider)]
+               [hostname ((process-provider-get-hostname proc-provider))]
+               [poll-ms (agent-config-poll-interval-ms config)])
+
+          ;; Event emission callback
+          (define (emit! event)
+            (when debug-mode
+              (displayln (format "EVENT: ~a [~a] ~a"
+                (security-event-event-type event)
+                (security-event-severity event)
+                (security-event-hostname event))))
+            (buffer-store! buffer event))
+
+          ;; Spawn all monitors
+          (when debug-mode
+            (displayln "Starting monitors..."))
+
+          ;; Core monitors
+          (spawn-process-monitor proc-provider emit! poll-ms hostname)
+          (spawn-network-monitor net-provider emit! 1000 hostname)
+
+          ;; Auxiliary monitors
+          (spawn-file-integrity-monitor emit! 10000 hostname)
+          (spawn-auth-monitor emit! 30000 hostname)
+          (spawn-kernel-module-monitor emit! 5000 hostname)
+          (spawn-cron-monitor emit! 10000 hostname)
+          (spawn-container-escape-monitor emit! 5000 hostname)
+          (spawn-dns-monitor net-provider emit! 1000 hostname)
+          (spawn-rootkit-monitor proc-provider emit! 30000 hostname)
+          (spawn-podman-monitor emit! 5000 hostname)
+          (spawn-selinux-monitor emit! 5000 hostname)
+
+          ;; Intrusion detection monitors
+          (spawn-persistence-monitor emit! 30000 hostname)
+          (spawn-lateral-movement-monitor net-provider proc-provider
+            emit! 5000 hostname)
+          (spawn-logtamper-monitor emit! 10000 hostname)
+          (spawn-webshell-monitor proc-provider emit! 2000 hostname)
+          (spawn-revshell-monitor proc-provider net-provider emit! 2000 hostname)
+
+          (when debug-mode
+            (displayln (format "All monitors started. Listening on ~a"
+              (agent-config-listen-addr config))))
+
+          ;; Start TCP server (blocks)
+          (let ([server (make-poll-server psk-auth buffer
+                          (agent-config-listen-addr config))])
+            (poll-server-run! server)))))))
diff --git a/bin/analyze.ss b/bin/analyze.ss
new file mode 100644
index 0000000..2208a3d
--- /dev/null
+++ b/bin/analyze.ss
@@ -0,0 +1,386 @@
+#!/usr/bin/env gxi
+;; secmon-analyze: Offline event analysis and detection
+(import
+  (chezscheme)
+  (jerboa prelude)
+  (secmon storage store)
+  (std text json))
+
+(def (main . args)
+  (let ([cmd (and (not (null? args)) (car args))]
+        [rest (if (null? args) '() (cdr args))])
+    (case (and cmd (string->symbol cmd))
+      [(summary)   (run-summary rest)]
+      [(query)     (run-query rest)]
+      [(timeline)  (run-timeline rest)]
+      [(ioc)       (run-ioc rest)]
+      [(entity)    (run-entity rest)]
+      [(detect)    (run-detect rest)]
+      [(hosts)     (run-hosts rest)]
+      [(retention) (run-retention rest)]
+      [else
+       (displayln "Usage: secmon-analyze <command> <events.db> [options]")
+       (displayln "")
+       (displayln "Commands:")
+       (displayln "  summary   <db>                       Database summary")
+       (displayln "  query     <db> [filters]              Query events")
+       (displayln "  timeline  <db> --host <name>          Host timeline")
+       (displayln "  ioc       <db> --list <file>          IOC matching")
+       (displayln "  entity    <db> <type> <value>         Entity timeline")
+       (displayln "  detect    <db> [--rule name]          Run detection rules")
+       (displayln "  hosts     <db>                        List known hosts")
+       (displayln "  retention <db> --delete-before <time> Delete old events")
+       (displayln "")
+       (displayln "Filters: --host, --type, --severity, --since, --until,")
+       (displayln "         --pid, --process, --search, --limit, --format json")
+       (exit 1)])))
+
+;; --- Argument parsing ---
+
+(def (get-db-path args)
+  (if (null? args)
+    (begin (displayln "ERROR: database path required") (exit 1))
+    (car args)))
+
+(def (get-flag args flag)
+  (let loop ([as args])
+    (cond
+      [(null? as) #f]
+      [(equal? (car as) flag)
+       (if (null? (cdr as)) #f (cadr as))]
+      [else (loop (cdr as))])))
+
+(def (has-flag? args flag)
+  (member flag args))
+
+(def (parse-filter-args args)
+  ;; Returns keyword arg list for query-events
+  (let ([result '()])
+    (let loop ([as args])
+      (cond
+        [(null? as) result]
+        [(equal? (car as) "--host")
+         (when (not (null? (cdr as)))
+           (set! result (append result (list host: (cadr as))))
+           (loop (cddr as)))]
+        [(equal? (car as) "--type")
+         (when (not (null? (cdr as)))
+           (set! result (append result (list type: (cadr as))))
+           (loop (cddr as)))]
+        [(equal? (car as) "--severity")
+         (when (not (null? (cdr as)))
+           (set! result (append result (list severity: (cadr as))))
+           (loop (cddr as)))]
+        [(equal? (car as) "--since")
+         (when (not (null? (cdr as)))
+           (set! result (append result (list since-ms: (parse-time-arg (cadr as)))))
+           (loop (cddr as)))]
+        [(equal? (car as) "--until")
+         (when (not (null? (cdr as)))
+           (set! result (append result (list until-ms: (parse-time-arg (cadr as)))))
+           (loop (cddr as)))]
+        [(equal? (car as) "--pid")
+         (when (not (null? (cdr as)))
+           (set! result (append result (list pid: (string->number (cadr as)))))
+           (loop (cddr as)))]
+        [(equal? (car as) "--process")
+         (when (not (null? (cdr as)))
+           (set! result (append result (list process: (cadr as))))
+           (loop (cddr as)))]
+        [(equal? (car as) "--search")
+         (when (not (null? (cdr as)))
+           (set! result (append result (list search: (cadr as))))
+           (loop (cddr as)))]
+        [(equal? (car as) "--limit")
+         (when (not (null? (cdr as)))
+           (set! result (append result (list limit: (string->number (cadr as)))))
+           (loop (cddr as)))]
+        [else (loop (cdr as))]))))
+
+(def (parse-time-arg s)
+  ;; Parse "24h", "7d", "30d" or epoch ms
+  (let ([now (current-time-ms)])
+    (cond
+      [(string-suffix? "h" s)
+       (let ([n (string->number (substring s 0 (- (string-length s) 1)))])
+         (- now (* (or n 24) 3600000)))]
+      [(string-suffix? "d" s)
+       (let ([n (string->number (substring s 0 (- (string-length s) 1)))])
+         (- now (* (or n 7) 86400000)))]
+      [else
+       (or (string->number s) (- now 86400000))])))
+
+;; --- Commands ---
+
+(def (run-summary args)
+  (let* ([db (get-db-path args)]
+         [store (open-event-store db)]
+         [summary (store-summary store)])
+    (displayln (format "=== Event Store Summary: ~a ===" db))
+    (displayln (format "Total events: ~a" (hashtable-ref summary "total" 0)))
+    (displayln "")
+    (displayln "By severity:")
+    (for-each
+      (lambda (row)
+        (displayln (format "  ~a: ~a"
+          (cdr (assoc "severity" row))
+          (cdr (assoc "cnt" row)))))
+      (hashtable-ref summary "by_severity" '()))
+    (displayln "")
+    (displayln "By type:")
+    (for-each
+      (lambda (row)
+        (displayln (format "  ~a: ~a"
+          (cdr (assoc "event_type" row))
+          (cdr (assoc "cnt" row)))))
+      (hashtable-ref summary "by_type" '()))
+    (displayln "")
+    (displayln "By host:")
+    (for-each
+      (lambda (row)
+        (displayln (format "  ~a: ~a"
+          (cdr (assoc "host" row))
+          (cdr (assoc "cnt" row)))))
+      (hashtable-ref summary "by_host" '()))
+    (let ([tr (hashtable-ref summary "time_range" '())])
+      (when (not (null? tr))
+        (displayln "")
+        (displayln (format "Time range: ~a to ~a"
+          (format-ts (cdr (assoc "min_ts" tr)))
+          (format-ts (cdr (assoc "max_ts" tr)))))))))
+
+(def (run-query args)
+  (let* ([db (get-db-path args)]
+         [rest (cdr args)]
+         [store (open-event-store db)]
+         [format-json? (has-flag? rest "--format")]
+         [filters (parse-filter-args rest)]
+         [rows (apply query-events store filters)])
+    (for-each
+      (lambda (row) (print-row row format-json?))
+      rows)))
+
+(def (run-timeline args)
+  (let* ([db (get-db-path args)]
+         [rest (cdr args)]
+         [host (get-flag rest "--host")]
+         [store (open-event-store db)]
+         [format-json? (has-flag? rest "--format")]
+         [filters (parse-filter-args rest)])
+    (unless host
+      (displayln "ERROR: --host required for timeline")
+      (exit 1))
+    (let ([rows (apply query-events store host: host filters)])
+      (for-each
+        (lambda (row) (print-row row format-json?))
+        (reverse rows)))))
+
+(def (run-ioc args)
+  (let* ([db (get-db-path args)]
+         [rest (cdr args)]
+         [ioc-file (get-flag rest "--list")]
+         [ioc-type (get-flag rest "--type")]
+         [store (open-event-store db)]
+         [format-json? (has-flag? rest "--format")])
+    (unless ioc-file
+      (displayln "ERROR: --list <file> required for IOC matching")
+      (exit 1))
+    (let ([iocs (load-ioc-file ioc-file)])
+      (displayln (format "Loaded ~a IOCs from ~a" (length iocs) ioc-file))
+      (for-each
+        (lambda (ioc)
+          (let* ([value (car ioc)]
+                 [type (or (and ioc-type (string->symbol ioc-type))
+                           (cdr ioc))]
+                 [matches (ioc-match store value type)])
+            (unless (null? matches)
+              (displayln (format "--- IOC: ~a (~a) - ~a matches ---"
+                value type (length matches)))
+              (for-each
+                (lambda (row) (print-row row format-json?))
+                matches))))
+        iocs))))
+
+(def (load-ioc-file path)
+  ;; Returns list of (value . type)
+  (guard (e [#t (displayln (format "ERROR: cannot read ~a" path)) '()])
+    (let ([lines (filter
+                   (lambda (line)
+                     (and (not (string=? line ""))
+                          (not (string-prefix? "#" line))))
+                   (string-split
+                     (call-with-input-file path
+                       (lambda (p) (get-string-all p)))
+                     #\newline))])
+      (map
+        (lambda (line)
+          (let ([val (string-trim line)])
+            (cons val (auto-detect-ioc-type val))))
+        lines))))
+
+(def (auto-detect-ioc-type val)
+  (cond
+    ;; IPv4
+    [(and (string-contains val ".")
+          (let ([parts (string-split val #\.)])
+            (and (= (length parts) 4)
+                 (andmap (lambda (p) (string->number p)) parts))))
+     'ip]
+    ;; Hex hash (32=MD5, 40=SHA1, 64=SHA256)
+    [(and (memv (string-length val) '(32 40 64))
+          (string-every (lambda (c) (or (char-numeric? c)
+                                        (memv c '(#\a #\b #\c #\d #\e #\f)))) val))
+     'hash]
+    ;; Domain (contains dot, no spaces)
+    [(and (string-contains val ".")
+          (not (string-contains val " ")))
+     'domain]
+    ;; Default to process
+    [else 'process]))
+
+(def (string-every pred s)
+  (let loop ([i 0])
+    (if (= i (string-length s)) #t
+      (if (pred (string-ref s i))
+        (loop (+ i 1))
+        #f))))
+
+(def (run-entity args)
+  (when (< (length args) 3)
+    (displayln "Usage: secmon-analyze entity <db> <type> <value> [filters]")
+    (exit 1))
+  (let* ([db (car args)]
+         [entity-type (string->symbol (cadr args))]
+         [entity-value (caddr args)]
+         [rest (cdddr args)]
+         [store (open-event-store db)]
+         [format-json? (has-flag? rest "--format")]
+         [rows (entity-timeline store entity-type entity-value)])
+    (displayln (format "--- Entity: ~a (~a) - ~a events ---"
+      entity-value entity-type (length rows)))
+    (for-each
+      (lambda (row) (print-row row format-json?))
+      rows)))
+
+(def detection-rules
+  '((brute_force . "5+ auth failures for same user in 10min")
+    (credential_stuffing . "5+ distinct usernames failing from same source in 10min")
+    (dns_tunnel . "50+ DNS queries from same process in 5min")
+    (suspicious_cron . "Cron change by non-root user")
+    (recon_port_scan . "Same process connecting to 10+ distinct ports in 5min")
+    (data_exfil . "Same process making 20+ outbound connections in 5min")
+    (priv_escalation_chain . "Auth success -> privilege escalation within 5min")
+    (persistence_after_access . "Reverse shell/webshell -> persistence within 1h")
+    (log_cover . "Critical event -> log tampering within 1h")
+    (lateral_after_shell . "Reverse shell -> lateral movement within 1h")))
+
+(def (run-detect args)
+  (let* ([db (get-db-path args)]
+         [rest (cdr args)]
+         [store (open-event-store db)]
+         [rule-name (get-flag rest "--rule")]
+         [list-rules? (has-flag? rest "--list-rules")]
+         [format-json? (has-flag? rest "--format")]
+         [since-ms (or (and (get-flag rest "--since")
+                            (parse-time-arg (get-flag rest "--since")))
+                       0)])
+    (cond
+      [list-rules?
+       (displayln "Available detection rules:")
+       (for-each
+         (lambda (r)
+           (displayln (format "  ~a: ~a" (car r) (cdr r))))
+         detection-rules)]
+      [rule-name
+       (let ([results (run-detection-rules store
+                        (string->symbol rule-name) since-ms: since-ms)])
+         (displayln (format "--- Rule: ~a - ~a hits ---"
+           rule-name (length results)))
+         (for-each
+           (lambda (row) (print-detection-row row format-json?))
+           results))]
+      [else
+       (let ([results (run-detection-rules store 'all since-ms: since-ms)])
+         (displayln (format "--- All rules - ~a total hits ---"
+           (length results)))
+         (for-each
+           (lambda (row) (print-detection-row row format-json?))
+           results))])))
+
+(def (run-hosts args)
+  (let* ([db (get-db-path args)]
+         [store (open-event-store db)]
+         [hosts (store-list-hosts store)])
+    (displayln "Known hosts:")
+    (for-each
+      (lambda (row)
+        (displayln (format "  ~a (last_seq=~a, last_seen=~a, hostname=~a)"
+          (cdr (assoc "source" row))
+          (cdr (assoc "last_seq" row))
+          (format-ts (cdr (assoc "last_seen" row)))
+          (cdr (assoc "hostname" row)))))
+      hosts)))
+
+(def (run-retention args)
+  (let* ([db (get-db-path args)]
+         [rest (cdr args)]
+         [before (get-flag rest "--delete-before")]
+         [store (open-event-store db)])
+    (unless before
+      (displayln "ERROR: --delete-before <time> required")
+      (exit 1))
+    (let ([before-ms (parse-time-arg before)])
+      (displayln (format "Deleting events before ~a..." (format-ts before-ms)))
+      (store-retention! store before-ms)
+      (displayln "Done."))))
+
+;; --- Output helpers ---
+
+(def (print-row row format-json?)
+  (if format-json?
+    (displayln (row->json row))
+    (begin
+      (display (format-ts (cdr (assoc "timestamp_ms" row))))
+      (display " ")
+      (display (severity-marker (cdr (assoc "severity" row))))
+      (display " [")
+      (display (cdr (assoc "host" row)))
+      (display "] ")
+      (display (cdr (assoc "event_type" row)))
+      (display " ")
+      (display (cdr (assoc "summary" row)))
+      (newline))))
+
+(def (print-detection-row row format-json?)
+  (if format-json?
+    (displayln (row->json row))
+    (displayln (format "  ~a" row))))
+
+(def (row->json row)
+  (let ([ht (make-hashtable string-hash string=?)])
+    (for-each
+      (lambda (pair)
+        (hashtable-set! ht (car pair) (cdr pair)))
+      row)
+    (json-object->string ht)))
+
+(def (severity-marker sev)
+  (cond
+    [(equal? sev "critical") "!!!"]
+    [(equal? sev "high")     "!! "]
+    [(equal? sev "medium")   "!  "]
+    [else                    "   "]))
+
+(def (format-ts ms)
+  (guard (e [#t (format "~a" ms)])
+    (let* ([secs (quotient ms 1000)]
+           [t (make-time 'time-utc 0 secs)]
+           [d (time-utc->date t 0)])
+      (format "~4,'0d-~2,'0d-~2,'0d ~2,'0d:~2,'0d:~2,'0d"
+        (date-year d) (date-month d) (date-day d)
+        (date-hour d) (date-minute d) (date-second d)))))
+
+(def (current-time-ms)
+  (let ([t (current-time)])
+    (+ (* (time-second t) 1000)
+       (quotient (time-nanosecond t) 1000000))))
diff --git a/bin/collector.ss b/bin/collector.ss
new file mode 100644
index 0000000..5677e37
--- /dev/null
+++ b/bin/collector.ss
@@ -0,0 +1,430 @@
+#!/usr/bin/env gxi
+;; secmon-collector: Collect and decrypt events from remote agents
+(import
+  (chezscheme)
+  (jerboa prelude)
+  (secmon config)
+  (secmon crypto ecies)
+  (secmon crypto psk)
+  (secmon server protocol)
+  (secmon monitor events)
+  (secmon storage store)
+  (std text json)
+  (std text hex))
+
+(def (main . args)
+  (let ([cmd (and (not (null? args)) (car args))]
+        [rest (if (null? args) '() (cdr args))])
+    (case (and cmd (string->symbol cmd))
+      [(watch)   (run-watch rest)]
+      [(poll)    (run-poll rest)]
+      [(status)  (run-status rest)]
+      [else
+       (displayln "Usage: secmon-collector <command> [options]")
+       (displayln "")
+       (displayln "Commands:")
+       (displayln "  watch  <host:port...> [--format json] [--db path]")
+       (displayln "  poll   <host:port> [--after seq] [--format json]")
+       (displayln "  status <host:port>")
+       (exit 1)])))
+
+;; --- Key loading ---
+
+(def (load-keys)
+  (let-values ([(private-key psk) (load-collector-config)])
+    (unless (and private-key (= (bytevector-length private-key) 32))
+      (displayln "ERROR: Missing or invalid private key")
+      (displayln "Set SECMON_PRIVATE_KEY or provide keys/private.key")
+      (exit 1))
+    (unless (and psk (= (bytevector-length psk) 32))
+      (displayln "ERROR: Missing or invalid PSK")
+      (displayln "Set SECMON_PSK or provide keys/psk.key")
+      (exit 1))
+    (values private-key psk)))
+
+;; --- TCP client ---
+
+(def (connect-to-agent host port)
+  (let ([c-socket (foreign-procedure "socket" (int int int) int)]
+        [c-connect (foreign-procedure "connect" (int u8* int) int)])
+    (let ([fd (c-socket 2 1 0)])  ;; AF_INET SOCK_STREAM
+      (when (< fd 0)
+        (error 'connect "socket() failed"))
+      (let ([addr (make-connect-addr host port)])
+        (let ([rc (c-connect fd addr (bytevector-length addr))])
+          (when (< rc 0)
+            (error 'connect "connect() failed" host port))
+          (values (open-fd-input-port fd)
+                  (open-fd-output-port fd)))))))
+
+(def (make-connect-addr host port)
+  ;; struct sockaddr_in
+  (let ([addr (make-bytevector 16 0)]
+        [ip-parts (map string->number (string-split host #\.))])
+    (bytevector-u16-set! addr 0 2 (endianness native))  ;; AF_INET
+    (bytevector-u16-set! addr 2 port (endianness big))
+    (when (= (length ip-parts) 4)
+      (bytevector-u8-set! addr 4 (list-ref ip-parts 0))
+      (bytevector-u8-set! addr 5 (list-ref ip-parts 1))
+      (bytevector-u8-set! addr 6 (list-ref ip-parts 2))
+      (bytevector-u8-set! addr 7 (list-ref ip-parts 3)))
+    addr))
+
+;; --- Transport I/O (same as server side) ---
+
+(def (send-msg! port psk-auth payload)
+  (let* ([encrypted (psk-encrypt-transport psk-auth payload)]
+         [len-bytes (pack-u32-le (bytevector-length encrypted))])
+    (put-bytevector port len-bytes)
+    (put-bytevector port encrypted)
+    (flush-output-port port)))
+
+(def (recv-msg! port psk-auth)
+  (guard (e [#t #f])
+    (let* ([len-bv (get-bytevector-n port 4)]
+           [len (unpack-u32-le len-bv 0)])
+      (when (> len 10485760)
+        (error 'recv-msg! "message too large"))
+      (let ([encrypted (get-bytevector-n port len)])
+        (psk-decrypt-transport psk-auth encrypted)))))
+
+;; --- Authenticate to agent ---
+
+(def (authenticate! in-port out-port psk-auth)
+  ;; 1. Receive challenge
+  (let ([challenge-msg (recv-msg! in-port psk-auth)])
+    (unless challenge-msg
+      (error 'authenticate "no challenge received"))
+    (let ([tag (bytevector-u8-ref challenge-msg 0)])
+      (unless (= tag MSG-CHALLENGE)
+        (error 'authenticate "unexpected message" tag))
+      ;; Parse challenge
+      (let* ([challenge-data (make-bytevector (- (bytevector-length challenge-msg) 1))]
+             [_ (bytevector-copy! challenge-msg 1 challenge-data 0
+                   (bytevector-length challenge-data))]
+             [challenge (bytevector->psk-challenge challenge-data)]
+             ;; 2. Respond
+             [response (psk-respond-to-challenge psk-auth challenge)]
+             [resp-bv (psk-response->bytevector response)]
+             [msg (bv-tag MSG-CHALLENGE-RESPONSE resp-bv)])
+        (send-msg! out-port psk-auth msg)
+        ;; 3. Wait for auth result or first request
+        #t))))
+
+(def (bv-tag tag . parts)
+  (let* ([total (+ 1 (apply + (map bytevector-length parts)))]
+         [out (make-bytevector total)])
+    (bytevector-u8-set! out 0 tag)
+    (let loop ([parts parts] [offset 1])
+      (if (null? parts) out
+        (let ([bv (car parts)])
+          (bytevector-copy! bv 0 out offset (bytevector-length bv))
+          (loop (cdr parts) (+ offset (bytevector-length bv))))))))
+
+;; --- Send request and get response ---
+
+(def (send-request! in-port out-port psk-auth req-tag . data-bvs)
+  (let ([request (apply bv-tag MSG-REQUEST
+                   (make-bytevector 1 req-tag) data-bvs)])
+    (send-msg! out-port psk-auth request)
+    ;; Get response
+    (let ([resp (recv-msg! in-port psk-auth)])
+      (unless resp
+        (error 'send-request "no response"))
+      ;; Strip MSG-RESPONSE tag, parse response
+      (let ([tag (bytevector-u8-ref resp 0)])
+        (cond
+          [(= tag MSG-RESPONSE)
+           (let ([sub (make-bytevector (- (bytevector-length resp) 1))])
+             (bytevector-copy! resp 1 sub 0 (bytevector-length sub))
+             (unpack-response sub))]
+          [(= tag MSG-AUTH-FAILED)
+           (error 'send-request "authentication failed")]
+          [else
+           (error 'send-request "unexpected response" tag)])))))
+
+;; --- Event decryption ---
+
+(def (decrypt-event decryptor stored-ev)
+  (guard (e [#t #f])
+    (let* ([enc-bv (stored-event-encrypted-data stored-ev)]
+           [enc-payload (bytevector->encrypted-payload enc-bv)]
+           [plaintext (ecies-decrypt decryptor enc-payload)])
+      (bytevector->security-event plaintext))))
+
+;; --- Print event ---
+
+(def (print-event ev format-json?)
+  (if format-json?
+    (displayln (json-object->string (security-event->json ev)))
+    (begin
+      (display (format-timestamp (security-event-timestamp-ms ev)))
+      (display " ")
+      (display (severity-marker (security-event-severity ev)))
+      (display " [")
+      (display (security-event-hostname ev))
+      (display "] ")
+      (display (security-event-event-type ev))
+      (display " ")
+      (print-event-body ev)
+      (newline))))
+
+(def (format-timestamp ms)
+  ;; Simple timestamp format
+  (let* ([secs (quotient ms 1000)]
+         [t (make-time 'time-utc 0 secs)]
+         [d (time-utc->date t 0)])
+    (format "~4,'0d-~2,'0d-~2,'0d ~2,'0d:~2,'0d:~2,'0d"
+      (date-year d) (date-month d) (date-day d)
+      (date-hour d) (date-minute d) (date-second d))))
+
+(def (severity-marker sev)
+  (case sev
+    [(critical) "!!!"]
+    [(high)     "!! "]
+    [(medium)   "!  "]
+    [else       "   "]))
+
+(def (print-event-body ev)
+  (let ([data (security-event-data ev)]
+        [type (security-event-event-type ev)])
+    (case type
+      [(process_start)
+       (display (format "pid=~a ppid=~a name=~a exe=~a"
+         (ht-ref data "pid") (ht-ref data "ppid")
+         (ht-ref data "name") (ht-ref data "exe")))]
+      [(process_exit)
+       (display (format "pid=~a name=~a" (ht-ref data "pid") (ht-ref data "name")))]
+      [(suspicious_exec)
+       (display (format "*** SUSPICIOUS EXEC *** pid=~a name=~a reason=~a parent=~a"
+         (ht-ref data "pid") (ht-ref data "name")
+         (ht-ref data "reason") (ht-ref data "parent_name" "")))]
+      [(new_connection suspicious_connection)
+       (display (format "~a ~a:~a -> ~a:~a pid=~a ~a"
+         (ht-ref data "protocol") (ht-ref data "local_addr")
+         (ht-ref data "local_port") (ht-ref data "remote_addr")
+         (ht-ref data "remote_port") (ht-ref data "pid")
+         (ht-ref data "process_name")))]
+      [(new_listener)
+       (display (format "~a ~a:~a pid=~a"
+         (ht-ref data "protocol") (ht-ref data "local_addr")
+         (ht-ref data "local_port") (ht-ref data "pid")))]
+      [(file_change)
+       (display (format "~a ~a" (ht-ref data "change_type") (ht-ref data "path")))]
+      [(auth_event)
+       (display (format "~a ~a user=~a from=~a"
+         (ht-ref data "auth_type") (ht-ref data "status")
+         (ht-ref data "username") (ht-ref data "remote_host")))]
+      [(kernel_module)
+       (display (format "~a ~a size=~a"
+         (ht-ref data "action") (ht-ref data "module_name")
+         (ht-ref data "module_size")))]
+      [(scheduled_task_change)
+       (display (format "~a ~a" (ht-ref data "change_type") (ht-ref data "path")))]
+      [(container_escape)
+       (display (format "*** CONTAINER ESCAPE *** ~a ~a"
+         (ht-ref data "detection_type") (ht-ref data "detail")))]
+      [(dns_query)
+       (display (format "~a server=~a pid=~a ~a"
+         (ht-ref data "query_name") (ht-ref data "server_addr")
+         (ht-ref data "pid") (ht-ref data "process_name")))]
+      [(rootkit_detected)
+       (display (format "*** ROOTKIT *** pid=~a ~a"
+         (ht-ref data "pid") (ht-ref data "detail")))]
+      [(reverse_shell_event)
+       (display (format "*** REVERSE SHELL *** pid=~a ~a -> ~a:~a reason=~a"
+         (ht-ref data "pid") (ht-ref data "process_name")
+         (ht-ref data "remote_addr") (ht-ref data "remote_port")
+         (ht-ref data "reason")))]
+      [(persistence_event)
+       (display (format "*** PERSISTENCE *** ~a ~a"
+         (ht-ref data "change_type") (ht-ref data "path")))]
+      [(lateral_movement_event)
+       (display (format "*** LATERAL MOVEMENT *** ~a pid=~a -> ~a:~a"
+         (ht-ref data "movement_type") (ht-ref data "pid")
+         (ht-ref data "target_host") (ht-ref data "target_port")))]
+      [(log_tamper_event)
+       (display (format "*** LOG TAMPERING *** ~a ~a (~a -> ~a bytes)"
+         (ht-ref data "tamper_type") (ht-ref data "path")
+         (ht-ref data "old_size") (ht-ref data "new_size")))]
+      [(webshell_event)
+       (display (format "*** WEBSHELL *** ~a spawned ~a (pid=~a)"
+         (ht-ref data "web_server_name") (ht-ref data "spawned_process")
+         (ht-ref data "pid")))]
+      [(selinux_event)
+       (display (format "SELinux: ~a ~a"
+         (ht-ref data "selinux_event_type" "")
+         (ht-ref data "detail" "")))]
+      [(container_event)
+       (display (format "Container ~a: ~a"
+         (ht-ref data "action") (ht-ref data "container_id")))]
+      [else
+       (display (json-object->string data))])))
+
+(def (ht-ref ht key . default)
+  (let ([d (if (null? default) "" (car default))])
+    (if (hashtable? ht)
+      (hashtable-ref ht key d)
+      d)))
+
+;; --- Commands ---
+
+(def (parse-host-port str)
+  (let ([parts (string-split str #\:)])
+    (if (>= (length parts) 2)
+      (values (car parts) (or (string->number (cadr parts)) 31337))
+      (values str 31337))))
+
+(def (run-status args)
+  (when (null? args)
+    (displayln "Usage: secmon-collector status <host:port>")
+    (exit 1))
+  (let-values ([(private-key psk) (load-keys)])
+    (let ([psk-auth (make-psk-auth psk)])
+      (let-values ([(host port) (parse-host-port (car args))])
+        (let-values ([(in out) (connect-to-agent host port)])
+          (authenticate! in out psk-auth)
+          (let ([resp (send-request! in out psk-auth REQ-STATUS)])
+            (displayln (format "Buffered events: ~a" (cadr resp)))
+            (displayln (format "Latest sequence: ~a" (caddr resp)))
+            (displayln (format "Uptime: ~as" (cadddr resp))))
+          (close-port in)
+          (close-port out))))))
+
+(def (run-poll args)
+  (let ([format-json? (member "--format" args)]
+        [after-seq 0]
+        [host-port #f])
+    ;; Parse args
+    (let loop ([as args])
+      (cond
+        [(null? as) (void)]
+        [(equal? (car as) "--after")
+         (when (not (null? (cdr as)))
+           (set! after-seq (or (string->number (cadr as)) 0))
+           (loop (cddr as)))]
+        [(equal? (car as) "--format")
+         (loop (if (null? (cdr as)) '() (cddr as)))]
+        [else
+         (set! host-port (car as))
+         (loop (cdr as))]))
+    (unless host-port
+      (displayln "Usage: secmon-collector poll <host:port> [--after seq]")
+      (exit 1))
+    (let-values ([(private-key psk) (load-keys)])
+      (let ([psk-auth (make-psk-auth psk)]
+            [decryptor (make-ecies-decryptor private-key)])
+        (let-values ([(host port) (parse-host-port host-port)])
+          (let-values ([(in out) (connect-to-agent host port)])
+            (authenticate! in out psk-auth)
+            (let ([resp (send-request! in out psk-auth
+                          REQ-GET-EVENTS-AFTER (pack-u64-le after-seq))])
+              (when (and (pair? resp) (eq? (car resp) 'events))
+                (for-each
+                  (lambda (stored-ev)
+                    (let ([ev (decrypt-event decryptor stored-ev)])
+                      (when ev (print-event ev format-json?))))
+                  (cadr resp))))
+            (close-port in)
+            (close-port out)))))))
+
+(def (run-watch args)
+  (let ([format-json? (member "--format" args)]
+        [db-path #f]
+        [hosts '()])
+    ;; Parse args
+    (let loop ([as args])
+      (cond
+        [(null? as) (void)]
+        [(equal? (car as) "--format")
+         (set! format-json? #t)
+         (loop (if (null? (cdr as)) '() (cddr as)))]
+        [(equal? (car as) "--db")
+         (when (not (null? (cdr as)))
+           (set! db-path (cadr as))
+           (loop (cddr as)))]
+        [else
+         (set! hosts (cons (car as) hosts))
+         (loop (cdr as))]))
+    (when (null? hosts)
+      (displayln "Usage: secmon-collector watch <host:port...> [--format json] [--db path]")
+      (exit 1))
+    (let-values ([(private-key psk) (load-keys)])
+      (let ([psk-auth (make-psk-auth psk)]
+            [decryptor (make-ecies-decryptor private-key)]
+            [store (and db-path (open-event-store db-path))])
+        ;; Spawn watcher thread per host
+        (for-each
+          (lambda (host-str)
+            (fork-thread
+              (lambda ()
+                (watch-host host-str psk-auth decryptor store format-json?))))
+          (reverse hosts))
+        ;; Block main thread
+        (let loop ()
+          (sleep (make-time 'time-duration 0 3600))
+          (loop))))))
+
+(def (watch-host host-str psk-auth decryptor store format-json?)
+  (let-values ([(host port) (parse-host-port host-str)])
+    (let ([last-seq (box 0)]
+          [backoff (box 1)])
+      (let loop ()
+        (guard (e [#t
+                   ;; Exponential backoff on error
+                   (let ([wait (unbox backoff)])
+                     (sleep (make-time 'time-duration 0 wait))
+                     (set-box! backoff (min 30 (* wait 2)))
+                     (loop))])
+          (let-values ([(in out) (connect-to-agent host port)])
+            (authenticate! in out psk-auth)
+            (set-box! backoff 1)  ;; Reset backoff on successful connect
+            ;; Continuous poll loop
+            (let poll-loop ()
+              (let ([resp (send-request! in out psk-auth
+                            REQ-GET-EVENTS-AFTER
+                            (pack-u64-le (unbox last-seq)))])
+                (when (and (pair? resp) (eq? (car resp) 'events))
+                  (for-each
+                    (lambda (stored-ev)
+                      (let ([ev (decrypt-event decryptor stored-ev)])
+                        (when ev
+                          (print-event ev format-json?)
+                          ;; Store to DB if configured
+                          (when store
+                            (store-decrypted-event! store stored-ev ev host-str)))
+                        (set-box! last-seq (stored-event-seq stored-ev))))
+                    (cadr resp))))
+              (sleep (make-time 'time-duration 0 1))
+              (poll-loop))))))))
+
+(def (store-decrypted-event! store stored-ev ev host-str)
+  (guard (e [#t (void)])
+    (let ([data (security-event-data ev)])
+      (store-event! store
+        (security-event-hostname ev)
+        host-str
+        (stored-event-seq stored-ev)
+        (security-event-timestamp-ms ev)
+        (symbol->string (security-event-event-type ev))
+        (symbol->string (security-event-severity ev))
+        (if (hashtable? data) (hashtable-ref data "pid" #f) #f)
+        (if (hashtable? data) (hashtable-ref data "process_name" #f) #f)
+        (format-event-summary ev)
+        (json-object->string (security-event->json ev)))
+      (store-set-last-seq! store host-str
+        (stored-event-seq stored-ev)
+        (security-event-hostname ev)))))
+
+(def (format-event-summary ev)
+  (let ([type (security-event-event-type ev)]
+        [data (security-event-data ev)])
+    (case type
+      [(process_start)
+       (format "Process start: ~a (pid=~a)"
+         (ht-ref data "name") (ht-ref data "pid"))]
+      [(reverse_shell_event)
+       (format "Reverse shell: ~a -> ~a:~a"
+         (ht-ref data "process_name")
+         (ht-ref data "remote_addr") (ht-ref data "remote_port"))]
+      [else (symbol->string type)])))
diff --git a/bin/keygen.ss b/bin/keygen.ss
new file mode 100644
index 0000000..9c20604
--- /dev/null
+++ b/bin/keygen.ss
@@ -0,0 +1,45 @@
+#!/usr/bin/env gxi
+;; secmon-keygen: Generate ECIES keypair and PSK
+(import
+  (secmon crypto keys)
+  (std text hex))
+
+(def (main . args)
+  ;; Generate ECIES keypair
+  (let-values ([(private-key public-key) (generate-keypair)])
+    ;; Generate PSK
+    (let ([psk (generate-psk)])
+      ;; Print keys
+      (displayln "=== SecMon Key Generation ===")
+      (displayln "")
+      (displayln (string-append "ECIES_PUBLIC_KEY="
+                   (u8vector->hex-string public-key)))
+      (displayln (string-append "ECIES_PRIVATE_KEY="
+                   (u8vector->hex-string private-key)))
+      (displayln (string-append "PSK="
+                   (u8vector->hex-string psk)))
+      (displayln "")
+      (displayln "--- Deployment Instructions ---")
+      (displayln "")
+      (displayln "1. Save agent keys (deploy to monitored host):")
+      (displayln (string-append "   echo '"
+                   (u8vector->hex-string public-key)
+                   "' > keys/public.key"))
+      (displayln (string-append "   echo '"
+                   (u8vector->hex-string psk)
+                   "' > keys/psk.key"))