Antivirus/crates/hound-defs/examples/build-delta.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

182 lines
7.2 KiB
Rust

//! Incremental definition builder: emit a small daily delta instead of a
//! fresh 44 MB pack every night.
//!
//! build-delta <osv-dir> <published-dir> <ecosystem> <staging-dir> <key> <version> [created]
//!
//! The problem it solves: the client already downloads only packs whose
//! sha256 it does not have, but the old nightly rebuilt the full
//! per-ecosystem pack every day, so its hash changed daily and every client
//! re-fetched the whole thing. Here each day's *new* indicators go in a
//! small delta pack; the baseline stays byte-identical between rebuilds, so
//! a client fetches it once and then only the daily deltas (kilobytes).
//!
//! What it does, for one ecosystem:
//! 1. Parse today's full indicator set from the OSV export.
//! 2. Read the currently-published baseline + deltas to learn which
//! indicators the feed already carries.
//! 3. Decide:
//! - no baseline yet, or too many deltas piled up → REBASELINE:
//! write a fresh full baseline (the client re-fetches it once and
//! the old files are dropped from the index, so it self-cleans).
//! - new indicators since yesterday → DELTA: write just those.
//! - nothing new → UNCHANGED: write nothing.
//! 4. Print one decision line for the publish script to act on.
//!
//! It only ever writes into the staging dir and only ever reads the
//! published dir — moving files into place and rebuilding the index is the
//! publish script's job, so this stays a pure, testable computation.
use ed25519_dalek::{SigningKey, VerifyingKey};
use hound_defs::{osv, pack, Indicator, Pack};
use std::collections::BTreeSet;
use std::path::Path;
/// Fold the deltas back into a fresh baseline once this many have piled up,
/// so a new install never has to replay an unbounded chain and the served
/// directory stays small. One baseline plus at most this many deltas.
const REBASELINE_AFTER: usize = 14;
fn die(msg: impl std::fmt::Display) -> ! {
eprintln!("build-delta: {msg}");
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)
}
/// A stable identity for an indicator, matching build-pack's dedup key.
fn key_of(i: &Indicator) -> (String, String, String) {
(i.ecosystem.clone(), i.name.clone(), i.id.clone())
}
/// Every `.pack` in `dir` whose name is `<eco>-<digit>…`, verified against
/// our own key, flattened to the indicator identities they already carry.
/// Also returns how many delta files were seen, for the re-baseline call.
fn published_state(
dir: &Path,
eco: &str,
pubkey: &VerifyingKey,
) -> (BTreeSet<(String, String, String)>, usize, bool) {
let trusted = [("hound-2026", *pubkey)];
let mut known = BTreeSet::new();
let mut deltas = 0usize;
let mut has_baseline = false;
let prefix = format!("{eco}-");
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(&prefix) {
continue;
}
// Guard against "go-" matching "golang-": the char after the prefix
// must start a version (a digit).
if !name[prefix.len()..].starts_with(|c: char| c.is_ascii_digit()) {
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-delta: WARNING ignoring unverifiable {name}");
continue;
};
for ind in p.indicators {
known.insert(key_of(&ind));
}
if name.contains(".delta.pack") {
deltas += 1;
} else {
has_baseline = true;
}
}
(known, deltas, has_baseline)
}
/// Parse today's full indicator set for the ecosystem from its OSV export.
fn todays_indicators(osv_dir: &Path) -> Vec<Indicator> {
let mut out = Vec::new();
for entry in std::fs::read_dir(osv_dir)
.unwrap_or_else(|e| die(format!("reading {}: {e}", osv_dir.display())))
.flatten()
{
let path = entry.path();
if path.extension().is_none_or(|e| e != "json") {
continue;
}
if let Ok(text) = std::fs::read_to_string(&path) {
out.extend(osv::parse_record(&text));
}
}
out.sort_by(|a, b| key_of(a).cmp(&key_of(b)));
out.dedup_by(|a, b| key_of(a) == key_of(b));
out
}
fn write_pack(pack_data: &Pack, key: &SigningKey, out: &Path) {
let key_id = std::env::var("HOUND_KEY_ID").unwrap_or_else(|_| "hound-2026".into());
let signed = pack::sign(pack_data, key, &key_id).unwrap_or_else(|e| die(format!("signing: {e}")));
std::fs::write(out, serde_json::to_string(&signed).unwrap())
.unwrap_or_else(|e| die(format!("writing {}: {e}", out.display())));
}
fn main() {
let a: Vec<String> = std::env::args().skip(1).collect();
if a.len() < 6 {
die("usage: build-delta <osv-dir> <published-dir> <ecosystem> <staging-dir> <key> <version> [created]");
}
let (osv_dir, published, eco, staging, key_path, version) =
(Path::new(&a[0]), Path::new(&a[1]), &a[2], Path::new(&a[3]), &a[4], &a[5]);
let created = a.get(6).cloned().unwrap_or_else(|| "1970-01-01T00:00:00Z".into());
let key = load_key(key_path);
let pubkey = key.verifying_key();
let today = todays_indicators(osv_dir);
if today.is_empty() {
// The ingest produced nothing for this ecosystem; never publish an
// empty pack (a client that installed it would show zero indicators).
println!("UNCHANGED {eco} (no records ingested)");
return;
}
let (known, deltas, has_baseline) = published_state(published, eco, &pubkey);
let sources = vec!["ossf/malicious-packages (Apache-2.0)".to_string(), "osv.dev".to_string()];
let rebaseline = !has_baseline || deltas >= REBASELINE_AFTER;
if rebaseline {
let out = staging.join(format!("{eco}-{version}.pack"));
write_pack(
&Pack { version: version.clone(), created, sources, indicators: today.clone() },
&key,
&out,
);
// The publish script archives every existing {eco}-* file when it
// sees REBASELINE, so the fresh baseline stands alone.
println!("REBASELINE {eco} {} indicators -> {}", today.len(), out.display());
return;
}
let new: Vec<Indicator> = today
.into_iter()
.filter(|i| !known.contains(&key_of(i)))
.collect();
if new.is_empty() {
println!("UNCHANGED {eco} (no new indicators)");
return;
}
let out = staging.join(format!("{eco}-{version}.delta.pack"));
write_pack(
&Pack { version: version.clone(), created, sources, indicators: new.clone() },
&key,
&out,
);
println!("DELTA {eco} {} new indicator(s) -> {}", new.len(), out.display());
}