jsecmon: port dga::shannon_entropy (nested for/fold + f64 math)
Jaime Fournier <jaimef@linbsd.org>
1e0c552d59bf60b4f1749ece6d173dffcbaffb49
diff --git a/README.md b/README.md
index 0df11c5..291e3eb 100644
--- a/README.md
+++ b/README.md
@@ -34,8 +34,8 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage).
| secmon module | jsecmon | status |
|--------------------------|--------------------|---------------------------------|
| `dga::max_consonant_run` | `typed/dga.ss` | ✅ ported, vectors pass |
-| `dga::shannon_entropy` | `typed/dga.ss` | ⏳ needs backend f64 `log2` |
-| `dga::score_domain` | `typed/dga.ss` | ⏳ after entropy + string ops |
+| `dga::shannon_entropy` | `typed/dga.ss` | ✅ ported, vectors pass |
+| `dga::score_domain` | `typed/dga.ss` | ⏳ needs string ops (lowercase, split, ends_with, char classes) |
| `lolbin`, `sigma`, `triage` | — | ⏳ pure logic, queued |
| `crypto::{psk,ecies}` | — | ⏳ FFI-delegated; orchestration only |
| monitors / server / storage / ebpf / dtrace | — | ⏳ I/O+async+FFI, last |
diff --git a/tests/dga_vectors.rs b/tests/dga_vectors.rs
index 9ca9141..27cc18a 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;
+use jerboa_typed_generated::jsecmon_dga::{max_consonant_run, shannon_entropy};
#[test]
fn consonant_run_matches_secmon_vectors() {
@@ -15,3 +15,14 @@ fn consonant_run_matches_secmon_vectors() {
// uppercase folds the same as lowercase
assert_eq!(max_consonant_run("STRENGTHS".to_string()), 5);
}
+
+#[test]
+fn shannon_entropy_matches_secmon_vectors() {
+ // secmon's dga::tests::shannon_entropy_baseline
+ assert!(shannon_entropy("aaaaaa".to_string()) < 0.1);
+ assert!(shannon_entropy("abcdef".to_string()) > 2.5);
+ // empty string is 0.0 (no special-casing needed)
+ assert_eq!(shannon_entropy("".to_string()), 0.0);
+ // 6 distinct symbols, each once → exactly log2(6) ≈ 2.585
+ assert!((shannon_entropy("abcdef".to_string()) - 6f64.log2()).abs() < 1e-9);
+}
diff --git a/typed/dga.ss b/typed/dga.ss
index c399170..44fc2da 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)
+ (export max-consonant-run shannon-entropy)
;; 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
@@ -38,4 +38,27 @@
(if (ascii-consonant? (bytevector-u8-ref bs i))
(let ((r (+ (CrState-run st) 1)))
(make-CrState r (if (> r (CrState-best st)) r (CrState-best st))))
- (make-CrState 0 (CrState-best st))))))))
+ (make-CrState 0 (CrState-best st)))))))
+
+ ;; How many of the first n bytes of bs equal the byte value v. The inner
+ ;; loop of the entropy histogram, written as its own for/fold so the outer
+ ;; loop stays a single-accumulator fold.
+ (def (count-byte (bs : Bytes) (v : Nat) (n : Nat)) : Nat
+ (for/fold ((c 0)) ((i (in-range n)))
+ (if (= (bytevector-u8-ref bs i) v)
+ (+ c 1)
+ c)))
+
+ ;; Shannon entropy in bits/char: -Σ p·log2(p) over the byte-value histogram.
+ ;; Rather than a mutable 256-entry counts array, we sweep each possible byte
+ ;; value and count its occurrences (labels are short, so 256·n is trivial).
+ ;; The empty string yields 0.0 for free: every count is 0, so no term fires.
+ (def (shannon-entropy (s : String)) : Float
+ (let ((bs (string->utf8 s)))
+ (let ((n (bytevector-length bs)))
+ (for/fold ((ent 0.0)) ((v (in-range 256)))
+ (let ((c (count-byte bs v n)))
+ (if (> c 0)
+ (let ((p (/ (exact->inexact c) (exact->inexact n))))
+ (- ent (* p (log2 p))))
+ ent)))))))