jsecmon: port dga score_domain scoring core (score-label)

Jaime Fournier <jaimef@linbsd.org>

83b8c5e67d0541dfb69c67f685808f48bc94eafe

diff --git a/README.md b/README.md
index 423a3c1..38c6cd9 100644
--- a/README.md
+++ b/README.md
@@ -35,7 +35,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`      | `typed/dga.ss`     | ⏳ needs string ops (lowercase, split, ends_with, char classes) |
+| `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) |
 | `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 |
diff --git a/tests/dga_vectors.rs b/tests/dga_vectors.rs
index 27cc18a..71ff535 100644
--- a/tests/dga_vectors.rs
+++ b/tests/dga_vectors.rs
@@ -1,7 +1,7 @@
 // Verifies the Typed-Jerboa-generated dga kernel against secmon's own
 // dga::tests vectors (secmon/src/dga.rs). `make rust` copies this file into
 // the generated crate's tests/ dir before `cargo test`.
-use jerboa_typed_generated::jsecmon_dga::{max_consonant_run, shannon_entropy};
+use jerboa_typed_generated::jsecmon_dga::{max_consonant_run, score_label, shannon_entropy};
 
 #[test]
 fn consonant_run_matches_secmon_vectors() {
@@ -26,3 +26,30 @@ fn shannon_entropy_matches_secmon_vectors() {
     // 6 distinct symbols, each once → exactly log2(6) ≈ 2.585
     assert!((shannon_entropy("abcdef".to_string()) - 6f64.log2()).abs() < 1e-9);
 }
+
+#[test]
+fn score_label_matches_secmon_verdicts() {
+    let s = |label: &str| score_label(label.to_string());
+
+    // dga::tests::known_dga_style_fires — the leftmost label of
+    // "kxq8z23nplkdq.example.com" fires (>= 60).
+    assert!(s("kxq8z23nplkdq") >= 60);
+
+    // dga::tests::hex_blob_fires — long all-hex label (SHA-prefix-like) fires.
+    assert!(s("a1b2c3d4e5f6789012345") >= 60);
+
+    // dga::tests::long_consonant_run_fires — 8 consonants in a row scores the
+    // +35 consonant weight (>= 30), even though it is short and low-entropy.
+    assert!(s("xkqzmnpw") >= 30);
+
+    // dga::tests::benign_domains_score_zero — the leftmost labels of the
+    // benign domains stay well under the firing threshold.
+    for label in ["google", "mail", "api", "www", "github"] {
+        assert!(s(label) < 60, "{label} unexpectedly DGA: {}", s(label));
+    }
+
+    // score is clamped to 0..=100
+    assert!(s("a1b2c3d4e5f6789012345") <= 100);
+    // empty label scores 0 (caller treats empty as a clean verdict)
+    assert_eq!(s(""), 0);
+}
diff --git a/typed/dga.ss b/typed/dga.ss
index 44fc2da..9ff8376 100644
--- a/typed/dga.ss
+++ b/typed/dga.ss
@@ -4,7 +4,7 @@
 ;;; to Rust by the jerboa typed→rust backend. Domains are ASCII, so we score
 ;;; over UTF-8 bytes (string->utf8 + bytevector-u8-ref) rather than chars.
 (typed-library (jsecmon dga)
-  (export max-consonant-run shannon-entropy)
+  (export max-consonant-run shannon-entropy score-label)
 
   ;; Running state for the consonant-run fold: the current run length and the
   ;; longest run seen so far. A record accumulator lets a single-accumulator
@@ -61,4 +61,47 @@
             (if (> c 0)
                 (let ((p (/ (exact->inexact c) (exact->inexact n))))
                   (- ent (* p (log2 p))))
-                ent)))))))
+                ent))))))
+
+  ;; ASCII hex digit: 0-9 (48-57), a-f (97-102), A-F (65-70). Case-insensitive,
+  ;; matching char::is_ascii_hexdigit.
+  (def (ascii-hex? (b : Nat)) : Bool
+    (or (and (>= b 48) (<= b 57))
+        (or (and (>= b 97) (<= b 102))
+            (and (>= b 65) (<= b 70)))))
+
+  ;; ASCII decimal digit 0-9.
+  (def (ascii-digit? (b : Nat)) : Bool
+    (and (>= b 48) (<= b 57)))
+
+  ;; #t iff all n bytes of bs are ASCII hex digits. A fold (not short-circuit)
+  ;; -- same result as chars().all(), and constant-time besides.
+  (def (all-hex? (bs : Bytes) (n : Nat)) : Bool
+    (for/fold ((ok #t)) ((i (in-range n)))
+      (and ok (ascii-hex? (bytevector-u8-ref bs i)))))
+
+  ;; count ASCII decimal digits in the first n bytes (chars().filter().count()).
+  (def (digit-count (bs : Bytes) (n : Nat)) : Nat
+    (for/fold ((c 0)) ((i (in-range n)))
+      (if (ascii-digit? (bytevector-u8-ref bs i)) (+ c 1) c)))
+
+  ;; The 0..100 DGA score for an already-lowercased leftmost label, mirroring
+  ;; the additive weight table in dga::score_domain. The headline score is what
+  ;; fires the detection (>= 60); the diagnostic `reasons` list and the
+  ;; label-extraction / benign-suffix steps live in the (untyped) caller, which
+  ;; does the lowercasing + split('.') that this kernel assumes already done.
+  (def (score-label (label : String)) : Nat
+    (let ((bs (string->utf8 label)))
+      (let ((len (bytevector-length bs))
+            (ent (shannon-entropy label))
+            (run (max-consonant-run label)))
+        (let ((hexish (and (> len 0) (all-hex? bs len)))
+              (digits (digit-count bs len)))
+          (let ((s0 (if (and (>= len 12) (>= ent 3.3)) 35
+                        (if (and (>= len 8) (>= ent 3.8)) 20 0))))
+            (let ((s1 (+ s0 (if (>= run 5) 35
+                                (if (and (>= run 4) (>= len 10)) 15 0)))))
+              (let ((s2 (+ s1 (if (and hexish (>= len 20)) 40 0))))
+                (let ((s3 (+ s2 (if (and (>= len 10) (>= (* digits 2) len)) 25 0))))
+                  (let ((s4 (+ s3 (if (and (>= len 20) (>= ent 4.0)) 20 0))))
+                    (if (> s4 100) 100 s4)))))))))))