Two changes to how definitions are distributed, sharing one mechanism. Incremental updates. The old nightly rebuilt the full per-ecosystem pack every day, so its hash changed and every client re-downloaded 44 MB of npm daily. Now the server publishes an immutable baseline plus small daily delta packs (crates/hound-defs/examples/build-delta.rs); the client — which already fetches only packs whose sha256 it lacks — pulls the baseline once and then kilobytes a day. When deltas pile up the builder folds them into a fresh baseline and drops the old files from the index; the client prunes whatever the index stops listing, so both the server dir and every client's defs dir stay bounded. Verified end to end: day-2 fetched only the delta (baseline untouched), day-3 rebaseline pruned the superseded packs. Free community tier. Free now gets a recent subset of the public OSV feed (build-community.rs, ~5,000 newest indicators) so a Free install detects current threats out of the box — not just the heuristics. Pro is the full 235k corpus, daily/near-real-time freshness, and the curated threat pack. The client always fetches the community channel and gates the full feed + threat pack on the licence; a lapse prunes both back to exactly what a fresh Free install has — community pack + built-in rules — while leaving any custom .yar the user placed themselves untouched. reload_rules() lets a new threat pack go live without a daemon restart. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
115 lines
4.7 KiB
Rust
115 lines
4.7 KiB
Rust
//! 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 <published-dir> <out.pack> <key> <version> [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<Indicator> {
|
|
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::<pack::SignedPack>(&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<String> = std::env::args().skip(1).collect();
|
|
if a.len() < 4 {
|
|
die("usage: build-community <published-dir> <out.pack> <key> <version> [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());
|
|
}
|