Port cron scheduled-task path tables + classifier to untyped Jerboa

Jaime Fournier <jaimef@linbsd.org>

b2648bfcc0fa3df464bd88e26104b0c1d5a67424

diff --git a/Makefile b/Makefile
index b27bbc3..8727b16 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 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 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)"
@@ -256,6 +256,13 @@ ioc-check:
 revshell-check:
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/revshell_check.ss
 
+# Scheduled-task path tables + classifier (secmon src/monitor/cron.rs): the
+# per-platform CRON_PATHS / PERIODIC_PATHS tables (linux/freebsd/other cfg arms)
+# and is_systemd_or_periodic_path ("systemd"/"periodic" substring routing). Pure
+# — the baseline walk is the deferred I/O. cron.rs has no #[test]; check is spec.
+cron-check:
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/cron_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
@@ -291,6 +298,7 @@ checks: kernels-check
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/event_summary_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/ioc_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/revshell_check.ss
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/cron_check.ss
 
 clean:
 	rm -rf $(BUILD)
diff --git a/README.md b/README.md
index 1fcb45a..fcb277a 100644
--- a/README.md
+++ b/README.md
@@ -55,6 +55,7 @@ make collector-cli-check # collector bin: --after/--format/--db + host normalize
 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
 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 checks          # every Jerboa-side check in one shot
 ```
 
@@ -137,4 +138,5 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage).
 | `storage` event readers (`extract_pid` / `extract_process_name` / `build_summary`) | `jsecmon/event-summary.ss` | ✅ **untyped layer** — the pure readers that turn an event's flat JSON `data` (a hash table, as `string->json-object` yields) back into a pid / process name / one-line summary, with the SQLite query + serde plumbing left to storage. Each field is read through a **typed** getter so only a JSON value of the right type counts (`as_u64`/`as_i64`/`as_str`/`as_bool`); `extract_pid` walks pid→source_pid→spawned_pid→web_server_pid and truncates the first hit to **u32** (Rust `v as u32`, so ≥2³² wraps, and pid 0 is a real hit); `extract_process_name` walks process_name→name→exe→source_process→spawned_process. `build_summary` reproduces every per-type format with the exact `unwrap_or` defaults (`"?"`/`0`), the `process_exit` exit-code *option* (Some(0) still prints `(0)`), the nested `selinux_event` perm/class/path-vs-message branches with the 80-char message cap, and the catch-all that scans values **in sorted key order** (serde's default BTreeMap) for the first string longer than 3 chars (capped at 80) else the event type. `make event-summary-check` (43 cases) reproduces secmon's `test_extract_helpers` + `test_build_summary` and adds every per-type / typed-getter corner derived from the source. |
 | `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. |
 | monitors / server / ebpf / dtrace | —  | ⏳ I/O+async+FFI, last           |
