//! Supply-chain and agent-era threat detection. //! //! This is the part of Hound with no competitor on Linux. ClamAV's corpus //! is overwhelmingly Windows malware and says nothing about the way //! developer machines actually get compromised in 2026: a malicious //! `postinstall`, a typosquatted package, an MCP server that reads your //! SSH key on startup, a repository carrying instructions aimed at your //! coding agent, a model file whose pickle stream calls `os.system`. //! //! Everything here is deliberately platform-independent — file parsing //! and logic, no fanotify, no `/proc`, no eBPF. That is what makes the //! macOS and Windows port a matter of weeks rather than a second product. //! //! **Every finding must carry a sentence a non-expert can act on.** The //! audience includes people who cannot triage a YARA match and should //! never be shown one. A finding that only a security engineer can read //! is a finding that gets ignored, and an ignored finding is worse than //! none because it also costs trust. pub mod injection; pub mod installscript; pub mod mcp; pub mod pickle; pub mod sweep; pub mod typosquat; use serde::{Deserialize, Serialize}; /// How bad, in the only three grades anyone actually acts on. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Severity { /// Worth knowing, not worth interrupting anyone. Info, /// Suspicious. A human should look before trusting this. Warning, /// Actively malicious behaviour. Do not run this. Critical, } impl Severity { pub fn as_str(&self) -> &'static str { match self { Severity::Info => "info", Severity::Warning => "warning", Severity::Critical => "critical", } } } /// One thing worth telling somebody about. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Finding { /// Machine-readable class, e.g. "typosquat", "pickle-rce". pub kind: String, pub severity: Severity, /// What it is: a package spec, a path, a config key. pub subject: String, /// Where we found it. pub location: String, /// **Plain language, for a human who is not a security engineer.** /// One or two sentences, no jargon, no rule identifiers. pub explanation: String, /// The rule or feed that produced this, for people who do want it. pub source: String, /// What the reader should do next, in their words. pub advice: String, } impl Finding { pub fn new( kind: &str, severity: Severity, subject: impl Into, location: impl Into, explanation: impl Into, source: &str, advice: impl Into, ) -> Self { Self { kind: kind.to_string(), severity, subject: subject.into(), location: location.into(), explanation: explanation.into(), source: source.to_string(), advice: advice.into(), } } } /// A completed sweep. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Report { pub findings: Vec, /// How many files were examined, so an empty report is distinguishable /// from a sweep that never looked at anything. pub examined: u64, pub roots: Vec, } impl Report { pub fn count(&self, severity: Severity) -> usize { self.findings.iter().filter(|f| f.severity == severity).count() } /// Most severe first, so the top of the list is the thing to read. pub fn sorted(mut self) -> Self { self.findings.sort_by(|a, b| b.severity.cmp(&a.severity)); self } pub fn is_clean(&self) -> bool { self.findings.is_empty() } } #[cfg(test)] mod tests { use super::*; #[test] fn severity_orders_by_urgency() { assert!(Severity::Critical > Severity::Warning); assert!(Severity::Warning > Severity::Info); } #[test] fn report_sorts_most_severe_first() { let mut r = Report::default(); r.findings.push(Finding::new( "a", Severity::Info, "s", "l", "e", "src", "do nothing", )); r.findings.push(Finding::new( "b", Severity::Critical, "s", "l", "e", "src", "act now", )); r.findings.push(Finding::new( "c", Severity::Warning, "s", "l", "e", "src", "look", )); let r = r.sorted(); assert_eq!(r.findings[0].severity, Severity::Critical); assert_eq!(r.findings[2].severity, Severity::Info); } #[test] fn counts_by_severity() { let mut r = Report::default(); for sev in [Severity::Critical, Severity::Critical, Severity::Warning] { r.findings.push(Finding::new("k", sev, "s", "l", "e", "src", "a")); } assert_eq!(r.count(Severity::Critical), 2); assert_eq!(r.count(Severity::Warning), 1); assert_eq!(r.count(Severity::Info), 0); assert!(!r.is_clean()); } #[test] fn an_empty_report_is_clean() { assert!(Report::default().is_clean()); } /// The rule that makes this product usable by its actual audience. #[test] fn explanations_avoid_jargon() { // Sampled across every detector, so a new one cannot quietly ship // a rule identifier as its explanation. let samples: Vec = vec![ crate::pickle::scan(b"\x80\x04c__builtin__\neval\n\x85R.", "m.pkl") .into_iter() .next() .expect("pickle detector must produce a finding"), ]; for f in samples { let e = f.explanation.to_lowercase(); for jargon in ["yara", "opcode 0x", "cve-", "regex", "ast node"] { assert!( !e.contains(jargon), "explanation leaks jargon ({jargon}): {}", f.explanation ); } assert!( f.explanation.len() > 40, "explanation is too terse to act on: {}", f.explanation ); assert!(!f.advice.is_empty(), "every finding needs a next step"); } } }