jsecmon: complete dga::score_domain end-to-end (multi-module)
Jaime Fournier <jaimef@linbsd.org>
08f621fed149c73bd1b1f4e53379b5b18826ad57
--- 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 | --- 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); +} --- 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))))))))))