//! Build the free community pack: a snapshot of the most recent public OSV //! malicious packages, so a Free install detects current threats out of the //! box. //! //! build-community [created] [limit] //! //! This is the free half of the feed. It is deliberately a *recent* subset, //! not the full corpus: Pro is not "access to public data" — anyone can pull //! OSV — it is the full 235k history, the daily/near-real-time freshness, and //! the curated threat pack. The community pack costs us nothing to give away //! (it is public data) and makes Free genuinely useful. //! //! "Recent" is approximated by sorting indicator ids descending: the OSSF //! feed mints `MAL-YYYY-NNNNN`, so the newest advisories sort first. The pack //! is built from the already-published, already-verified feed, so there is a //! single source of truth and no second ingest to drift. use ed25519_dalek::{SigningKey, VerifyingKey}; use hound_defs::{pack, Indicator, Pack}; use std::collections::BTreeSet; use std::path::Path; /// How many indicators the free pack carries. A few thousand recent ones — /// enough to be useful, small enough that the full feed is clearly more. const DEFAULT_LIMIT: usize = 5000; fn die(m: impl std::fmt::Display) -> ! { eprintln!("build-community: {m}"); std::process::exit(1); } fn load_key(path: &str) -> SigningKey { let bytes = std::fs::read(path).unwrap_or_else(|e| die(format!("reading key {path}: {e}"))); let seed: [u8; 32] = bytes .get(..32) .and_then(|s| s.try_into().ok()) .unwrap_or_else(|| die(format!("{path} is not at least a 32-byte key"))); SigningKey::from_bytes(&seed) } /// Every indicator in the published full feed (baselines + deltas), verified /// against our own key. Community packs are excluded so this does not feed on /// itself. fn all_published(dir: &Path, pubkey: &VerifyingKey) -> Vec { let trusted = [("hound-2026", *pubkey)]; let mut seen = BTreeSet::new(); let mut out = Vec::new(); for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() { let path = entry.path(); let Some(name) = path.file_name().and_then(|n| n.to_str()) else { continue }; if !name.ends_with(".pack") || name.starts_with("community-") { continue; } let Ok(text) = std::fs::read_to_string(&path) else { continue }; let Ok(signed) = serde_json::from_str::(&text) else { continue }; let Ok(p) = pack::verify(&signed, &trusted) else { eprintln!("build-community: WARNING ignoring unverifiable {name}"); continue; }; for ind in p.indicators { let key = (ind.ecosystem.clone(), ind.name.clone(), ind.id.clone()); if seen.insert(key) { out.push(ind); } } } out } fn main() { let a: Vec = std::env::args().skip(1).collect(); if a.len() < 4 { die("usage: build-community [created] [limit]"); } let (published, out_path, key_path, version) = (Path::new(&a[0]), &a[1], &a[2], &a[3]); let created = a.get(4).cloned().unwrap_or_else(|| "1970-01-01T00:00:00Z".into()); let limit: usize = a.get(5).and_then(|s| s.parse().ok()).unwrap_or(DEFAULT_LIMIT); let key = load_key(key_path); let mut all = all_published(published, &key.verifying_key()); if all.is_empty() { die("the published feed has no indicators to draw a community pack from"); } // Most recent first (MAL-YYYY-NNNNN sorts by recency descending), then // take the head. A stable secondary sort on the full identity keeps the // output deterministic when ids collide. all.sort_by(|x, y| { y.id.cmp(&x.id).then_with(|| { (&x.ecosystem, &x.name).cmp(&(&y.ecosystem, &y.name)) }) }); all.truncate(limit); // Re-sort into the canonical build order so the pack bytes are stable. all.sort_by(|x, y| { (&x.ecosystem, &x.name, &x.id).cmp(&(&y.ecosystem, &y.name, &y.id)) }); let p = Pack { version: version.clone(), created, sources: vec![ "ossf/malicious-packages (Apache-2.0), recent subset".to_string(), "osv.dev".to_string(), ], indicators: all.clone(), }; let key_id = std::env::var("HOUND_KEY_ID").unwrap_or_else(|_| "hound-2026".into()); let signed = pack::sign(&p, &key, &key_id).unwrap_or_else(|e| die(format!("signing: {e}"))); std::fs::write(out_path, serde_json::to_string(&signed).unwrap()) .unwrap_or_else(|e| die(format!("writing {out_path}: {e}"))); eprintln!("wrote {out_path} — {} recent indicators, version {version}", all.len()); }