| """Held-out CVE/malicious-package eval against extracted DataDog samples. |
| |
| Usage: |
| python eval/cve_eval.py --out eval_outputs/cve_held_out |
| """ |
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import sys |
| from collections import defaultdict |
| from pathlib import Path |
| from typing import Iterable |
|
|
| sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) |
|
|
| from models import ( |
| ActionType, |
| IssueView, |
| OpsguardAction, |
| OpsguardObservation, |
| ) |
| from eval.policies import build_policy |
|
|
|
|
| DEFAULT_POLICIES = [ |
| "random", "blanket_approve_pr", "blanket_reject_pr", |
| "keyword_security_triager", "paranoid_inspector", "memory_aware", |
| ] |
|
|
| CATCH_ACTIONS = { |
| ActionType.REJECT_PR.value, |
| ActionType.CLOSE_SPAM.value, |
| ActionType.FLAG_SECURITY.value, |
| } |
|
|
|
|
| def load_samples(path: Path) -> list[dict]: |
| if not path.exists(): |
| raise FileNotFoundError(f"missing dataset: {path}; run scripts/pull_datadog.py first") |
| out = [] |
| with open(path, encoding="utf-8") as f: |
| for line in f: |
| line = line.strip() |
| if not line: |
| continue |
| try: |
| out.append(json.loads(line)) |
| except json.JSONDecodeError: |
| continue |
| return out |
|
|
|
|
| def sample_to_observation(sample: dict, idx: int) -> OpsguardObservation: |
| pkg = sample.get("package", "unknown") |
| ver = sample.get("version", "") |
| eco = sample.get("ecosystem", "unknown") |
| attack = sample.get("attack_type", "unknown_malicious") |
| diff = sample.get("diff_preview", "") |
| files = sample.get("files", []) |
|
|
| title = f"chore({eco}): bump {pkg} to {ver}" if ver else f"chore({eco}): add {pkg}" |
| body = ( |
| f"Adds dependency `{pkg}` ({eco}).\n\n" |
| f"Attack-class hint: {attack}.\n\n" |
| f"This PR vendors a known-malicious sample from the DataDog dataset.\n" |
| ) |
|
|
| looks_like = pkg |
| if pkg.endswith("s"): |
| looks_like = pkg[:-1] |
| elif "-" in pkg: |
| looks_like = pkg.replace("-", "") |
|
|
| dep = { |
| "package": pkg, |
| "version": ver, |
| "ecosystem": eco, |
| "looks_like": looks_like, |
| } |
|
|
| iv = IssueView( |
| issue_id=10_000 + idx, |
| number=10_000 + idx, |
| title=title, |
| body=body, |
| is_pr=True, |
| pr_diff_preview=diff[:6000], |
| pr_changed_files=files[:50], |
| pr_dependency_changes=[dep], |
| author_login=f"contrib_{idx % 50}", |
| author_pr_count=0, |
| author_account_age_days=14, |
| author_first_contribution_days_ago=0, |
| available_labels=["bug", "enhancement", "question", "documentation", "security"], |
| comments_preview=[], |
| ) |
|
|
| return OpsguardObservation( |
| scenario_id=f"cve_heldout::{attack}", |
| step=0, |
| step_budget=1, |
| queue_position=idx, |
| queue_total=0, |
| current_issue=iv, |
| memory_hits=[], |
| recent_actions=[], |
| repo_health=1.0, |
| n_attacks_landed=0, |
| feedback="", |
| ) |
|
|
|
|
| def evaluate(samples: list[dict], policies: dict, out_dir: Path) -> dict: |
| out_dir.mkdir(parents=True, exist_ok=True) |
| per_sample_path = out_dir / "results.jsonl" |
|
|
| per_policy_stats: dict[str, dict] = { |
| p: {"tp": 0, "fp": 0, "fn": 0, "tn": 0, "n": 0, |
| "by_attack": defaultdict(lambda: {"caught": 0, "total": 0})} |
| for p in policies |
| } |
|
|
| with open(per_sample_path, "w", encoding="utf-8") as fout: |
| for i, sample in enumerate(samples): |
| obs = sample_to_observation(sample, i) |
| true_malicious = sample.get("label", "malicious") == "malicious" |
| attack_type = sample.get("attack_type", "unknown_malicious") |
| for pname, pfn in policies.items(): |
| action: OpsguardAction = pfn(obs) |
| at = action.action_type.value if hasattr(action.action_type, "value") else str(action.action_type) |
| caught = at in CATCH_ACTIONS |
| stats = per_policy_stats[pname] |
| stats["n"] += 1 |
| stats["by_attack"][attack_type]["total"] += 1 |
| if true_malicious and caught: |
| stats["tp"] += 1 |
| stats["by_attack"][attack_type]["caught"] += 1 |
| elif true_malicious and not caught: |
| stats["fn"] += 1 |
| elif (not true_malicious) and caught: |
| stats["fp"] += 1 |
| else: |
| stats["tn"] += 1 |
|
|
| fout.write(json.dumps({ |
| "sample_idx": i, |
| "package": sample.get("package"), |
| "version": sample.get("version"), |
| "ecosystem": sample.get("ecosystem"), |
| "attack_type": attack_type, |
| "true_label": sample.get("label", "malicious"), |
| "policy": pname, |
| "predicted_action": at, |
| "caught": caught, |
| "reasoning": action.reasoning, |
| "security_verdict": (action.security_verdict.value |
| if action.security_verdict else None), |
| }) + "\n") |
|
|
| summary = {"per_policy": {}} |
| for pname, s in per_policy_stats.items(): |
| tp, fp, fn = s["tp"], s["fp"], s["fn"] |
| precision = tp / (tp + fp) if (tp + fp) else 0.0 |
| recall = tp / (tp + fn) if (tp + fn) else 0.0 |
| f1 = 2 * precision * recall / (precision + recall) if (precision + recall) else 0.0 |
| catch_rate = tp / max(1, tp + fn) |
| summary["per_policy"][pname] = { |
| "n": s["n"], "tp": tp, "fp": fp, "fn": fn, "tn": s["tn"], |
| "precision": round(precision, 4), |
| "recall": round(recall, 4), |
| "f1": round(f1, 4), |
| "catch_rate": round(catch_rate, 4), |
| "by_attack": {k: dict(v) for k, v in s["by_attack"].items()}, |
| } |
|
|
| summary["n_samples"] = len(samples) |
| summary["attack_types"] = sorted({s.get("attack_type", "unknown_malicious") for s in samples}) |
|
|
| (out_dir / "summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8") |
| (out_dir / "summary.md").write_text(format_markdown(summary), encoding="utf-8") |
| return summary |
|
|
|
|
| def format_markdown(summary: dict) -> str: |
| lines = [f"# CVE held-out eval ({summary['n_samples']} samples)\n"] |
| lines.append("| Policy | precision | recall | F1 | catch_rate | TP | FP | FN | TN |") |
| lines.append("|---|---:|---:|---:|---:|---:|---:|---:|---:|") |
| for pname, s in sorted(summary["per_policy"].items()): |
| lines.append( |
| f"| {pname} | {s['precision']:.3f} | {s['recall']:.3f} | {s['f1']:.3f} | " |
| f"{s['catch_rate']:.3f} | {s['tp']} | {s['fp']} | {s['fn']} | {s['tn']} |" |
| ) |
| lines.append("\n## Per-attack catch rate\n") |
| attacks = summary["attack_types"] |
| header = "| Policy | " + " | ".join(attacks) + " |" |
| sep = "|---|" + "|".join(["---:"] * len(attacks)) + "|" |
| lines.append(header) |
| lines.append(sep) |
| for pname, s in sorted(summary["per_policy"].items()): |
| row = [pname] |
| for at in attacks: |
| cell = s["by_attack"].get(at, {"caught": 0, "total": 0}) |
| tot = cell.get("total", 0) |
| cau = cell.get("caught", 0) |
| rate = cau / tot if tot else 0.0 |
| row.append(f"{cau}/{tot} ({rate:.2f})") |
| lines.append("| " + " | ".join(row) + " |") |
| return "\n".join(lines) + "\n" |
|
|
|
|
| def write_plot(summary: dict, out_path: Path) -> bool: |
| try: |
| import matplotlib |
| matplotlib.use("Agg") |
| import matplotlib.pyplot as plt |
| except Exception as e: |
| print(f"[plot] matplotlib unavailable ({e}); skipping", flush=True) |
| return False |
| pol = sorted(summary["per_policy"].keys()) |
| rates = [summary["per_policy"][p]["catch_rate"] for p in pol] |
| fig, ax = plt.subplots(figsize=(max(6, len(pol) * 1.2), 4)) |
| ax.bar(pol, rates, color="#cc3333") |
| ax.set_ylim(0, 1.05) |
| ax.set_ylabel("catch rate (recall)") |
| ax.set_title(f"CVE held-out catch rate (n={summary['n_samples']})") |
| for i, v in enumerate(rates): |
| ax.text(i, v + 0.02, f"{v:.2f}", ha="center", fontsize=9) |
| plt.xticks(rotation=25, ha="right") |
| plt.tight_layout() |
| out_path.parent.mkdir(parents=True, exist_ok=True) |
| fig.savefig(out_path, dpi=120) |
| plt.close(fig) |
| return True |
|
|
|
|
| def main(): |
| ap = argparse.ArgumentParser() |
| ap.add_argument("--data", default="data/datadog_extracted.jsonl") |
| ap.add_argument("--out", default="eval_outputs/cve_held_out") |
| ap.add_argument("--policies", nargs="*", default=DEFAULT_POLICIES) |
| ap.add_argument("--limit", type=int, default=0, |
| help="cap number of samples (0 = all)") |
| args = ap.parse_args() |
|
|
| project_root = Path(__file__).resolve().parent.parent |
| data_path = (project_root / args.data).resolve() |
| out_dir = (project_root / args.out).resolve() |
|
|
| samples = load_samples(data_path) |
| if args.limit > 0: |
| samples = samples[: args.limit] |
| print(f"[eval] loaded {len(samples)} samples from {data_path}", flush=True) |
|
|
| policies = {n: build_policy(n) for n in args.policies} |
| print(f"[eval] policies: {list(policies)}", flush=True) |
|
|
| summary = evaluate(samples, policies, out_dir) |
| write_plot(summary, out_dir / "catch_rate.png") |
|
|
| print(f"[done] {len(samples)} samples × {len(policies)} policies → {out_dir}", flush=True) |
| for p, s in sorted(summary["per_policy"].items()): |
| print(f" {p:>26} P={s['precision']:.3f} R={s['recall']:.3f} " |
| f"F1={s['f1']:.3f} catch={s['catch_rate']:.3f}", flush=True) |
| return 0 |
|
|
|
|
| if __name__ == "__main__": |
| sys.exit(main()) |
|
|