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>
105 lines
3.8 KiB
Rust
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}");
|
|
}
|