|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""estimate_severity.py — LLM severity estimation against the EF bug-bounty model. |
| 3 | +
|
| 4 | +Severity in the bounty is NETWORK-SCALE IMPACT x REMOTE REACHABILITY (a single |
| 5 | +packet / on-chain tx). Asking an LLM for "Critical?" directly over-rates. Instead |
| 6 | +we DECOMPOSE into the bounty's own axes and let a deterministic guardrail cap the |
| 7 | +tier, then CALIBRATE against the 143 rows the bounty actually graded. |
| 8 | +
|
| 9 | +Per row the LLM emits: |
| 10 | + impact_type chain_split | liveness_dos | value_integrity | validator_slashing |
| 11 | + | local_only | none |
| 12 | + reachability remote_single_message_or_tx | remote_needs_conditions | local_internal |
| 13 | + blast_radius spec_level (all clients / whole network) | client_specific | subset |
| 14 | + severity_est Critical | High | Medium | Low | not-eligible |
| 15 | + confidence, why |
| 16 | +
|
| 17 | +Guardrails (applied after the LLM): |
| 18 | + * local_internal reachability OR impact_type in {local_only, none} -> not-eligible |
| 19 | + * client_specific bug is capped by that client's network share tier |
| 20 | + * spec_level chain_split / value_integrity can reach High/Critical |
| 21 | +
|
| 22 | +--validate : run on the bounty-graded rows and report agreement (exact / ±1 tier) |
| 23 | +--apply : write severity_estimated + rationale for all rows (severity_source |
| 24 | + = 'bounty-graded' where a real grade exists, else 'llm-estimated') |
| 25 | +""" |
| 26 | +from __future__ import annotations |
| 27 | + |
| 28 | +import argparse |
| 29 | +import json |
| 30 | +import re |
| 31 | +import sys |
| 32 | +from concurrent.futures import ThreadPoolExecutor |
| 33 | +from pathlib import Path |
| 34 | + |
| 35 | +import pandas as pd |
| 36 | + |
| 37 | +sys.path.insert(0, str(Path(__file__).resolve().parent)) |
| 38 | +import llm_classify_fixes as llm # noqa: E402 |
| 39 | +import local_diffs as ld # noqa: E402 |
| 40 | + |
| 41 | +# rough, historical network-share tiers — only to bound blast radius for a |
| 42 | +# client-specific bug (a spec-level bug affects the whole network regardless). |
| 43 | +SHARE = { |
| 44 | + "geth": "DOMINANT execution client (~45-55% of execution nodes)", |
| 45 | + "nethermind": "MAJOR execution client (~20-30%)", |
| 46 | + "erigon": "MODERATE execution client (~10-20%)", |
| 47 | + "besu": "MINOR execution client (<10%)", |
| 48 | + "reth": "MINOR (growing) execution client (<10%)", |
| 49 | + "prysm": "MAJOR consensus client (~30-40%)", |
| 50 | + "lighthouse": "MAJOR consensus client (~30-40%)", |
| 51 | + "teku": "MODERATE consensus client (~10-15%)", |
| 52 | + "nimbus": "MINOR consensus client (<10%)", |
| 53 | + "lodestar": "MINOR consensus client (<5%)", |
| 54 | + "grandine": "MINOR consensus client (<5%)", |
| 55 | +} |
| 56 | +TIER = {"critical": 4, "high": 3, "medium": 2, "low": 1, "not-eligible": 0, "": 0} |
| 57 | +DEF = """EF bug-bounty severity = network-scale impact reachable by a SINGLE network packet or on-chain transaction: |
| 58 | +- Critical: create/finalize infinite ETH; steal or burn ETH from all EOAs; take down the ENTIRE network with one tx; slash >50% of validators. |
| 59 | +- High: chain split affecting >33% of the network; bring down >33% with one tx; slash >33% of validators. |
| 60 | +- Medium: split >5%; bring down >5%; slash >1%. |
| 61 | +- Low: split/down >0.01% by a single packet/tx. |
| 62 | +- not-eligible: only locally/internally triggerable (needs local access, not a single remote packet/tx), or no network impact (tooling / test / CLI / metrics / dependency hygiene).""" |
| 63 | + |
| 64 | + |
| 65 | +def build_prompt(r, diff): |
| 66 | + share = SHARE.get(r["source_platform"], "a client") |
| 67 | + return f"""Triage this Ethereum client fix for the Ethereum Foundation bug bounty. |
| 68 | +
|
| 69 | +{DEF} |
| 70 | +
|
| 71 | +Reason step by step, then map to a tier USING THE DEFINITION ABOVE (do not inflate): |
| 72 | +1. impact_type: what could an attacker actually achieve? |
| 73 | + {{chain_split, liveness_dos, value_integrity, validator_slashing, local_only, none}} |
| 74 | +2. reachability: {{remote_single_message_or_tx, remote_needs_conditions, local_internal}} |
| 75 | +3. blast_radius: is the defect in SHARED spec logic or CLIENT-SPECIFIC? |
| 76 | + {{spec_level, client_specific, subset}} |
| 77 | + IMPORTANT: EVM opcodes/precompiles/gas rules, consensus state-transition, |
| 78 | + fork-choice, attestation/slashing rules, and SSZ/RLP consensus encoding are |
| 79 | + SPEC-LEVEL — every client must produce the identical result, so a divergence |
| 80 | + or crash there can split or stall the WHOLE network (High/Critical), not just |
| 81 | + this client. Only genuinely client-local code (this client's DB, RPC server, |
| 82 | + CLI, sync internals) is client_specific. |
| 83 | + This client is: {share}. |
| 84 | +4. severity_est: Critical | High | Medium | Low | not-eligible. |
| 85 | +
|
| 86 | +Context — area: {r.get('label')} · root_cause: {r.get('root_cause')} · attack_path: {r.get('attack_path')} |
| 87 | +Title: {str(r.get('title') or '')[:200]} |
| 88 | +Description: {str(r.get('description') or '')[:600]} |
| 89 | +Code diff (truncated): |
| 90 | +{(diff or '(no diff)')[:2800]} |
| 91 | +
|
| 92 | +Output ONLY one JSON object on the last line: |
| 93 | +{{"impact_type":"...","reachability":"...","blast_radius":"...","severity_est":"...","confidence":0.0,"why":"<one sentence>"}}""" |
| 94 | + |
| 95 | + |
| 96 | +def guardrail(o, client): |
| 97 | + est = str(o.get("severity_est", "")).lower() |
| 98 | + if o.get("reachability") == "local_internal" or o.get("impact_type") in ("local_only", "none"): |
| 99 | + return "not-eligible" |
| 100 | + # client-specific liveness DoS on a minor client can't reach >33% -> cap High->Medium |
| 101 | + if o.get("blast_radius") == "client_specific" and est in ("critical", "high"): |
| 102 | + if o.get("impact_type") in ("liveness_dos",) and "MINOR" in SHARE.get(client, ""): |
| 103 | + return "medium" |
| 104 | + return est or "not-eligible" |
| 105 | + |
| 106 | + |
| 107 | +def classify(row): |
| 108 | + r, diff = row |
| 109 | + try: |
| 110 | + out = llm._call_llm(build_prompt(r, diff)) |
| 111 | + m = re.search(r"\{[^{}]*\"severity_est\"[^{}]*\}", out, re.S) |
| 112 | + o = json.loads(m.group(0)) if m else {} |
| 113 | + except Exception as e: |
| 114 | + o = {"error": str(e)} |
| 115 | + o["severity_final"] = guardrail(o, r["source_platform"]) if "error" not in o else "" |
| 116 | + return r["id"], o |
| 117 | + |
| 118 | + |
| 119 | +def main(): |
| 120 | + ap = argparse.ArgumentParser() |
| 121 | + ap.add_argument("--in", dest="inp", default=Path("data/ethereum_vulns.parquet"), type=Path) |
| 122 | + ap.add_argument("--validate", action="store_true") |
| 123 | + ap.add_argument("--apply", action="store_true") |
| 124 | + ap.add_argument("--out", default=Path("data/severity_est.csv"), type=Path) |
| 125 | + ap.add_argument("--cache", default=Path("scratchpad_crawl/diff_cache.json"), type=Path) |
| 126 | + ap.add_argument("--limit", type=int, default=0) |
| 127 | + ap.add_argument("--workers", type=int, default=6) |
| 128 | + import os |
| 129 | + ap.add_argument("--engine", default="openai"); ap.add_argument("--model", default="") |
| 130 | + ap.add_argument("--base-url", default="https://ollama.com/v1") |
| 131 | + ap.add_argument("--api-key-env", default="OLLAMA_API_KEY") |
| 132 | + a = ap.parse_args() |
| 133 | + llm.ENGINE.update(engine=a.engine, model=a.model or "gemma4:31b", base_url=a.base_url, |
| 134 | + api_key=os.environ.get(a.api_key_env, "")) |
| 135 | + |
| 136 | + d = pd.read_parquet(a.inp) |
| 137 | + dcache = json.loads(a.cache.read_text()) if a.cache.exists() else {} |
| 138 | + sev = d.severity.str.lower() |
| 139 | + sub = d[sev.isin(["critical", "high", "medium", "low"])] if a.validate else d |
| 140 | + if a.limit: |
| 141 | + sub = sub.head(a.limit) |
| 142 | + rows = [] |
| 143 | + for r in sub.to_dict("records"): |
| 144 | + diff = ld.get_diff_cached(str(r["source_url"]), r["source_platform"], dcache) \ |
| 145 | + if r["source_platform"] in ld.CLIENT_REPOS else None |
| 146 | + rows.append((r, diff)) |
| 147 | + print(f"[severity] {len(rows)} rows (validate={a.validate})", file=sys.stderr) |
| 148 | + res = {} |
| 149 | + with ThreadPoolExecutor(max_workers=a.workers) as ex: |
| 150 | + for rid, o in ex.map(classify, rows): |
| 151 | + res[rid] = o |
| 152 | + a.cache.write_text(json.dumps(dcache)) |
| 153 | + |
| 154 | + if a.validate: |
| 155 | + exact = within1 = neel = tot = 0 |
| 156 | + conf = {} |
| 157 | + for r in sub.to_dict("records"): |
| 158 | + o = res.get(r["id"], {}); pred = str(o.get("severity_final", "")).lower() |
| 159 | + true = r["severity"].lower() |
| 160 | + if pred in ("", "error") or "error" in o: |
| 161 | + continue |
| 162 | + tot += 1 |
| 163 | + gp, gt = TIER.get(pred, 0), TIER.get(true, 0) |
| 164 | + if pred == "not-eligible": |
| 165 | + neel += 1 |
| 166 | + if gp == gt: |
| 167 | + exact += 1 |
| 168 | + if abs(gp - gt) <= 1: |
| 169 | + within1 += 1 |
| 170 | + conf[(true, pred)] = conf.get((true, pred), 0) + 1 |
| 171 | + print(f"\n=== validation vs bounty grades (n={tot}) ===") |
| 172 | + print(f" exact-tier agreement : {exact}/{tot} ({100*exact/tot:.0f}%)") |
| 173 | + print(f" within +/-1 tier : {within1}/{tot} ({100*within1/tot:.0f}%)") |
| 174 | + print(f" predicted not-eligible: {neel} (should be ~0 — graded rows ARE reachable)") |
| 175 | + print(" confusion (true -> pred):") |
| 176 | + for (t, p), c in sorted(conf.items(), key=lambda x: -x[1])[:12]: |
| 177 | + print(f" {t:9s} -> {p:12s} {c}") |
| 178 | + if a.apply: |
| 179 | + import csv |
| 180 | + real = {r["id"]: r["severity"] for r in d.to_dict("records")} |
| 181 | + with a.out.open("w", newline="") as fh: |
| 182 | + w = csv.writer(fh); w.writerow(["id", "severity_estimated", "severity_source", |
| 183 | + "impact_type", "reachability", "blast_radius", "severity_why"]) |
| 184 | + for rid, o in res.items(): |
| 185 | + graded = real.get(rid, "Unrated").lower() in ("critical", "high", "medium", "low") |
| 186 | + w.writerow([rid, real[rid] if graded else o.get("severity_final", ""), |
| 187 | + "bounty-graded" if graded else "llm-estimated", |
| 188 | + o.get("impact_type", ""), o.get("reachability", ""), |
| 189 | + o.get("blast_radius", ""), str(o.get("why", ""))[:200]]) |
| 190 | + print(f"wrote {a.out}") |
| 191 | + |
| 192 | + |
| 193 | +if __name__ == "__main__": |
| 194 | + raise SystemExit(main()) |
0 commit comments