jsecmon: port dga score_domain scoring core (score-label)
Jaime Fournier <jaimef@linbsd.org>
83b8c5e67d0541dfb69c67f685808f48bc94eafe
--- 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 | --- 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); +} --- 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)))))))))))