autofte 0.2.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.
Files changed (51) hide show
  1. autofte/__init__.py +3 -0
  2. autofte/bench.py +427 -0
  3. autofte/binary_analysis.py +354 -0
  4. autofte/cli.py +825 -0
  5. autofte/config.py +89 -0
  6. autofte/crash_display.py +39 -0
  7. autofte/dashboard.py +247 -0
  8. autofte/dedup.py +256 -0
  9. autofte/demo_assets/vuln-demo/Makefile +23 -0
  10. autofte/demo_assets/vuln-demo/README.md +36 -0
  11. autofte/demo_assets/vuln-demo/crashes/crash-heap-000-len65 +1 -0
  12. autofte/demo_assets/vuln-demo/crashes/crash-heap-001-len79 +3 -0
  13. autofte/demo_assets/vuln-demo/crashes/crash-heap-002-len93 +1 -0
  14. autofte/demo_assets/vuln-demo/crashes/crash-null-000-len0 +1 -0
  15. autofte/demo_assets/vuln-demo/crashes/crash-null-001-len2 +1 -0
  16. autofte/demo_assets/vuln-demo/crashes/crash-null-002-len4 +1 -0
  17. autofte/demo_assets/vuln-demo/crashes/crash-stack-000-len65 +1 -0
  18. autofte/demo_assets/vuln-demo/crashes/crash-stack-002-len101 +1 -0
  19. autofte/demo_assets/vuln-demo/crashes/crash-stack-003-len119 +0 -0
  20. autofte/demo_assets/vuln-demo/crashes/crash-uaf-000-len1 +1 -0
  21. autofte/demo_assets/vuln-demo/crashes/crash-uaf-001-len3 +1 -0
  22. autofte/demo_assets/vuln-demo/crashes/crash-uaf-002-len5 +1 -0
  23. autofte/demo_assets/vuln-demo/in/seed.txt +1 -0
  24. autofte/demo_assets/vuln-demo/in/seed1 +1 -0
  25. autofte/demo_assets/vuln-demo/in/seed1.txt +1 -0
  26. autofte/demo_assets/vuln-demo/in/seed2 +2 -0
  27. autofte/demo_assets/vuln-demo/in/seed2.txt +1 -0
  28. autofte/demo_assets/vuln-demo/in/seed3 +1 -0
  29. autofte/demo_assets/vuln-demo/in/seed3.txt +1 -0
  30. autofte/demo_assets/vuln-demo/in/seed4 +1 -0
  31. autofte/demo_assets/vuln-demo/in/seed4.txt +1 -0
  32. autofte/demo_assets/vuln-demo/in/seed5 +1 -0
  33. autofte/demo_assets/vuln-demo/in/seed6 +1 -0
  34. autofte/demo_assets/vuln-demo/vuln.c +160 -0
  35. autofte/doctor.py +64 -0
  36. autofte/io_utils.py +17 -0
  37. autofte/llm.py +1134 -0
  38. autofte/metrics.py +163 -0
  39. autofte/paths.py +25 -0
  40. autofte/report.py +149 -0
  41. autofte/sanitizers.py +247 -0
  42. autofte/sarif.py +221 -0
  43. autofte/severity.py +368 -0
  44. autofte/triage.py +516 -0
  45. autofte/vendored_ignore_lists.py +523 -0
  46. autofte-0.2.0.dist-info/METADATA +378 -0
  47. autofte-0.2.0.dist-info/RECORD +51 -0
  48. autofte-0.2.0.dist-info/WHEEL +5 -0
  49. autofte-0.2.0.dist-info/entry_points.txt +2 -0
  50. autofte-0.2.0.dist-info/licenses/LICENSE +21 -0
  51. autofte-0.2.0.dist-info/top_level.txt +1 -0
