diff --git a/crates/hound-api/src/lib.rs b/crates/hound-api/src/lib.rs index 09bdabe..7761eee 100644 --- a/crates/hound-api/src/lib.rs +++ b/crates/hound-api/src/lib.rs @@ -108,6 +108,9 @@ pub struct Status { /// Execution-gate state. #[serde(default)] pub gate: GateStatus, + /// Loaded definition packs. + #[serde(default)] + pub defs: DefsStatus, } /// One thing on this machine that can make code run again after a reboot. @@ -155,6 +158,20 @@ pub struct PersistenceReport { pub changes: Vec, } +/// Loaded definitions, for the tray and `hound status`. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct DefsStatus { + /// Feed version, e.g. "2026.08.21". Empty when nothing loaded. + #[serde(default)] + pub version: String, + pub indicators: u64, + pub packs: u64, + /// Why nothing loaded, when nothing did. An operator who believes + /// they have definitions and does not is worse off than one who knows. + #[serde(default)] + pub detail: String, +} + /// Execution-gate state, for the tray and `hound status`. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct GateStatus { diff --git a/crates/hound/src/main.rs b/crates/hound/src/main.rs index 5f5a757..7cd186e 100644 --- a/crates/hound/src/main.rs +++ b/crates/hound/src/main.rs @@ -305,6 +305,24 @@ fn wrap(text: &str, width: usize) -> Vec { lines } +/// Report the loaded definitions. +/// +/// Prints even when nothing is loaded, and says why. Silence would let +/// somebody believe they were protected by a feed they never received. +fn print_defs(d: &hound_api::DefsStatus) { + if d.indicators == 0 { + let why = if d.detail.is_empty() { "none loaded" } else { &d.detail }; + println!(" Defs: {}", why.dimmed()); + return; + } + println!( + " Defs: {} indicators from {} pack(s) [{}]", + d.indicators.to_string().green(), + d.packs, + d.version + ); +} + /// Report the execution gate. /// /// An armed gate is the most consequential thing the daemon is doing, and @@ -383,6 +401,7 @@ fn run(client: &Client, cmd: &Cmd) -> Result { db.file, db.updated_at ); } + print_defs(&st.defs); print_gate(&st.gate); } else { println!( diff --git a/crates/houndd/src/main.rs b/crates/houndd/src/main.rs index d9aa91e..96f9780 100644 --- a/crates/houndd/src/main.rs +++ b/crates/houndd/src/main.rs @@ -676,6 +676,15 @@ fn status(st: &DaemonState) -> Result { realtime: st.realtime.status(), quarantined: st.quarantine.count(), gate: gate_status(st), + defs: { + let d = st.defs.current(); + hound_api::DefsStatus { + version: d.version.clone(), + indicators: d.indicators as u64, + packs: d.packs.len() as u64, + detail: d.detail.clone(), + } + }, }) } diff --git a/dist/hound_0.1.0_amd64.deb b/dist/hound_0.1.0_amd64.deb index 3951412..7b72dd8 100644 Binary files a/dist/hound_0.1.0_amd64.deb and b/dist/hound_0.1.0_amd64.deb differ diff --git a/tools/ingest-osv.py b/tools/ingest-osv.py new file mode 100755 index 0000000..65ef264 --- /dev/null +++ b/tools/ingest-osv.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Fetch OSV ecosystem exports and extract the malicious-package records. + +Hound's index holds malicious packages only, never vulnerability +advisories — they mean opposite things, and conflating them once had +Hound report tokio as malware. See crates/hound-defs/src/osv.rs. + +This tool does the coarse filter so `build-pack` does not have to walk a +quarter of a million files it will discard. The Rust parser still applies +its own classifier, so a record slipping through here changes nothing. + + tools/ingest-osv.py [ecosystem ...] + +Leaves //*.json ready for build-pack. +""" +import json +import os +import sys +import urllib.request +import zipfile + +BASE = "https://osv-vulnerabilities.storage.googleapis.com" +DEFAULT = ["npm", "PyPI", "crates.io", "RubyGems", "Go", "Packagist"] + + +def is_malicious(record: dict) -> bool: + """Mirror of osv::is_malicious_record in the Rust side.""" + if record.get("id", "").startswith("MAL-"): + return True + if "malicious-packages-origins" in record.get("database_specific", {}): + return True + summary = record.get("summary", "").lower() + return summary.startswith("malicious code in") or summary.startswith("malicious package") + + +def fetch(eco: str, work: str) -> str: + path = os.path.join(work, f"{eco}.zip") + if os.path.exists(path) and os.path.getsize(path) > 0: + print(f"{eco}: using cached {os.path.getsize(path) // 1048576} MB") + return path + url = f"{BASE}/{eco}/all.zip" + print(f"{eco}: fetching {url}") + urllib.request.urlretrieve(url, path) + return path + + +def extract(eco: str, zip_path: str, work: str) -> int: + out_dir = os.path.join(work, eco.replace(".", "-").lower()) + os.makedirs(out_dir, exist_ok=True) + kept = skipped = 0 + with zipfile.ZipFile(zip_path) as z: + for name in z.namelist(): + if not name.endswith(".json"): + continue + raw = z.read(name) + try: + record = json.loads(raw) + except json.JSONDecodeError: + skipped += 1 + continue + if not is_malicious(record): + skipped += 1 + continue + with open(os.path.join(out_dir, os.path.basename(name)), "wb") as f: + f.write(raw) + kept += 1 + print(f"{eco}: {kept} malicious, {skipped} advisories skipped -> {out_dir}") + return kept + + +def main() -> int: + if len(sys.argv) < 2: + print(__doc__) + return 2 + work = sys.argv[1] + ecosystems = sys.argv[2:] or DEFAULT + os.makedirs(work, exist_ok=True) + + total = 0 + for eco in ecosystems: + try: + total += extract(eco, fetch(eco, work), work) + except Exception as e: # a missing export must not stop the rest + print(f"{eco}: skipped ({e})") + print(f"\ntotal malicious records: {total}") + return 0 + + +if __name__ == "__main__": + sys.exit(main())