#!/usr/bin/env python3 """Agent for implementing AFL++ fuzz testing in CI/CD pipelines.""" import json import argparse import subprocess import os from pathlib import Path def compile_target(source_file, output_binary, compiler="afl-clang-fast"): """Compile target binary with AFL++ instrumentation.""" cmd = [compiler, "-g", "-O1", "-fno-omit-frame-pointer", "-o", output_binary, source_file] env = os.environ.copy() env["AFL_HARDEN"] = "1" result = subprocess.run(cmd, capture_output=True, text=True, env=env, timeout=120) return { "source": source_file, "binary": output_binary, "compiler": compiler, "returncode": result.returncode, "stdout": result.stdout[:500], "stderr": result.stderr[:500], "instrumented": result.returncode == 0, } def prepare_corpus(seed_dir, corpus_dir): """Prepare and minimize seed corpus using afl-cmin.""" Path(corpus_dir).mkdir(parents=True, exist_ok=True) seeds = list(Path(seed_dir).glob("*")) if not seeds: # Create a minimal seed if none provided minimal = Path(seed_dir) / "seed_minimal" minimal.write_bytes(b"AAAA") seeds = [minimal] return { "seed_dir": str(seed_dir), "corpus_dir": str(corpus_dir), "seed_count": len(seeds), "seeds": [str(s) for s in seeds[:50]], } def minimize_corpus(binary, input_dir, output_dir, timeout=60): """Minimize seed corpus using afl-cmin.""" Path(output_dir).mkdir(parents=True, exist_ok=True) cmd = ["afl-cmin", "-i", input_dir, "-o", output_dir, "-t", str(timeout * 1000), "--", binary] result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) minimized = list(Path(output_dir).glob("*")) return { "input_count": len(list(Path(input_dir).glob("*"))), "output_count": len(minimized), "returncode": result.returncode, } def run_fuzzer(binary, input_dir, output_dir, duration_seconds=300, memory_limit="512"): """Run AFL++ fuzzer for a specified duration.""" Path(output_dir).mkdir(parents=True, exist_ok=True) env = os.environ.copy() env["AFL_SKIP_CPUFREQ"] = "1" env["AFL_I_DONT_CARE_ABOUT_MISSING_CRASHES"] = "1" env["AFL_NO_UI"] = "1" cmd = [ "afl-fuzz", "-i", input_dir, "-o", output_dir, "-m", memory_limit, "-V", str(duration_seconds), "--", binary, ] result = subprocess.run(cmd, capture_output=True, text=True, env=env, timeout=duration_seconds + 60) stats = parse_fuzzer_stats(os.path.join(output_dir, "default", "fuzzer_stats")) crashes_dir = os.path.join(output_dir, "default", "crashes") crash_files = list(Path(crashes_dir).glob("id:*")) if os.path.isdir(crashes_dir) else [] return { "binary": binary, "duration_seconds": duration_seconds, "returncode": result.returncode, "stats": stats, "crashes_found": len(crash_files), "crash_files": [str(f) for f in crash_files[:50]], } def parse_fuzzer_stats(stats_file): """Parse AFL++ fuzzer_stats file into a dict.""" stats = {} try: with open(stats_file, "r") as f: for line in f: if ":" in line: key, _, value = line.partition(":") stats[key.strip()] = value.strip() except FileNotFoundError: return {"error": "fuzzer_stats not found"} return { "execs_done": stats.get("execs_done", "0"), "execs_per_sec": stats.get("execs_per_sec", "0"), "paths_total": stats.get("paths_total", "0"), "paths_found": stats.get("paths_found", "0"), "unique_crashes": stats.get("saved_crashes", "0"), "unique_hangs": stats.get("saved_hangs", "0"), "stability": stats.get("stability", "unknown"), "bitmap_cvg": stats.get("bitmap_cvg", "unknown"), } def triage_crashes(binary, crashes_dir): """Triage crash inputs to deduplicate and classify.""" crash_files = sorted(Path(crashes_dir).glob("id:*")) results = [] for crash_file in crash_files[:100]: cmd = [binary] try: proc = subprocess.run( cmd, input=crash_file.read_bytes(), capture_output=True, timeout=5 ) results.append({ "file": str(crash_file), "returncode": proc.returncode, "signal": -proc.returncode if proc.returncode < 0 else None, "stderr_snippet": proc.stderr[:200].decode("utf-8", errors="replace"), "crash_type": _classify_signal(proc.returncode), }) except subprocess.TimeoutExpired: results.append({"file": str(crash_file), "crash_type": "hang/timeout"}) return { "total_crashes": len(crash_files), "triaged": len(results), "by_type": _count_by(results, "crash_type"), "results": results, } def _classify_signal(returncode): signal_map = {-6: "SIGABRT", -11: "SIGSEGV", -8: "SIGFPE", -4: "SIGILL", -7: "SIGBUS"} return signal_map.get(returncode, f"exit({returncode})") def _count_by(items, key): counts = {} for item in items: val = item.get(key, "unknown") counts[val] = counts.get(val, 0) + 1 return counts def main(): parser = argparse.ArgumentParser(description="AFL++ Fuzz Testing CI/CD Agent") sub = parser.add_subparsers(dest="command") c = sub.add_parser("compile", help="Compile target with AFL++ instrumentation") c.add_argument("--source", required=True) c.add_argument("--output", required=True) c.add_argument("--compiler", default="afl-clang-fast") f = sub.add_parser("fuzz", help="Run AFL++ fuzzer") f.add_argument("--binary", required=True) f.add_argument("--input", required=True) f.add_argument("--output", required=True) f.add_argument("--duration", type=int, default=300, help="Duration in seconds") f.add_argument("--memory", default="512", help="Memory limit in MB") t = sub.add_parser("triage", help="Triage crash inputs") t.add_argument("--binary", required=True) t.add_argument("--crashes-dir", required=True) s = sub.add_parser("stats", help="Parse fuzzer stats") s.add_argument("--stats-file", required=True) args = parser.parse_args() if args.command == "compile": result = compile_target(args.source, args.output, args.compiler) elif args.command == "fuzz": result = run_fuzzer(args.binary, args.input, args.output, args.duration, args.memory) elif args.command == "triage": result = triage_crashes(args.binary, args.crashes_dir) elif args.command == "stats": result = parse_fuzzer_stats(args.stats_file) else: parser.print_help() return print(json.dumps(result, indent=2, default=str)) if __name__ == "__main__": main()