Antivirus/tools/refresh-definitions.sh
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

183 lines
7.5 KiB
Bash
Executable file

#!/usr/bin/env bash
#
# Rebuild the definition feed from OSV and publish it — incrementally.
#
# The old version rebuilt the full per-ecosystem pack every night, so its
# sha256 changed daily and every client re-downloaded the whole 44 MB npm
# pack even when a handful of records had been added. This version publishes
# an immutable baseline plus small daily deltas: the client (which already
# fetches only packs whose sha256 it lacks) downloads the baseline once and
# then kilobytes a day. See crates/hound-defs/examples/build-delta.rs.
#
# Publishing is atomic per pack: each is written to a temporary name in the
# destination directory and renamed into place, so an agent fetching mid-run
# never sees a half-written pack. index.json is written last, because it is
# what tells an agent a pack exists — writing it first would advertise files
# that are not there yet.
#
# When build-delta decides to REBASELINE an ecosystem (no baseline yet, or
# too many deltas piled up), every existing file for that ecosystem is moved
# to archive/ before the fresh baseline lands; the index then lists only the
# new baseline, and clients delete their now-unlisted copies. That is what
# keeps both the server directory and every client's defs dir from growing
# without bound.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
KEY="${HOUND_DEFS_KEY:-$HOME/agents/hound/.secrets/defs-signing.key}"
DEST="${HOUND_DEFS_DIR:-/srv/houndav/defs}"
WORK="${HOUND_DEFS_WORK:-/var/tmp/hound-defs}"
VERSION="$(date -u +%Y.%m.%d)"
CREATED="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
log() { printf '%s %s\n' "$(date -u +%H:%M:%S)" "$*"; }
[ -f "$KEY" ] || { echo "no signing key at $KEY" >&2; exit 1; }
[ -d "$DEST" ] || { echo "no destination directory $DEST" >&2; exit 1; }
mkdir -p "$DEST/archive"
mkdir -p "$WORK"
log "refreshing definitions for $VERSION (incremental)"
# The OSV exports are large and change slowly; a failure to fetch one
# ecosystem must not discard the others.
python3 "$ROOT/tools/ingest-osv.py" "$WORK"
BUILDER="$ROOT/target/release/examples/build-delta"
COMMUNITY_BUILDER="$ROOT/target/release/examples/build-community"
if [ ! -x "$BUILDER" ] || [ ! -x "$COMMUNITY_BUILDER" ]; then
log "building the definition builders"
( cd "$ROOT" && cargo build --release -p hound-defs --example build-delta --example build-community )
fi
# Outside $WORK on purpose: the loop below treats every directory in $WORK as
# an ecosystem, and a staging directory in there gets built into a pack named
# after the mktemp suffix. Which is exactly what happened the first time.
STAGE="$(mktemp -d "${TMPDIR:-/var/tmp}/hound-defs-stage.XXXXXX")"
trap 'rm -rf "$STAGE"' EXIT
changed=0
for dir in "$WORK"/*/; do
eco="$(basename "$dir")"
# Lower-case, and '.' is not wanted in a filename component.
name="$(echo "$eco" | tr '[:upper:]' '[:lower:]' | tr '.' '-')"
case "$name" in
stage*|.*) log "$eco: not an ecosystem, skipping"; continue ;;
esac
count="$(find "$dir" -maxdepth 1 -name '*.json' | wc -l)"
if [ "$count" -eq 0 ]; then
log "$eco: no malicious records, skipping"
continue
fi
# build-delta reads the published dir, decides, and writes to staging.
decision="$("$BUILDER" "$dir" "$DEST" "$name" "$STAGE" "$KEY" "$VERSION" "$CREATED" 2>&1)" || {
log "$eco: BUILD FAILED — keeping the previous pack(s): $decision"
continue
}
log "$eco: $decision"
case "$decision" in
REBASELINE*)
# Retire every existing file for this ecosystem, then install the
# fresh baseline. Matches '<name>-<digit>' so 'go' never touches
# another ecosystem's files.
for old in "$DEST/${name}-"[0-9]*.pack; do
[ -e "$old" ] && mv -f "$old" "$DEST/archive/"
done
changed=1 ;;
DELTA*)
changed=1 ;;
UNCHANGED*)
: ;;
esac
done
# Install whatever landed in staging (baselines and deltas), atomically.
for pack in "$STAGE"/*.pack; do
[ -e "$pack" ] || continue
base="$(basename "$pack")"
cp "$pack" "$DEST/.$base.tmp"
chmod 644 "$DEST/.$base.tmp"
mv -f "$DEST/.$base.tmp" "$DEST/$base"
done
# Rebuild the free community pack from the freshly-published feed: a recent
# subset a Free install can use. Rebuilt only when the feed actually changed
# — Pro's value is DAILY freshness, so the free snapshot lags on purpose.
if [ "$changed" -eq 1 ]; then
cpack="$STAGE/community-${VERSION}.pack"
if "$COMMUNITY_BUILDER" "$DEST" "$cpack" "$KEY" "$VERSION" "$CREATED" "${HOUND_COMMUNITY_LIMIT:-5000}"; then
for old in "$DEST"/community-*.pack; do
[ -e "$old" ] && mv -f "$old" "$DEST/archive/"
done
base="$(basename "$cpack")"
cp "$cpack" "$DEST/.$base.tmp"; chmod 644 "$DEST/.$base.tmp"; mv -f "$DEST/.$base.tmp" "$DEST/$base"
log "community: published $base"
else
log "community: build failed — keeping the previous community pack"
fi
fi
# Never publish an empty feed: if nothing changed AND the destination has no
# packs at all, something is wrong — leave whatever is there untouched.
if [ "$changed" -eq 0 ] && [ -z "$(find "$DEST" -maxdepth 1 -name '*.pack' -print -quit)" ]; then
log "nothing to publish and no existing feed; leaving it untouched"
exit 1
fi
if [ "$changed" -eq 0 ]; then
log "no ecosystem changed today; feed already current, refreshing index only"
fi
python3 - "$DEST" <<'PY'
import base64, hashlib, json, os, sys
dest = sys.argv[1]
# List EVERY current pack in the served directory — every baseline and every
# delta. The client fetches whatever it is missing and deletes what the index
# stops listing, so "current" is exactly "present here" (archive/ is a
# subdirectory and is not walked). The version comes from inside the signed
# payload rather than from the filename, so the delta naming
# (npm-2026.08.22.delta.pack) needs no special parsing.
def entry(f):
p = os.path.join(dest, f)
raw = open(p, "rb").read()
try:
version = json.loads(base64.b64decode(json.loads(raw)["payload"])).get("version", "")
except Exception:
version = ""
return {"file": f, "sha256": hashlib.sha256(raw).hexdigest(),
"size": len(raw), "version": version}
# Route by name: community-*.pack is the free channel; every other .pack is
# the full Pro feed; .rpack is the curated threat pack (Pro).
packs, community, rules = [], [], []
for f in sorted(os.listdir(dest)):
if not os.path.isfile(os.path.join(dest, f)):
continue
if f.endswith(".rpack"):
rules.append(entry(f))
elif f.startswith("community-") and f.endswith(".pack"):
community.append(entry(f))
elif f.endswith(".pack"):
packs.append(entry(f))
tmp = os.path.join(dest, ".index.json.tmp")
with open(tmp, "w") as fh:
json.dump({"packs": packs, "community": community, "rules": rules}, fh, indent=2)
fh.write("\n")
os.chmod(tmp, 0o644)
os.replace(tmp, os.path.join(dest, "index.json"))
print(f"index.json: {len(packs)} feed pack(s), {len(community)} community pack(s), {len(rules)} rules pack(s)")
PY
log "definitions refresh complete for $VERSION"
# Clear the extracted records. They are the bulk of the scratch — 1.2 GB
# after a single run — and the daily timer regenerates them every time. The
# downloaded zips stay, because ingest-osv.py reuses them and fetching is the
# slow part; the JSON extracted from them costs seconds to rebuild and is
# pure waste to keep. Left alone, a nightly job quietly fills the disk of the
# machine that publishes a security feed.
find "$WORK" -mindepth 1 -maxdepth 1 -type d -exec rm -rf {} +
log "scratch now $(du -sh "$WORK" 2>/dev/null | cut -f1)"