autofte/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """AutoFTE: a local-first crash triage and binary analysis pipeline."""
2
+
3
+ __version__ = "0.2.0"
autofte/bench.py ADDED
@@ -0,0 +1,427 @@
1
+ """`autofte bench` -- score AutoFTE's dedup bucketing against a labeled
2
+ corpus of captured sanitizer reports.
3
+
4
+ This is the "ground truth" harness described in
5
+ `planning/research/05-accuracy-and-ground-truth.md` SS6 and
6
+ `planning/HARDENING.md` Part 2: it never re-fuzzes or re-runs a target.
7
+ Each item in a corpus is *already-captured report text* paired with a
8
+ ground-truth bug label; the pipeline here is exactly parse -> hash ->
9
+ score, independent of the live gdb/subprocess triage flow in `triage.py`.
10
+
11
+ Two corpus shapes are supported:
12
+
13
+ - **micro** -- the small, checked-in, hand-labeled corpus at
14
+ `tests/fixtures/bench_micro_corpus/<label>/<case>.txt`. One directory
15
+ per ground-truth label, one report per file. Fast, no network, this is
16
+ what runs on every PR.
17
+ - **igor** -- the GPTrace/Igor `data_sources.tar.gz` corpus (Apache-2.0,
18
+ Zenodo record 10.5281/zenodo.18708473), fetched by
19
+ `scripts/fetch_bench_corpus.sh` into `~/.cache/autofte/bench/`. Its real
20
+ on-disk shape, inspected directly rather than assumed from the paper, is
21
+
22
+ data_sources/<vendor>__<target>/asan_logs/poc_<LABEL>_raw/<poc-file>
23
+
24
+ where `<poc-file>` is plain ASan report text saved under the original
25
+ fuzzer testcase's filename (so it may have a misleading extension like
26
+ `.ttf` or `.pdf` -- it is text, not the testcase itself; the testcase
27
+ bytes live in the sibling `traces/` and `crashwalk/` directories
28
+ instead). `<LABEL>` is the ground-truth bug id Igor/GPTrace assigned
29
+ (a single letter for Igor's own SCIs, or a Magma-style id like `AAH010`
30
+ for the forward-ported CVEs). There is no separate label-mapping file
31
+ inside `data_sources.tar.gz` -- the label is the directory name itself.
32
+ Labels are namespaced by target (`target::LABEL`) when scoring, since
33
+ the same letter (e.g. `A`) is reused across unrelated targets.
34
+
35
+ For each report: `sanitizers.parse_sanitizer_output` -> (on success)
36
+ `dedup.stack_hashes(record["crash_stack"], extra_context=[record["bug_class"]])`.
37
+ A report that fails to parse is counted in `parse_failures` and excluded
38
+ from scoring rather than aborting the run. A report that parses but has
39
+ no hashable frames (e.g. a genuinely frameless allocator-out-of-memory
40
+ report -- V1-RELEASE.md W2 closed the far larger, and far more common,
41
+ case where `sanitizers.py` simply failed to recognize the crash-stack
42
+ boundary) still gets a bucket, keyed on its bug class, so it isn't
43
+ silently dropped from the corpus; this mirrors the same "fall back to a
44
+ raw label when there's nothing to hash" property `triage.py` relies on,
45
+ without importing anything from `triage.py`.
46
+ """
47
+
48
+ import json
49
+ import re
50
+ from collections import defaultdict
51
+ from pathlib import Path
52
+
53
+ from .dedup import stack_hashes
54
+ from .metrics import compute_metrics
55
+ from .sanitizers import parse_sanitizer_output
56
+
57
+ MICRO_CORPUS_DIR = (
58
+ Path(__file__).resolve().parent.parent / "tests" / "fixtures" / "bench_micro_corpus"
59
+ )
60
+ IGOR_CACHE_DIR = Path.home() / ".cache" / "autofte" / "bench"
61
+ IGOR_DATA_DIR_NAME = "data_sources"
62
+
63
+ _POC_LABEL_RE = re.compile(r"^poc_(.+)_raw$")
64
+
65
+ METRIC_KEYS = (
66
+ "purity",
67
+ "inverse_purity",
68
+ "f_measure",
69
+ "overcounting_mean",
70
+ "undercounting_mean",
71
+ )
72
+
73
+ MACRO_METRIC_KEYS = ("purity", "inverse_purity", "f_measure")
74
+
75
+ # TASK 1 (planning/V1-RELEASE.md E2/E3 aggregation question, resolved
76
+ # 2026-08-08, see benchmarks/results.md "TASK 1 -- aggregation methodology
77
+ # settled"): the GPTrace ICSE'26 paper (arXiv 2512.01609) defines
78
+ # purity/inverse-purity/F "for a fixed target program" (N scoped per
79
+ # target, not to the pooled corpus) and its Table 3 reports one row per
80
+ # target plus an unweighted "Average" row -- i.e. MACRO aggregation
81
+ # (per-target metric, then averaged across targets), not micro/pooled.
82
+ # Confirmed independently by a fresh adversarial review; the one open
83
+ # caveat is that the paper never states in prose whether its own Average
84
+ # row is a weighted or unweighted mean -- that is inferred, not quoted.
85
+ # AutoFTE's E2/E3 exit criteria never specified an aggregation (a real
86
+ # spec bug, fixed in planning/V1-RELEASE.md alongside this change) --
87
+ # report both so nobody has to guess which one a number means.
88
+ AGGREGATION_NOTE = (
89
+ "Micro = pooled over all reports (one purity/IP/F computed on the whole "
90
+ "corpus at once). Macro = per-target mean (metric computed separately "
91
+ "per target, then averaged across targets, each target weighted "
92
+ "equally regardless of size). The GPTrace ICSE'26 paper (arXiv "
93
+ "2512.01609) scopes its purity/inverse-purity/F formulas 'for a fixed "
94
+ "target program' and reports one row per target plus an Average row -- "
95
+ "i.e. MACRO. Its own prose never states whether that Average row is a "
96
+ "weighted or unweighted mean, so treat that one detail as inferred, "
97
+ "not quoted -- see benchmarks/results.md 'TASK 1' for the full citation "
98
+ "and the adversarial review that checked it."
99
+ )
100
+
101
+
102
+ class CorpusNotFoundError(Exception):
103
+ pass
104
+
105
+
106
+ def resolve_corpus(spec):
107
+ if spec == "micro":
108
+ if not MICRO_CORPUS_DIR.is_dir():
109
+ raise CorpusNotFoundError(f"micro corpus not found at {MICRO_CORPUS_DIR}")
110
+ return MICRO_CORPUS_DIR, "micro"
111
+ if spec == "igor":
112
+ data_dir = IGOR_CACHE_DIR / IGOR_DATA_DIR_NAME
113
+ if not data_dir.is_dir():
114
+ raise CorpusNotFoundError(
115
+ f"Igor/GPTrace corpus not found at {data_dir}. "
116
+ "Run scripts/fetch_bench_corpus.sh first."
117
+ )
118
+ return data_dir, "igor"
119
+ path = Path(spec)
120
+ if not path.is_dir():
121
+ raise CorpusNotFoundError(f"corpus path not found: {path}")
122
+ return path, "auto"
123
+
124
+
125
+ def detect_corpus_kind(path):
126
+ path = Path(path)
127
+ if next(path.rglob("asan_logs"), None) is not None:
128
+ return "igor"
129
+ return "micro"
130
+
131
+
132
+ def iter_micro_corpus(path):
133
+ path = Path(path)
134
+ if not path.is_dir():
135
+ raise CorpusNotFoundError(f"micro corpus path not found: {path}")
136
+ for label_dir in sorted(p for p in path.iterdir() if p.is_dir()):
137
+ label = label_dir.name
138
+ for report_path in sorted(label_dir.glob("*.txt")):
139
+ item_id = f"{label}/{report_path.name}"
140
+ text = report_path.read_text(encoding="utf-8", errors="replace")
141
+ yield item_id, label, text
142
+
143
+
144
+ def iter_igor_corpus(path):
145
+ path = Path(path)
146
+ if not path.is_dir():
147
+ raise CorpusNotFoundError(f"Igor corpus path not found: {path}")
148
+ for asan_logs_dir in sorted(path.rglob("asan_logs")):
149
+ target = asan_logs_dir.parent.name
150
+ for label_dir in sorted(p for p in asan_logs_dir.iterdir() if p.is_dir()):
151
+ match = _POC_LABEL_RE.match(label_dir.name)
152
+ if not match:
153
+ continue
154
+ label = f"{target}::{match.group(1)}"
155
+ for report_path in sorted(label_dir.iterdir()):
156
+ if not report_path.is_file():
157
+ continue
158
+ item_id = f"{target}/{label_dir.name}/{report_path.name}"
159
+ text = report_path.read_text(encoding="utf-8", errors="replace")
160
+ yield item_id, label, text
161
+
162
+
163
+ def _fallback_bucket_key(record):
164
+ parts = [record["bug_class"]]
165
+ if record["access_type"]:
166
+ size = f" {record['access_size']}" if record["access_size"] is not None else ""
167
+ parts.append(f"({record['access_type']}{size})")
168
+ crash_stack = record["crash_stack"]
169
+ if crash_stack:
170
+ top = crash_stack[0]
171
+ ident = top.get("func") or top.get("addr")
172
+ if ident:
173
+ parts.append(f"in {ident}")
174
+ return " ".join(parts)
175
+
176
+
177
+ def load_corpus(corpus_path, corpus_kind):
178
+ kind = corpus_kind
179
+ if kind == "auto":
180
+ kind = detect_corpus_kind(corpus_path)
181
+ if kind == "micro":
182
+ return kind, list(iter_micro_corpus(corpus_path))
183
+ if kind == "igor":
184
+ return kind, list(iter_igor_corpus(corpus_path))
185
+ raise ValueError(f"unknown corpus kind: {corpus_kind}")
186
+
187
+
188
+ def run_bench(corpus_path, corpus_kind="auto"):
189
+ resolved_kind, items = load_corpus(corpus_path, corpus_kind)
190
+
191
+ assignments = []
192
+ parse_failures = 0
193
+ nohash_count = 0
194
+ for item_id, label, text in items:
195
+ record = parse_sanitizer_output(text)
196
+ if record is None:
197
+ parse_failures += 1
198
+ continue
199
+
200
+ major_hash, _minor_hash = stack_hashes(
201
+ record["crash_stack"], extra_context=[record["bug_class"]]
202
+ )
203
+ if major_hash is not None:
204
+ bucket = f"hash:{major_hash}"
205
+ else:
206
+ bucket = f"nohash:{_fallback_bucket_key(record)}"
207
+ nohash_count += 1
208
+
209
+ if resolved_kind == "igor":
210
+ # Bucket ids are namespaced by target: a real triage run only ever
211
+ # dedups crashes from one target's crash directory at a time, so
212
+ # pooling all 14 targets into one bench run must not let an
213
+ # unrelated bug in a different target collide into the same
214
+ # bucket just because it shares a generic bug_class.
215
+ target = label.split("::", 1)[0]
216
+ bucket = f"{target}::{bucket}"
217
+ assignments.append((item_id, label, bucket))
218
+
219
+ result = {
220
+ "corpus_path": str(corpus_path),
221
+ "corpus_kind": resolved_kind,
222
+ "n_reports": len(items),
223
+ "parse_failures": parse_failures,
224
+ "nohash_count": nohash_count,
225
+ "metrics": None,
226
+ }
227
+ if not assignments:
228
+ result["error"] = "no reports could be parsed and hashed; nothing to score"
229
+ return result
230
+
231
+ result["metrics"] = compute_metrics(assignments)
232
+ if resolved_kind == "igor":
233
+ result["per_target"] = _per_target_metrics(assignments)
234
+ result["macro_metrics"] = _macro_metrics(result["per_target"])
235
+ return result
236
+
237
+
238
+ def _per_target_metrics(assignments):
239
+ """Split igor-corpus assignments by target (the `target::LABEL` namespace
240
+ `iter_igor_corpus` already applies to labels) and run `compute_metrics`
241
+ separately on each target's subset, so a target that is hiding a
242
+ disaster behind a healthy pooled/aggregate score (V1-RELEASE.md W3)
243
+ shows up on its own.
244
+ """
245
+ by_target = defaultdict(list)
246
+ for item_id, label, bucket in assignments:
247
+ target = label.split("::", 1)[0]
248
+ by_target[target].append((item_id, label, bucket))
249
+ return {target: compute_metrics(items) for target, items in sorted(by_target.items())}
250
+
251
+
252
+ def _macro_metrics(per_target):
253
+ """Unweighted mean of purity/inverse_purity/f_measure across targets --
254
+ the macro aggregation the GPTrace ICSE'26 paper's own Table 3 uses (see
255
+ AGGREGATION_NOTE and benchmarks/results.md 'TASK 1'). Each target counts
256
+ once regardless of its report volume, unlike the pooled/micro metric in
257
+ `result["metrics"]` where a large target (e.g. xmllint at 58% of the
258
+ igor corpus by item count) dominates the number.
259
+ """
260
+ targets = sorted(per_target)
261
+ n = len(targets)
262
+ if n == 0:
263
+ return None
264
+ macro = {"n_targets": n}
265
+ for key in MACRO_METRIC_KEYS:
266
+ macro[key] = sum(per_target[t][key] for t in targets) / n
267
+ return macro
268
+
269
+
270
+ def render_table(result):
271
+ lines = []
272
+ lines.append(f"Corpus: {result['corpus_path']} ({result['corpus_kind']})")
273
+ lines.append(
274
+ f"Reports: {result['n_reports']} "
275
+ f"Parse failures: {result['parse_failures']} "
276
+ f"No-hash fallbacks: {result['nohash_count']}"
277
+ )
278
+ metrics = result.get("metrics")
279
+ if metrics is None:
280
+ lines.append(f"No metrics: {result.get('error', 'unknown error')}")
281
+ return "\n".join(lines)
282
+
283
+ lines.append("")
284
+ lines.append(f"{'metric':<20}{'value':>10}")
285
+ lines.append(f"{'-' * 30}")
286
+ lines.append(f"{'n_items':<20}{metrics['n_items']:>10}")
287
+ lines.append(f"{'n_labels':<20}{metrics['n_labels']:>10}")
288
+ lines.append(f"{'n_buckets':<20}{metrics['n_buckets']:>10}")
289
+ lines.append(f"{'purity':<20}{metrics['purity']:>10.4f}")
290
+ lines.append(f"{'inverse_purity':<20}{metrics['inverse_purity']:>10.4f}")
291
+ lines.append(f"{'f_measure':<20}{metrics['f_measure']:>10.4f}")
292
+ lines.append(
293
+ f"{'overcounting':<20}"
294
+ f"{metrics['overcounting_mean']:>10.4f} (std {metrics['overcounting_std']:.4f})"
295
+ )
296
+ lines.append(
297
+ f"{'undercounting':<20}"
298
+ f"{metrics['undercounting_mean']:>10.4f} (std {metrics['undercounting_std']:.4f})"
299
+ )
300
+ return "\n".join(lines)
301
+
302
+
303
+ def render_per_target_table(result):
304
+ """Render the igor corpus's per-target breakdown, worst f_measure first.
305
+
306
+ Sorted on f_measure rather than purity or inverse_purity alone: F is
307
+ the only column here that is low whenever *either* purity or inverse
308
+ purity is bad for that target, so it surfaces both failure shapes at
309
+ the top in one pass -- the literature's cited "825 buckets for 8
310
+ bugs" disaster (an inverse-purity failure, one bug shattered across
311
+ hundreds of buckets) and a silent-bug-merging purity failure would
312
+ both rise to the top under this ordering, whereas sorting on inverse
313
+ purity alone could leave a target whose real problem is purity
314
+ buried further down the table.
315
+ """
316
+ per_target = result.get("per_target")
317
+ if not per_target:
318
+ return "No per-target metrics (per-target breakdown is igor-corpus only)."
319
+
320
+ lines = []
321
+ lines.append("Per-target breakdown, worst f_measure first:")
322
+ lines.append("")
323
+ header = (
324
+ f"{'target':<45}{'n_items':>9}{'n_labels':>9}{'n_buckets':>10}"
325
+ f"{'purity':>9}{'inv_purity':>11}{'f_measure':>10}"
326
+ )
327
+ lines.append(header)
328
+ lines.append("-" * len(header))
329
+ for target, metrics in sorted(per_target.items(), key=lambda kv: kv[1]["f_measure"]):
330
+ lines.append(
331
+ f"{target:<45}{metrics['n_items']:>9}{metrics['n_labels']:>9}"
332
+ f"{metrics['n_buckets']:>10}{metrics['purity']:>9.4f}"
333
+ f"{metrics['inverse_purity']:>11.4f}{metrics['f_measure']:>10.4f}"
334
+ )
335
+ return "\n".join(lines)
336
+
337
+
338
+ def render_aggregation_table(result):
339
+ """Print micro (pooled) and macro (per-target mean) purity/IP/F side by
340
+ side, labelled, with a one-line note on which one the published
341
+ baselines use. Igor-corpus only -- the micro corpus has no per-target
342
+ axis to average over. See planning/V1-RELEASE.md E2/E3 and
343
+ benchmarks/results.md 'TASK 1'/'TASK 2'.
344
+ """
345
+ metrics = result.get("metrics")
346
+ macro = result.get("macro_metrics")
347
+ if metrics is None or macro is None:
348
+ return "No micro/macro comparison (igor corpus only)."
349
+
350
+ lines = []
351
+ lines.append(f"Aggregation comparison ({macro['n_targets']} targets):")
352
+ lines.append("")
353
+ header = f"{'aggregation':<28}{'purity':>10}{'inv_purity':>12}{'f_measure':>11}"
354
+ lines.append(header)
355
+ lines.append("-" * len(header))
356
+ lines.append(
357
+ f"{'micro (pooled)':<28}{metrics['purity']:>10.4f}"
358
+ f"{metrics['inverse_purity']:>12.4f}{metrics['f_measure']:>11.4f}"
359
+ )
360
+ lines.append(
361
+ f"{'macro (per-target mean)':<28}{macro['purity']:>10.4f}"
362
+ f"{macro['inverse_purity']:>12.4f}{macro['f_measure']:>11.4f}"
363
+ )
364
+ lines.append("")
365
+ lines.append(AGGREGATION_NOTE)
366
+ return "\n".join(lines)
367
+
368
+
369
+ def diff_against_baseline(result, baseline):
370
+ result_metrics = result.get("metrics")
371
+ baseline_metrics = baseline.get("metrics")
372
+ if result_metrics is None or baseline_metrics is None:
373
+ return []
374
+
375
+ lines = []
376
+ for key in METRIC_KEYS:
377
+ current = result_metrics.get(key)
378
+ previous = baseline_metrics.get(key)
379
+ if current is None or previous is None:
380
+ continue
381
+ delta = current - previous
382
+ sign = "+" if delta >= 0 else ""
383
+ lines.append(f"{key:<20}{previous:>10.4f} -> {current:>10.4f} (delta {sign}{delta:.4f})")
384
+ return lines
385
+
386
+
387
+ def check_regression(result, baseline, fail_purity_drop_points, fail_under_f):
388
+ result_metrics = result.get("metrics")
389
+ reasons = []
390
+
391
+ if fail_under_f is not None and result_metrics is not None:
392
+ if result_metrics["f_measure"] < fail_under_f:
393
+ reasons.append(
394
+ f"f_measure {result_metrics['f_measure']:.4f} is below "
395
+ f"--fail-under-f {fail_under_f:.4f}"
396
+ )
397
+
398
+ if baseline is not None and result_metrics is not None:
399
+ baseline_metrics = baseline.get("metrics")
400
+ if baseline_metrics is not None:
401
+ purity_drop = baseline_metrics["purity"] - result_metrics["purity"]
402
+ threshold = fail_purity_drop_points / 100.0
403
+ if purity_drop > threshold:
404
+ reasons.append(
405
+ f"purity dropped {purity_drop * 100:.2f} points "
406
+ f"(baseline {baseline_metrics['purity']:.4f} -> "
407
+ f"{result_metrics['purity']:.4f}), exceeds "
408
+ f"--fail-purity-drop {fail_purity_drop_points:.2f}"
409
+ )
410
+ f_delta = result_metrics["f_measure"] - baseline_metrics["f_measure"]
411
+ if f_delta < 0:
412
+ reasons.append(
413
+ f"f_measure dropped from {baseline_metrics['f_measure']:.4f} "
414
+ f"to {result_metrics['f_measure']:.4f}"
415
+ )
416
+
417
+ return reasons
418
+
419
+
420
+ def write_results(path, result):
421
+ with open(path, "w", encoding="utf-8") as handle:
422
+ json.dump(result, handle, indent=2, sort_keys=True)
423
+
424
+
425
+ def load_results(path):
426
+ with open(path, encoding="utf-8") as handle:
427
+ return json.load(handle)