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>
190 lines
6.1 KiB
Rust
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");
|
|
}
|
|
}
|
|
}
|