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