An LLM-driven fuzzing pipeline powered by the GitHub Security Lab Taskflow Agent
An LLM-driven, OSS-Fuzz-style fuzzing pipeline for native C/C++ projects. AFL++ for execution, clang+lcov for coverage, an LLM agent for harness writing, coverage-feedback decisions, triage, and reporting.
This repository contains the fuzzing taskflow for the
GitHub Security Lab Taskflow Agent.
It depends on the
seclab-taskflows
companion repository for a few shared building blocks
(fetch_source_code taskflow, local_file_viewer / gh_file_viewer
toolboxes, and the default model_config) — those are installed
automatically as a Python dependency.
Contributions are welcome! Please see CONTRIBUTING.md for guidelines.
aptgh)pip install git+https://github.com/GitHubSecurityLab/seclab-taskflows-fuzzing
This pulls in seclab-taskflow-agent and seclab-taskflows (parent)
transitively, so every dotted reference of the form
seclab_taskflows.taskflows.audit.*,
seclab_taskflows.toolboxes.local_file_viewer,
seclab_taskflows.toolboxes.gh_file_viewer, and
seclab_taskflows.configs.model_config resolves out of the parent
distribution at runtime.
This taskflow is a fully autonomous fuzzing pipeline. Given a GitHub repo of a native C/C++ project, it will:
.afl binary and a coverage-instrumented .cov binary,The pipeline is OSS-Fuzz-style in spirit: it uses many of the same techniques (per-format mutators and dictionaries, structure-aware token splicing, coverage-driven harness improvements, machine-readable reports, deduped stack-hashed crashes) but it is much smaller and self-contained.
# Inside the codespace (or a host with python + git available):
./scripts/fuzzing/run_fuzzing.sh tukaani-project/xz
That's the whole interface. The script is autonomous; it will install AFL++
on first run, then drive the rest of the taskflow. Output files are written
to ~/.local/share/seclab-taskflow-agent/seclab-taskflows/.
The dashboard auto-starts in the background; in a Codespace, port 8765 is
auto-forwarded — open it in any browser to watch progress live.
For a quick smoke-test, use a small target:
./scripts/fuzzing/run_fuzzing.sh DaveGamble/cJSON
Three layers, top to bottom:
┌────────────────────────────────────────────────────────────────────┐
│ scripts/fuzzing/run_fuzzing.sh │
│ shell driver; chains the taskflow stages with `set +e` │
└────────────────────┬───────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────────┐
│ src/seclab_taskflows/taskflows/fuzzing/*.yaml │
│ LLM agent prompts; one YAML per pipeline stage │
└────────────────────┬───────────────────────────────────────────────┘
│ (calls MCP tools)
▼
┌────────────────────────────────────────────────────────────────────┐
│ src/seclab_taskflows/mcp_servers/ │
│ ├ fuzz_context.py persistence (SQLite via SQLAlchemy) │
│ └ fuzz_runner.py subprocess wrappers (AFL, clang, lcov, ...) │
│ │
│ scripts/fuzzing/dashboard.py │
│ read-only HTML view of fuzz_context.db │
└────────────────────────────────────────────────────────────────────┘
Key design rules:
fuzz_context.db.run_afl_for, compile_harness, store_crash, etc.afl-clang-lto -fsanitize=address,undefined (the .afl binary) and once
with clang -fprofile-instr-generate -fcoverage-mapping (the .cov
binary). The .afl binary fuzzes; the .cov binary replays the AFL
queue to produce real source-line/function/branch coverage.| # | Stage | Taskflow YAML |
|---|---|---|
| 1 | Install AFL++ + tooling | scripts/fuzzing/install_afl.sh |
| 2 | Fetch source | seclab_taskflows.taskflows.audit.fetch_source_code |
| 3 | Identify fuzz targets | seclab_taskflows_fuzzing.taskflows.fuzzing.identify_fuzz_targets |
| 4 | Analyse build system | seclab_taskflows_fuzzing.taskflows.fuzzing.analyze_build_system |
| 5a | Write initial harnesses (×N candidates if requested) | seclab_taskflows_fuzzing.taskflows.fuzzing.write_initial_harnesses |
| 5b | Build harnesses (AFL + coverage) | seclab_taskflows_fuzzing.taskflows.fuzzing.build_harnesses |
| 5c | Qualify candidates (when HARNESS_CANDIDATES > 1) | seclab_taskflows_fuzzing.taskflows.fuzzing.qualify_harnesses |
| 6 | Fuzz/coverage/improve loop (×N iterations) | seclab_taskflows_fuzzing.taskflows.fuzzing.fuzz_iteration |
| 7 | Triage crashes | seclab_taskflows_fuzzing.taskflows.fuzzing.triage_crashes |
| 8 | Confirm previously-known crashes still reproduce | seclab_taskflows_fuzzing.taskflows.fuzzing.confirm_fixed_crashes |
| 9 | Build call graph + untouched-API report | seclab_taskflows_fuzzing.taskflows.fuzzing.analyze_call_graph |
| 10 | Write per-crash vuln reports | seclab_taskflows_fuzzing.taskflows.fuzzing.write_vuln_reports |
| 11 | Write campaign report | seclab_taskflows_fuzzing.taskflows.fuzzing.write_report |
Each stage is a self-contained taskflow YAML that the agent runs
end-to-end. Stages communicate exclusively through the SQLite database
in fuzz_context.db — there is no in-memory hand-off.
This is the heart of the pipeline. Time budgets double every iteration:
30s → 60s → 120s → 240s → 480s → 960s (≈ 32 min/target)
Per iteration, per harness, the agent:
get_persistent_corpus_dir(harness_id) for this harness's stable
corpus dir.run_afl_for(afl_binary_path, seed_dir=<persistent corpus>, output_dir=<run dir>, seconds=<budget>, dictionary=<auto.dict>).run_coverage(cov_binary_path, inputs_dir=<run>/default/queue, output_dir=<run>/coverage) to produce an LCOV tracefile and HTML report.store_coverage_from_lcov(run_id, lcov_path, html_path) to persist
a coverage_report row + per-uncovered-item coverage_gap rows.fold_queue_into_persistent_corpus(...) to merge AFL's iteration
queue into the persistent corpus and run cmin to keep size bounded.get_coverage_summary + get_coverage_gaps, then either:
coverage_feedback) to reach an uncovered
branch,enrich_dictionary_from_uncovered(...) to auto-add dictionary
entries for the magic constants AFL needs to satisfy a guard, orstore_iteration_note(repo, iteration_number, harness_id, note=<one line summary>) so the dashboard's iteration timeline tracks what
changed.Plateau detection. The loop exits early once two consecutive iterations
have both gained < FUZZ_PLATEAU_THRESHOLD_PCT (default 1.0) absolute
percentage points of line coverage.
Three complementary mechanisms produce stronger inputs than raw byte mutation.
For targets whose input_kind matches a known format, the taskflow ships
pre-built dictionaries and LLVMFuzzerCustomMutator C source files:
| Format | Dictionary | Mutator | Notes |
|---|---|---|---|
json | json.dict | json_mutator.c | Token splice, balanced bracket dup/drop, type flip |
xml | xml.dict | xml_mutator.c | Tags, entities, DTDs, billion-laughs tokens |
regex | regex.dict | regex_mutator.c | Anchors, classes, quantifiers, real ReDoS patterns |
binary_tlv | (none) | binary_tlv_mutator.c | Length-prefixed records: length-overflow / dup / drop |
png | png.dict | (reuses binary_tlv) | PNG dictionary + binary_tlv mutator |
These are picked up automatically by write_initial_harnesses (dictionary
copied next to seeds) and build_harnesses (mutator linked into the AFL
binary). Each mutator delegates 50% of mutations to AFL's default byte
mutator so we don't lose the engine's randomisation.
To add a new format: drop a <name>.dict and/or a <name>_mutator.c into
src/seclab_taskflows/dictionaries/, then register it in the
_FORMAT_ASSETS map at the bottom of fuzz_runner.py.
For unfamiliar formats, or whenever you want stronger project-specific
tokens, generate_smart_mutator scans the target repo's own .c/.h
files and emits an LLVMFuzzerCustomMutator C file whose splice
dictionaries are extracted from:
#define, case, and enum (after
filtering generic small-int noise like 0, 1, 256, 0xff…).Three focuses are available:
| Focus | What it splices | When to use |
|---|---|---|
strings | Project string literals only | Text formats (JSON, XML, YAML, CSV) |
constants | 32-bit numeric magic values only | Binary protocols, headers with magic numbers |
combined | Both | Default; usually best |
Pair generate_smart_mutators(...) (plural) with HARNESS_CANDIDATES >= 3
so each focus becomes a candidate harness in the qualifier round.
Two complementary tools build and grow an AFL -x dictionary as the
campaign progresses:
generate_project_dictionary(source_root, output_path) — runs once
before iteration 1, statically extracts the same source-token set used by
the smart mutator and writes it as an AFL dictionary. Numeric constants
are emitted in BOTH endiannesses so the fuzzer can satisfy
memcmp(x, &magic, 4) regardless of host byte order.
enrich_dictionary_from_uncovered(source_root, dictionary_path, uncovered_locations) — runs after every iteration's coverage step,
scans the surrounding source for conditional guards
(strncmp/memcmp/strstr, case 0xN:, == 0xN, == 'X') near the
uncovered lines, and APPENDS any new tokens to the dictionary. Idempotent:
never re-adds an entry that's already present.
When corpus_dir is passed to generate_smart_mutator, the generated C
also gets a corpus-splice operator: on first call it loads up to 64 files
from that directory (capped at 4 KiB each), and from then on can splice
random sub-regions of those files into the mutated input. This gives the
mutator a recombination-style operator that AFL's stock havoc doesn't do
well. Pair with get_persistent_corpus_dir(...) so the splice library
is "remix what AFL has already discovered".
Each harness has a stable corpus directory at:
<workspace>/corpus/harness_<id>/
This is what fuzz_iteration uses as seed_dir for run_afl_for (rather
than <harness>/seeds). At the end of every iteration,
fold_queue_into_persistent_corpus(...) merges AFL's iteration queue into
this dir and runs afl-cmin to keep it bounded.
The result: yesterday's queue carries into today's run AND across re-runs of the same project. A stop-and-restart of the campaign loses no progress.
After the fuzz/coverage/improve loop finishes, three stages run automatically:
triage_crashesFor every crash file in <run>/default/crashes/:
afl-tmin to minimise the input,replay_under_asan to capture a stack trace and stack_top_hash
(top-N normalised frames; templates, libcxx inline namespaces, anonymous
namespaces, and LTO numeric suffixes are stripped so semantically
identical crashes hash identically),crash row with bug-class classification +
confidence note (high / medium / low).confirm_fixed_crashesReplays every previously-classified crash (whose verdict isn't already
fixed/duplicate/non_reproducible) through the current AFL+ASan binary.
If it no longer crashes, marks verdict="fixed". Useful when re-running a
campaign against a project that has had upstream fixes applied since the
last campaign.
write_vuln_reportsFor every unique crash, the agent reads the harness source + the crashing function's source, walks the call chain from the public API, then assigns one of ten OSS-Fuzz-style verdicts and writes a markdown vuln report:
| Verdict | Meaning |
|---|---|
vulnerability | Real, exploitable through a public API |
library_hardening | Real bug but no realistic public-API path; library should still defend itself |
harness_bug | The bug is in our harness, not the library |
non_reproducible | Replay does not reproduce the crash on the minimised input |
oom | Out-of-memory; vuln only if attacker-controllable size is unbounded |
timeout | DoS via algorithmic blow-up |
assertion_failure | assert() hit; security relevance varies |
fixed | Set by confirm_fixed_crashes: input no longer reproduces |
duplicate | Same root cause as another crash with a different stack hash |
needs_investigation | Could not determine; flagged for human review |
Each vuln report includes:
The dashboard is started automatically in the background by
run_fuzzing.sh. Disable with FUZZ_NO_DASHBOARD=1; override the port
with FUZZ_DASHBOARD_PORT (default 8765).
In a Codespace, port 8765 is auto-forwarded — open the forwarded URL in
any browser. The page auto-refreshes every 5 s and shows:
fuzz_runvulnerability first), linking
to each vuln report and minimised inputThe dashboard also exposes a tiny read-only JSON API for scripts:
# All known repos
curl http://127.0.0.1:8765/api/json
# Per-repo: harnesses, per-iteration coverage, crashes with verdicts
curl 'http://127.0.0.1:8765/api/json?repo=kkos/oniguruma' | jq .
All under ~/.local/share/seclab-taskflow-agent/seclab-taskflows/.
| Path | Contents |
|---|---|
fuzz_context/fuzz_context.db | SQLite — targets, harnesses, runs, coverage, crashes, verdicts, call graphs, harness suggestions, iteration notes |
fuzz_runner/builds/ | Built .afl and .cov binaries |
fuzz_runner/runs/ | AFL output dirs + LCOV files + HTML coverage reports |
fuzz_runner/corpus/harness_<id>/ | Persistent corpus per harness (carries across iterations & campaigns) |
fuzz_runner/repo/<owner>__<repo>/REPORT.md | Markdown campaign summary, crashes grouped by verdict |
fuzz_runner/repo/<owner>__<repo>/vuln_<crash_id>.md | Per-crash markdown vuln report |
fuzz_runner/repo/<owner>__<repo>/call_graph.{dot,svg,md} | Static call graph + reached/unreached overlay |
Tables in fuzz_context.db (SQLite via SQLAlchemy):
| Table | Columns of interest |
|---|---|
fuzz_target | repo, file, function, signature, input_kind |
harness | target_id, repo, harness_path, afl_binary_path, cov_binary_path, build_status, version, sanitizers |
seed_corpus | target_id, source, path, bytes_count, added_in_iteration |
fuzz_run | harness_id, iteration_number, exec_per_sec, paths_total, crashes_count, status, output_dir, started_at, ended_at |
coverage_report | run_id, lines_total, lines_hit, line_pct, fns_*, branches_*, lcov_path, html_path |
coverage_gap | report_id, file, function, line, kind, reason_hint |
crash | run_id, input_blob_path, minimized_path, stack_top_hash, sanitizer_output, verdict, bug_class, cwe, severity, vuln_report_path, reproducer_path, classification, notes |
call_graph | repo, target_id, dot_path, svg_path, functions_total, functions_in_graph, functions_reached, functions_unreached, untouched_surface_json |
harness_suggestion | repo, function_name, file, rationale, input_kind, priority |
iteration_note | repo, harness_id, iteration_number, note, created_at |
Schema migrations live in _migrate() in fuzz_context.py. New TABLES are
auto-created by Base.metadata.create_all(); only new COLUMNS need
PRAGMA-based ALTER TABLE.
The agent never calls AFL or clang directly — it composes the pipeline by calling MCP tools. The full set, grouped by purpose:
fuzz_context.py)store_fuzz_target, get_fuzz_targetsstore_harness, update_harness_build, get_harnessesstore_seed, start_fuzz_run, finish_fuzz_run, get_fuzz_runsstore_coverage_from_lcov, get_coverage_summary, get_coverage_gaps,
coverage_plateau_reachedstore_crash, update_crash_verdict, get_crashes,
get_crashes_grouped, suggest_severitystore_call_graph, get_call_graphs, get_repo_reached_functionsstore_harness_suggestion, get_harness_suggestionsstore_iteration_note, get_iteration_notesfuzz_runner.py)check_tooling, workspace_pathscompile_harness — builds .afl and .cov binariesrun_afl_for, cmin, tmin, replay_under_asan, reproduce_crashrun_coverage — replays AFL queue against the .cov binary, exports LCOVextract_dictionary — mine printable strings from a binarypackage_reproducer — bundle a single-crash .tgzget_persistent_corpus_dir, fold_queue_into_persistent_corpuslist_format_assets, get_format_dictionary, write_format_mutatorgenerate_smart_mutator, generate_smart_mutatorsgenerate_project_dictionary, enrich_dictionary_from_uncoveredTool functions are decorated with @mcp.tool() (FastMCP). Inside tests,
invoke them via the .fn attribute, e.g.
fr.run_afl_for.fn(afl_binary_path=..., ...).
| Variable | Default | Purpose |
|---|---|---|
HARNESS_CANDIDATES | 1 | Number of candidate harnesses written per target. Set to 2 or 3 for OSS-Fuzz-Gen-style competition. The qualifier stage runs each for QUALIFIER_SECONDS and keeps the best by line %. |
QUALIFIER_SECONDS | 60 | Per-candidate wall-clock budget in the qualifier stage. |
FUZZ_PLATEAU_THRESHOLD_PCT | 1.0 | Line-coverage gain (in absolute pp) below which two consecutive iterations are considered a plateau and the loop stops early. |
FUZZ_DASHBOARD_PORT | 8765 | Port for the live dashboard. |
FUZZ_NO_DASHBOARD | (unset) | Set to 1 to skip starting the dashboard. |
FUZZ_RUNNER_TIMEOUT | 1200 | Per-tool subprocess timeout in fuzz_runner (seconds). |
LOCAL_SHELL_TIMEOUT | 180 | Per-command timeout in local_shell (seconds). |
Plus the standard agent variables (COPILOT_TOKEN, LOG_DIR,
FUZZ_CONTEXT_DIR, …). See the project root README for the full list.
dictionaries/<name>.dict (AFL -x format) and/or
dictionaries/<name>_mutator.c (libFuzzer custom mutator)._FORMAT_ASSETS at the bottom of fuzz_runner.py:
"<name>": {
"dictionary": "<name>.dict",
"mutator": "<name>_mutator.c",
"description": "Short one-liner about the format",
},
list_format_assets().@mcp.tool()-decorated function in fuzz_context.py (for
persistence) or fuzz_runner.py (for subprocess work).Annotated[type, Field(description=...)] for every arg — the
description is what the LLM sees.tests/test_fuzz_context.py /
tests/test_fuzz_runner.py. Invoke the tool via its .fn attribute
(FastMCP convention).user_prompt.src/seclab_taskflows/taskflows/fuzzing/. Use
one of the existing files (e.g. triage_crashes.yaml) as a template.scripts/fuzzing/run_fuzzing.sh between the right two
existing stages.scripts/fuzzing/dashboard.py.When adding a new SQL table:
fuzz_context_models.py.Base.metadata.create_all() is called at engine
init and creates new tables automatically.When adding a new COLUMN to an existing table:
PRAGMA table_info + ALTER TABLE ADD COLUMN block in
_migrate() in fuzz_context.py so old DBs are upgraded transparently._migrate_if_writable() in scripts/fuzzing/dashboard.py.benchmark/projects.yaml lists the reference projects. They're chosen so
the full v4+ pipeline can run end-to-end on a codespace dev image without
human intervention.
| # | Repo | Why it's interesting | Notes |
|---|---|---|---|
| 1 | tukaani-project/xz | Real-world parser-heavy library (liblzma); rich filter chain + integer/VLI parsing surface | Baseline |
| 2 | DaveGamble/cJSON | Small single-file C JSON parser; trivial CMake | Quick smoke for the pipeline |
| 3 | akheron/jansson | Compact C JSON library with documented json_loadb() byte-buffer entry point | CMake; very fast exec/sec |
| 4 | libexpat/libexpat | Mature streaming XML parser; many historical CVEs | CMake or autotools |
| 5 | kkos/oniguruma | Regex engine; takes attacker pattern + subject | Autotools; pattern compilation is the hot path |
Reference numbers from a full v4-pipeline run on the codespace dev image (≈32 min/target):
| Repo | Targets | Harnesses | AFL runs | Crashes | Verdicts |
|---|---|---|---|---|---|
tukaani-project/xz | 8 | 8 | 48 | 0 | — |
DaveGamble/cJSON | 6 | 6 | 36 | 0 | — |
akheron/jansson | 7 | 7 | 35 | 10 | harness_bug, library_hardening, duplicate, needs_investigation |
libexpat/libexpat | 3 | 3 | 18 | 0 | — |
kkos/oniguruma | 10 | 10 | 60 | 13 | vulnerability (×2 OOB read in regerror.c), library_hardening, harness_bug, non_reproducible |
The xz / cJSON / libexpat zero-crash results are expected: those projects
are heavily fuzzed upstream. The two vulnerability-classified findings
in oniguruma are real out-of-bounds reads in the warning-formatting code
path of onig_snprintf_with_pattern (one-byte read past pat_end when
the pattern ends with a backslash); the per-crash markdown reports
include suggested patches.
To add a new benchmark project, add an entry to benchmark/projects.yaml
and (optionally) document why in benchmark/README.md. Anything that the
existing analyze_build_system stage can build with clang + AFL++ flags
is a reasonable candidate. Pure-C parsers, decoders, and serialisers tend
to work best.
BUILD_FAILED:
and skips them.kernel.core_pattern=core and a
CPU governor tweak. In a Codespace these are unavailable, so the
taskflow exports AFL_SKIP_CPUFREQ=1 and
AFL_I_DONT_CARE_ABOUT_MISSING_CRASHES=1 by default. AFL prints
warnings but still finds crashes via libFuzzer-style abort handling.<dirent.h>. Fine for Linux/macOS; would not compile on Windows.compile_harness use
libAFLDriver in argv mode. replay_under_asan and tmin therefore
default to stdin_input=False because libAFLDriver loops forever when
driven via stdin.generate_smart_mutator + generate_smart_mutators use Python
.format() — every literal { / } in the C template must be doubled
({{ / }}). If you edit the template and start seeing KeyError,
that's why.This taskflow runs afl-fuzz, clang, llvm-cov, and arbitrary build
commands chosen by the LLM, directly on the host (no container). A
prompt-injected agent could in principle do anything your user can. Run
only:
git, apt, and the build system
need.The local_shell toolbox is NOT behind a confirmation prompt — the
taskflow is autonomous and runs without a human in the loop, so an
interactive confirmation would just block forever. Every shell command is
logged to $LOG_DIR/mcp_local_shell.log for after-the-fact review.
# Run the test suite (Python 3.11+ required by hatch-test envs)
hatch test
# Run the linter
hatch fmt --linter --check
# Auto-fix lint issues
hatch fmt --linter
# Lint a single file
hatch fmt --linter --check -- src/seclab_taskflows/mcp_servers/fuzz_runner.py
Codebase conventions (see also benchmark/improvements.md for the
campaign-history version of these):
os.environ.get(NAME) or "default" rather than
os.environ.get(NAME, "default"). Empty strings from YAML template
substitution would otherwise be returned.X | None (PEP 604) in new annotations, not Optional[X]..fn(...), not the decorated name directly./tmp/... literals in tests — use the tmp_path pytest fixture
(lint rule S108).# noqa: PLC0415 if you
can't move them to the top of the file (e.g. when conditionally imported
after a pytest.skip).PT018).The improvements tracker (benchmark/improvements.md) is the persistent
log of what's been added to the pipeline across versions. When you add a
substantive feature, add a section there describing what changed, where it
lives, and what tests guard it.
LLVMFuzzerTestOneInput).llvm-cov export -format=lcov and parse it ourselves.stack_top_hash — A 16-char hash of the top N normalised frames of
an ASan/UBSan stack trace. Used for crash deduplication.<workspace>/corpus/harness_<id>/ that carries AFL's interesting inputs
across iterations and re-runs of the same campaign.LLVMFuzzerCustomMutator whose splice tokens are
extracted from the target's own source code (generate_smart_mutator).This project is licensed under the terms of the MIT open source license. Please refer to the LICENSE file for the full terms.
See CODEOWNERS or reach out to the GitHub Security Lab team.
See SUPPORT.md for details on how to get help with this project.
This project builds on top of AFL++, OSS-Fuzz, and Fuzz-Introspector concepts and techniques.