Antivirus/tools/publish-rules-pack.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

82 lines
3.2 KiB
Bash
Executable file

#!/usr/bin/env bash
#
# Build, gate, sign and publish the Hound Linux threat pack.
#
# Unlike the definition feed (rebuilt nightly from OSV), the threat pack is
# curated YARA and changes only when a human edits the rules, so this is run
# by hand — or by CI on a change to crates/hound-defs/rules/hound-linux.yar.
#
# The builder refuses to sign a pack that does not compile or that matches a
# system binary (the goodware gate), so a bad edit fails here, not in the
# field. Publishing is atomic per file and the index is rewritten last.
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}"
SRC="${HOUND_RULES_SRC:-$ROOT/crates/hound-defs/rules/hound-linux.yar}"
NAME="hound-linux"
VERSION="${1:-$(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; }
[ -f "$SRC" ] || { echo "no rules source at $SRC" >&2; exit 1; }
[ -d "$DEST" ] || { echo "no destination directory $DEST" >&2; exit 1; }
BUILDER="$ROOT/target/release/examples/build-rules-pack"
if [ ! -x "$BUILDER" ]; then
log "building the rules-pack builder"
( cd "$ROOT" && cargo build --release -p houndd --example build-rules-pack )
fi
STAGE="$(mktemp -d "${TMPDIR:-/var/tmp}/hound-rules-stage.XXXXXX")"
trap 'rm -rf "$STAGE"' EXIT
PACK="$STAGE/${NAME}-${VERSION}.rpack"
# This compiles, runs the goodware gate against this host's binaries, and
# signs — or exits non-zero without writing anything.
"$BUILDER" "$SRC" "$NAME" "$PACK" "$KEY" "$VERSION" "$CREATED"
base="$(basename "$PACK")"
cp "$PACK" "$DEST/.$base.tmp"
chmod 644 "$DEST/.$base.tmp"
mv -f "$DEST/.$base.tmp" "$DEST/$base"
log "published $base"
# Rebuild the index over everything on disk — every definition pack
# (baselines and deltas) and every rules pack. Identical logic to
# refresh-definitions.sh so running either keeps the delta feed intact;
# version comes from inside the signed payload, not the filename.
python3 - "$DEST" <<'PY'
import base64, hashlib, json, os, sys
dest = sys.argv[1]
def entry(f):
raw = open(os.path.join(dest, f), "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}
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, {len(community)} community, {len(rules)} rules pack(s)")
PY
log "done"