NullRabbit/nr-bundle-classifier
0
1"""nr-bundle-classifier — Gradio Space for the NullRabbit bundle v1 classifier.2 3Accepts a user-uploaded bundle directory (zip or extracted), validates it4against the open bundle v1 spec (nr-bundle-spec), runs both V8 (cipher-5agnostic byte-amplification binary detector) and multiclass-folded (9-class6V8-V14+V16 unified detector) inference, and displays:7 8 - bundle metadata (corpus_id, primitive_id if labelled, fidelity_class)9 - V8 binary verdict + score10 - multiclass-folded 9-class softmax with per-class probabilities11 - scoreability + feature-coverage flags12 - any coverage warnings (e.g. pcap-sensitive misclassification risk)13 14Demonstrates the spec → corpus → model → unified-detector path end-to-end15on user-supplied data. The Space is a hosted variant of the operator-16internal demo at github.com/NullRabbitLabs/nr-substrate.17 18License: Apache-2.0. SDK: Gradio.19"""20 21from __future__ import annotations22 23import json24import shutil25import tempfile26import zipfile27from pathlib import Path28from typing import Any29 30import gradio as gr31import joblib32import numpy as np33import pyarrow.parquet as pq34from bundle_spec import BundleManifest35from huggingface_hub import hf_hub_download36 37V8_REPO = "NullRabbit/v8-cipher-agnostic"38MULTICLASS_REPO = "NullRabbit/multiclass-folded"39DATASET_REPO = "NullRabbit/nr-bundles-public"40 41 42_models_cache: dict[str, Any] = {}43 44 45def _load_models() -> tuple[dict, dict]:46 """Lazy-load both models on first inference call."""47 if "v8" not in _models_cache:48 v8_path = hf_hub_download(repo_id=V8_REPO, filename="model.joblib")49 _models_cache["v8"] = joblib.load(v8_path)50 if "multiclass" not in _models_cache:51 mc_path = hf_hub_download(repo_id=MULTICLASS_REPO, filename="model.joblib")52 _models_cache["multiclass"] = joblib.load(mc_path)53 return _models_cache["v8"], _models_cache["multiclass"]54 55 56def _modality_state(bundle_dir: Path) -> tuple[bool, int, bool]:57 responses_path = bundle_dir / "responses.parquet"58 n_resp = 059 has_resp = False60 if responses_path.is_file():61 table = pq.read_table(responses_path)62 n_resp = table.num_rows63 has_resp = n_resp > 064 has_pcap = (bundle_dir / "packets.pcap").is_file()65 return has_resp, n_resp, has_pcap66 67 68def _extract_v8_features(bundle_dir: Path) -> dict[str, float]:69 features = {n: 0.0 for n in [70 "pcap.unique_dst_ports", "pcap.unique_src_ports",71 "resp.amp_ratio_max", "resp.amp_ratio_mean", "resp.amp_ratio_median",72 "resp.req_bytes_max", "resp.resp_bytes_max",73 ]}74 rp = bundle_dir / "responses.parquet"75 if rp.is_file():76 table = pq.read_table(rp)77 if table.num_rows > 0:78 req = table.column("request_size_bytes").to_numpy()79 resp = table.column("response_size_bytes").to_numpy()80 features["resp.req_bytes_max"] = float(req.max())81 features["resp.resp_bytes_max"] = float(resp.max())82 with np.errstate(divide="ignore", invalid="ignore"):83 ratios = np.where(req > 0, resp / req, 0.0)84 features["resp.amp_ratio_max"] = float(ratios.max())85 features["resp.amp_ratio_mean"] = float(ratios.mean())86 features["resp.amp_ratio_median"] = float(np.median(ratios))87 return features88 89 90def _extract_multiclass_features(bundle_dir: Path, feature_names: list[str]) -> np.ndarray:91 """Minimal fallback feature extractor for the multi-class model.92 93 Only populates resp.* features (the rest default to 0). The model's94 OOD-by-construction behaviour on partial-coverage inputs is surfaced95 via the coverage_warning in the inference output.96 """97 features = {n: 0.0 for n in feature_names}98 rp = bundle_dir / "responses.parquet"99 if rp.is_file():100 table = pq.read_table(rp)101 if table.num_rows > 0:102 req = table.column("request_size_bytes").to_numpy()103 resp = table.column("response_size_bytes").to_numpy()104 with np.errstate(divide="ignore", invalid="ignore"):105 ratios = np.where(req > 0, resp / req, 0.0)106 for name, value in [107 ("resp.req_bytes_max", float(req.max())),108 ("resp.resp_bytes_max", float(resp.max())),109 ("resp.amp_ratio_max", float(ratios.max())),110 ("resp.amp_ratio_mean", float(ratios.mean())),111 ("resp.amp_ratio_median", float(np.median(ratios))),112 ]:113 if name in features:114 features[name] = value115 return np.array([[features[n] for n in feature_names]], dtype=float)116 117 118def classify_bundle(uploaded_path: str | None) -> dict[str, Any]:119 """Main entrypoint. Accepts a bundle directory (zip or extracted)120 and returns a verdict dict suitable for Gradio JSON display."""121 if not uploaded_path:122 return {"error": "Please upload a bundle (.zip or extracted directory)."}123 124 upload = Path(uploaded_path)125 workdir = Path(tempfile.mkdtemp(prefix="nr-bundle-"))126 127 try:128 # Handle zip vs directory uploads.129 if upload.is_file() and upload.suffix == ".zip":130 with zipfile.ZipFile(upload, "r") as zf:131 zf.extractall(workdir)132 bundle_root = workdir133 # If the zip contains a single top-level directory, descend.134 entries = [p for p in workdir.iterdir() if p.is_dir()]135 if len(entries) == 1 and not (workdir / "manifest.json").is_file():136 bundle_root = entries[0]137 elif upload.is_dir():138 bundle_root = upload139 else:140 return {"error": "Unsupported upload: provide a .zip or directory."}141 142 mf_path = bundle_root / "manifest.json"143 if not mf_path.is_file():144 return {"error": f"No manifest.json found in upload (looked at {bundle_root})."}145 146 # Validate against bundle v1 spec.147 try:148 manifest = BundleManifest.model_validate_json(mf_path.read_text())149 except Exception as exc:150 return {151 "error": "Bundle does not validate against nr-bundle-spec v0.1.0.",152 "detail": str(exc)[:400],153 }154 155 has_resp, n_resp, has_pcap = _modality_state(bundle_root)156 157 v8_payload, mc_payload = _load_models()158 159 # V8 binary inference.160 v8_features = _extract_v8_features(bundle_root)161 X_v8 = np.array([[v8_features[n] for n in v8_payload["feature_names"]]], dtype=float)162 v8_score = float(v8_payload["model"].predict_proba(X_v8)[0, 1])163 v8_verdict = "attack" if v8_score >= 0.5 else "benign"164 165 # Multi-class inference.166 if not (has_resp or has_pcap):167 mc_block = {168 "verdict": "unscoreable",169 "reason": "No responses.parquet (with rows) and no packets.pcap present.",170 }171 else:172 X_mc = _extract_multiclass_features(bundle_root, mc_payload["feature_names"])173 proba = mc_payload["model"].predict_proba(X_mc)[0]174 class_order = mc_payload["class_order"]175 argmax = int(np.argmax(proba))176 argmax_class = class_order[argmax]177 argmax_p = float(proba[argmax])178 coverage = ("full" if has_resp and has_pcap179 else "resp_only" if has_resp180 else "pcap_only" if has_pcap181 else "none")182 183 warning = None184 if coverage == "resp_only" and argmax_class != "V16" and argmax_p < 0.8:185 warning = (186 f"argmax={argmax_class} with P={argmax_p:.3f} on resp_only "187 "coverage; multiclass-folded was trained on full-modality "188 "bundles. For reliable V8-V14 inference provide bundles "189 "with raw packets.pcap present."190 )191 elif coverage == "resp_only" and argmax_class == "V16":192 warning = (193 "argmax=V16 with resp_only coverage. V16 is load-bearing "194 "on pcap.* features; this is likely a missing-modality "195 "artefact, not a true gossip-abuse detection. Provide "196 "bundles with raw packets.pcap for V16 inference."197 )198 199 mc_block = {200 "verdict": argmax_class,201 "argmax_p": round(argmax_p, 4),202 "class_probs": {c: round(float(proba[i]), 4)203 for i, c in enumerate(class_order)},204 "feature_coverage": coverage,205 "coverage_warning": warning,206 }207 208 return {209 "bundle_manifest": {210 "corpus_id": manifest.corpus_id,211 "primitive_id": manifest.primitive_id,212 "family_id": manifest.family_id,213 "chain": manifest.chain,214 "fidelity_class": (215 manifest.provenance.fidelity_class.value216 if hasattr(manifest.provenance.fidelity_class, "value")217 else str(manifest.provenance.fidelity_class)218 ),219 "ground_truth_label": (220 manifest.ground_truth_label.value221 if hasattr(manifest.ground_truth_label, "value")222 else str(manifest.ground_truth_label)223 ),224 },225 "modality_state": {226 "responses_rows": n_resp,227 "packets_pcap_present": has_pcap,228 },229 "v8_binary": {230 "score": round(v8_score, 4),231 "verdict": v8_verdict,232 },233 "multiclass_folded": mc_block,234 }235 finally:236 shutil.rmtree(workdir, ignore_errors=True)237 238 239# ── Gradio interface ──────────────────────────────────────────────240 241DESCRIPTION = """242# nr-bundle-classifier243 244Run a bundle (in the open [bundle v1 format](https://github.com/NullRabbitLabs/nr-bundle-spec)) through NullRabbit's published detectors:245 246- **[V8 cipher-agnostic byte-amplification detector](https://huggingface.co/NullRabbit/v8-cipher-agnostic)** — binary attack/benign classification for byte-amplification family247- **[Multi-class softmax folded detector](https://huggingface.co/NullRabbit/multiclass-folded)** — 9-class unified detector (benign + V8/V9/V10/V11/V12/V13/V14/V16)248 249Upload a bundle directory (zip or extracted) — the Space validates against bundle v1 spec, runs both detectors, and returns per-class probabilities plus scoreability + coverage flags. Sample bundles available at [NullRabbit/nr-bundles-public](https://huggingface.co/datasets/NullRabbit/nr-bundles-public).250 251This is the data-layer artefact of NullRabbit Labs' research on **autonomous defence for decentralised networks**. The methodology is documented in the [substrate paper](https://github.com/NullRabbitLabs/nr-bundle-spec) (in preparation); the governance layer is published separately as the [earned-autonomy paper](https://doi.org/10.5281/zenodo.18406828).252 253**Note**: bundles in `nr-bundles-public` have raw `packets.pcap` dropped per the dataset's safety policy. Some class manifolds (V8/V13/V14) survive this and produce correct verdicts; others (V11, benign-with-traffic, V16) are load-bearing on pcap features and skew accordingly. Coverage warnings emit when the predicted class is sensitive to the missing modality. For reliable inference on V11/benign-with-traffic/V16, provide bundles with raw pcap retained.254"""255 256with gr.Blocks(title="nr-bundle-classifier") as demo:257 gr.Markdown(DESCRIPTION)258 with gr.Row():259 with gr.Column():260 upload = gr.File(label="Bundle (.zip or extracted dir)",261 file_count="single")262 run_btn = gr.Button("Classify", variant="primary")263 with gr.Column():264 output = gr.JSON(label="Verdict")265 266 run_btn.click(fn=classify_bundle, inputs=upload, outputs=output)267 268 gr.Markdown("""269---270 271**Related**:272 273- [`nr-bundle-spec`](https://github.com/NullRabbitLabs/nr-bundle-spec) — open bundle v1 format (MIT)274- [`nr-bundles-public`](https://huggingface.co/datasets/NullRabbit/nr-bundles-public) — curated public sample (CC-BY-4.0)275- [`v8-cipher-agnostic`](https://huggingface.co/NullRabbit/v8-cipher-agnostic) — binary detector (Apache-2.0)276- [`multiclass-folded`](https://huggingface.co/NullRabbit/multiclass-folded) — unified detector (Apache-2.0)277- [NullRabbit Labs](https://huggingface.co/NullRabbit) · [nullrabbit.ai](https://nullrabbit.ai)278""")279 280 281if __name__ == "__main__":282 demo.launch()283 