Skip to content

Commit be5320d

Browse files
feat+docs: LLM severity estimation against the bug-bounty model (method+tool)
collection/estimate_severity.py: estimates EF-bounty severity by DECOMPOSING each fix into impact_type / reachability / blast_radius, then mapping to a tier with a deterministic guardrail (spec-level EVM/consensus -> whole-network High/Critical; local/internal -> not-eligible; client-specific DoS on a minor client -> capped). --validate calibrates against the bounty-graded rows; --apply writes data/severity_est.csv with severity_estimated + severity_source (never overwrites the real severity). docs/severity_labeling.md documents the approach and the calibration findings: - two severity models are mixed in the data (EF client-bug vs upstream dependency CVSS) and must be separated; - spec-level vs client-specific is the hard axis and needs an explicit guardrail; - on real severe client vulns: exact-tier 60% / within +/-1 80%; - the LLM doubles as a severity-noise detector (flags features mis-tagged High); - concurrency degrades gemma -> run <=2 workers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 942e53b commit be5320d

3 files changed

Lines changed: 297 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ docs/ BUILD_REPORT · IMPROVEMENT_LOG · silent_fix_detection · mode
188188
- [`docs/security_report.md`](docs/security_report.md) — 🔎 **Field guide for security researchers** — the bug-bounty severity model as premise, the severity-realization map (what pays → where to look), recurring anti-patterns, cross-client variant analysis, case studies, and a hunting playbook
189189
- [`docs/analysis.md`](docs/analysis.md)**what the data says** (silent-fix majority, availability-first vuln profile, cross-language diversity), read through the dataset-research literature
190190
- [`docs/limitations.md`](docs/limitations.md)**honest inventory of coverage gaps & caveats** (read before relying on the data)
191+
- [`docs/severity_labeling.md`](docs/severity_labeling.md)**methodology**: LLM severity estimation against the bug-bounty model (decompose → map → calibrate)
191192
- [`docs/label_design.md`](docs/label_design.md) — the `label` / `root_cause` / `attack_path` / pre+post-code design, tied to the specs
192193
- [`docs/silent_fix_detection.md`](docs/silent_fix_detection.md) — research background + the algorithm
193194
- [`docs/model_evaluation.md`](docs/model_evaluation.md) — LLM model benchmark (accuracy + speed)

collection/estimate_severity.py

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
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())

