Port logtamper decision core to untyped Jerboa
ober
712d9ef284224a2e2497e60ac0e2d2a5d85941a3
--- 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) --- 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 | new file mode 100644 --- /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))) new file mode 100644 --- /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))))