jsecmon: complete dga::score_domain end-to-end (multi-module)

Jaime Fournier <jaimef@linbsd.org>

08f621fed149c73bd1b1f4e53379b5b18826ad57

diff --git a/README.md b/README.md
index 2202a57..07fe0eb 100644
--- a/README.md
+++ b/README.md
@@ -35,8 +35,7 @@ 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) | —             | ⏳ assembles via `strbytes` below; reasons list pending |
+| `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           |
 | `psk::constant_time_eq`  | `typed/psk.ss`     | ✅ ported, vectors pass         |
diff --git a/tests/dga_vectors.rs b/tests/dga_vectors.rs
index 71ff535..57c5f5e 100644
--- a/tests/dga_vectors.rs
+++ b/tests/dga_vectors.rs
@@ -1,7 +1,9 @@
 // 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, score_label, shannon_entropy};
+use jerboa_typed_generated::jsecmon_dga::{
+    max_consonant_run, score_domain, score_label, shannon_entropy,
+};
 
 #[test]
 fn consonant_run_matches_secmon_vectors() {
@@ -53,3 +55,46 @@ fn score_label_matches_secmon_verdicts() {
     // empty label scores 0 (caller treats empty as a clean verdict)
     assert_eq!(s(""), 0);
 }
+
+#[test]
+fn score_domain_matches_secmon_verdicts() {
+    let d = |domain: &str| score_domain(domain.to_string());
+
+    // dga::tests::known_dga_style_fires
+    assert!(d("kxq8z23nplkdq.example.com") >= 60);
+    // dga::tests::hex_blob_fires
+    assert!(d("a1b2c3d4e5f6789012345.evil.com") >= 60);
+
+    // dga::tests::benign_domains_score_zero — full domains, leftmost label scored
+    for dom in [
+        "google.com",
+        "mail.google.com",
+        "api.snapcraft.io",
+        "www.cybergreen.net",
+        "github.com",
+    ] {
+        assert!(d(dom) < 60, "{dom} unexpectedly DGA: {}", d(dom));
+    }
+
+    // dga::tests::cdn_suffix_suppressed — a high-entropy CDN label is forced to 0
+    assert_eq!(d("d2hk78xq2k.cloudfront.net"), 0);
+    // every benign suffix suppresses, even with a wildly random leftmost label
+    for cdn in [
+        "zzqxk9v2p7w.amazonaws.com",
+        "blob.s3.amazonaws.com",
+        "x9k2.azureedge.net",
+        "q7z.fastly.net",
+        "abc123.github.io",
+        "r4nd0m.azure-api.net",
+    ] {
+        assert_eq!(d(cdn), 0, "{cdn} should be suppressed");
+    }
+
+    // trailing FQDN dot is stripped before the suffix check (trim_end_matches)
+    assert_eq!(d("d2hk78xq2k.cloudfront.net."), 0);
+    // case-insensitive: uppercased CDN domain still suppressed
+    assert_eq!(d("D2HK78XQ.CLOUDFRONT.NET"), 0);
+    // empty / dot-only domains are clean
+    assert_eq!(d(""), 0);
+    assert_eq!(d("."), 0);
+}
diff --git a/typed/dga.ss b/typed/dga.ss
index 9ff8376..8598dd4 100644
--- a/typed/dga.ss
+++ b/typed/dga.ss
@@ -4,7 +4,9 @@
 ;;; 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 score-label)
+  (export max-consonant-run shannon-entropy score-label score-domain)
+  ;; the Bytes-based &str toolkit (to_ascii_lowercase, ends_with, split('.'))
+  (import (jsecmon strbytes))
 
   ;; 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
@@ -104,4 +106,51 @@
               (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)))))))))))
+                    (if (> s4 100) 100 s4))))))))))
+
+  ;; copy bs[0..len] into a fresh buffer (sub-slice without mutation).
+  (def (sub-bytes (bs : Bytes) (len : Nat)) : Bytes
+    (bytes-build len (i (bytevector-u8-ref bs i))))
+
+  ;; length of bs with all trailing '.' (46) stripped: str::trim_end_matches('.').
+  (def (rtrim-dot-len (bs : Bytes) (len : Nat)) : Nat
+    (if (and (> len 0) (= (bytevector-u8-ref bs (- len 1)) 46))
+        (rtrim-dot-len bs (- len 1))
+        len))
+
+  ;; #t iff d ends with any benign high-entropy CDN/cloud suffix. These are
+  ;; deliberately high-entropy and force a clean verdict; this is exactly
+  ;; dga::BENIGN_HIGH_ENTROPY_SUFFIXES. Uses the imported bytes-suffix?.
+  (def (benign-suffix? (d : Bytes)) : Bool
+    (or (bytes-suffix? d (string->utf8 ".cloudfront.net"))
+        (or (bytes-suffix? d (string->utf8 ".amazonaws.com"))
+            (or (bytes-suffix? d (string->utf8 ".s3.amazonaws.com"))
+                (or (bytes-suffix? d (string->utf8 ".azureedge.net"))
+                    (or (bytes-suffix? d (string->utf8 ".akamai.net"))
+                        (or (bytes-suffix? d (string->utf8 ".akamaized.net"))
+                            (or (bytes-suffix? d (string->utf8 ".fastly.net"))
+                                (or (bytes-suffix? d (string->utf8 ".cdn.cloudflare.net"))
+                                    (or (bytes-suffix? d (string->utf8 ".googleusercontent.com"))
+                                        (or (bytes-suffix? d (string->utf8 ".appspot.com"))
+                                            (or (bytes-suffix? d (string->utf8 ".herokuapp.com"))
+                                                (or (bytes-suffix? d (string->utf8 ".github.io"))
+                                                    (or (bytes-suffix? d (string->utf8 ".azurewebsites.net"))
+                                                        (or (bytes-suffix? d (string->utf8 ".windows.net"))
+                                                            (or (bytes-suffix? d (string->utf8 ".bing.net"))
+                                                                (or (bytes-suffix? d (string->utf8 ".trafficmanager.net"))
+                                                                    (bytes-suffix? d (string->utf8 ".azure-api.net")))))))))))))))))))
+
+  ;; Full dga::score_domain headline: lowercase, strip a trailing FQDN dot,
+  ;; suppress benign CDN suffixes, then score the leftmost label. Returns the
+  ;; 0..100 score (>= 60 fires). The DgaVerdict's diagnostic fields (entropy,
+  ;; reasons, ...) are recoverable from the kernels above; here we return the
+  ;; headline that drives the detection.
+  (def (score-domain (domain : String)) : Nat
+    (let ((lowered (ascii-lower-bytes domain)))
+      (let ((trimmed (sub-bytes lowered (rtrim-dot-len lowered (bytevector-length lowered)))))
+        (if (benign-suffix? trimmed)
+            0
+            (let ((label-len (first-label-len trimmed)))
+              (if (= label-len 0)
+                  0
+                  (score-label (utf8->string (sub-bytes trimmed label-len))))))))))