jsecmon: add Bytes-based ASCII string toolkit (strbytes)
Jaime Fournier <jaimef@linbsd.org>
8d2c62b03c887bca249f71a2e98d5ebac608280b
--- a/README.md +++ b/README.md @@ -36,7 +36,8 @@ 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` (scoring core) | `typed/dga.ss` | ✅ `score-label` 0..100 headline, vectors pass | -| `dga::score_domain` (wrapper) | — | ⏳ split('.') + benign-suffix + reasons list (string ops) | +| `dga::score_domain` (wrapper) | — | ⏳ assembles via `strbytes` below; 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 | | `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 | new file mode 100644 --- /dev/null +++ b/tests/strbytes_vectors.rs @@ -0,0 +1,58 @@ +//! Vectors for the Bytes-based ASCII string toolkit, checked against the exact +//! cases secmon's detectors rely on (dga::score_domain's benign-suffix +//! 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, +}; + +fn b(s: &str) -> Vec<u8> { + s.as_bytes().to_vec() +} + +#[test] +fn suffix_matches_benign_cdn_check() { + // dga::score_domain bails when the trimmed domain ends with a benign + // high-entropy CDN suffix. + assert!(bytes_suffix_p(b("d2hk78xq2k.cloudfront.net"), b(".cloudfront.net"))); + assert!(bytes_suffix_p(b("bucket.s3.amazonaws.com"), b(".amazonaws.com"))); + // a normal domain matches none of them + assert!(!bytes_suffix_p(b("mail.google.com"), b(".cloudfront.net"))); + // ends_with self, and the empty needle, are true (Rust semantics) + assert!(bytes_suffix_p(b("github.io"), b("github.io"))); + assert!(bytes_suffix_p(b("anything"), b(""))); + // needle longer than haystack is false + assert!(!bytes_suffix_p(b("io"), b(".github.io"))); +} + +#[test] +fn prefix_and_contains() { + assert!(bytes_prefix_p(b("cloudfront.net"), b("cloud"))); + assert!(!bytes_prefix_p(b("cloudfront.net"), b("front"))); + assert!(bytes_contains_p(b("x.s3.amazonaws.com"), b(".amazonaws.com"))); + assert!(bytes_contains_p(b("abcdef"), b("cde"))); + assert!(!bytes_contains_p(b("abcdef"), b("xyz"))); + assert!(bytes_contains_p(b("abc"), b(""))); // empty needle is everywhere +} + +#[test] +fn label_split_matches_secmon() { + // split('.').next() length: "kxq8z23nplkdq.example.com" → "kxq8z23nplkdq" (13) + assert_eq!(first_label_len(b("kxq8z23nplkdq.example.com")), 13); + // no dot → the whole string is one label + assert_eq!(first_label_len(b("localhost")), 9); + // leading dot → empty first label (length 0) + assert_eq!(first_label_len(b(".hidden")), 0); + // index_of_byte finds the first '.'; absent → length + assert_eq!(index_of_byte(b("a.b.c"), b'.' as u64), 1); + assert_eq!(index_of_byte(b("nodot"), b'.' as u64), 5); +} + +#[test] +fn lowercase_is_ascii_faithful() { + assert_eq!(ascii_lower_bytes("GOOGLE.COM".to_string()), b("google.com")); + // digits, dots, and already-lowercase bytes are untouched + assert_eq!(ascii_lower_bytes("a1B2.Net".to_string()), b("a1b2.net")); + assert_eq!(ascii_lower_bytes("".to_string()), b("")); +} new file mode 100644 --- /dev/null +++ b/typed/strbytes.ss @@ -0,0 +1,70 @@ +;;; jsecmon — Bytes-based ASCII string matching toolkit. +;;; +;;; secmon's detection modules (dga::score_domain, lolbin, sigma) lean on +;;; `&str` methods: to_ascii_lowercase, ends_with, starts_with, contains, +;;; split('.').next(). The Jerboa typed backend has no Rust `str` API, but the +;;; inputs here are all ASCII, so every one of those is expressible over UTF-8 +;;; bytes using only buffer indexing, recursion, and bytes-build. This module +;;; is the shared primitive layer those ports build on. + +(typed-library (jsecmon strbytes) + (export ascii-lower-bytes bytes-suffix? bytes-prefix? bytes-contains? + 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 + (if (and (>= b 65) (<= b 90)) (+ b 32) b)) + + ;; Lowercase a whole string into a fresh Bytes buffer (no mutation). + ;; Mirrors str::to_ascii_lowercase for the ASCII domain we operate in. + (def (ascii-lower-bytes (s : String)) : Bytes + (let ((bs (string->utf8 s))) + (bytes-build (bytevector-length bs) (i (lower1 (bytevector-u8-ref bs i)))))) + + ;; #t iff hay[off..off+m] equals need[0..m] (m = length of need). + (def (match-at? (hay : Bytes) (off : Nat) (need : Bytes) (i : Nat) (m : Nat)) : Bool + (if (>= i m) + #t + (and (= (bytevector-u8-ref hay (+ off i)) (bytevector-u8-ref need i)) + (match-at? hay off need (+ i 1) m)))) + + ;; str::ends_with — does hay end with need? + (def (bytes-suffix? (hay : Bytes) (need : Bytes)) : Bool + (let ((hn (bytevector-length hay)) + (nn (bytevector-length need))) + (if (> nn hn) #f (match-at? hay (- hn nn) need 0 nn)))) + + ;; str::starts_with — does hay start with need? + (def (bytes-prefix? (hay : Bytes) (need : Bytes)) : Bool + (let ((hn (bytevector-length hay)) + (nn (bytevector-length need))) + (if (> nn hn) #f (match-at? hay 0 need 0 nn)))) + + ;; naive substring search from `start`; #t iff need occurs in hay at/after it. + (def (search-from (hay : Bytes) (hn : Nat) (need : Bytes) (nn : Nat) (start : Nat)) : Bool + (if (> (+ start nn) hn) + #f + (if (match-at? hay start need 0 nn) + #t + (search-from hay hn need nn (+ start 1))))) + + ;; str::contains — does need occur anywhere in hay? The empty needle is + ;; contained everywhere (matching Rust). + (def (bytes-contains? (hay : Bytes) (need : Bytes)) : Bool + (search-from hay (bytevector-length hay) need (bytevector-length need) 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) + n + (if (= (bytevector-u8-ref bs i) v) i (scan-byte bs v (+ i 1) n)))) + + ;; index of the first byte equal to v, or the length if absent. The Bytes + ;; analogue of str::find(c) (returning len rather than None). + (def (index-of-byte (bs : Bytes) (v : Nat)) : Nat + (scan-byte bs v 0 (bytevector-length bs))) + + ;; length of the leftmost dot-delimited label: split('.').next(). The label + ;; is bs[0..first-label-len]; with no dot the whole buffer is one label. + (def (first-label-len (bs : Bytes)) : Nat + (index-of-byte bs 46)))