docs/severity_labeling.md

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Severity labeling with an LLM — methodology
2+
3+
Severity is present on only **6.4%** of rows (the bounty-graded ones). This
4+
document is the design for estimating severity on the rest **against the Ethereum
5+
Foundation bug-bounty model**, with the calibration results that justify it. The
6+
tool is `collection/estimate_severity.py`.
7+
8+
## The premise (and why naive labeling fails)
9+
10+
Bounty severity is **network-scale impact reachable by a single packet / on-chain
11+
tx** (Critical = infinite-ETH / take-down-network / slash >50%; High = split or
12+
down >33%; …). Asking an LLM "is this Critical?" directly **over-rates**, because
13+
the tier depends on *how much of the network* the exploit reaches — which is not
14+
in the diff. So we do not ask for the tier; we **decompose, then map**.
15+
16+
### Pitfall 1 — two different severity models are mixed in the data
17+
Of the 143 rated rows, **~83 are upstream *dependency* CVEs** (log4j, Netty,
18+
`golang.org/x/crypto`, `nim-libp2p`) carrying their **CVSS** severity, and only
19+
**~60 are real client-code bugs** carrying (implicitly) the **EF-bounty** severity.
20+
A log4j bump does not split the Ethereum network. **These must be separated:**
21+
estimate EF-severity only for client-code bugs; dependency rows keep the upstream
22+
CVSS and are `not-eligible` under the bounty model. (Conflating them was the
23+
single biggest source of apparent disagreement.)
24+
25+
### Pitfall 2 — spec-level vs client-specific is the hard axis
26+
The tier hinges on blast radius: a bug in **shared spec logic** (EVM
27+
opcodes/precompiles/gas, consensus state-transition, fork-choice, SSZ) forces a
28+
divergence *every client shares* → whole-network impact → High/Critical; a bug in
29+
**client-local** code (this client's DB, RPC server, CLI, sync internals) caps at
30+
that client's share. The LLM must be told this explicitly, or it under-rates
31+
EVM/consensus bugs as "client_specific" (see calibration).
32+
33+
## The method — decompose, then map
34+
35+
Per row the LLM (given the bounty definition, the fix's diff, and our
36+
`root_cause` / `attack_path` / `label`) emits four **assessable** fields:
37+
38+
| field | values |
39+
|---|---|
40+
| `impact_type` | chain_split · liveness_dos · value_integrity · validator_slashing · local_only · none |
41+
| `reachability` | remote_single_message_or_tx · remote_needs_conditions · local_internal |
42+
| `blast_radius` | spec_level · client_specific · subset |
43+
| `severity_est` | Critical · High · Medium · Low · not-eligible |
44+
45+
A deterministic **guardrail** then corrects the tier:
46+
- `local_internal` reachability, or `impact_type ∈ {local_only, none}`**not-eligible** (out of bounty scope);
47+
- `client_specific` **liveness_dos** on a MINOR client cannot reach >33% → capped to **Medium**;
48+
- `spec_level` `chain_split` / `value_integrity` may reach **High/Critical** regardless of which client shipped the fix.
49+
50+
The components are the reliable, reusable output; the tier is a **calibrated
51+
estimate**, never presented as a bounty grade.
52+
53+
## Calibration (validated against the bounty grades)
54+
55+
Run `estimate_severity.py --validate`. Key results and what they mean:
56+
57+
- **On real severe client vulnerabilities** (RETURNDATA corruption, Consensus
58+
flaw, `MulMod` DoS, 0x4-precompile, effective-balance, p2p DoS):
59+
**exact-tier 60%, within ±1 tier 80%** after the spec-level guardrail. The
60+
genuine High bugs are recovered as High.
61+
- **The LLM doubles as a severity-noise detector.** Several rows the *dataset*
62+
labels High are actually features/tests/specs mis-tagged by the crawl
63+
("Implement Kintsugi specs", "Run sim single node test"); the LLM correctly
64+
returns `impact_type = none → not-eligible`. Much of the raw "disagreement" is
65+
the dataset's label noise, not the model's error — a useful by-product.
66+
- **Dependency CVEs** (log4j/Netty/…) are correctly returned `not-eligible` under
67+
the bounty model even though the row carries a CVSS High — confirming Pitfall 1.
68+
69+
Residual weakness: value-integrity Criticals (e.g. besu gas-allocation) are still
70+
sometimes under-rated to Medium/High because the "infinite/incorrect ETH" impact
71+
is subtle from the diff. Treat Critical estimates as a floor, not a ceiling.
72+
73+
## Operational notes
74+
- **Concurrency degrades the model.** gemma4:31b on the long severity prompt
75+
returns truncated JSON under parallel load (empty `severity_est` → spurious
76+
`not-eligible`). Run at **≤2 workers** (or add a retry) — the sequential result
77+
is materially better than the 6-wide batch.
78+
- Engine is pluggable (`--engine openai|claude|ollama`); gemma4:31b via Ollama
79+
Cloud is the default. A Claude pass would likely raise exact-tier further.
80+
81+
## Output contract (honest columns)
82+
`--apply` writes `data/severity_est.csv` keyed by `id`, joined like the other
83+
enrichments. It **never overwrites** the real `severity`:
84+
85+
| column | meaning |
86+
|---|---|
87+
| `severity_estimated` | the tier — the real grade where one exists, else the LLM estimate |
88+
| `severity_source` | `bounty-graded` \| `llm-estimated` (so consumers can filter to ground truth) |
89+
| `impact_type` · `reachability` · `blast_radius` | the decomposition (the reliable part) |
90+
| `severity_why` | one-sentence rationale |
91+
92+
## Recommended rollout
93+
1. Estimate EF-severity **only for client-code rows**; leave dependency-CVE rows
94+
as upstream CVSS + `not-eligible`.
95+
2. Ship `severity_estimated` + `severity_source` + the components — never
96+
silently overwrite `severity`; let users take the `bounty-graded` slice as
97+
ground truth and the `llm-estimated` slice as a triage prior.
98+
3. Re-validate whenever the prompt or model changes; report exact / ±1 tier on
99+
the client-code graded rows.
100+
101+
*See [`security_report.md`](./security_report.md) §2 for the bounty severity
102+
definitions and [`limitations.md`](./limitations.md) for caveats.*

0 commit comments

Comments
 (0)