frame: port server/protocol length-prefixed wire framing (untyped)
ober
f8098350d03071c0a0597ca2df318167d9fb9c33
--- 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 geoip-check sigma-check yaml-rules-check buffer-check dns-sniffer-check suspicious-check netconn-check kernmod-check selinux-check container-check dns-servers-check sensitive-path-check dtrace-parse-check proc-linux-check freebsd-parse-check event-meta-check config-check event-danger-check persistence-check file-change-check webshell-check platform-mounts-check analyze-cli-check collector-cli-check event-summary-check ioc-check revshell-check cron-check logtamper-check detection-rules-check ipaddr-check auth-check lolbin-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 sigma-check yaml-rules-check buffer-check dns-sniffer-check suspicious-check netconn-check kernmod-check selinux-check container-check dns-servers-check sensitive-path-check dtrace-parse-check proc-linux-check freebsd-parse-check event-meta-check config-check event-danger-check persistence-check file-change-check webshell-check platform-mounts-check analyze-cli-check collector-cli-check event-summary-check ioc-check frame-check revshell-check cron-check logtamper-check detection-rules-check ipaddr-check auth-check lolbin-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)" @@ -252,6 +252,12 @@ event-summary-check: ioc-check: $(SCHEME) --libdirs $(LIBDIRS) --script examples/ioc_check.ss +# Length-prefixed wire framing (secmon src/server/protocol.rs): frame_encode +# (4-byte LE u32 len ++ payload), frame_read_length (LE u32), frame_decode (the +# two from_bytes guards + payload slice). bincode of the message body is deferred. +frame-check: + $(SCHEME) --libdirs $(LIBDIRS) --script examples/frame_check.ss + # Reverse-shell classifiers (secmon src/monitor/revshell.rs): is_shell / # is_revshell_tool (exact name), is_c2_port (17-port set), is_legitimate_service # (443/8080/8443 + name substring), extract_addr_from_cmdline (/dev/tcp/ split), @@ -330,6 +336,7 @@ checks: kernels-check $(SCHEME) --libdirs $(LIBDIRS) --script examples/collector_cli_check.ss $(SCHEME) --libdirs $(LIBDIRS) --script examples/event_summary_check.ss $(SCHEME) --libdirs $(LIBDIRS) --script examples/ioc_check.ss + $(SCHEME) --libdirs $(LIBDIRS) --script examples/frame_check.ss $(SCHEME) --libdirs $(LIBDIRS) --script examples/revshell_check.ss $(SCHEME) --libdirs $(LIBDIRS) --script examples/cron_check.ss $(SCHEME) --libdirs $(LIBDIRS) --script examples/logtamper_check.ss --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ make analyze-cli-check # analyze bin: parse_duration_ms + AlertSink::parse + --f make collector-cli-check # collector bin: --after/--format/--db + host normalize + hosts-file make event-summary-check # storage readers: extract_pid/extract_process_name/build_summary make ioc-check # storage IOC: detect_ioc_type + is_ipv4 + parse_ioc_text + match field routing +make frame-check # server protocol: length-prefixed wire framing (encode/read_length/decode) make revshell-check # revshell: is_shell/is_c2_port/is_legitimate + classify_connection make cron-check # cron: per-platform CRON/PERIODIC path tables + systemd/periodic route make logtamper-check # logtamper: system-log/history tables + classify-tamper (trunc/mtime) @@ -126,6 +127,7 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage). | `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. | | `buffer::ring` (StoredEvent ring buffer) | `jsecmon/buffer.ss` | ✅ **untyped layer** — port of secmon's `src/buffer/ring.rs`: the agent's bounded in-memory event ring. FIFO list + monotonic seq numbering, priority eviction (`event_severity_u8` table, drop lowest-severity oldest-first, oldest-critical last), seq/time-range polling, FIFO delivery-ack (`clear_before`), and the little-endian header codec (`seq u64 ∥ ts i64 ∥ sev u8 ∥ payload`). Pure mechanics, so untyped — the one security step, ECIES payload encryption, is FFI-deferred: the caller hands `buffer-store!` opaque ciphertext bytes. `make buffer-check` reproduces secmon's three ring tests (store/seq, priority eviction, FIFO-oldest) + codec round-trip. | +| `server::protocol` (length-prefixed framing) | `jsecmon/frame.ss` | ✅ **untyped layer** — the wire framing the collector↔agent transport wraps every `ProtocolMessage` in: `frame_encode` prepends a 4-byte little-endian u32 length to the payload (`to_bytes`'s framing half), `frame_read_length` reads that header as an LE u32 (`read_length`), and `frame_decode` reproduces `from_bytes`'s two guards — `< 4` bytes → `data too short`, `< 4 + declared len` → `incomplete message` — then slices out `data[4 .. 4+len]`. Pure byte mechanics, so untyped; the bincode (de)serialization of the message *body* is the Rust-specific deferred piece (the caller (de)serializes the payload `frame_decode` hands back). LE u32 verified to round-trip `0x12345678` and `0xFFFFFFFF`. `make frame-check` pins the byte layout, the encode∘decode round-trip, both error guards, and the empty/zero-length-payload edge. | | `monitor::events::SuspiciousPatterns` (process-spawn classifier) | `jsecmon/suspicious.ss` | ✅ **untyped layer** — `check_suspicious(process, parent)`: shell-from-service, attack-tool-from-service (name exact-match or exe suffix), reverse-shell command-line patterns, and crypto-miner name/cmdline patterns, in secmon's order, returning the same reason string. Pure string classification like triage. Pins two corners the Rust depends on: a missing parent short-circuits to "clean" before any check, and `str::contains` is a *literal* substring test (so `python -c.*socket` is literal, not a regex). `make suspicious-check` reproduces secmon's two events.rs tests + the other three signals + both corners. | | `monitor::network::NetworkMonitor` (connection classifier) | `jsecmon/netconn.ss` | ✅ **untyped layer** — `check_suspicious(port, addr, process)`: known reverse-shell/C2/l33t port, ephemeral port (49152..65535) that is a round multiple of 1000, and a web-server process (nginx/apache/httpd/php-fpm) connecting to a non-private address, in secmon's order with the same reason string. Pure metadata classification. secmon hides the web-server names with `obfstr!` (same scheme as `typed/obfuscate.ss`); they decode to these plaintext literals at runtime. Pins the faithfulness quirk that the "private" prefix set is literal `{127. 10. 192.168. 172.}`, so `172.` matches all of 172.x, not just RFC1918 172.16/12. `make netconn-check` reproduces secmon's two network.rs tests + the full bad-port list + the high-port and web-server rules with private-address negatives. | | `monitor::kernel::KernelModuleMonitor` (kernel-module classifier) | `jsecmon/kernmod.ss` | ✅ **untyped layer** — `is_suspicious_module(name)`: lower-cased name contains a known-rootkit substring (diamorphine/reptile/hide/rootkit/keylog/…), or a 1-2 char name not on the legitimate-short allow-list (ip dm sd sr nf if), or a >4 char name with no vowel, in secmon's order. Pure string classification like the other classifiers. obfstr!-hidden name lists decode to these plaintext literals. Pins the faithfulness corner that only the substring test lower-cases the name — the short-name and vowel tests use the original case, and the vowel set is both-case `aeiouAEIOU`. `make kernmod-check` reproduces secmon's two kernel.rs tests + each signal exercised independently + the case corners. | new file mode 100644 --- /dev/null +++ b/examples/frame_check.ss @@ -0,0 +1,60 @@ +;;; Parity check for (jsecmon frame) against secmon src/server/protocol.rs. +;;; Pins the 4-byte LE length prefix layout, the encode/decode round-trip, and +;;; from_bytes's two error guards. +;;; +;;; scheme --libdirs "$JERBOA/lib:." --script examples/frame_check.ss + +(import (jerboa prelude) + (jsecmon frame)) + +(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))))) + +(def (bv . xs) (u8-list->bytevector xs)) +(def (->u8 b) (bytevector->u8-list b)) + +;; ── frame-encode: 4-byte LE length prefix, then payload ────────────────────── +(displayln "frame-encode:") +(check "3-byte payload -> len prefix 03 00 00 00 ++ body" + (->u8 (frame-encode (bv 65 66 67))) + '(3 0 0 0 65 66 67)) +(check "empty payload -> just zero length" + (->u8 (frame-encode (bv))) + '(0 0 0 0)) + +;; ── frame-read-length: little-endian u32 ───────────────────────────────────── +(displayln "frame-read-length:") +(check "01 00 00 00 -> 1" (frame-read-length (bv 1 0 0 0)) 1) +(check "78 56 34 12 -> 0x12345678" (frame-read-length (bv 120 86 52 18)) 305419896) +(check "ff ff ff ff -> 2^32-1" (frame-read-length (bv 255 255 255 255)) 4294967295) + +;; ── frame-decode: guards + payload slice ───────────────────────────────────── +(displayln "frame-decode:") +(check "under 4 bytes -> data too short" + (unwrap-err (frame-decode (bv 1 2 3))) "data too short") +(check "declares more than present -> incomplete message" + (unwrap-err (frame-decode (bv 5 0 0 0 65 66))) "incomplete message") +(check "exact frame -> payload" + (let ((r (frame-decode (bv 2 0 0 0 65 66)))) (and (ok? r) (->u8 (unwrap r)))) + '(65 66)) +(check "trailing bytes past declared len are ignored" + (let ((r (frame-decode (bv 2 0 0 0 65 66 99 99)))) (and (ok? r) (->u8 (unwrap r)))) + '(65 66)) +(check "zero-length frame -> empty payload" + (let ((r (frame-decode (bv 0 0 0 0)))) (and (ok? r) (->u8 (unwrap r)))) + '()) + +;; ── round-trip: encode then decode is identity on the payload ──────────────── +(displayln "round-trip:") +(check "encode∘decode round-trips payload" + (let ((r (frame-decode (frame-encode (bv 9 8 7 6 5))))) (and (ok? r) (->u8 (unwrap r)))) + '(9 8 7 6 5)) + +(newline) +(if (= fails 0) + (displayln "OK: frame matches secmon's protocol.rs length-prefix framing.") + (begin (displayln fails " FAILURES") (exit 1))) new file mode 100644 --- /dev/null +++ b/jsecmon/frame.ss @@ -0,0 +1,59 @@ +#!chezscheme +;;; jsecmon length-prefixed wire framing (secmon src/server/protocol.rs), untyped. +;;; +;;; The collector↔agent protocol frames every message as a 4-byte little-endian +;;; u32 length prefix followed by that many payload bytes. ProtocolMessage's +;;; bincode (de)serialization of the message *body* is Rust-specific and stays +;;; deferred — what is pure and portable is the framing around it: +;;; frame-encode : payload-bv -> bv (4-byte LE len ++ payload) +;;; frame-read-length : 4-byte header -> nat (LE u32, like read_length) +;;; frame-decode : data-bv -> (ok payload-bv) | (err msg) +;;; +;;; frame-decode mirrors from_bytes's two guards exactly: +;;; < 4 bytes -> (err "data too short") +;;; < 4 + declared length -> (err "incomplete message") +;;; and otherwise returns the payload slice data[4 .. 4+len] for the caller to +;;; deserialize. The length is read as an R6RS little-endian u32, matching Rust's +;;; u32::to_le_bytes / from_le_bytes (verified to round-trip 0x12345678 and +;;; 0xFFFFFFFF). frame-encode prepends the length the same way to_bytes does. +;;; +;;; examples/frame_check.ss round-trips frames, pins the byte layout, and covers +;;; both error guards and the empty-payload edge. + +(library (jsecmon frame) + (export frame-encode frame-read-length frame-decode) + (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?)) + + ;; to_bytes (framing half): 4-byte LE length prefix ++ payload bytes. + (def (frame-encode payload) + (let* ((n (bytevector-length payload)) + (out (make-bytevector (+ 4 n) 0))) + (bytevector-u32-set! out 0 n (endianness little)) + (bytevector-copy! payload 0 out 4 n) + out)) + + ;; read_length: interpret a 4-byte header as a little-endian u32. + (def (frame-read-length header) + (bytevector-u32-ref header 0 (endianness little))) + + ;; from_bytes (framing half): validate the prefix, slice out the payload. + ;; The bincode deserialization of that payload is the deferred Rust piece. + (def (frame-decode data) + (let ((n (bytevector-length data))) + (if (< n 4) + (err "data too short") + (let ((len (bytevector-u32-ref data 0 (endianness little)))) + (if (< n (+ 4 len)) + (err "incomplete message") + (let ((payload (make-bytevector len 0))) + (bytevector-copy! data 4 payload 0 len) + (ok payload))))))))