Port logtamper decision core to untyped Jerboa

ober

712d9ef284224a2e2497e60ac0e2d2a5d85941a3

diff --git a/Makefile b/Makefile
index 8727b16..1dd766d 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 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 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)"
@@ -263,6 +263,13 @@ revshell-check:
 cron-check:
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/cron_check.ss
 
+# Log-tamper decision core (secmon src/monitor/logtamper.rs): the SYSTEM_LOGS /
+# HISTORY_FILES tables, TRUNCATION_THRESHOLD, is_history_file (substring), and
+# classify-tamper (size drop > threshold -> history-cleared|truncated; mtime
+# backwards -> timestamp-modified). Pure — fs::metadata polling is deferred I/O.
+logtamper-check:
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/logtamper_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
@@ -299,6 +306,7 @@ checks: kernels-check
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/ioc_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
 
 clean:
 	rm -rf $(BUILD)
diff --git a/README.md b/README.md
index fcb277a..bba0f4b 100644
--- a/README.md
+++ b/README.md
@@ -56,6 +56,7 @@ make event-summary-check # storage readers: extract_pid/extract_process_name/bui
 make ioc-check       # storage IOC: detect_ioc_type + is_ipv4 + parse_ioc_text
 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)
 make checks          # every Jerboa-side check in one shot
 ```
 
@@ -139,4 +140,5 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage).
 | `storage` IOC parsing (`detect_ioc_type` / `is_ipv4` / `parse_ioc_text`) | `jsecmon/ioc.ss` | ✅ **untyped layer** — the pure indicator classifier behind threat-list ingestion (the file read in `load_ioc_file` is the deferred I/O). `detect_ioc_type` is first-match ip→hash→domain→process: `is_ipv4` (split on `.`, exactly 4 non-empty ≤3-char all-digit groups — **no** 0–255 range check, so `999.999.999.999` is still Ip and `1.2.3.4444` is not), then IPv6 (`:` present and every char hex-or-`:`), then a 32/40/64-length all-hex Hash (MD5/SHA1/SHA256, case-insensitive), then a `.`-bearing space-free Domain, else Process. `parse_ioc_text` trims, drops blanks and `#` comments, and tags each remaining line (order preserved). `make ioc-check` reproduces the `test_ioc_type_detection` #[test] plus the is_ipv4 / parse corners. |
 | `monitor/revshell` reverse-shell classifiers (`classify_connection` + helpers) | `jsecmon/revshell.ss` | ✅ **untyped layer** — the pure deciders of the reverse-shell monitor (connection/PID enumeration, event emission, and the dedup set stay in the monitor loop). `is_shell` / `is_revshell_tool` are **exact** lower-cased name membership; `is_c2_port` tests the 17-port C2 set; `is_legitimate_service` fires only on 443/8080/8443 and matches a **substring** of the name. `extract_addr_from_cmdline` finds `/dev/tcp/`, splits the remainder on `/`, and on ≥2 pieces returns `(addr . u16-port)` (first whitespace token of piece 1, junk/out-of-range→0), else `("unknown" . 0)`. `classify_connection` runs checks 1–4 first-match: shell→`shell-outbound`, C2-port-and-not-legit→`known-c2-port`, revshell-tool→`shell-outbound`, any `REVSHELL_PATTERNS` substring→`suspicious-redirect`, else `#f`. revshell.rs has no #[test], so `make revshell-check` (47 cases) **is** the spec. |
 | `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. |
 | monitors / server / ebpf / dtrace | —  | ⏳ I/O+async+FFI, last           |
diff --git a/examples/logtamper_check.ss b/examples/logtamper_check.ss
new file mode 100644
index 0000000..d3f6756
--- /dev/null
+++ b/examples/logtamper_check.ss
@@ -0,0 +1,96 @@
+;;; Parity check for (jsecmon logtamper) against secmon src/monitor/logtamper.rs.
+;;; logtamper.rs has no #[test], so every expectation here derives from the Rust
+;;; source: the SYSTEM_LOGS / HISTORY_FILES tables, TRUNCATION_THRESHOLD, the
+;;; is_history_file substring scan, and check_tampering's Ok-arm decision.
+;;;
+;;;   scheme --libdirs "$JERBOA/lib:." --script examples/logtamper_check.ss
+
+(import (jerboa prelude)
+        (jsecmon logtamper))
+
+(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)))))
+
+;; ── the constant tables, verbatim ────────────────────────────────────────────
+(displayln "tables:")
+(check "system-logs"
+       system-logs
+       '("/var/log/auth.log" "/var/log/secure" "/var/log/syslog"
+         "/var/log/messages" "/var/log/kern.log" "/var/log/daemon.log"
+         "/var/log/cron.log" "/var/log/maillog" "/var/log/audit/audit.log"
+         "/var/log/faillog" "/var/log/lastlog" "/var/log/wtmp" "/var/log/btmp"
+         "/var/log/utmp" "/run/utmp"))
+(check "history-files"
+       history-files
+       '(".bash_history" ".zsh_history" ".sh_history" ".history"
+         ".python_history" ".mysql_history" ".psql_history" ".lesshst"
+         ".viminfo"))
+(check "truncation-threshold" truncation-threshold 1000)
+
+;; ── is-history-file: any HISTORY_FILES substring ─────────────────────────────
+(displayln "is-history-file:")
+(check "exact .bash_history"      (is-history-file ".bash_history") #t)
+;; substring match: a full home path containing the pattern still matches
+(check "full path /home/u/.zsh_history"
+       (is-history-file "/home/u/.zsh_history") #t)
+(check ".viminfo anywhere"        (is-history-file "/root/.viminfo") #t)
+(check "system log -> #f"         (is-history-file "/var/log/syslog") #f)
+(check "plain file -> #f"         (is-history-file "/etc/passwd") #f)
+
+;; ── classify-tamper: truncation branch (size drop > threshold) ───────────────
+(displayln "classify-tamper truncation:")
+;; system log dropped by >1000 -> Truncated
+(check "syslog -2000 -> truncated"
+       (classify-tamper 5000 3000 100 100 "/var/log/syslog")
+       '(truncated))
+;; history file dropped by >1000 -> HistoryCleared
+(check "history -5000 -> history-cleared"
+       (classify-tamper 6000 1000 100 100 "/home/u/.bash_history")
+       '(history-cleared))
+;; drop of exactly the threshold is NOT > threshold -> nothing
+(check "drop == threshold -> none"
+       (classify-tamper 2000 1000 100 100 "/var/log/syslog")
+       '())
+;; drop just over threshold -> truncated
+(check "drop 1001 -> truncated"
+       (classify-tamper 2001 1000 100 100 "/var/log/syslog")
+       '(truncated))
+;; size grew -> no truncation
+(check "size grew -> none"
+       (classify-tamper 1000 9000 100 100 "/var/log/syslog")
+       '())
+
+;; ── classify-tamper: timestamp branch (mtime went backwards, >0) ─────────────
+(displayln "classify-tamper timestamp:")
+(check "mtime backwards -> timestamp-modified"
+       (classify-tamper 1000 1000 500 400 "/var/log/syslog")
+       '(timestamp-modified))
+;; new-mtime 0 is excluded even though 0 < old
+(check "new-mtime 0 -> none"
+       (classify-tamper 1000 1000 500 0 "/var/log/syslog")
+       '())
+;; mtime forward -> none
+(check "mtime forward -> none"
+       (classify-tamper 1000 1000 400 500 "/var/log/syslog")
+       '())
+
+;; ── classify-tamper: both fire, truncation pushed first ──────────────────────
+(displayln "classify-tamper combined:")
+(check "history cleared AND mtime back -> [history-cleared timestamp-modified]"
+       (classify-tamper 6000 1000 500 400 "/home/u/.bash_history")
+       '(history-cleared timestamp-modified))
+(check "truncated AND mtime back -> [truncated timestamp-modified]"
+       (classify-tamper 6000 1000 500 400 "/var/log/syslog")
+       '(truncated timestamp-modified))
+(check "no change -> none"
+       (classify-tamper 1000 1000 500 500 "/var/log/syslog")
+       '())
+
+(newline)
+(if (= fails 0)
+    (displayln "OK: logtamper matches secmon's monitor/logtamper.rs decision core.")
+    (begin (displayln fails " FAILURES") (exit 1)))
diff --git a/jsecmon/logtamper.ss b/jsecmon/logtamper.ss
new file mode 100644
index 0000000..873cd6e
--- /dev/null
+++ b/jsecmon/logtamper.ss
@@ -0,0 +1,69 @@
+#!chezscheme
+;;; jsecmon log-tamper classifiers (secmon src/monitor/logtamper.rs), untyped.
+;;;
+;;; The pure pieces of the log-tampering monitor; the fs::metadata polling, the
+;;; size/mtime tracking map, and event emission stay in the monitor loop:
+;;;   system-logs         : the watched system-log set (SYSTEM_LOGS table)
+;;;   history-files        : the shell-history patterns (HISTORY_FILES table)
+;;;   truncation-threshold : minimum size drop to flag (TRUNCATION_THRESHOLD=1000)
+;;;   is-history-file      : path -> #t | #f   (any HISTORY_FILES SUBSTRING)
+;;;   classify-tamper      : old-size new-size old-mtime new-mtime path -> (type …)
+;;;
+;;; classify-tamper reproduces check_tampering's per-file pure core for the
+;;; fs::metadata Ok arm, in push order (so both can fire for one file):
+;;;   1. new-size < old-size AND (old-size - new-size) > truncation-threshold
+;;;        -> 'history-cleared if (is-history-file path) else 'truncated
+;;;   2. new-mtime < old-mtime AND new-mtime > 0
+;;;        -> 'timestamp-modified
+;;; The 'deleted case is the fs::metadata Err arm (a missing file) and the
+;;; size/mtime values come from a real stat, so both are the deferred I/O the
+;;; monitor loop supplies — classify-tamper only decides, given the numbers.
+;;; LogTamperType variants map to 'truncated / 'history-cleared /
+;;; 'timestamp-modified / 'deleted.
+;;;
+;;; logtamper.rs has no #[test]; examples/logtamper_check.ss asserts the tables,
+;;; is-history-file, and every classify-tamper branch, and IS the spec.
+
+(library (jsecmon logtamper)
+  (export system-logs history-files truncation-threshold
+          is-history-file classify-tamper)
+  (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 system-logs
+    '("/var/log/auth.log" "/var/log/secure" "/var/log/syslog"
+      "/var/log/messages" "/var/log/kern.log" "/var/log/daemon.log"
+      "/var/log/cron.log" "/var/log/maillog" "/var/log/audit/audit.log"
+      "/var/log/faillog" "/var/log/lastlog" "/var/log/wtmp" "/var/log/btmp"
+      "/var/log/utmp" "/run/utmp"))
+
+  (def history-files
+    '(".bash_history" ".zsh_history" ".sh_history" ".history"
+      ".python_history" ".mysql_history" ".psql_history" ".lesshst"
+      ".viminfo"))
+
+  (def truncation-threshold 1000)
+
+  (def (is-history-file path)
+    (if (any (lambda (p) (string-contains path p)) history-files) #t #f))
+
+  ;; check_tampering's Ok-arm decision, in push order (truncation then mtime).
+  (def (classify-tamper old-size new-size old-mtime new-mtime path)
+    (let* ((acc '())
+           (acc (if (and (< new-size old-size)
+                         (> (- old-size new-size) truncation-threshold))
+                    (cons (if (is-history-file path) 'history-cleared 'truncated)
+                          acc)
+                    acc))
+           (acc (if (and (< new-mtime old-mtime) (> new-mtime 0))
+                    (cons 'timestamp-modified acc)
+                    acc)))
+      (reverse acc))))