Port lateral is_internal_ip + faithful IpAddr parser to untyped Jerboa

ober

0d353c75a758243b05104eb33fc8b14182ed4d50

diff --git a/Makefile b/Makefile
index 380e3fb..c9c7b19 100644
--- a/Makefile
+++ b/Makefile
@@ -8,7 +8,7 @@ SCHEME ?= $(JERBOA)/.chez/bin/scheme
 BUILD  ?= build/rust
 TYPED  := $(wildcard typed/*.ss)
 
-.PHONY: rust test ffi-demo kernels-check triage-check triage-store-check analytics-check detect-check storage-check threats-check 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)
diff --git a/README.md b/README.md
index 39b204b..9b8652e 100644
--- 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           |
diff --git a/examples/ipaddr_check.ss b/examples/ipaddr_check.ss
new file mode 100644
index 0000000..b570ae8
--- /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)))
diff --git a/jsecmon/ipaddr.ss b/jsecmon/ipaddr.ss
new file mode 100644
index 0000000..3d99278
--- /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)))))