ethibench 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ethibench/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """ethibench — An evaluation framework for AI pentesting agents."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from dotenv import load_dotenv
6
+
7
+ load_dotenv()
File without changes
@@ -0,0 +1,89 @@
1
+ """Duplicate detection — findings that matched GT in raw matching but were removed by bipartite."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from loguru import logger
7
+
8
+
9
+ def calculate_duplicates_for_run(raw_matching: dict, matching: dict) -> tuple[int, list[str]]:
10
+ """Count duplicates and return their UUIDs.
11
+
12
+ A duplicate is a finding with non-empty selected_gt_ids in raw_matching
13
+ but empty selected_gt_ids in the final matching.
14
+ """
15
+ dup_count = 0
16
+ dup_uuids: list[str] = []
17
+
18
+ for uuid, raw_data in raw_matching.items():
19
+ raw_gt_ids = raw_data.get("selected_gt_ids", [])
20
+ if not raw_gt_ids:
21
+ continue
22
+ final_gt_ids = matching.get(uuid, {}).get("selected_gt_ids", [])
23
+ if not final_gt_ids:
24
+ dup_count += 1
25
+ dup_uuids.append(uuid)
26
+
27
+ return dup_count, dup_uuids
28
+
29
+
30
+ def extract_duplicates(output_dir: Path, findings_file: Path | None = None) -> dict:
31
+ """Analyze duplicates across all subsets in an evaluation output directory.
32
+
33
+ Args:
34
+ output_dir: The evaluation_outputs directory containing raw_matchings/ and matchings/.
35
+ findings_file: Optional findings_parsed.jsonl for extracting full finding objects.
36
+
37
+ Returns:
38
+ Dict mapping subset name → {"count": int, "uuids": list[str], "findings": list[dict]}.
39
+ """
40
+ raw_dir = output_dir / "raw_matchings"
41
+ match_dir = output_dir / "matchings"
42
+
43
+ if not raw_dir.is_dir() or not match_dir.is_dir():
44
+ logger.warning("No raw_matchings/ or matchings/ found in output_dir.")
45
+ return {}
46
+
47
+ # Load findings if provided
48
+ findings_by_uuid: dict[str, dict] = {}
49
+ if findings_file and findings_file.exists():
50
+ with open(findings_file) as f:
51
+ for line in f:
52
+ if line.strip():
53
+ item = json.loads(line)
54
+ if "uuid" in item:
55
+ findings_by_uuid[item["uuid"]] = item
56
+
57
+ results: dict[str, dict] = {}
58
+
59
+ for raw_file in sorted(raw_dir.glob("matchings_*.json")):
60
+ subset = raw_file.stem.replace("matchings_", "")
61
+ match_file = match_dir / raw_file.name
62
+
63
+ if not match_file.exists():
64
+ continue
65
+
66
+ with open(raw_file) as f:
67
+ raw_list = json.load(f)
68
+ with open(match_file) as f:
69
+ match_list = json.load(f)
70
+
71
+ all_uuids: list[str] = []
72
+ total_dups = 0
73
+
74
+ for raw_m, final_m in zip(raw_list, match_list):
75
+ count, uuids = calculate_duplicates_for_run(raw_m, final_m)
76
+ total_dups += count
77
+ all_uuids.extend(uuids)
78
+
79
+ unique_uuids = list(set(all_uuids))
80
+ dup_findings = [findings_by_uuid[u] for u in unique_uuids if u in findings_by_uuid]
81
+
82
+ results[subset] = {
83
+ "count": total_dups,
84
+ "uuids": unique_uuids,
85
+ "findings": dup_findings,
86
+ }
87
+ logger.info(f" {subset}: {total_dups} duplicates ({len(unique_uuids)} unique)")
88
+
89
+ return results
@@ -0,0 +1,95 @@
1
+ """Matching statistics — GT coverage, findings-per-GT distribution."""
2
+
3
+ import json
4
+ from collections import defaultdict
5
+ from pathlib import Path
6
+
7
+ from loguru import logger
8
+
9
+
10
+ def load_ground_truths(gt_dir: Path) -> tuple[dict[str, dict], dict[str, list[str]]]:
11
+ """Load all GT files and return (gt_by_id, gt_ids_by_subset)."""
12
+ gt_by_id: dict[str, dict] = {}
13
+ gt_ids_by_subset: dict[str, list[str]] = defaultdict(list)
14
+
15
+ for gt_file in gt_dir.glob("*_gt.jsonl"):
16
+ with open(gt_file) as f:
17
+ for line in f:
18
+ if not line.strip():
19
+ continue
20
+ gt = json.loads(line)
21
+ gt_id = gt.get("id")
22
+ subset = gt.get("subset_name")
23
+ if gt_id and subset:
24
+ gt_by_id[gt_id] = gt
25
+ gt_ids_by_subset[subset].append(gt_id)
26
+
27
+ return gt_by_id, dict(gt_ids_by_subset)
28
+
29
+
30
+ def compute_statistics(
31
+ output_dir: Path,
32
+ gt_dir: Path,
33
+ ) -> dict:
34
+ """Compute matching statistics from raw_matchings and ground truth.
35
+
36
+ Returns a dict with:
37
+ - total_gt: total ground truth entries
38
+ - total_subsets: number of subsets
39
+ - per_subset: dict of subset → {total_gt, matched_gt, unmatched_gt, findings_per_gt_avg, ...}
40
+ - unmatched_gts: list of GT entries with no findings across any run
41
+ """
42
+ gt_by_id, gt_ids_by_subset = load_ground_truths(gt_dir)
43
+ raw_dir = output_dir / "raw_matchings"
44
+
45
+ if not raw_dir.is_dir():
46
+ logger.warning("No raw_matchings/ found.")
47
+ return {}
48
+
49
+ # Build gt_id → list of finding UUIDs from raw matchings
50
+ gt_to_findings: dict[str, set[str]] = defaultdict(set)
51
+
52
+ for raw_file in raw_dir.glob("matchings_*.json"):
53
+ with open(raw_file) as f:
54
+ raw_list = json.load(f)
55
+ for raw_m in raw_list:
56
+ for uuid, data in raw_m.items():
57
+ for gt_id in data.get("selected_gt_ids", []):
58
+ gt_to_findings[gt_id].add(uuid)
59
+
60
+ # Per-subset stats
61
+ per_subset: dict[str, dict] = {}
62
+ all_unmatched: list[dict] = []
63
+
64
+ for subset, gt_ids in sorted(gt_ids_by_subset.items()):
65
+ matched = 0
66
+ findings_counts: list[int] = []
67
+ for gt_id in gt_ids:
68
+ n = len(gt_to_findings.get(gt_id, set()))
69
+ findings_counts.append(n)
70
+ if n > 0:
71
+ matched += 1
72
+ else:
73
+ all_unmatched.append(gt_by_id[gt_id])
74
+
75
+ total = len(gt_ids)
76
+ avg = sum(findings_counts) / total if total else 0
77
+ max_f = max(findings_counts) if findings_counts else 0
78
+ min_f = min(findings_counts) if findings_counts else 0
79
+
80
+ per_subset[subset] = {
81
+ "total_gt": total,
82
+ "matched_gt": matched,
83
+ "unmatched_gt": total - matched,
84
+ "findings_per_gt_avg": round(avg, 2),
85
+ "findings_per_gt_max": max_f,
86
+ "findings_per_gt_min": min_f,
87
+ }
88
+ logger.info(f" {subset}: {matched}/{total} GTs matched, avg {avg:.1f} findings/GT")
89
+
90
+ return {
91
+ "total_gt": len(gt_by_id),
92
+ "total_subsets": len(gt_ids_by_subset),
93
+ "per_subset": per_subset,
94
+ "unmatched_gts": all_unmatched,
95
+ }
@@ -0,0 +1,53 @@
1
+ """Extract findings with no ground truth match (false positives)."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from loguru import logger
7
+
8
+
9
+ def extract_unmatched(output_dir: Path, findings_file: Path | None = None) -> dict:
10
+ """Find findings that had no match in raw_matchings (true false positives).
11
+
12
+ Args:
13
+ output_dir: The evaluation_outputs directory containing raw_matchings/.
14
+ findings_file: Optional findings_parsed.jsonl for full finding objects.
15
+
16
+ Returns:
17
+ Dict mapping subset → {"uuids": list[str], "findings": list[dict]}.
18
+ """
19
+ raw_dir = output_dir / "raw_matchings"
20
+ if not raw_dir.is_dir():
21
+ logger.warning("No raw_matchings/ found in output_dir.")
22
+ return {}
23
+
24
+ findings_by_uuid: dict[str, dict] = {}
25
+ if findings_file and findings_file.exists():
26
+ with open(findings_file) as f:
27
+ for line in f:
28
+ if line.strip():
29
+ item = json.loads(line)
30
+ if "uuid" in item:
31
+ findings_by_uuid[item["uuid"]] = item
32
+
33
+ results: dict[str, dict] = {}
34
+
35
+ for raw_file in sorted(raw_dir.glob("matchings_*.json")):
36
+ subset = raw_file.stem.replace("matchings_", "")
37
+
38
+ with open(raw_file) as f:
39
+ raw_list = json.load(f)
40
+
41
+ unmatched_uuids: set[str] = set()
42
+ for raw_m in raw_list:
43
+ for uuid, data in raw_m.items():
44
+ if not data.get("selected_gt_ids", []):
45
+ unmatched_uuids.add(uuid)
46
+
47
+ uuids = sorted(unmatched_uuids)
48
+ findings = [findings_by_uuid[u] for u in uuids if u in findings_by_uuid]
49
+
50
+ results[subset] = {"uuids": uuids, "findings": findings}
51
+ logger.info(f" {subset}: {len(uuids)} unmatched findings")
52
+
53
+ return results