Antivirus/crates/hound-defs/examples/build-community.rs
dev e92e865ff5 defs: incremental delta feed + free community tier (Option C)
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>
2026-08-21 16:22:39 -05:00

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());
}