Antivirus/crates/hound-defs/examples/build-pack.rs
Hound 42cd97d59f 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>
2026-08-21 07:29:47 -05:00

105 lines
3.8 KiB
Rust

//! 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}");
}