Antivirus/crates/hound-defs/src/index.rs
Hound 3be16eeef9 hound-defs: OSV ingest, the IOC index, and signed packs
Phase 3's foundation. Three jobs that share a data model:

  osv    parse the ossf/malicious-packages feed (Apache-2.0, ~226k
         records, daily) into indicators
  index  answer "is this package known bad?" fast enough to ask it
         thousands of times per project sweep
  pack   sign a definitions pack, and verify one before loading it

Validated against the real feed rather than fixtures: the whole
crates.io OSV export, 2,749 records, parsed with zero failures — 3,885
indicators across ten ecosystems, 19 of them MAL-. `rustdecimal` (the
real typosquat of rust_decimal) resolves in crates.io and stays clean
in npm and PyPI, which is the ecosystem isolation working.

Notes on the three:

* A malicious-package record is not a vulnerability record. It almost
  always carries introduced:"0" with no fix, meaning EVERY version is
  malicious — the package exists only to be malware, so there is no safe
  version to upgrade to. Conflating that with a version-bounded
  vulnerability either misses real hits or condemns safe versions of
  legitimate packages, so the two are modelled separately.

* The index is a cuckoo filter in front of a map. Cuckoo rather than
  bloom specifically because a definitions feed needs DELETION: OSV
  withdraws records — it once withdrew 157 malware reports after a
  false-positive incident — and a filter you cannot remove from means a
  withdrawn record costs a probe forever or forces a rebuild. 226k
  indicators fit in under 4 MB; the crates.io set is 8 KB.

  The property that must never break is no false negatives, and it has
  its own test. A false positive costs a hash lookup; a false negative
  is malware reported as clean. That is also why a fingerprint hashing
  to zero is nudged to one — zero marks an empty slot, so without the
  nudge one key in 65,536 would silently vanish.

* Signing is not about entitlement; the subscription gates the server.
  It answers "is this pack really from us?", because someone who can
  substitute a definitions file can add an entry for /usr/bin/sudo and
  have Hound quarantine it on every machine that updates — a supply
  chain attack delivered through the security product. Verification
  happens on the raw bytes BEFORE anything parses them, so a hostile
  pack never reaches the parser at all.

256 tests pass across the workspace.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 07:06:00 -05:00

413 lines
14 KiB
Rust

