npx skills add mukul975/Anthropic-Cybersecurity-SkillsMITRE ATT&CK
NIST CSF 2.0
MITRE ATLAS
Overview
AFL++ (American Fuzzy Lop Plus Plus) is a community-maintained fork of AFL that provides state-of-the-art coverage-guided fuzz testing for discovering vulnerabilities in compiled applications. AFL++ uses genetic algorithms to mutate inputs, tracking code coverage to find new execution paths that trigger crashes, hangs, and undefined behavior. In CI/CD environments, AFL++ can be integrated to continuously test parsers, protocol handlers, file format processors, and any code that handles untrusted input. AFL++ supports persistent mode for high-speed fuzzing (up to 100,000+ executions per second), custom mutators, QEMU mode for binary-only fuzzing, and CmpLog/RedQueen for automatic dictionary extraction.
When to Use
- When deploying or configuring implementing fuzz testing in cicd with aflplusplus capabilities in your environment
- When establishing security controls aligned to compliance requirements
- When building or improving security architecture for this domain
- When conducting security assessments that require this implementation
Prerequisites
- Linux-based CI runners (AFL++ does not support Windows natively)
- GCC or Clang compiler toolchain
- AFL++ installed (
apt install aflplusplusor built from source) - Target application with harness functions isolating input processing
- Seed corpus of valid input samples
Core Concepts
Coverage-Guided Fuzzing
AFL++ instruments the target binary at compile time (or via QEMU/Frida for binary-only targets) to track which code paths each input exercises. When a mutated input triggers a new code path, it is saved to the corpus for further mutation. This feedback loop enables AFL++ to systematically explore program state space.
Instrumentation Modes
| Mode | Use Case | Performance |
|---|---|---|
afl-clang-fast (LTO) |
Source available, best performance | Highest |
afl-clang-fast |
Source available, standard | High |
afl-gcc-fast |
GCC-based projects | High |
QEMU mode |
Binary-only, no source | Medium |
Frida mode |
Binary-only, cross-platform | Medium |
Unicorn mode |
Firmware, embedded | Low |
Persistent Mode
Persistent mode avoids fork overhead by fuzzing within a loop:
#include <unistd.h>
__AFL_FUZZ_INIT();
int main() {
__AFL_INIT();
unsigned char *buf = __AFL_FUZZ_TESTCASE_BUF;
while (__AFL_LOOP(10000)) {
int len = __AFL_FUZZ_TESTCASE_LEN;
// Process buf[0..len-1]
parse_input(buf, len);
}
return 0;
}Workflow
Step 1 --- Build the Fuzzing Harness
Create a harness that feeds AFL++ input to the target function:
// fuzz_harness.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "target_parser.h"
__AFL_FUZZ_INIT();
int main() {
__AFL_INIT();
unsigned char *buf = __AFL_FUZZ_TESTCASE_BUF;
while (__AFL_LOOP(10000)) {
int len = __AFL_FUZZ_TESTCASE_LEN;
if (len < 4) continue;
// Reset state between iterations
parser_context_t ctx;
parser_init(&ctx);
parser_process(&ctx, buf, len);
parser_cleanup(&ctx);
}
return 0;
}Step 2 --- Compile with AFL++ Instrumentation
# Standard instrumentation
export CC=afl-clang-fast
export CXX=afl-clang-fast++
# Enable AddressSanitizer for better crash detection
export AFL_USE_ASAN=1
# Build the target with instrumentation
$CC -o fuzz_harness fuzz_harness.c -ltarget_parser -fsanitize=address
# Build a CmpLog binary for better coverage
$CC -o fuzz_harness_cmplog fuzz_harness.c -ltarget_parser \
-fsanitize=address -DCMPLOGStep 3 --- Prepare Seed Corpus
mkdir -p corpus/
# Add valid input samples
cp test_inputs/* corpus/
# Minimize the corpus
afl-cmin -i corpus/ -o corpus_min/ -- ./fuzz_harness @@
# Further minimize individual inputs
mkdir -p corpus_tmin/
for f in corpus_min/*; do
afl-tmin -i "$f" -o "corpus_tmin/$(basename $f)" -- ./fuzz_harness @@
doneStep 4 --- Configure CI/CD Integration
GitHub Actions:
name: Fuzz Testing
on:
push:
branches: [main]
schedule:
- cron: '0 2 * * *' # Nightly fuzzing
jobs:
fuzz:
runs-on: ubuntu-latest
timeout-minutes: 120
steps:
- uses: actions/checkout@v4
- name: Install AFL++
run: |
sudo apt-get update
sudo apt-get install -y aflplusplus
- name: Restore corpus cache
uses: actions/cache@v4
with:
path: corpus/
key: fuzz-corpus-${{ github.sha }}
restore-keys: fuzz-corpus-
- name: Build fuzzing harness
run: |
export CC=afl-clang-fast
export AFL_USE_ASAN=1
make fuzz_harness
- name: Run AFL++ fuzzing (CI mode)
env:
AFL_CMPLOG_ONLY_NEW: 1
AFL_FAST_CAL: 1
AFL_NO_STARTUP_CALIBRATION: 1
run: |
mkdir -p findings/
timeout 7200 afl-fuzz \
-S ci_fuzzer \
-i corpus/ \
-o findings/ \
-t 5000 \
-- ./fuzz_harness @@ || true
- name: Check for crashes
run: |
CRASHES=$(find findings/ -path "*/crashes/*" -not -name "README.txt" | wc -l)
echo "Found $CRASHES unique crashes"
if [ "$CRASHES" -gt 0 ]; then
echo "::error::AFL++ found $CRASHES crashes"
for crash in findings/*/crashes/*; do
[ -f "$crash" ] && echo "Crash: $crash ($(wc -c < $crash) bytes)"
done
exit 1
fi
- name: Update corpus cache
if: always()
run: |
afl-cmin -i findings/ci_fuzzer/queue/ -o corpus/ -- ./fuzz_harness @@Step 5 --- Parallel Fuzzing for Nightly Runs
# Launch multiple secondary instances for better coverage
for i in $(seq 1 $(nproc)); do
afl-fuzz -S fuzzer_$i \
-i corpus/ \
-o findings/ \
-- ./fuzz_harness @@ &
done
# Wait for all fuzzers
wait
# Merge and minimize corpus
afl-cmin -i findings/*/queue/ -o corpus_merged/ -- ./fuzz_harness @@Step 6 --- Crash Triage
# Reproduce and categorize crashes
for crash in findings/*/crashes/*; do
echo "=== Testing: $crash ==="
timeout 5 ./fuzz_harness_asan "$crash" 2>&1 | head -20
echo "---"
done
# Deduplicate crashes by stack trace
afl-collect findings/ crashes_deduped/ -- ./fuzz_harness @@CI/CD Best Practices for AFL++
| Setting | CI Short Run | Nightly Long Run |
|---|---|---|
| Duration | 30-60 min | 4-24 hours |
| Mode | -S (secondary only) |
-S (no -M for CI) |
AFL_CMPLOG_ONLY_NEW |
1 | 1 |
AFL_FAST_CAL |
1 | 0 |
AFL_NO_STARTUP_CALIBRATION |
1 | 0 |
| Corpus caching | Required | Required |
| Parallel instances | 1-2 | nproc |
Monitoring Fuzzing Campaigns
# View fuzzing statistics
afl-whatsup findings/
# Key metrics to track:
# - Total paths found (code coverage indicator)
# - Unique crashes / unique hangs
# - Stability percentage (should be >90%)
# - Exec speed (execs/sec)
# - Cycles done (full corpus cycles completed)References
References and resources
Everything below is rendered for inspection. Script files are read-only and never run.
References 3
api-reference.md2.1 KB
API Reference — Implementing Fuzz Testing in CI/CD with AFL++
Libraries Used
- subprocess: Execute AFL++ toolchain commands (afl-clang-fast, afl-fuzz, afl-cmin)
- pathlib: File system operations for corpus and crash management
CLI Interface
python agent.py compile --source target.c --output target_fuzz [--compiler afl-clang-fast]
python agent.py fuzz --binary ./target_fuzz --input seeds/ --output findings/ [--duration 300]
python agent.py triage --binary ./target_fuzz --crashes-dir findings/default/crashes/
python agent.py stats --stats-file findings/default/fuzzer_statsCore Functions
compile_target(source_file, output_binary, compiler)
Compiles target with AFL++ instrumentation. Sets AFL_HARDEN=1 for memory sanitizers.
run_fuzzer(binary, input_dir, output_dir, duration_seconds, memory_limit)
Runs afl-fuzz with headless mode (AFL_NO_UI=1), time-limited (-V flag).
Environment Variables Set:
| Variable | Value | Purpose |
|---|---|---|
AFL_SKIP_CPUFREQ |
1 | Skip CPU frequency check (CI/CD) |
AFL_NO_UI |
1 | Headless mode for CI environments |
AFL_I_DONT_CARE_ABOUT_MISSING_CRASHES |
1 | Continue on crash dir issues |
parse_fuzzer_stats(stats_file)
Parses AFL++ fuzzer_stats file. Key metrics: execs_per_sec, paths_total, saved_crashes, bitmap_cvg.
triage_crashes(binary, crashes_dir)
Re-runs crash inputs through the binary and classifies by signal (SIGSEGV, SIGABRT, etc.).
minimize_corpus(binary, input_dir, output_dir, timeout)
Runs afl-cmin to remove redundant seeds from the corpus.
AFL++ Commands Used
| Command | Purpose |
|---|---|
afl-clang-fast |
Compile with LLVM-based instrumentation |
afl-fuzz -i <in> -o <out> -- <binary> |
Main fuzzing loop |
afl-cmin -i <in> -o <out> -- <binary> |
Corpus minimization |
afl-tmin -i <crash> -o <min> -- <binary> |
Test case minimization |
Dependencies
AFL++ must be installed: apt install aflplusplus or build from source.
pip install # No Python packages needed beyond stdlibstandards.md1.8 KB
Standards Reference for Fuzz Testing
NIST SP 800-53 Rev 5 Controls
| Control | Description | Fuzzing Alignment |
|---|---|---|
| SA-11(5) | Penetration Testing | Fuzz testing discovers vulnerabilities through automated input mutation |
| SA-11(8) | Dynamic Code Analysis | AFL++ provides runtime analysis with instrumented binaries |
| SI-10 | Information Input Validation | Fuzzing validates input handling robustness |
| SI-17 | Fail-Safe Procedures | Crash detection ensures failures are handled safely |
OWASP Testing Guide v4.2
- WSTG-INPV-07: Testing for Input Validation --- AFL++ systematically tests boundary conditions
- WSTG-ERRH-01: Error Handling --- Crash analysis reveals improper error handling
CWE Categories Commonly Found by Fuzzing
| CWE | Name | AFL++ Detection Method |
|---|---|---|
| CWE-120 | Buffer Overflow | ASan crash on out-of-bounds write |
| CWE-125 | Out-of-Bounds Read | ASan crash on invalid read |
| CWE-416 | Use After Free | ASan detects freed memory access |
| CWE-476 | NULL Pointer Dereference | SIGSEGV on null deref |
| CWE-190 | Integer Overflow | UBSan detects arithmetic overflow |
| CWE-787 | Out-of-Bounds Write | ASan detects heap/stack buffer overflow |
| CWE-400 | Uncontrolled Resource Consumption | Timeout detection for hangs |
Fuzzing Maturity Levels
| Level | Description | CI Integration |
|---|---|---|
| 1 Basic | Manual ad-hoc fuzzing | None |
| 2 Structured | Harness-based with corpus management | PR-triggered short runs |
| 3 Continuous | Nightly campaigns with crash tracking | Nightly + corpus caching |
| 4 Optimized | Multi-tool (AFL++, libFuzzer), crash dedup, coverage tracking | Full CI/CD integration with gating |
workflows.md1.4 KB
AFL++ Fuzz Testing Workflows
Workflow 1: CI Pipeline Integration
Code pushed to branch
|
Fuzzing harness compiled with afl-clang-fast + ASan
|
Corpus restored from CI cache
|
AFL++ runs in secondary mode for fixed duration
|
[No crashes] --> Corpus updated in cache, pipeline passes
[Crashes found] --> Pipeline fails, crash artifacts uploaded
|
Developer triages crashes
|
Fix applied, re-run confirms no regressionWorkflow 2: Nightly Fuzzing Campaign
Scheduled nightly trigger (cron)
|
Build instrumented binary + CmpLog binary
|
Restore merged corpus from last run
|
Launch parallel AFL++ instances (nproc count)
|
Run for 4-8 hours
|
Collect results from all instances
|
afl-cmin merges and minimizes corpus
|
Deduplicate crashes by stack hash
|
New crashes create Jira/GitHub issues automatically
|
Updated corpus cached for next runWorkflow 3: Crash Triage and Fix
Crash file identified in findings/
|
Reproduce crash with ASan-instrumented binary
|
Capture ASan stack trace and error type
|
Minimize crash input with afl-tmin
|
Identify root cause from stack trace
|
Develop fix and add crash input as regression test
|
Verify fix by re-running AFL++ with crash input
|
Update corpus to include edge case inputsScripts 2
agent.py6.8 KB
#!/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()
process.py5.9 KB
#!/usr/bin/env python3
"""
AFL++ Fuzzing Results Analyzer
Parses AFL++ output directories and generates reports on
crash findings, corpus growth, and coverage statistics.
"""
import json
import os
import sys
from datetime import datetime
from pathlib import Path
from collections import defaultdict
def parse_fuzzer_stats(stats_file: str) -> dict:
stats = {}
if not os.path.exists(stats_file):
return stats
with open(stats_file) as f:
for line in f:
line = line.strip()
if ":" in line:
key, value = line.split(":", 1)
stats[key.strip()] = value.strip()
return stats
def count_files_in_dir(directory: str) -> int:
if not os.path.isdir(directory):
return 0
return len([f for f in os.listdir(directory) if f != "README.txt" and os.path.isfile(os.path.join(directory, f))])
def analyze_fuzzer_instance(instance_dir: str) -> dict:
name = os.path.basename(instance_dir)
stats = parse_fuzzer_stats(os.path.join(instance_dir, "fuzzer_stats"))
return {
"name": name,
"start_time": stats.get("start_time", ""),
"last_update": stats.get("last_update", ""),
"execs_done": int(stats.get("execs_done", 0)),
"execs_per_sec": float(stats.get("execs_per_sec", 0)),
"corpus_count": count_files_in_dir(os.path.join(instance_dir, "queue")),
"crashes_total": count_files_in_dir(os.path.join(instance_dir, "crashes")),
"hangs_total": count_files_in_dir(os.path.join(instance_dir, "hangs")),
"paths_total": int(stats.get("paths_total", 0)),
"paths_found": int(stats.get("paths_found", 0)),
"stability": stats.get("stability", ""),
"cycles_done": int(stats.get("cycles_done", 0)),
"bitmap_cvg": stats.get("bitmap_cvg", ""),
"command_line": stats.get("command_line", ""),
}
def collect_crash_info(instance_dir: str) -> list:
crashes_dir = os.path.join(instance_dir, "crashes")
crashes = []
if not os.path.isdir(crashes_dir):
return crashes
for fname in sorted(os.listdir(crashes_dir)):
if fname == "README.txt":
continue
fpath = os.path.join(crashes_dir, fname)
if os.path.isfile(fpath):
crashes.append({
"file": fname,
"path": fpath,
"size": os.path.getsize(fpath),
"instance": os.path.basename(instance_dir),
})
return crashes
def analyze_campaign(findings_dir: str) -> dict:
report = {
"findings_dir": findings_dir,
"analyzed_at": datetime.utcnow().isoformat() + "Z",
"instances": [],
"total_execs": 0,
"total_crashes": 0,
"total_hangs": 0,
"total_corpus": 0,
"all_crashes": [],
"avg_execs_per_sec": 0,
}
instance_dirs = []
for entry in sorted(os.listdir(findings_dir)):
full_path = os.path.join(findings_dir, entry)
if os.path.isdir(full_path) and os.path.exists(os.path.join(full_path, "fuzzer_stats")):
instance_dirs.append(full_path)
if not instance_dirs:
print(f"No fuzzer instances found in {findings_dir}")
return report
exec_speeds = []
for inst_dir in instance_dirs:
inst = analyze_fuzzer_instance(inst_dir)
report["instances"].append(inst)
report["total_execs"] += inst["execs_done"]
report["total_crashes"] += inst["crashes_total"]
report["total_hangs"] += inst["hangs_total"]
report["total_corpus"] += inst["corpus_count"]
if inst["execs_per_sec"] > 0:
exec_speeds.append(inst["execs_per_sec"])
crashes = collect_crash_info(inst_dir)
report["all_crashes"].extend(crashes)
if exec_speeds:
report["avg_execs_per_sec"] = round(sum(exec_speeds) / len(exec_speeds), 1)
return report
def print_report(report: dict) -> None:
print(f"\n{'='*60}")
print(f"AFL++ Fuzzing Campaign Report")
print(f"{'='*60}")
print(f"Findings directory: {report['findings_dir']}")
print(f"Analyzed at: {report['analyzed_at']}")
print(f"Fuzzer instances: {len(report['instances'])}")
print(f"\nAggregate Statistics:")
print(f" Total executions: {report['total_execs']:,}")
print(f" Avg exec/sec: {report['avg_execs_per_sec']:,.1f}")
print(f" Total corpus entries: {report['total_corpus']}")
print(f" Total unique crashes: {report['total_crashes']}")
print(f" Total hangs: {report['total_hangs']}")
print(f"\nInstance Details:")
for inst in report["instances"]:
print(f" {inst['name']:20s} | Execs: {inst['execs_done']:>12,} | "
f"Speed: {inst['execs_per_sec']:>8.1f}/s | "
f"Crashes: {inst['crashes_total']:3d} | "
f"Corpus: {inst['corpus_count']:5d} | "
f"Cycles: {inst['cycles_done']}")
if report["all_crashes"]:
print(f"\nCrash Files ({len(report['all_crashes'])} total):")
for crash in report["all_crashes"][:20]:
print(f" [{crash['instance']}] {crash['file']} ({crash['size']} bytes)")
if len(report["all_crashes"]) > 20:
print(f" ... and {len(report['all_crashes']) - 20} more")
verdict = "PASS" if report["total_crashes"] == 0 else "FAIL"
print(f"\nCI Verdict: {verdict}")
def main():
if len(sys.argv) < 2:
print("Usage: python process.py <findings_directory>")
sys.exit(1)
findings_dir = sys.argv[1]
if not os.path.isdir(findings_dir):
print(f"Directory not found: {findings_dir}")
sys.exit(1)
report = analyze_campaign(findings_dir)
print_report(report)
output = os.path.join(findings_dir, "campaign_report.json")
with open(output, "w") as f:
json.dump(report, f, indent=2, default=str)
print(f"\nReport saved to: {output}")
sys.exit(1 if report["total_crashes"] > 0 else 0)
if __name__ == "__main__":
main()