Port lateral is_internal_ip + faithful IpAddr parser to untyped Jerboa
ober
0d353c75a758243b05104eb33fc8b14182ed4d50
--- 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 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 revshell-check cron-check logtamper-check detection-rules-check ipaddr-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)" @@ -278,6 +278,14 @@ logtamper-check: detection-rules-check: $(SCHEME) --libdirs $(LIBDIRS) --script examples/detection_rules_check.ss +# IP-address parsing + internal-network test (secmon src/monitor/lateral.rs): +# parse-ipv4 / parse-ipv6 faithfully reproduce Rust std's IpAddr FromStr (no +# leading zeros, <=255, "::" compression, embedded IPv4), then is_internal_ip +# classifies 10/8 + 172.16/12 + 192.168/16 + 127/8 + fc00::/7 + loopback. The +# expectations were generated by a std-only Rust oracle (~70 inputs). +ipaddr-check: + $(SCHEME) --libdirs $(LIBDIRS) --script examples/ipaddr_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 @@ -316,6 +324,7 @@ checks: kernels-check $(SCHEME) --libdirs $(LIBDIRS) --script examples/cron_check.ss $(SCHEME) --libdirs $(LIBDIRS) --script examples/logtamper_check.ss $(SCHEME) --libdirs $(LIBDIRS) --script examples/detection_rules_check.ss + $(SCHEME) --libdirs $(LIBDIRS) --script examples/ipaddr_check.ss clean: rm -rf $(BUILD) --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ make revshell-check # revshell: is_shell/is_c2_port/is_legitimate + classify_co 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) make detection-rules-check # DETECTION_RULES ATT&CK catalog: rule_attack + anomaly_rule_attack +make ipaddr-check # lateral: faithful IpAddr parse (v4/v6) + is_internal_ip (RFC1918/fc00) make checks # every Jerboa-side check in one shot ``` @@ -143,4 +144,5 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage). | `monitor/cron` scheduled-task path tables + classifier | `jsecmon/cron.ss` | ✅ **untyped layer** — the pure pieces of the cron / systemd-timer / periodic monitor (the baseline walk + change detection stay in the loop). secmon keys `CRON_PATHS` / `PERIODIC_PATHS` off `#[cfg(target_os)]`, so `cron-paths` / `periodic-paths` are functions of a platform symbol (`'linux` / `'freebsd` / `'other`) reproducing the three cfg arms verbatim. `is-systemd-or-periodic-path` is the routing predicate `baseline_all` uses to decide whether a `PERIODIC_PATHS` entry is a systemd unit dir vs another cron-like dir — a plain **substring** test for `"systemd"` OR `"periodic"`. cron.rs has no #[test], so `make cron-check` asserts the full tables + the classifier and **is** the spec. | | `monitor/logtamper` log-tamper decision core | `jsecmon/logtamper.ss` | ✅ **untyped layer** — the pure pieces of the log-tampering monitor (the `fs::metadata` polling + size/mtime tracking map stay in the loop). Exposes the `SYSTEM_LOGS` / `HISTORY_FILES` constant tables and `TRUNCATION_THRESHOLD` (1000). `is-history-file` is any-`HISTORY_FILES`-**substring**. `classify-tamper old-size new-size old-mtime new-mtime path` reproduces `check_tampering`'s Ok-arm in push order: size dropped by **>** threshold → `history-cleared` (if a history file) else `truncated`; mtime went backwards and `> 0` → `timestamp-modified` (both can fire for one file). The `deleted` case is the `fs::metadata` Err arm (deferred I/O). logtamper.rs has no #[test], so `make logtamper-check` **is** the spec. | | `storage` detection-rule ATT&CK catalog (`DETECTION_RULES` / `rule_attack` / `anomaly_rule_attack`) | `jsecmon/detection-rules.ss` | ✅ **untyped layer** — the static `DETECTION_RULES` table (13 rows: name / description / severity / MITRE techniques) and its pure lookups, used to annotate findings with ATT&CK IDs and to list/validate rule names (the SQL detectors that fire these live in `(jsecmon threats)`). `rule-attack` finds the row by exact name and returns its `attack` list, else `()` (unwrap_or_default); `rule-names` / `rule-known?` mirror the listing/validation paths. `anomaly-rule-attack` is the separate match for the `detect_anomalies` **statistical** rules (not in the table): `kill_chain` → recon/lateral/exfil tactics, `off_hours` → Defense Evasion, others (frequency_spike, severity_cluster) → `()`. const has no #[test], so `make detection-rules-check` asserts every row + both lookups and **is** the spec. | +| `monitor/lateral` IP parsing + internal-network test (`is_internal_ip`) | `jsecmon/ipaddr.ss` | ✅ **untyped layer** — `is_internal_ip` parses `ip_str.parse::<IpAddr>()` first and only classifies on success, so `parse-ipv4` / `parse-ipv6` faithfully reproduce **Rust std's `IpAddr` FromStr** boundary: IPv4 = exactly 4 octets, 1–3 digits, **no leading zeros**, ≤255; IPv6 = colon-separated 1–4-digit hex groups with at most one `::` (eliding ≥1 zero group) and an optional trailing embedded IPv4 (forbidden before `::`). `is-internal-ip` then mirrors lateral.rs: V4 `10/8` · `172.16/12` · `192.168/16` · `127/8`, V6 `fc00::/7` (`seg0 & 0xfe00 == 0xfc00`) or loopback. lateral.rs has no #[test]; expectations were generated by a **std-only Rust oracle** over ~70 inputs, so `make ipaddr-check` (88 cases) pins the port to real Rust and **is** the spec. | | monitors / server / ebpf / dtrace | — | ⏳ I/O+async+FFI, last | new file mode 100644 --- /dev/null +++ b/examples/ipaddr_check.ss @@ -0,0 +1,92 @@ +;;; Parity check for (jsecmon ipaddr) against secmon src/monitor/lateral.rs. +;;; lateral.rs has no #[test]; instead every is-internal-ip expectation below was +;;; produced by a std-only Rust oracle (IpAddr::from_str + the verbatim +;;; is_internal_ip body) over this exact ~70-input battery, so this check pins +;;; both the parse accept/reject boundary AND the RFC1918/fc00 classification to +;;; real Rust behaviour. parse-ipv4 / parse-ipv6 structure is asserted too. +;;; +;;; scheme --libdirs "$JERBOA/lib:." --script examples/ipaddr_check.ss + +(import (jerboa prelude) + (jsecmon ipaddr)) + +(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))))) + +;; ── is-internal-ip: the full Rust-oracle battery (input . expected) ────────── +;; Every expected bool is the `int=` column emitted by the Rust oracle. +(def ip-cases + (list + ;; IPv4 internal + (cons "10.0.0.1" #t) (cons "10.255.255.255" #t) (cons "172.16.0.1" #t) + (cons "172.31.255.255" #t) (cons "172.20.5.5" #t) (cons "192.168.1.1" #t) + (cons "127.0.0.1" #t) (cons "127.255.255.255" #t) + ;; IPv4 parsed-but-external + (cons "172.15.0.1" #f) (cons "172.32.0.1" #f) (cons "192.169.1.1" #f) + (cons "8.8.8.8" #f) (cons "0.0.0.0" #f) (cons "255.255.255.255" #f) + (cons "1.2.3.4" #f) (cons "9.255.255.255" #f) (cons "11.0.0.0" #f) + ;; IPv4 unparseable (-> false) + (cons "256.0.0.1" #f) (cons "1.2.3" #f) (cons "1.2.3.4.5" #f) + (cons "1.2.3.04" #f) (cons "01.2.3.4" #f) (cons "1.2.3.4444" #f) + (cons "1.2.3." #f) (cons ".1.2.3" #f) (cons "1.2.3.x" #f) + (cons "00.0.0.0" #f) (cons "010.0.0.1" #f) + ;; IPv6 internal (fc00::/7 or loopback) + (cons "::1" #t) (cons "fc00::1" #t) (cons "fcff:ffff::" #t) + (cons "fd00::1" #t) (cons "fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff" #t) + (cons "fc00::192.168.1.1" #t) (cons "0:0:0:0:0:0:0:1" #t) + (cons "FC00::1" #t) (cons "fd12:3456::abcd" #t) (cons "fc00::" #t) + (cons "fc00:0::" #t) + ;; IPv6 parsed-but-external + (cons "::" #f) (cons "fe80::1" #f) (cons "fb00::1" #f) (cons "febf::1" #f) + (cons "2001:db8::1" #f) (cons "::ffff:192.168.1.1" #f) + (cons "1:2:3:4:5:6:7:8" #f) (cons "::1.2.3.4" #f) (cons "ffff::ffff" #f) + (cons "0::0" #f) (cons "00ab::1" #f) (cons "1:2:3:4:5:6:1.2.3.4" #f) + (cons "fe00::1" #f) (cons "::ffff:10.0.0.1" #f) + ;; IPv6 unparseable (-> false) + (cons "1:2:3:4:5:6:7:8:9" #f) (cons "g::1" #f) (cons "fc00:::1" #f) + (cons "12345::" #f) (cons "1:2:3:4:5:6:7:" #f) (cons ":1:2:3:4:5:6:7" #f) + (cons "1.2.3.4::1" #f) (cons "1::2::3" #f) (cons "abcde::" #f) + (cons "1:2:3:4:5:6:7:8::" #f) (cons "::1:2:3:4:5:6:7:8" #f) + (cons "1:2:3:4:5:6:7" #f) (cons "1:2:3:4:5:6:7:8:9:10" #f) + ;; junk / whitespace + (cons "" #f) (cons "hello" #f) (cons " 10.0.0.1" #f) (cons "10.0.0.1 " #f))) + +(displayln "is-internal-ip (Rust-oracle battery):") +(for-each (lambda (c) (check (str "[" (car c) "]") (is-internal-ip (car c)) (cdr c))) + ip-cases) + +;; ── parse-ipv4 structure ───────────────────────────────────────────────────── +(displayln "parse-ipv4:") +(check "10.0.0.1" (parse-ipv4 "10.0.0.1") '(10 0 0 1)) +(check "255.255.255.255" (parse-ipv4 "255.255.255.255") '(255 255 255 255)) +(check "leading zero #f" (parse-ipv4 "01.2.3.4") #f) +(check "4-digit octet #f" (parse-ipv4 "1.2.3.4444") #f) +(check "256 out of range #f" (parse-ipv4 "256.0.0.1") #f) + +;; ── parse-ipv6 structure (segments in decimal) ─────────────────────────────── +(displayln "parse-ipv6:") +(check "::1" (parse-ipv6 "::1") '(0 0 0 0 0 0 0 1)) +(check "::" (parse-ipv6 "::") '(0 0 0 0 0 0 0 0)) +(check "fc00::1" (parse-ipv6 "fc00::1") '(64512 0 0 0 0 0 0 1)) +(check "FC00::1 (case-insensitive)" (parse-ipv6 "FC00::1") '(64512 0 0 0 0 0 0 1)) +(check "fc00::192.168.1.1 (embedded v4)" + (parse-ipv6 "fc00::192.168.1.1") '(64512 0 0 0 0 0 49320 257)) +(check "::ffff:192.168.1.1" + (parse-ipv6 "::ffff:192.168.1.1") '(0 0 0 0 0 65535 49320 257)) +(check "::1.2.3.4" (parse-ipv6 "::1.2.3.4") '(0 0 0 0 0 0 258 772)) +(check "1:2:3:4:5:6:1.2.3.4 (full + embedded v4)" + (parse-ipv6 "1:2:3:4:5:6:1.2.3.4") '(1 2 3 4 5 6 258 772)) +(check "1:2:3:4:5:6:7:8" (parse-ipv6 "1:2:3:4:5:6:7:8") '(1 2 3 4 5 6 7 8)) +(check "triple colon #f" (parse-ipv6 "fc00:::1") #f) +(check "no-elide :: #f" (parse-ipv6 "1:2:3:4:5:6:7:8::") #f) +(check "5-hex group #f" (parse-ipv6 "12345::") #f) +(check "v4 before :: #f" (parse-ipv6 "1.2.3.4::1") #f) + +(newline) +(if (= fails 0) + (displayln "OK: ipaddr matches the Rust IpAddr oracle + lateral.rs is_internal_ip.") + (begin (displayln fails " FAILURES") (exit 1))) new file mode 100644 --- /dev/null +++ b/jsecmon/ipaddr.ss @@ -0,0 +1,136 @@ +#!chezscheme +;;; jsecmon IP-address parsing + internal-network test (secmon monitor/lateral.rs). +;;; +;;; lateral.rs's is_internal_ip first does `ip_str.parse::<IpAddr>()` and only +;;; classifies on success, so a faithful port has to reproduce Rust std's +;;; IpAddr FromStr accept/reject boundary, not just the RFC1918 ranges. The +;;; parsers here were validated against a std-only Rust oracle over ~70 inputs +;;; (the same battery examples/ipaddr_check.ss asserts): +;;; parse-ipv4 : string -> (o0 o1 o2 o3) | #f +;;; parse-ipv6 : string -> (g0 … g7) | #f (eight u16s) +;;; is-internal-ip : string -> #t | #f +;;; +;;; Rust std semantics reproduced exactly: +;;; IPv4 — EXACTLY 4 dot-separated octets, each 1–3 ASCII digits, NO leading +;;; zero (so "01"/"00"/"010" all fail), value 0–255. Any other char (space, +;;; ':') fails. "1.2.3" / "1.2.3.4.5" / "256.0.0.1" / "1.2.3.4444" all fail. +;;; IPv6 — colon-separated 16-bit hex groups (1–4 hex digits, leading zeros +;;; OK), with AT MOST ONE "::" standing for ONE-OR-MORE elided zero groups, +;;; and an OPTIONAL trailing embedded IPv4 (the last 32 bits, e.g. +;;; "::ffff:1.2.3.4") parsed by the same strict IPv4 rule. An embedded IPv4 +;;; is NOT allowed before "::". Without "::" there must be exactly 8 groups; +;;; with "::" the explicit groups must total ≤ 7 (so "a:b:…:h::" fails). +;;; is-internal-ip then mirrors lateral.rs: V4 is 10/8, 172.16/12, 192.168/16, +;;; or 127/8; V6 is fc00::/7 (`(seg0 & 0xfe00) == 0xfc00`) or loopback (::1). +;;; +;;; lateral.rs has no #[test]; examples/ipaddr_check.ss replays the Rust-oracle +;;; battery (is-internal-ip for every input, plus parse structure) and IS the +;;; spec for this port. + +(library (jsecmon ipaddr) + (export parse-ipv4 parse-ipv6 is-internal-ip) + (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?)) + + (def (ascii-digit? c) (and (char>=? c #\0) (char<=? c #\9))) + (def (ascii-hexdigit? c) + (or (ascii-digit? c) + (and (char>=? c #\a) (char<=? c #\f)) + (and (char>=? c #\A) (char<=? c #\F)))) + + (def (string-all? pred s) + (let ((n (string-length s))) + (let loop ((i 0)) + (or (>= i n) (and (pred (string-ref s i)) (loop (+ i 1))))))) + + ;; Rust std Ipv4Addr::from_str: 4 octets, 1–3 digits, no leading zero, ≤255. + (def (parse-ipv4 s) + (let ((parts (string-split s #\.))) + (and (= (length parts) 4) + (let loop ((ps parts) (acc '())) + (if (null? ps) + (reverse acc) + (let ((p (car ps))) + (and (>= (string-length p) 1) + (<= (string-length p) 3) + (string-all? ascii-digit? p) + (not (and (> (string-length p) 1) + (char=? (string-ref p 0) #\0))) + (let ((v (string->number p))) + (and v (<= v 255) + (loop (cdr ps) (cons v acc))))))))))) + + ;; A colon-separated run of hex groups (each 1–4 hex digits, leading zeros + ;; OK), optionally ending in an embedded IPv4 when allow-ipv4 is #t (the IPv4 + ;; contributes two groups). Empty input -> '(); any malformed token -> #f. + (def (parse-group-list str allow-ipv4) + (if (string-empty? str) + '() + (let* ((tokens (string-split str #\:)) + (n (length tokens))) + (let loop ((ts tokens) (k 0) (acc '())) + (if (null? ts) + (reverse acc) + (let ((tok (car ts))) + (cond + ((string-empty? tok) #f) + ((string-contains tok ".") + (and allow-ipv4 + (= k (- n 1)) + (let ((o (parse-ipv4 tok))) + (and o + (loop (cdr ts) (+ k 1) + (cons (+ (* (list-ref o 2) 256) (list-ref o 3)) + (cons (+ (* (list-ref o 0) 256) (list-ref o 1)) + acc))))))) + (else + (and (>= (string-length tok) 1) + (<= (string-length tok) 4) + (string-all? ascii-hexdigit? tok) + (let ((v (string->number tok 16))) + (and v (loop (cdr ts) (+ k 1) (cons v acc))))))))))))) + + ;; Rust std Ipv6Addr::from_str, via at-most-one "::" split. + (def (parse-ipv6 s) + (let ((dc (string-contains s "::"))) + (if dc + (let ((head-str (substring s 0 dc)) + (after (substring s (+ dc 2) (string-length s)))) + (and (not (string-contains after "::")) ; only one "::" + (let ((head (parse-group-list head-str #f)) ; no IPv4 before :: + (tail (parse-group-list after #t))) + (and head tail + (let ((total (+ (length head) (length tail)))) + (and (<= total 7) ; "::" elides ≥1 group + (append head + (make-list (- 8 total) 0) + tail))))))) + (let ((groups (parse-group-list s #t))) + (and groups (= (length groups) 8) groups))))) + + (def (ipv6-loopback? g) (equal? g '(0 0 0 0 0 0 0 1))) + + (def (is-internal-ip s) + (if (string-contains s ":") + (let ((g (parse-ipv6 s))) + (if g + (or (= (bitwise-and (car g) #xfe00) #xfc00) + (ipv6-loopback? g)) + #f)) + (let ((o (parse-ipv4 s))) + (if o + (cond + ((= (car o) 10) #t) + ((and (= (car o) 172) (<= 16 (cadr o) 31)) #t) + ((and (= (car o) 192) (= (cadr o) 168)) #t) + ((= (car o) 127) #t) + (#t #f)) + #f)))))