Antivirus/crates/houndd/examples/build-rules-pack.rs
dev 79ea89713e Phase 1: license enforcement, threat pack, Apache-2.0, real Action verification
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>
2026-08-21 15:51:33 -05:00

138 lines
5.3 KiB
Rust

//! Build, gate and sign the Hound Linux threat pack.
//!
//! build-rules-pack <rules.yar> <name> <out.rpack> <key-file> <version> [created]
//!
//! Three things happen, in this order, and any one of them failing aborts
//! the build:
//!
//! 1. **Compile.** The YARA source has to compile under the exact
//! engine the agent runs (yara-x), or a broken pack would take down
//! every agent that loaded it — the daemon skips a pack it cannot
//! compile, so a bad pack is a silent loss of detection.
//!
//! 2. **Goodware gate.** Every rule is scanned against /usr/bin, /bin
//! and /usr/sbin on this machine. One hit on a system binary and the
//! build fails — a rule that quarantines a real binary is worse than
//! no rule at all, and the whole product dies the first time it eats
//! somebody's `sudo`. This is the same gate as the built-in pack's
//! unit test, run here before anything is signed.
//!
//! 3. **Sign.** Only a pack that compiled and passed the gate is wrapped
//! in the signed envelope and written out, so nothing unverifiable or
//! untested ever reaches the feed.
//!
//! The signing key is loaded from the key file (never generated here — a
//! threat pack signed by a throwaway key is a threat pack no agent trusts).
use ed25519_dalek::SigningKey;
use hound_defs::{pack, RulesPack};
fn die(msg: impl std::fmt::Display) -> ! {
eprintln!("build-rules-pack: {msg}");
std::process::exit(1);
}
fn load_key(path: &str) -> SigningKey {
let bytes = std::fs::read(path).unwrap_or_else(|e| die(format!("reading key {path}: {e}")));
let seed: [u8; 32] = bytes
.get(..32)
.and_then(|s| s.try_into().ok())
.unwrap_or_else(|| die(format!("{path} is not at least a 32-byte key")));
SigningKey::from_bytes(&seed)
}
/// Compile the pack together with the built-in rules, exactly as the
/// daemon does, and return the compiled ruleset.
fn compile(yara: &str) -> yara_x::Rules {
let mut compiler = yara_x::Compiler::new();
compiler
.add_source(yara_x::SourceCode::from(yara).with_origin("hound-linux.yar"))
.unwrap_or_else(|e| die(format!("the pack does not compile:\n{e}")));
compiler.build()
}
/// Scan the system binaries and abort on any match. Returns how many
/// binaries were checked, so the caller can insist the gate was meaningful.
fn goodware_gate(rules: &yara_x::Rules) -> usize {
let mut scanner = yara_x::Scanner::new(rules);
let mut checked = 0usize;
let mut failures: Vec<String> = Vec::new();
for dir in ["/usr/bin", "/bin", "/usr/sbin", "/usr/lib", "/lib"] {
let Ok(entries) = std::fs::read_dir(dir) else { continue };
for entry in entries.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 !failures.is_empty() {
die(format!(
"GOODWARE GATE FAILED — {} false positive(s) on system files:\n {}",
failures.len(),
failures.join("\n ")
));
}
checked
}
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.len() < 5 {
die("usage: build-rules-pack <rules.yar> <name> <out.rpack> <key-file> <version> [created]");
}
let (src_path, name, out, key_path, version) =
(&args[0], &args[1], &args[2], &args[3], &args[4]);
let created = args
.get(5)
.cloned()
.unwrap_or_else(|| "1970-01-01T00:00:00Z".into());
if name.is_empty()
|| name.len() > 64
|| !name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
{
die(format!("pack name {name:?} must be a plain [A-Za-z0-9_-] basename"));
}
let yara = std::fs::read_to_string(src_path)
.unwrap_or_else(|e| die(format!("reading {src_path}: {e}")));
let rules = compile(&yara);
let rule_count = rules.iter().count();
eprintln!("compiled {rule_count} rule(s)");
let checked = goodware_gate(&rules);
if checked < 200 {
die(format!(
"goodware gate only saw {checked} binaries — run this on a real system so the gate means something"
));
}
eprintln!("goodware gate: {checked} system files scanned, 0 false positives");
let rp = RulesPack {
version: version.clone(),
created,
name: name.clone(),
yara,
};
let payload = serde_json::to_vec(&rp).unwrap_or_else(|e| die(format!("encoding: {e}")));
let key = load_key(key_path);
let key_id = std::env::var("HOUND_KEY_ID").unwrap_or_else(|_| "hound-2026".into());
let signed = pack::sign_bytes(payload, &key, &key_id);
std::fs::write(out, serde_json::to_string(&signed).unwrap())
.unwrap_or_else(|e| die(format!("writing {out}: {e}")));
eprintln!("wrote {out}{rule_count} rules, version {version}, signed by {key_id}");
}