Antivirus/crates/hound-supply/src/lib.rs
Hound 6beb73771a hound-supply: the supply-chain and agent-era scanner
Phase 4's detection core, as a standalone crate. This is the part with no
competitor on Linux, and deliberately the part with no Linux in it —
file parsing and logic only, no fanotify, no /proc, no eBPF — so the
macOS and Windows port is weeks rather than a second product.

Five detectors, 88 tests:

  pickle        GLOBAL/STACK_GLOBAL walk over .pt/.ckpt/.pkl/.joblib.
                torch.load runs a stack machine; a model file is a
                program and downloading weights is a code-execution
                decision.
  injection     Instructions aimed at a coding agent in CLAUDE.md,
                AGENTS.md, .cursorrules, copilot-instructions.
  installscript preinstall/postinstall hooks that curl|sh, decode and
                run, reach for credentials, or install persistence.
  typosquat     Damerau-Levenshtein against popular names, plus
                slopsquat detection: new + near-zero downloads + one
                edit from something popular is the signature of a name
                a model invented and somebody then registered.
  mcp           Servers fetched unpinned at launch, handed secrets, or
                pointed at $HOME or credential paths.

Wired through `supply.sweep` on the socket and `hound supply-chain
<path>`, which exits 1 on a critical so it drops into CI.

Three things worth recording:

* Scoring is by independent category, not by keyword count. One
  suspicious phrase is a phrase; two categories at once is an attack.
  A file that only says "ignore previous instructions about formatting"
  is a warning, not a critical.

* Proximity matters more than presence. The first version flagged an
  entirely ordinary conventions file, because it mentioned ".env" in
  one paragraph and "prefer small commits" in another. A credential and
  a movement verb now have to appear within a sentence of each other.
  The test that caught it is kept as the regression.

* Pickle call detection has to come out of the opcode walk, not a byte
  search. REDUCE, INST and OBJ are the ASCII letters R, i and o, which
  also occur inside every string the stream carries — searching raw
  bytes finds the o in "os" and reports a call that never happens,
  turning every warning into a critical.

Every finding carries a plain-language explanation and a next step, and
there is a test asserting explanations do not leak rule identifiers or
jargon. The audience includes people who cannot triage a YARA match and
should never be shown one.

Verified against a demo project holding a squatted @vue plugin with a
curl|sh postinstall, a poisoned CLAUDE.md in a vendored repo, an
unpinned MCP server holding a GitHub token, and a pickle calling
os.system — four criticals and one warning, while the legitimate
CLAUDE.md, the real express manifest and the properly-scoped MCP server
beside them stayed clean.

189 tests pass across the workspace.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 23:48:48 -05:00

190 lines
6.1 KiB
Rust

//! 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<String>,
location: impl Into<String>,
explanation: impl Into<String>,
source: &str,
advice: impl Into<String>,
) -> 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<Finding>,
/// 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<String>,
}
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<Finding> = 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");
}
}
}