diff --git a/examples/cron_check.ss b/examples/cron_check.ss
new file mode 100644
index 0000000..897ec23
--- /dev/null
+++ b/examples/cron_check.ss
@@ -0,0 +1,73 @@
+;;; Parity check for (jsecmon cron) against secmon src/monitor/cron.rs.
+;;; cron.rs has no #[test], so every expectation here derives from the Rust
+;;; source: the three #[cfg(target_os)] CRON_PATHS / PERIODIC_PATHS tables and
+;;; is_systemd_or_periodic_path's "systemd"/"periodic" substring test.
+;;;
+;;;   scheme --libdirs "$JERBOA/lib:." --script examples/cron_check.ss
+
+(import (jerboa prelude)
+        (jsecmon cron))
+
+(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)))))
+
+;; ── cron-paths: the three cfg arms verbatim ──────────────────────────────────
+(displayln "cron-paths:")
+(check "linux cron paths"
+       (cron-paths 'linux)
+       '("/etc/crontab" "/etc/cron.d" "/etc/cron.daily" "/etc/cron.hourly"
+         "/etc/cron.weekly" "/etc/cron.monthly" "/var/spool/cron"
+         "/var/spool/cron/crontabs"))
+(check "freebsd cron paths"
+       (cron-paths 'freebsd)
+       '("/etc/crontab" "/var/cron/tabs"))
+(check "other cron paths"
+       (cron-paths 'other)
+       '("/etc/crontab"))
+
+;; ── periodic-paths: the three cfg arms verbatim ──────────────────────────────
+(displayln "periodic-paths:")
+(check "linux periodic paths"
+       (periodic-paths 'linux)
+       '("/etc/systemd/system" "/usr/lib/systemd/system" "/lib/systemd/system"))
+(check "freebsd periodic paths"
+       (periodic-paths 'freebsd)
+       '("/etc/periodic.conf" "/etc/periodic/daily" "/etc/periodic/weekly"
+         "/etc/periodic/monthly" "/etc/periodic/security"
+         "/usr/local/etc/periodic/daily" "/usr/local/etc/periodic/weekly"
+         "/usr/local/etc/periodic/monthly"))
+(check "other periodic paths empty"
+       (periodic-paths 'other)
+       '())
+
+;; ── is-systemd-or-periodic-path: "systemd" OR "periodic" substring ────────────
+(displayln "is-systemd-or-periodic-path:")
+(check "systemd unit dir"   (is-systemd-or-periodic-path "/etc/systemd/system") #t)
+(check "usr lib systemd"    (is-systemd-or-periodic-path "/usr/lib/systemd/system") #t)
+(check "freebsd periodic"   (is-systemd-or-periodic-path "/etc/periodic/daily") #t)
+(check "periodic.conf"      (is-systemd-or-periodic-path "/etc/periodic.conf") #t)
+;; substring anywhere, not just a prefix
+(check "systemd mid-path"   (is-systemd-or-periodic-path "/run/systemd/foo") #t)
+;; plain cron paths route the OTHER way (false)
+(check "crontab -> #f"      (is-systemd-or-periodic-path "/etc/crontab") #f)
+(check "cron.d -> #f"       (is-systemd-or-periodic-path "/etc/cron.d") #f)
+(check "var spool cron -> #f" (is-systemd-or-periodic-path "/var/spool/cron") #f)
+
+;; ── routing parity: every linux PERIODIC_PATHS entry is systemd-routed ────────
+(displayln "routing parity:")
+(check "all linux periodic-paths route systemd"
+       (every is-systemd-or-periodic-path (periodic-paths 'linux)) #t)
+(check "all freebsd periodic-paths route periodic"
+       (every is-systemd-or-periodic-path (periodic-paths 'freebsd)) #t)
+;; and no cron path is systemd/periodic-routed on linux
+(check "no linux cron-path routes systemd/periodic"
+       (any is-systemd-or-periodic-path (cron-paths 'linux)) #f)
+
+(newline)
+(if (= fails 0)
+    (displayln "OK: cron matches secmon's monitor/cron.rs path tables + classifier.")
+    (begin (displayln fails " FAILURES") (exit 1)))
diff --git a/jsecmon/cron.ss b/jsecmon/cron.ss
new file mode 100644
index 0000000..b094422
--- /dev/null
+++ b/jsecmon/cron.ss
@@ -0,0 +1,59 @@
+#!chezscheme
+;;; jsecmon scheduled-task path tables + classifier (secmon src/monitor/cron.rs).
+;;;
+;;; The pure pieces of the scheduled-task (cron / systemd-timer / periodic)
+;;; monitor; the baseline walk, change detection, and event emission stay in the
+;;; monitor loop. secmon keys CRON_PATHS / PERIODIC_PATHS off #[cfg(target_os)],
+;;; so here they are functions of a platform symbol ('linux | 'freebsd | 'other),
+;;; exactly the three cfg arms:
+;;;   cron-paths     : platform -> (path …)
+;;;   periodic-paths : platform -> (path …)
+;;;   is-systemd-or-periodic-path : path -> #t | #f
+;;; is-systemd-or-periodic-path is the routing predicate baseline_all uses to
+;;; decide whether a PERIODIC_PATHS entry is a systemd unit dir (watched as a
+;;; systemd path) or just another cron-like dir: it is a plain SUBSTRING test for
+;;; "systemd" OR "periodic" (the obfstr! literals), so e.g. the FreeBSD
+;;; "/etc/periodic/daily" entries route systemd-style while a bare "/etc/crontab"
+;;; does not.
+;;;
+;;; cron.rs has no #[test]; examples/cron_check.ss asserts the full platform
+;;; tables and the classifier and IS the spec for this port.
+
+(library (jsecmon cron)
+  (export cron-paths periodic-paths is-systemd-or-periodic-path)
+  (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 (cron-paths platform)
+    (cond
+      ((eq? platform 'linux)
+       '("/etc/crontab" "/etc/cron.d" "/etc/cron.daily" "/etc/cron.hourly"
+         "/etc/cron.weekly" "/etc/cron.monthly" "/var/spool/cron"
+         "/var/spool/cron/crontabs"))
+      ((eq? platform 'freebsd)
+       '("/etc/crontab" "/var/cron/tabs"))
+      (#t '("/etc/crontab"))))
+
+  (def (periodic-paths platform)
+    (cond
+      ((eq? platform 'linux)
+       '("/etc/systemd/system" "/usr/lib/systemd/system" "/lib/systemd/system"))
+      ((eq? platform 'freebsd)
+       '("/etc/periodic.conf" "/etc/periodic/daily" "/etc/periodic/weekly"
+         "/etc/periodic/monthly" "/etc/periodic/security"
+         "/usr/local/etc/periodic/daily" "/usr/local/etc/periodic/weekly"
+         "/usr/local/etc/periodic/monthly"))
+      (#t '())))
+
+  (def (is-systemd-or-periodic-path path)
+    (if (or (string-contains path "systemd")
+            (string-contains path "periodic"))
+        #t #f)))