//! The indicator index, and the cuckoo filter in front of it.
//!
//! Checking a project means asking "is this package known bad?" once per
//! dependency, and a real `node_modules` has thousands. Almost every
//! answer is no, so the structure should be optimised for saying no
//! quickly rather than for saying yes well.
//!
//! A cuckoo filter does that: a few hundred kilobytes answers "definitely
//! not in the set" for the overwhelming majority of lookups without ever
//! touching the full index. Only a positive — real or occasional false —
//! costs a map probe, and the map confirms it.
//!
//! Why cuckoo rather than bloom: a cuckoo filter supports **deletion**,
//! which a definitions feed genuinely needs. OSV withdraws records — it
//! once withdrew 157 malware reports after a false-positive incident —
//! and a filter you cannot remove from means a withdrawn record keeps
//! costing a map probe forever, or the whole structure has to be rebuilt.
//!
//! The one property that must never break is **no false negatives**. A
//! false positive costs a hash-map lookup. A false negative is a piece of
//! malware the scanner said was clean.
use crate::osv::{key_for, Indicator};
use std::collections::HashMap;
/// Slots per bucket. Four is the standard choice: enough that the table
/// fills to ~95% before insertion starts failing, small enough that a
/// lookup stays inside one cache line.
const SLOTS: usize = 4;
/// How many times an insert may evict a resident before giving up.
const MAX_KICKS: usize = 500;
/// A cuckoo filter over 16-bit fingerprints.
#[derive(Debug, Clone)]
pub struct CuckooFilter {
buckets: Vec<[u16; SLOTS]>,
mask: usize,
len: usize,
}
impl CuckooFilter {
/// Size for an expected item count. Rounded up to a power of two,
/// with headroom so insertion does not start failing near the end.
pub fn with_capacity(expected: usize) -> Self {
let needed = (expected.max(1) * 2 / SLOTS).max(16);
let buckets = needed.next_power_of_two();
Self {
buckets: vec![[0u16; SLOTS]; buckets],
mask: buckets - 1,
len: 0,
}
}
pub fn len(&self) -> usize {
self.len
}
pub fn is_empty(&self) -> bool {
self.len == 0
}
/// Bytes of memory the filter occupies.
pub fn size_bytes(&self) -> usize {
self.buckets.len() * SLOTS * std::mem::size_of::<u16>()
}
/// A non-zero 16-bit fingerprint, plus the primary bucket.
///
/// Zero is reserved to mean "empty slot", so a fingerprint that hashes
/// to zero is nudged to one. Skipping that produces a filter where one
/// key in 65,536 silently fails to store — a false negative.
fn fingerprint_and_bucket(&self, key: &str) -> (u16, usize) {
let h = fnv1a(key.as_bytes());
let mut fp = (h >> 32) as u16;
if fp == 0 {
fp = 1;
}
((fp), (h as usize) & self.mask)
}
/// The partner bucket, derived from the fingerprint alone.
///
/// This is what makes the filter work without storing keys: from
/// either bucket and the fingerprint, the other bucket is computable.
fn alt_bucket(&self, bucket: usize, fp: u16) -> usize {
(bucket ^ (fnv1a(&fp.to_le_bytes()) as usize)) & self.mask
}
pub fn insert(&mut self, key: &str) -> bool {
let (fp, b1) = self.fingerprint_and_bucket(key);
let b2 = self.alt_bucket(b1, fp);
for b in [b1, b2] {
if let Some(slot) = self.buckets[b].iter().position(|&s| s == 0) {
self.buckets[b][slot] = fp;
self.len += 1;
return true;
}
}
// Both full: evict a resident and rehome it. The victim slot is
// chosen deterministically from the fingerprint so the structure
// is reproducible — a definitions pack must build identically on
// every machine or its hash is not a version.
let mut bucket = b2;
let mut carried = fp;
for kick in 0..MAX_KICKS {
let slot = (fnv1a(&[carried.to_le_bytes(), (kick as u16).to_le_bytes()].concat())
as usize)
% SLOTS;
std::mem::swap(&mut carried, &mut self.buckets[bucket][slot]);
bucket = self.alt_bucket(bucket, carried);
if let Some(free) = self.buckets[bucket].iter().position(|&s| s == 0) {
self.buckets[bucket][free] = carried;
self.len += 1;
return true;
}
}
false
}
/// True when the key *may* be present. False means definitely absent.
pub fn contains(&self, key: &str) -> bool {
let (fp, b1) = self.fingerprint_and_bucket(key);
if self.buckets[b1].contains(&fp) {
return true;
}
let b2 = self.alt_bucket(b1, fp);
self.buckets[b2].contains(&fp)
}
/// Remove a key. Only ever call this for a key known to be present —
/// removing a fingerprint that belongs to a different key would create
/// a false negative for that other key.
pub fn remove(&mut self, key: &str) -> bool {
let (fp, b1) = self.fingerprint_and_bucket(key);
let b2 = self.alt_bucket(b1, fp);
for b in [b1, b2] {
if let Some(slot) = self.buckets[b].iter().position(|&s| s == fp) {
self.buckets[b][slot] = 0;
self.len -= 1;
return true;
}
}
false
}
}
/// FNV-1a, 64-bit. Not cryptographic and does not need to be: the filter
/// is a performance structure, and every positive is confirmed against the
/// real index before anything is reported.
fn fnv1a(data: &[u8]) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in data {
h ^= *b as u64;
h = h.wrapping_mul(0x100_0000_01b3);
}
h
}
/// Indicators, with the filter in front.
#[derive(Debug, Default)]
pub struct Index {
filter: Option<CuckooFilter>,
entries: HashMap<String, Vec<Indicator>>,
}
impl Index {
pub fn new() -> Self {
Self::default()
}
/// Build from a set of indicators.
pub fn build(indicators: Vec<Indicator>) -> Self {
let mut entries: HashMap<String, Vec<Indicator>> = HashMap::new();
for ind in indicators {
entries.entry(ind.key()).or_default().push(ind);
}
let mut filter = CuckooFilter::with_capacity(entries.len());
for key in entries.keys() {
// A filter that failed to store a key would produce a false
// negative, so a failed insert abandons the filter rather than
// shipping one that lies. The map still answers correctly.
if !filter.insert(key) {
return Self { filter: None, entries };
}
}
Self { filter: Some(filter), entries }
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn filter_bytes(&self) -> usize {
self.filter.as_ref().map(|f| f.size_bytes()).unwrap_or(0)
}
/// Look up a package. `None` for clean.
///
/// The filter short-circuits the common case without touching the map.
pub fn lookup(&self, ecosystem: &str, name: &str, version: &str) -> Option<&Indicator> {
let key = key_for(ecosystem, name);
if let Some(f) = &self.filter {
if !f.contains(&key) {
return None;
}
}
self.entries
.get(&key)?
.iter()
.find(|i| i.versions.covers(version))
}
/// Whether any version of a package is known bad, regardless of the
/// version in hand. Used when a lockfile does not pin one.
pub fn any_version(&self, ecosystem: &str, name: &str) -> Option<&Indicator> {
let key = key_for(ecosystem, name);
if let Some(f) = &self.filter {
if !f.contains(&key) {
return None;
}
}
self.entries.get(&key)?.first()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::osv::Versions;
fn ind(eco: &str, name: &str, versions: Versions) -> Indicator {
Indicator {
ecosystem: eco.into(),
name: name.into(),
versions,
id: "MAL-2024-1339".into(),
summary: "Malicious code".into(),
}
}
// ── the property that must never break ──
#[test]
fn the_filter_never_produces_a_false_negative() {
// A false positive costs a hash lookup. A false negative is
// malware reported as clean. Every inserted key must be found.
let mut f = CuckooFilter::with_capacity(20_000);
let keys: Vec<String> = (0..20_000).map(|i| format!("npm:package-{i}")).collect();
for k in &keys {
assert!(f.insert(k), "insert failed at {k}");
}
for k in &keys {
assert!(f.contains(k), "false negative for {k}");
}
assert_eq!(f.len(), 20_000);
}
#[test]
fn a_fingerprint_of_zero_is_still_stored() {
// Zero marks an empty slot, so a key whose fingerprint hashes to
// zero would silently vanish. Exercised across a wide key space
// because it is rare by construction.
let mut f = CuckooFilter::with_capacity(70_000);
let keys: Vec<String> = (0..65_600).map(|i| format!("k{i}")).collect();
for k in &keys {
f.insert(k);
}
let missing: Vec<&String> = keys.iter().filter(|k| !f.contains(k)).collect();
assert!(missing.is_empty(), "{} keys vanished", missing.len());
}
#[test]
fn the_false_positive_rate_is_low_enough_to_be_worth_it() {
let mut f = CuckooFilter::with_capacity(10_000);
for i in 0..10_000 {
f.insert(&format!("npm:real-{i}"));
}
let probes = 100_000;
let fp = (0..probes)
.filter(|i| f.contains(&format!("npm:absent-{i}")))
.count();
let rate = fp as f64 / probes as f64;
assert!(rate < 0.01, "false-positive rate {rate:.4} is too high to save work");
}
#[test]
fn removal_works_so_withdrawn_records_can_be_dropped() {
// OSV withdraws records. Without deletion, a withdrawn indicator
// costs a map probe forever or forces a full rebuild.
let mut f = CuckooFilter::with_capacity(100);
f.insert("npm:withdrawn");
assert!(f.contains("npm:withdrawn"));
assert!(f.remove("npm:withdrawn"));
assert!(!f.contains("npm:withdrawn"));
assert_eq!(f.len(), 0);
}
#[test]
fn building_the_filter_is_deterministic() {
// A pack's hash is its version. If the same input produced a
// different filter on two machines, the hash would be meaningless.
let keys: Vec<String> = (0..5_000).map(|i| format!("pypi:pkg{i}")).collect();
let build = || {
let mut f = CuckooFilter::with_capacity(5_000);
for k in &keys {
f.insert(k);
}
f.buckets.clone()
};
assert_eq!(build(), build());
}
#[test]
fn the_filter_is_small() {
let f = CuckooFilter::with_capacity(226_000);
let mb = f.size_bytes() as f64 / (1024.0 * 1024.0);
assert!(mb < 4.0, "226k indicators should fit in a few MB, got {mb:.1} MB");
}
// ── the index ──
#[test]
fn finds_a_known_malicious_package() {
let idx = Index::build(vec![ind("npm", "test-poc2", Versions::All)]);
let hit = idx.lookup("npm", "test-poc2", "1.0.0").expect("should match");
assert_eq!(hit.id, "MAL-2024-1339");
}
#[test]
fn a_clean_package_is_not_reported() {
let idx = Index::build(vec![ind("npm", "test-poc2", Versions::All)]);
assert!(idx.lookup("npm", "react", "18.2.0").is_none());
}
#[test]
fn the_ecosystem_has_to_match() {
// A malicious npm package named "requests" says nothing about the
// PyPI package of the same name.
let idx = Index::build(vec![ind("npm", "requests", Versions::All)]);
assert!(idx.lookup("npm", "requests", "1.0.0").is_some());
assert!(idx.lookup("pypi", "requests", "2.31.0").is_none());
}
#[test]
fn lookups_are_case_insensitive() {
let idx = Index::build(vec![ind("npm", "EvilPkg", Versions::All)]);
assert!(idx.lookup("NPM", "evilpkg", "1.0.0").is_some());
}
#[test]
fn an_exact_version_record_only_matches_that_version() {
let idx = Index::build(vec![ind(
"pypi",
"thing",
Versions::Exact(vec!["1.0.0".into()]),
)]);
assert!(idx.lookup("pypi", "thing", "1.0.0").is_some());
assert!(
idx.lookup("pypi", "thing", "2.0.0").is_none(),
"condemning a version the feed did not is how a scanner loses trust"
);
}
#[test]
fn any_version_ignores_the_version_when_a_lockfile_does_not_pin_one() {
let idx = Index::build(vec![ind(
"pypi",
"thing",
Versions::Exact(vec!["1.0.0".into()]),
)]);
assert!(idx.any_version("pypi", "thing").is_some());
assert!(idx.any_version("pypi", "other").is_none());
}
#[test]
fn several_records_for_one_package_all_stay_reachable() {
let idx = Index::build(vec![
ind("npm", "x", Versions::Exact(vec!["1.0.0".into()])),
ind("npm", "x", Versions::Exact(vec!["2.0.0".into()])),
]);
assert!(idx.lookup("npm", "x", "1.0.0").is_some());
assert!(idx.lookup("npm", "x", "2.0.0").is_some());
assert!(idx.lookup("npm", "x", "3.0.0").is_none());
}
#[test]
fn an_empty_index_answers_cleanly() {
let idx = Index::new();
assert!(idx.is_empty());
assert!(idx.lookup("npm", "anything", "1.0.0").is_none());
}
#[test]
fn a_large_index_still_answers_correctly_through_the_filter() {
let mut inds: Vec<Indicator> = (0..50_000)
.map(|i| ind("npm", &format!("bad-{i}"), Versions::All))
.collect();
inds.push(ind("pypi", "needle", Versions::All));
let idx = Index::build(inds);
assert!(idx.lookup("pypi", "needle", "1.0.0").is_some());
assert!(idx.lookup("npm", "bad-49999", "1.0.0").is_some());
assert!(idx.lookup("npm", "definitely-clean", "1.0.0").is_none());
assert!(idx.filter_bytes() > 0, "the filter should have been built");
}
}