diff --git a/.github/actions/check-new-seeds/action.yml b/.github/actions/check-new-seeds/action.yml new file mode 100644 index 0000000000..1c15fdbe0d --- /dev/null +++ b/.github/actions/check-new-seeds/action.yml @@ -0,0 +1,27 @@ +name: Verify new seeds +description: Run libfuzzer -merge=1 over (baseline + new) seeds for each harness with newly-added seeds in the PR. Hard-fail if any new seed is redundant. + +inputs: + project: + description: Project name. + required: true + base_sha: + description: Git base SHA to diff against (typically merge-base with the default branch). + required: true + out_base: + description: Directory containing the per-project oss-fuzz out tree (e.g. build/out, /tmp/oss-fuzz/build/out). + required: true + seeds_dir: + description: Workspace-relative seeds root grouped by harness (e.g. projects//seeds, .github/fuzz/seeds). + required: true + +runs: + using: composite + steps: + - shell: bash + env: + PROJECT: ${{ inputs.project }} + BASE_SHA: ${{ inputs.base_sha }} + OUT_BASE: ${{ inputs.out_base }} + SEEDS_DIR: ${{ inputs.seeds_dir }} + run: python3 "$GITHUB_ACTION_PATH/check_new_seeds.py" diff --git a/.github/actions/check-new-seeds/check_new_seeds.py b/.github/actions/check-new-seeds/check_new_seeds.py new file mode 100644 index 0000000000..3e7d5bfbf2 --- /dev/null +++ b/.github/actions/check-new-seeds/check_new_seeds.py @@ -0,0 +1,330 @@ +# This file ships into oss-fuzz review-repo PRs via the check-new-seeds +# composite action; oss-fuzz's `infra/presubmit.py` check_license greps every +# Python file under non-projects/ paths for the Apache 2.0 LICENSE-2.0 URL, +# so we carry the standard header here even though it's our own infra code. +# Copyright 2026 fuzz-for-me contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Verify newly-added fuzz seeds aren't redundant given the baseline corpus. + +For each harness whose seed dir got new files in this PR, run libfuzzer's +`-merge=1` over (baseline + new) and check every new seed survived. A seed +is redundant if any of: + - its content already exists in baseline (exact duplicate of preexisting), + - its content matches another newly-added seed (dup within the PR), + - libfuzzer's merge dropped it (covers no new edges beyond what's loaded). + +Hard-fails (exit 1) with a per-seed reason and a copy-paste reproducer. + +Inputs (env vars): + PROJECT project name (used for OUT_BASE//) + BASE_SHA git SHA to diff against (typically merge-base with main/master) + OUT_BASE dir containing built harness binaries + (e.g. build/out for oss-fuzz, /tmp/oss-fuzz/build/out for upstream) + SEEDS_DIR workspace-relative seeds root, grouped by harness + (e.g. projects//seeds for oss-fuzz, .github/fuzz/seeds for upstream) + BASE_RUNNER (optional) override base-runner image, default + gcr.io/oss-fuzz-base/base-runner +""" + +import hashlib +import os +import pathlib +import shutil +import subprocess +import sys +import tempfile +from collections import defaultdict + +DEFAULT_BASE_RUNNER = "gcr.io/oss-fuzz-base/base-runner" + + +def file_sha(p: pathlib.Path) -> str: + return hashlib.sha1(p.read_bytes()).hexdigest() + + +def hashes_in(d: pathlib.Path) -> set[str]: + return {file_sha(p) for p in d.iterdir() if p.is_file()} + + +def added_paths(base_sha: str, prefix: str) -> list[str]: + """Files added in commits between base_sha and HEAD, restricted to prefix/.""" + result = subprocess.run( + [ + "git", + "diff", + "--name-only", + "--diff-filter=A", + f"{base_sha}...HEAD", + "--", + prefix, + ], + capture_output=True, + text=True, + check=True, + ) + return [line for line in result.stdout.splitlines() if line] + + +def group_by_harness(paths: list[str], seeds_dir: str) -> dict[str, list[str]]: + """Group seed paths by their harness subdir. + + e.g. projects/foo/seeds/parser/a.in -> {"parser": ["projects/foo/seeds/parser/a.in"]} + """ + prefix = seeds_dir.rstrip("/") + "/" + groups: dict[str, list[str]] = defaultdict(list) + for p in paths: + if not p.startswith(prefix): + continue + rest = p[len(prefix):] + parts = rest.split("/", 1) + if len(parts) != 2: # seed file directly under seeds/, no harness subdir + continue + groups[parts[0]].append(p) + return groups + + +def docker_merge( + image: str, + out_dir: pathlib.Path, + harness: str, + minimized: pathlib.Path, + baseline: pathlib.Path, + new: pathlib.Path, +) -> subprocess.CompletedProcess: + """Run ` -merge=1 /minimized /baseline /new` inside the base-runner.""" + cmd = [ + "docker", + "run", + "--rm", + "--privileged", + "-e", + "FUZZING_ENGINE=libfuzzer", + "-e", + "SANITIZER=address", + "-e", + "ARCHITECTURE=x86_64", + "-e", + "OUT=/out", + "-v", + f"{out_dir.resolve()}:/out:ro", + "-v", + f"{baseline.resolve()}:/baseline:ro", + "-v", + f"{new.resolve()}:/new:ro", + "-v", + f"{minimized.resolve()}:/minimized", + "--entrypoint", + "/out/" + harness, + image, + "-merge=1", + "/minimized", + "/baseline", + "/new", + ] + return subprocess.run(cmd, capture_output=True, text=True) + + +def check_harness( + harness: str, + out_dir: pathlib.Path, + seeds_root: pathlib.Path, + new_names: set[str], + image: str, + work_root: pathlib.Path, +) -> tuple[list[tuple[str, str]], str]: + """Returns (redundant_seeds, debug_log). + + redundant_seeds: list of (filename, reason) for each redundant new seed. + debug_log: stdout+stderr of the merge run (always returned for diagnostics). + """ + harness_seeds = seeds_root / harness + if not harness_seeds.is_dir(): + return [], f"{harness}: seeds dir {harness_seeds} missing — skipping" + + binary = out_dir / harness + if not binary.is_file(): + return [], ( + f"{harness}: harness binary not built ({binary} missing) — skipping. " + "If this is unexpected, check the build_fuzzers step.") + + work = work_root / harness + baseline = work / "baseline" + new = work / "new" + minimized = work / "minimized" + for d in (baseline, new, minimized): + d.mkdir(parents=True, exist_ok=True) + + for f in harness_seeds.iterdir(): + if not f.is_file(): + continue + dst = (new if f.name in new_names else baseline) / f.name + shutil.copyfile(f, dst) + + baseline_hashes = hashes_in(baseline) + + proc = docker_merge(image, out_dir, harness, minimized, baseline, new) + log = ( + f"$ {' '.join(['', '-merge=1', '/minimized', '/baseline', '/new'])}\n" + f"--- exit {proc.returncode} ---\n" + f"--- stdout ---\n{proc.stdout}\n" + f"--- stderr ---\n{proc.stderr}") + if proc.returncode != 0: + return [(harness, f"libfuzzer merge failed (exit {proc.returncode})")], log + + minimized_hashes = hashes_in(minimized) + + # Detect merge-incompatible harnesses. libFuzzer's -merge=1 keeps a minimal + # covering set; with an empty baseline, *any* input that executes and records + # features yields a non-empty `minimized`. If the merge succeeds but keeps + # ZERO files even though there was ≥1 input, libFuzzer never recorded + # per-input features — i.e. the harness doesn't return cleanly to libFuzzer + # between inputs so the merge control file gets no FT lines. (u-boot's + # sandbox fuzzer is the canonical case: it runs the target on a separate + # thread via a coroutine handoff and os_abort()s when sandbox_main returns, + # killing the process before libFuzzer's merge bookkeeping completes.) + # Flagging every seed "redundant" here would be a false positive, so only + # report true byte-duplicates and skip the edge-coverage verdict. + inputs_present = any(baseline.iterdir()) or any(new.iterdir()) + merge_recorded_nothing = not minimized_hashes + incompatible = inputs_present and merge_recorded_nothing + + redundant: list[tuple[str, str]] = [] + seen_in_new: set[str] = set() + for src in sorted(new.iterdir()): + h = file_sha(src) + if h in baseline_hashes: + redundant.append((src.name, "duplicate of an existing baseline seed")) + elif h in seen_in_new: + redundant.append((src.name, "duplicate of another newly-added seed")) + elif incompatible: + # Not byte-duplicate; can't judge edge-coverage on a merge-incompatible + # harness — accept it. + seen_in_new.add(h) + elif h not in minimized_hashes: + redundant.append( + (src.name, "covers no new edges beyond baseline + earlier new seeds")) + else: + seen_in_new.add(h) + + if incompatible: + log += ( + "\n--- note ---\n" + "libFuzzer -merge=1 kept 0 files despite ≥1 input seed; the harness " + "does not return cleanly to libFuzzer between inputs (merge control " + "file has no FT lines), so per-seed edge-coverage cannot be verified. " + "Skipping the redundancy verdict for this harness; only exact " + "byte-duplicate seeds are reported.") + + return redundant, log + + +def _emit(line: str = ""): + print(line, flush=True) + + +def report_failure( + findings: list[tuple[str, list[tuple[str, str]], str]], + seeds_dir: str, + out_base: str, + project: str, +): + """Print a GH-Actions error block + per-harness fix instructions.""" + total = sum(len(rs) for _, rs, _ in findings) + _emit(f"::error::{total} redundant new seed(s) — see details below.") + _emit() + _emit("=" * 70) + _emit("Redundant new seeds detected") + _emit("=" * 70) + _emit() + _emit("libfuzzer's -merge=1 keeps only inputs that add new code coverage. " + "Each seed below either duplicated an existing seed or covered nothing " + "new on top of the corpus loaded before it.") + _emit() + for harness, redundant, log in findings: + _emit(f"### {harness}") + for name, reason in redundant: + _emit(f" - {seeds_dir}/{harness}/{name}") + _emit(f" -> {reason}") + _emit() + _emit(" merge log (last 20 lines of stderr):") + tail = "\n".join(log.splitlines()[-20:]) + for line in tail.splitlines(): + _emit(f" | {line}") + _emit() + _emit("=" * 70) + _emit("How to make CI go green") + _emit("=" * 70) + _emit() + _emit("Pick one per redundant file:") + _emit(" (a) Delete the file from the PR.") + _emit(" (b) Replace its content with input that exercises a new code path") + _emit(" (different parser branch, new field, edge value, etc.).") + _emit() + _emit("To reproduce locally on a built project:") + _emit(f" cd {out_base}/{project}") + _emit(" mkdir -p baseline new minimized") + _emit( + " # populate baseline/ with preexisting seeds, new/ with the added ones") + _emit(" ./ -merge=1 minimized/ baseline/ new/") + _emit( + " # any seed in new/ whose content-hash is not in minimized/ is redundant." + ) + + +def main() -> int: + project = os.environ["PROJECT"] + base_sha = os.environ["BASE_SHA"] + out_base = os.environ["OUT_BASE"] + seeds_dir = os.environ["SEEDS_DIR"] + image = os.environ.get("BASE_RUNNER", DEFAULT_BASE_RUNNER) + + added = added_paths(base_sha, seeds_dir) + if not added: + _emit(f"No new seed files under {seeds_dir} in this PR — skipping check.") + return 0 + + groups = group_by_harness(added, seeds_dir) + if not groups: + _emit(f"Seeds added under {seeds_dir} but none in a per-harness subdir — " + "skipping. (Expected layout: //.)") + return 0 + + out_dir = pathlib.Path(out_base) / project + seeds_root = pathlib.Path(seeds_dir) + + findings: list[tuple[str, list[tuple[str, str]], str]] = [] + with tempfile.TemporaryDirectory() as tmp: + tmp_root = pathlib.Path(tmp) + for harness, paths in sorted(groups.items()): + new_names = {pathlib.Path(p).name for p in paths} + _emit(f"::group::Verify new seeds: {harness} ({len(new_names)} new)") + redundant, log = check_harness(harness, out_dir, seeds_root, new_names, + image, tmp_root) + if redundant: + findings.append((harness, redundant, log)) + _emit(f" -> {len(redundant)} redundant") + else: + _emit(" -> all new seeds non-redundant") + _emit("::endgroup::") + + if findings: + report_failure(findings, seeds_dir, out_base, project) + return 1 + + _emit("All new seeds are non-redundant.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/actions/collect-fuzz-stats/action.yml b/.github/actions/collect-fuzz-stats/action.yml new file mode 100644 index 0000000000..cc2af2c8c2 --- /dev/null +++ b/.github/actions/collect-fuzz-stats/action.yml @@ -0,0 +1,69 @@ +name: Collect fuzz stats +description: Dump coverage summaries (project-wide + per-harness) and corpus file counts into stats-/ for the report job. + +inputs: + project: + description: Project name. + required: true + variant: + description: Variant label — "baseline" or "current". + required: true + sha: + description: Commit SHA measured in this variant. + required: true + has_project: + description: String "true" if the project was present and built for this variant; otherwise only meta.json is written. + required: true + out_base: + description: Directory containing the oss-fuzz out tree (e.g. build/out for oss-fuzz template, /tmp/oss-fuzz/build/out for upstream). + required: true + +runs: + using: composite + steps: + - shell: bash + env: + PROJECT: ${{ inputs.project }} + VARIANT: ${{ inputs.variant }} + SHA: ${{ inputs.sha }} + HAS_PROJECT: ${{ inputs.has_project }} + OUT_BASE: ${{ inputs.out_base }} + run: | + OUT="stats-$VARIANT" + mkdir -p "$OUT/harness" + + jq -n \ + --arg variant "$VARIANT" \ + --arg sha "${SHA:-}" \ + --arg project "$PROJECT" \ + --arg has_project "${HAS_PROJECT:-false}" \ + '{variant: $variant, sha: $sha, project: $project, has_project: ($has_project == "true")}' \ + > "$OUT/meta.json" + + if [ "$HAS_PROJECT" != "true" ]; then + echo "No project at this variant; stats collection skipped" + exit 0 + fi + + PROJ_SUM=$(find "$OUT_BASE/$PROJECT/report/linux" -maxdepth 1 -name summary.json 2>/dev/null | head -1 || true) + if [ -n "$PROJ_SUM" ]; then + cp "$PROJ_SUM" "$OUT/project.summary.json" + fi + + if [ -d "$OUT_BASE/$PROJECT/report_target" ]; then + for d in "$OUT_BASE/$PROJECT/report_target"/*/linux; do + [ -d "$d" ] || continue + HNAME=$(basename "$(dirname "$d")") + [ -f "$d/summary.json" ] && cp "$d/summary.json" "$OUT/harness/$HNAME.summary.json" + done + fi + + # summary.json carries only the harness-inclusive scalar; the per-function + # list is what lets the report exclude harness functions, so ship that. + INSPECTOR_FUNCS="$OUT_BASE/$PROJECT/inspector/all-fuzz-introspector-functions.json" + if [ -f "$INSPECTOR_FUNCS" ]; then + cp "$INSPECTOR_FUNCS" "$OUT/reachability.json" + fi + + echo "---- $OUT ----" + find "$OUT" -type f -printf '%p (%s bytes)\n' diff --git a/.github/actions/fuzz-all/action.yml b/.github/actions/fuzz-all/action.yml new file mode 100644 index 0000000000..f43779ddfb --- /dev/null +++ b/.github/actions/fuzz-all/action.yml @@ -0,0 +1,48 @@ +name: Fuzz all harnesses +description: Run every libFuzzer harness in $OUT concurrently for fuzz_seconds wall-time, capturing per-harness logs. + +inputs: + project: + description: oss-fuzz project name (or path, in external mode). + required: true + helper_py: + description: Path to oss-fuzz infra/helper.py. + required: true + out_dir: + description: Path to build/out/ with the harness binaries. + required: true + corpus_root: + description: Parent directory for per-harness corpus dirs. + required: true + logs_dir: + description: Directory for per-harness raw fuzz logs. + required: true + fuzz_seconds: + description: Wall-time budget per harness (passed as -max_total_time). + required: true + external: + description: Pass --external to helper.py run_fuzzer (for ClusterFuzzLite-style projects). + required: false + default: 'false' + +runs: + using: composite + steps: + - shell: bash + env: + PROJECT: ${{ inputs.project }} + HELPER_PY: ${{ inputs.helper_py }} + OUT_DIR: ${{ inputs.out_dir }} + CORPUS_ROOT: ${{ inputs.corpus_root }} + LOGS_DIR: ${{ inputs.logs_dir }} + FUZZ_SECONDS: ${{ inputs.fuzz_seconds }} + EXTERNAL_FLAG: ${{ inputs.external == 'true' && '--external' || '' }} + run: | + python3 "$GITHUB_ACTION_PATH/run_fuzzers.py" \ + --helper-py "$HELPER_PY" \ + --project "$PROJECT" \ + --out-dir "$OUT_DIR" \ + --corpus-root "$CORPUS_ROOT" \ + --logs-dir "$LOGS_DIR" \ + --max-total-time "$FUZZ_SECONDS" \ + $EXTERNAL_FLAG diff --git a/.github/actions/fuzz-all/run_fuzzers.py b/.github/actions/fuzz-all/run_fuzzers.py new file mode 100644 index 0000000000..8864739d26 --- /dev/null +++ b/.github/actions/fuzz-all/run_fuzzers.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +# This file ships into oss-fuzz review-repo PRs via the fuzz-all composite +# action; oss-fuzz's `infra/presubmit.py` check_license greps every Python +# file under non-projects/ paths for the Apache 2.0 LICENSE-2.0 URL, so we +# carry the standard header here even though it's our own infra code. +# Copyright 2026 fuzz-for-me contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Run all libFuzzer harnesses for an oss-fuzz project concurrently. + +Shared by CI workflows (the fuzz-verify reusable workflow) and the agent +CLI. stdlib-only so it can run in a stock oss-fuzz CI environment without the +fuzz_for_me package installed. +""" + +import argparse +import os +import shlex +import stat +import subprocess +import sys +from pathlib import Path + +SKIP_SUFFIXES = (".options", ".dict", "_seed_corpus.zip") +SKIP_NAMES = {"llvm-symbolizer", "jazzer_agent_deploy.jar", "jazzer_driver"} + + +def discover_harnesses(out_dir): + out = [] + for entry in sorted(out_dir.iterdir()): + if not entry.is_file(): + continue + if entry.name in SKIP_NAMES: + continue + if any(entry.name.endswith(s) for s in SKIP_SUFFIXES): + continue + if not (entry.stat().st_mode & stat.S_IXUSR): + continue + out.append(entry.name) + return out + + +def build_cmd(args, harness, corpus_dir): + # --out-dir is intentionally NOT passed to helper.py run_fuzzer: not all + # versions of upstream helper.py accept it. The default location + # (oss-fuzz/build/out/) is what build_fuzzers writes to. + cmd = [sys.executable, str(args.helper_py), "run_fuzzer"] + if args.external: + cmd.append("--external") + cmd += ["--corpus-dir", str(corpus_dir), args.project, harness] + if args.max_total_time > 0: + cmd += ["--", f"-max_total_time={args.max_total_time}"] + return cmd + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--helper-py", required=True, type=Path) + ap.add_argument("--project", + required=True, + help="oss-fuzz project name, or path in --external mode") + ap.add_argument("--out-dir", + required=True, + type=Path, + help="build/out/ — where the harness binaries are") + ap.add_argument("--corpus-root", required=True, type=Path) + ap.add_argument("--logs-dir", required=True, type=Path) + ap.add_argument("--max-total-time", + type=int, + default=0, + help="seconds per harness; 0 = unbounded (use --detach)") + ap.add_argument("--external", + action="store_true", + help="pass --external to helper.py") + ap.add_argument("--detach", + action="store_true", + help="launch and exit immediately, printing PIDs") + args = ap.parse_args() + + args.out_dir = args.out_dir.resolve() + args.corpus_root = args.corpus_root.resolve() + args.logs_dir = args.logs_dir.resolve() + args.helper_py = args.helper_py.resolve() + + if not args.detach and args.max_total_time <= 0: + ap.error("--max-total-time must be > 0 unless --detach is given") + + harnesses = discover_harnesses(args.out_dir) + if not harnesses: + print(f"::error::no harness binaries found in {args.out_dir}", + file=sys.stderr) + return 1 + + args.corpus_root.mkdir(parents=True, exist_ok=True) + args.logs_dir.mkdir(parents=True, exist_ok=True) + + print( + f"Harnesses ({len(harnesses)}): {' '.join(harnesses)} — " + f"max_total_time={args.max_total_time}s, detach={args.detach}", + flush=True) + + procs = [] + for h in harnesses: + corpus_dir = args.corpus_root / h + corpus_dir.mkdir(parents=True, exist_ok=True) + log_path = args.logs_dir / f"{h}.log" + log_fp = open(log_path, "w") + cmd = build_cmd(args, h, corpus_dir) + proc = subprocess.Popen(cmd, + stdout=log_fp, + stderr=subprocess.STDOUT, + start_new_session=True) + procs.append((h, proc, log_fp, log_path, corpus_dir)) + + if args.detach: + for h, proc, log_fp, log_path, _ in procs: + log_fp.close() + print(f"{h} pid={proc.pid} log={log_path}") + return 0 + + rc = 0 + timeout = args.max_total_time + 60 + for h, proc, log_fp, _, _ in procs: + try: + proc.wait(timeout=timeout) + except subprocess.TimeoutExpired: + print(f"::warning::{h} exceeded timeout, killing", file=sys.stderr) + proc.kill() + proc.wait() + rc = max(rc, 1) + finally: + log_fp.close() + + for h, proc, _, log_path, corpus_dir in procs: + n_corpus = sum( + 1 for _ in corpus_dir.iterdir()) if corpus_dir.is_dir() else 0 + print(f"::group::{h}", flush=True) + try: + sys.stdout.write(log_path.read_text(errors="replace")) + except OSError as e: + print(f"(failed to read log: {e})") + print(f"\nCorpus: {n_corpus} files (exit={proc.returncode})", flush=True) + print("::endgroup::", flush=True) + + return rc + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/actions/post-fuzz-report/action.yml b/.github/actions/post-fuzz-report/action.yml new file mode 100644 index 0000000000..2fa5136157 --- /dev/null +++ b/.github/actions/post-fuzz-report/action.yml @@ -0,0 +1,82 @@ +name: Post fuzz coverage report +description: Download baseline/current stats artifacts, render markdown, post or update a sticky PR comment. + +inputs: + footer: + description: Footer flavor — "generic" for oss-fuzz, "upstream" for upstream source PRs. + required: false + default: generic + fuzz_seconds: + description: Total fuzz budget used (for display only). + required: true + github_token: + description: Token with pull-requests:write to post the comment. + required: true + +runs: + using: composite + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: stats-baseline + path: stats/baseline + continue-on-error: true + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: stats-current + path: stats/current + continue-on-error: true + + # Reachability is produced by a separate parallel job (see the + # `reachability` job). Land its reachability.json next to the other + # stats so render_comment.py finds it at the same path as before — + # no renderer change. Soft: a missing artifact (introspector failed) + # just renders the reachability row as unavailable. + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: reachability-baseline + path: stats/baseline + continue-on-error: true + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: reachability-current + path: stats/current + continue-on-error: true + + - name: Render comment + shell: bash + env: + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + FUZZ_SECONDS: ${{ inputs.fuzz_seconds }} + FOOTER: ${{ inputs.footer }} + STATS_ROOT: stats + run: | + python3 "$GITHUB_ACTION_PATH/render_comment.py" > comment.md + echo "---- comment.md ----" + cat comment.md + + - name: Post or update PR comment + shell: bash + env: + GH_TOKEN: ${{ inputs.github_token }} + REPO: ${{ github.repository }} + BRANCH: ${{ github.ref_name }} + run: | + PR=$(gh pr list --repo "$REPO" --head "$BRANCH" --state open --json number --jq '.[0].number // empty') + if [ -z "$PR" ]; then + echo "No open PR for branch $BRANCH — skipping comment" + exit 0 + fi + MARKER='' + EXISTING=$(gh api "repos/$REPO/issues/$PR/comments" --paginate \ + --jq ".[] | select(.body | contains(\"$MARKER\")) | .id" | head -1) + jq -Rs '{body: .}' < comment.md > payload.json + if [ -n "$EXISTING" ]; then + gh api --method PATCH "repos/$REPO/issues/comments/$EXISTING" --input payload.json > /dev/null + echo "Updated existing comment $EXISTING on PR #$PR" + else + gh api --method POST "repos/$REPO/issues/$PR/comments" --input payload.json > /dev/null + echo "Created new comment on PR #$PR" + fi diff --git a/.github/actions/post-fuzz-report/cov.py b/.github/actions/post-fuzz-report/cov.py new file mode 100644 index 0000000000..2920e01bbc --- /dev/null +++ b/.github/actions/post-fuzz-report/cov.py @@ -0,0 +1,87 @@ +# This file ships into oss-fuzz review-repo PRs via the post-fuzz-report +# composite action (next to render_comment.py); oss-fuzz's +# `infra/presubmit.py` check_license greps every Python file under +# non-projects/ paths for the Apache 2.0 LICENSE-2.0 URL, so we carry the +# standard header here even though it's our own infra code. +# Copyright 2026 fuzz-for-me contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Single source of truth for "project coverage". + +Project coverage = the aggregate over in-scope source files, *excluding* the +fuzz harnesses. Harness bodies run on every input, so they're ~100% covered by +construction; counting them inflates the headline and is pure noise. llvm-cov's +precomputed ``data[0]["totals"]`` and Fuzz Introspector's +``MergedProjectProfile.stats`` are both harness-inclusive, so the headline must +be re-derived from the per-file / per-function lists instead of trusting those +free aggregates. + +stdlib-only and dependency-free: this module ships standalone next to +``render_comment.py`` into bare CI, and is also imported in-tree by +``manager.py``. +""" + +HARNESS_PREFIX = "/src/harnesses/" + +_METRICS = ("lines", "branches", "functions") + + +def _triple(covered, count): + return (covered, count, (100.0 * covered / count) if count else 0.0) + + +def coverage_totals(summary): + """Harness-excluded totals from an llvm-cov export ``summary.json``. + + Sums the per-file ``summary`` blocks (``data[0]["files"]``) for files not + under ``HARNESS_PREFIX``. Returns ``{metric: (covered, count, pct)}`` for + lines/branches/functions, or ``None`` when no usable data is present. + """ + if not summary: + return None + try: + files = summary["data"][0]["files"] + except (KeyError, IndexError, TypeError): + return None + acc = {m: [0, 0] for m in _METRICS} + for f in files: + if f["filename"].startswith(HARNESS_PREFIX): + continue + s = f["summary"] + for m in _METRICS: + sm = s.get(m) + if sm: + acc[m][0] += sm["covered"] + acc[m][1] += sm["count"] + return {m: _triple(*acc[m]) for m in _METRICS} + + +def reach_totals(functions): + """Harness-excluded static reachability from Fuzz Introspector. + + ``functions`` is the parsed ``all-fuzz-introspector-functions.json`` list + (``summary.json`` itself carries only the harness-inclusive scalar). Counts + entries whose ``Functions filename`` is not under ``HARNESS_PREFIX``; an + entry is "reached" when ``Combined reached by Fuzzers`` is non-empty. + Returns ``{"reach": (reached, total, pct)}`` or ``None``. + """ + if not functions: + return None + reached = total = 0 + for fn in functions: + if fn["Functions filename"].startswith(HARNESS_PREFIX): + continue + total += 1 + if fn["Combined reached by Fuzzers"]: + reached += 1 + return {"reach": _triple(reached, total)} diff --git a/.github/actions/post-fuzz-report/render_comment.py b/.github/actions/post-fuzz-report/render_comment.py new file mode 100644 index 0000000000..4b2eace9e3 --- /dev/null +++ b/.github/actions/post-fuzz-report/render_comment.py @@ -0,0 +1,252 @@ +# This file ships into oss-fuzz review-repo PRs via the post-fuzz-report +# composite action; oss-fuzz's `infra/presubmit.py` check_license greps every +# Python file under non-projects/ paths for the Apache 2.0 LICENSE-2.0 URL, +# so we carry the standard header here even though it's our own infra code. +# Copyright 2026 fuzz-for-me contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Render before/after coverage comparison as a sticky PR comment body. + +Reads artifacts downloaded by the calling workflow: + stats/baseline/meta.json, project.summary.json, corpus.json, harness/*.summary.json + stats/current/ ... (same layout) + +Writes the markdown body to stdout. +""" + +import datetime +import json +import os +import pathlib +import sys + +try: + # In-tree (tests, host package). + from fuzz_for_me.ci import cov +except ImportError: # pragma: no cover - only the shipped bare-CI layout + # Shipped standalone next to this file in the composite action; exercised + # end-to-end by TestShippedStandalone via a subprocess. + import cov # ty: ignore[unresolved-import] + +# Must match the `reachability` job's `timeout-minutes` in +# ci/fuzz-verify.yml. Pinned by test_reach_timeout_label_matches_workflow_cap +# so the rendered cell can't advertise a cap that differs from the one +# GitHub actually enforces. +_REACH_TIMEOUT_MIN = 45 + +MARKER = "" + +# Δ is computed on covered counts (not percent points) so the same metric is +# meaningful when the denominator changes — e.g. when new code lands the +# instrumented line count grows, so comparing raw percentages understates real +# coverage gains. "new" / "deleted" cover the divide-by-zero edges. +FOOTER_DELTA_NOTE = ( + "Δ = (after − before) / before, to accommodate that denominators " + 'may change. "new" when before is 0; "deleted" when after is 0.') +FOOTER_GENERIC = FOOTER_DELTA_NOTE +FOOTER_UPSTREAM = ("Same harness config applied to both sides " + "(baseline = base source + PR harness).\n" + + FOOTER_DELTA_NOTE) + + +def _load_json(path: pathlib.Path): + if not path.exists(): + return None + try: + return json.loads(path.read_text()) + except json.JSONDecodeError: + return None + + +def _load_variant(base: pathlib.Path) -> dict: + harness: dict = {} + hd = base / "harness" + if hd.is_dir(): + for f in sorted(hd.glob("*.summary.json")): + data = _load_json(f) + if data is not None: + harness[f.name.removesuffix(".summary.json")] = data + return { + "meta": _load_json(base / "meta.json"), + "project": _load_json(base / "project.summary.json"), + "harness": harness, + "reachability": _load_json(base / "reachability.json"), + } + + +def _totals(summary): + """Harness-excluded coverage totals from an llvm-cov ``summary.json``.""" + return cov.coverage_totals(summary) + + +def _reach_totals(functions): + """Harness-excluded static reachability from the introspector per-function + list (``all-fuzz-introspector-functions.json``, carried by the + reachability.json artifact). ``None`` when no data is available.""" + return cov.reach_totals(functions) + + +def _fmt_cov(tot, key): + if not tot: + return "0%" + cov, n, pct = tot[key] + return f"{pct:.1f}% ({cov}/{n})" + + +def _fmt_delta(b, a, key, c_has=True): + if not b and not a: + return "—" + if not a: + # Baseline has data, current doesn't. + return "**removed**" if c_has else "**build failed**" + cov_a = a[key][0] + cov_b = b[key][0] if b else 0 + if cov_b == 0 and cov_a == 0: + return "—" + if cov_b == 0: + return "**new**" + if cov_a == 0: + return "**deleted**" + d = (cov_a - cov_b) / cov_b * 100 + sign = "+" if d >= 0 else "" + return f"**{sign}{d:.1f}%**" + + +def _fmt_reach_cell(reach, variant_has, run_url): + """Format a reachability value cell. + + ``>{_REACH_TIMEOUT_MIN}m`` (linked to the workflow run) when the variant + ran but the introspector build produced no summary — overwhelmingly + because it didn't finish within the soft job's guard timeout (an unbounded + Fuzz Introspector analysis; upstream OSS-Fuzz hits the same wall even with + a 20h budget). ``0%`` when the variant didn't run at all (matching the + coverage-row convention). + """ + if reach: + return _fmt_cov(reach, "reach") + if variant_has: + return f"[>{_REACH_TIMEOUT_MIN}m]({run_url})" + return "0%" + + +def _fmt_reach_delta(br, cr): + """Delta for the reachability row. Like ``_fmt_delta`` but treats a missing + variant as a build failure (``continue-on-error: true``) rather than a + removed metric — there is no "remove static reachability" intent.""" + if not br and not cr: + return "—" + if not br: + d = cr["reach"][2] + return f"**+{d:.1f}%**" + if not cr: + return "**build failed**" + d = cr["reach"][2] - br["reach"][2] + sign = "+" if d >= 0 else "" + return f"**{sign}{d:.1f}%**" + + +def render( + stats_root: pathlib.Path, + run_url: str, + fuzz_seconds: str, + now_utc: str, + footer: str, +) -> str: + b = _load_variant(stats_root / "baseline") + c = _load_variant(stats_root / "current") + + b_meta = b["meta"] or {} + c_meta = c["meta"] or {} + b_sha_full = b_meta.get("sha") or "" + c_sha_full = c_meta.get("sha") or "" + b_sha = b_sha_full[:7] if b_sha_full else "unknown" + c_sha = c_sha_full[:7] if c_sha_full else "unknown" + project = c_meta.get("project") or b_meta.get("project") or "?" + b_has = bool(b_meta.get("has_project")) + c_has = bool(c_meta.get("has_project")) + + out = [MARKER, "", "## Fuzzing Coverage Report", ""] + + tested = f"**Tested:** project `{project}` · base `{b_sha}`" + if not b_has: + tested += ( + " _(no baseline — project not present at base or baseline build failed)_" + ) + tested += f" → head `{c_sha}`" + if not c_has: + tested += " _(current measurement failed)_" + tested += (f" · {fuzz_seconds}s total fuzz budget" + f" · updated {now_utc}" + f" · [workflow run]({run_url})") + out += [tested, ""] + + bt = _totals(b["project"]) + ct = _totals(c["project"]) + br = _reach_totals(b["reachability"]) + cr = _reach_totals(c["reachability"]) + if b_has or c_has or bt or ct or br or cr: + out += [ + "| Metric | Before | After | Delta |", + "|---|---|---|---|", + f"| Static reachability | {_fmt_reach_cell(br, b_has, run_url)} | " + f"{_fmt_reach_cell(cr, c_has, run_url)} | " + f"{_fmt_reach_delta(br, cr)} |", + ] + if bt or ct: + out += [ + f"| Line coverage | {_fmt_cov(bt, 'lines')} | {_fmt_cov(ct, 'lines')} | {_fmt_delta(bt, ct, 'lines', c_has)} |", + f"| Branch coverage | {_fmt_cov(bt, 'branches')} | {_fmt_cov(ct, 'branches')} | {_fmt_delta(bt, ct, 'branches', c_has)} |", + f"| Function coverage | {_fmt_cov(bt, 'functions')} | {_fmt_cov(ct, 'functions')} | {_fmt_delta(bt, ct, 'functions', c_has)} |", + ] + out.append("") + + all_h = sorted(set(b["harness"].keys()) | set(c["harness"].keys())) + if all_h: + out += [ + "### Per-harness", + "", + "| Harness | Lines before | Lines after | Δ |", + "|---|---|---|---|", + ] + for h in all_h: + bh = _totals(b["harness"].get(h)) + ch = _totals(c["harness"].get(h)) + out.append( + f"| `{h}` | {_fmt_cov(bh, 'lines')} | {_fmt_cov(ch, 'lines')} | " + f"{_fmt_delta(bh, ch, 'lines', c_has)} |") + out.append("") + + if not (b_has or c_has or bt or ct or all_h or br or cr): + out += [ + "_No coverage data collected. Check the workflow run for build errors._", + "", + ] + + out.append(footer) + return "\n".join(out) + + +def main(): + stats_root = pathlib.Path(os.environ.get("STATS_ROOT", "stats")) + run_url = os.environ["RUN_URL"] + fuzz_seconds = os.environ.get("FUZZ_SECONDS", "300") + footer_kind = os.environ.get("FOOTER", "generic") + now_utc = datetime.datetime.now( + datetime.timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + footer = FOOTER_UPSTREAM if footer_kind == "upstream" else FOOTER_GENERIC + sys.stdout.write(render(stats_root, run_url, fuzz_seconds, now_utc, footer)) + sys.stdout.write("\n") + + +if __name__ == "__main__": + main() diff --git a/.github/actions/prepare/action.yml b/.github/actions/prepare/action.yml new file mode 100644 index 0000000000..777d344f64 --- /dev/null +++ b/.github/actions/prepare/action.yml @@ -0,0 +1,45 @@ +name: Prepare fuzz-verify +description: Resolve project/variant, baseline checkout/overlay, oss-fuzz clone, pristine snapshot. Emits normalized outputs. + +inputs: + mode: + description: upstream | oss-fuzz + required: true + variant: + description: baseline | current + required: true + project_name: + description: Project name (upstream); empty for oss-fuzz (auto-detected). + required: false + default: '' + +outputs: + project: + value: ${{ steps.run.outputs.project }} + sha: + value: ${{ steps.run.outputs.sha }} + merge_base_sha: + value: ${{ steps.run.outputs.merge_base_sha }} + has_project: + value: ${{ steps.run.outputs.has_project }} + helper_dir: + value: ${{ steps.run.outputs.helper_dir }} + out_base: + value: ${{ steps.run.outputs.out_base }} + corpus_root: + value: ${{ steps.run.outputs.corpus_root }} + seeds_dir: + value: ${{ steps.run.outputs.seeds_dir }} + pristine_dir: + value: ${{ steps.run.outputs.pristine_dir }} + +runs: + using: composite + steps: + - id: run + shell: bash + env: + MODE: ${{ inputs.mode }} + VARIANT: ${{ inputs.variant }} + PROJECT_NAME: ${{ inputs.project_name }} + run: python3 "$GITHUB_ACTION_PATH/prepare.py" diff --git a/.github/actions/prepare/prepare.py b/.github/actions/prepare/prepare.py new file mode 100644 index 0000000000..13bd6bc0ea --- /dev/null +++ b/.github/actions/prepare/prepare.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +# This file ships into oss-fuzz review-repo PRs via the prepare composite +# action; oss-fuzz's `infra/presubmit.py` check_license greps every Python +# file under non-projects/ paths for the Apache 2.0 LICENSE-2.0 URL, so we +# carry the standard header here even though it's our own infra code. +# Copyright 2026 fuzz-for-me contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Mode-specific prep for the fuzz-verify reusable workflow. + +Resolves the project + variant SHA, performs baseline checkout/overlay, +clones oss-fuzz (upstream mode), snapshots a pristine source tree +(upstream mode, bug #2 fix), and emits normalized outputs the +mode-agnostic reusable workflow consumes. +""" + +import os +import pathlib +import re +import shutil +import subprocess +import sys + +_OSS_FUZZ_DIR = "/tmp/oss-fuzz" +_PRISTINE_DIR = "/tmp/fuzz-verify-pristine" + + +def _run(args, cwd=None, check=True): + return subprocess.run(args, + cwd=cwd, + check=check, + capture_output=True, + text=True) + + +def detect_oss_fuzz_project(repo: pathlib.Path) -> str: + out = _run( + ["git", "-C", + str(repo), "diff", "--name-only", "origin/master...HEAD"], + check=False, + ).stdout + for line in out.splitlines(): + if line.startswith("projects/"): + return line.split("/")[1] + # Fallback for local test repos without an `origin` remote. + out = _run( + ["git", "-C", + str(repo), "diff", "--name-only", "master...HEAD"], + check=False, + ).stdout + for line in out.splitlines(): + if line.startswith("projects/"): + return line.split("/")[1] + return "" + + +def compute_outputs(mode: str, project: str) -> dict[str, str]: + if mode == "oss-fuzz": + return { + "helper_dir": ".", + "out_base": "build/out", + "corpus_root": f"corpus/{project}", + "seeds_dir": f"projects/{project}/seeds", + } + if mode == "upstream": + return { + "helper_dir": _OSS_FUZZ_DIR, + "out_base": f"{_OSS_FUZZ_DIR}/build/out", + "corpus_root": f"{_OSS_FUZZ_DIR}/corpus/{project}", + "seeds_dir": ".github/fuzz/seeds", + } + raise ValueError(f"unknown mode: {mode}") + + +def _merge_base(repo: pathlib.Path, ref_a: str, ref_b: str) -> str: + return _run(["git", "-C", str(repo), "merge-base", ref_a, + ref_b]).stdout.strip() + + +def _default_remote_branch(repo: pathlib.Path) -> str: + r = _run( + [ + "git", "-C", + str(repo), "symbolic-ref", "--short", "refs/remotes/origin/HEAD" + ], + check=False, + ).stdout.strip() + return r.split("origin/")[-1] if r else "" + + +def _compute_merge_base(repo: pathlib.Path) -> str: + base_ref = "origin/master" + if _run(["git", "-C", + str(repo), "rev-parse", "--verify", "-q", base_ref], + check=False).returncode != 0: + base_ref = "master" + sha = _merge_base(repo, base_ref, "HEAD") + if not re.fullmatch(r"[0-9a-f]{7,40}", sha): + raise RuntimeError( + f"merge-base produced no usable SHA (base_ref={base_ref})") + return sha + + +def resolve_variant(mode: str, variant: str, project: str, + repo: pathlib.Path) -> dict[str, str]: + head = _run(["git", "-C", str(repo), "rev-parse", "HEAD"]).stdout.strip() + if variant == "current": + return {"sha": os.environ.get("GITHUB_SHA", head), "has_project": "true"} + + sha = _compute_merge_base(repo) + + if mode == "oss-fuzz": + present = _run( + [ + "git", "-C", + str(repo), "cat-file", "-e", f"{sha}:projects/{project}/Dockerfile" + ], + check=False, + ).returncode == 0 + if not present: + return {"sha": sha, "has_project": "false"} + shutil.rmtree(repo / "projects" / project, ignore_errors=True) + _run(["git", "-C", str(repo), "checkout", sha, "--", f"projects/{project}"]) + return {"sha": sha, "has_project": "true"} + + # upstream baseline: stash overlay, hard-reset to merge-base, restore. + fuzz = repo / ".github" / "fuzz" + if not fuzz.is_dir(): + print("::error::.github/fuzz not present on PR — " + "cannot overlay onto baseline") + return {"sha": sha, "has_project": "false"} + actions = repo / ".github" / "actions" + tmp = pathlib.Path(_run(["mktemp", "-d"]).stdout.strip()) + shutil.copytree(fuzz, tmp / "fuzz") + if actions.is_dir(): + shutil.copytree(actions, tmp / "actions") + _run(["git", "-C", str(repo), "reset", "--hard", sha]) + _run( + ["git", "-C", + str(repo), "submodule", "update", "--init", "--recursive"], + check=False, + ) + shutil.rmtree(repo / ".github" / "fuzz", ignore_errors=True) + shutil.rmtree(repo / ".github" / "actions", ignore_errors=True) + (repo / ".github").mkdir(exist_ok=True) + shutil.copytree(tmp / "fuzz", repo / ".github" / "fuzz") + if (tmp / "actions").is_dir(): + shutil.copytree(tmp / "actions", repo / ".github" / "actions") + return {"sha": sha, "has_project": "true"} + + +def _copy_tree(src: pathlib.Path, dst: pathlib.Path) -> str: + if shutil.which("rsync"): + dst.mkdir(parents=True, exist_ok=True) + _run(["rsync", "-a", "--delete", f"{src}/", f"{dst}/"]) + else: + if dst.exists(): + shutil.rmtree(dst) + shutil.copytree(src, dst, symlinks=True) + return str(dst) + + +def snapshot_pristine(workspace: pathlib.Path | str, + dest_root: pathlib.Path | str) -> str: + return _copy_tree(pathlib.Path(workspace), pathlib.Path(dest_root)) + + +def materialize_working_copy(pristine: pathlib.Path | str, + dest: pathlib.Path | str) -> str: + return _copy_tree(pathlib.Path(pristine), pathlib.Path(dest)) + + +def _emit(outputs: dict[str, str]): + path = os.environ["GITHUB_OUTPUT"] + with open(path, "a") as fh: + for k, v in outputs.items(): + fh.write(f"{k}={v}\n") + + +def main(): + mode = os.environ["MODE"] + variant = os.environ["VARIANT"] + ws = pathlib.Path(os.environ["GITHUB_WORKSPACE"]) + project = os.environ.get("PROJECT_NAME", "") or detect_oss_fuzz_project(ws) + + var = resolve_variant(mode, variant, project, ws) + out = {"project": project, **var, "merge_base_sha": _compute_merge_base(ws)} + is_upstream_project = var["has_project"] == "true" and mode == "upstream" + + if is_upstream_project: + fuzz = ws / ".github" / "fuzz" + if not fuzz.is_dir(): + print("::error::.github/fuzz not present — " + "cannot overlay onto baseline") + return 1 + _run([ + "git", "clone", "--depth", "1", + "https://github.com/google/oss-fuzz.git", _OSS_FUZZ_DIR + ], + check=False) + dest = pathlib.Path(f"{_OSS_FUZZ_DIR}/projects/{project}") + dest.mkdir(parents=True, exist_ok=True) + for item in fuzz.iterdir(): + tgt = dest / item.name + (shutil.copytree if item.is_dir() else shutil.copy)(item, tgt) + + paths = compute_outputs(mode, project) + if is_upstream_project: + out["pristine_dir"] = snapshot_pristine(ws, _PRISTINE_DIR) + else: + out["pristine_dir"] = "" + out.update(paths) + _emit(out) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/fuzz/Dockerfile b/.github/fuzz/Dockerfile new file mode 100644 index 0000000000..79424525e5 --- /dev/null +++ b/.github/fuzz/Dockerfile @@ -0,0 +1,21 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +################################################################################ + +FROM gcr.io/oss-fuzz-base/base-builder +RUN apt-get update && apt-get install -y autoconf bison +RUN git clone --depth=1 https://github.com/krb5/krb5.git +RUN printf '#!/bin/bash -eu\nexec bash "$SRC/krb5/src/tests/fuzzing/oss-fuzz.sh"\n' > $SRC/build.sh +WORKDIR $SRC/krb5/ diff --git a/.github/fuzz/project.yaml b/.github/fuzz/project.yaml new file mode 100644 index 0000000000..df0fe0f0a8 --- /dev/null +++ b/.github/fuzz/project.yaml @@ -0,0 +1,14 @@ +homepage: "https://web.mit.edu/kerberos/" +language: c +primary_contact: "krbcore-security@mit.edu" +auto_ccs: + - "pkillarjun@protonmail.com" +fuzzing_engines: + - libfuzzer + - afl + - honggfuzz +sanitizers: + - address + - memory + - undefined +main_repo: 'https://github.com/krb5/krb5' diff --git a/.github/workflows/_fuzz-verify.yml b/.github/workflows/_fuzz-verify.yml new file mode 100644 index 0000000000..cdbe807718 --- /dev/null +++ b/.github/workflows/_fuzz-verify.yml @@ -0,0 +1,261 @@ +# Reusable workflow injected as .github/workflows/_fuzz-verify.yml by pr_server. +# Mode-agnostic: every path/arg comes from the `prepare` action's outputs. +name: Fuzz Verify +on: + workflow_call: + inputs: + mode: { required: true, type: string } + project_name: { required: false, type: string, default: '' } + footer: { required: true, type: string } + fuzz_seconds: { required: true, type: string } + +permissions: + # Default to read-only at the workflow level; the report job elevates to pull-requests:write. + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + measure: + name: Measure (${{ matrix.variant }}) + runs-on: ubuntu-latest + timeout-minutes: 120 + continue-on-error: ${{ matrix.variant == 'baseline' }} + strategy: + fail-fast: false + matrix: + variant: [baseline, current] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: { fetch-depth: 0, persist-credentials: false, submodules: recursive } + + - id: prep + uses: ./.github/actions/prepare + with: + mode: ${{ inputs.mode }} + variant: ${{ matrix.variant }} + project_name: ${{ inputs.project_name }} + + - name: Install oss-fuzz deps + if: steps.prep.outputs.has_project == 'true' + env: + HELPER_DIR: ${{ steps.prep.outputs.helper_dir }} + run: pip install -r "$HELPER_DIR/infra/ci/requirements.txt" 2>/dev/null || pip install docker + + - name: Working copy (address) + id: wc-addr + if: steps.prep.outputs.has_project == 'true' && steps.prep.outputs.pristine_dir != '' + env: + PRISTINE_DIR: ${{ steps.prep.outputs.pristine_dir }} + run: | + rsync -a --delete "$PRISTINE_DIR/" /tmp/ws-address/ + echo "src=/tmp/ws-address" >> "$GITHUB_OUTPUT" + + - name: Build image + id: bi + if: steps.prep.outputs.has_project == 'true' + continue-on-error: ${{ matrix.variant == 'baseline' }} + env: + HELPER_DIR: ${{ steps.prep.outputs.helper_dir }} + PROJECT: ${{ steps.prep.outputs.project }} + run: echo n | python3 "$HELPER_DIR/infra/helper.py" build_image "$PROJECT" + + - name: Build fuzzers + id: bf + if: steps.prep.outputs.has_project == 'true' && steps.bi.outcome == 'success' + continue-on-error: ${{ matrix.variant == 'baseline' }} + env: + HELPER_DIR: ${{ steps.prep.outputs.helper_dir }} + PROJECT: ${{ steps.prep.outputs.project }} + WC_SRC: ${{ steps.wc-addr.outputs.src }} + run: | + if [ -n "$WC_SRC" ]; then + echo n | python3 "$HELPER_DIR/infra/helper.py" build_fuzzers \ + --sanitizer address "$PROJECT" "$WC_SRC" + else + echo n | python3 "$HELPER_DIR/infra/helper.py" build_fuzzers \ + --sanitizer address "$PROJECT" + fi + + - name: Verify new seeds + if: matrix.variant == 'current' && steps.prep.outputs.has_project == 'true' && steps.bf.outcome == 'success' + uses: ./.github/actions/check-new-seeds + with: + project: ${{ steps.prep.outputs.project }} + base_sha: ${{ steps.prep.outputs.merge_base_sha }} + out_base: ${{ steps.prep.outputs.out_base }} + seeds_dir: ${{ steps.prep.outputs.seeds_dir }} + + - name: Fuzz + if: steps.prep.outputs.has_project == 'true' && steps.bf.outcome == 'success' + uses: ./.github/actions/fuzz-all + with: + project: ${{ steps.prep.outputs.project }} + helper_py: ${{ steps.prep.outputs.helper_dir }}/infra/helper.py + out_dir: ${{ steps.prep.outputs.out_base }}/${{ steps.prep.outputs.project }} + corpus_root: ${{ steps.prep.outputs.corpus_root }} + logs_dir: /tmp/fuzz-logs + fuzz_seconds: ${{ inputs.fuzz_seconds }} + + - name: Coverage + if: steps.prep.outputs.has_project == 'true' && steps.bf.outcome == 'success' + env: + HELPER_DIR: ${{ steps.prep.outputs.helper_dir }} + PROJECT: ${{ steps.prep.outputs.project }} + PRISTINE_DIR: ${{ steps.prep.outputs.pristine_dir }} + CORPUS_ROOT: ${{ steps.prep.outputs.corpus_root }} + run: | + if [ -n "$PRISTINE_DIR" ]; then + rsync -a --delete "$PRISTINE_DIR/" /tmp/ws-cov/ + echo n | python3 "$HELPER_DIR/infra/helper.py" build_fuzzers \ + --sanitizer coverage "$PROJECT" /tmp/ws-cov + else + echo n | python3 "$HELPER_DIR/infra/helper.py" build_fuzzers \ + --sanitizer coverage "$PROJECT" + fi + CORPUS_LINK_DIR="$HELPER_DIR/build/corpus" + mkdir -p "$CORPUS_LINK_DIR" + case "$CORPUS_ROOT" in + /*) CORPUS_TGT="$CORPUS_ROOT" ;; + *) CORPUS_TGT="$(pwd)/$CORPUS_ROOT" ;; + esac + ln -sfn "$CORPUS_TGT" "$CORPUS_LINK_DIR/$PROJECT" + python3 "$HELPER_DIR/infra/helper.py" coverage --no-corpus-download --no-serve "$PROJECT" 2>&1 || true + + - name: Collect stats + if: always() + continue-on-error: ${{ matrix.variant == 'baseline' }} + uses: ./.github/actions/collect-fuzz-stats + with: + project: ${{ steps.prep.outputs.project }} + variant: ${{ matrix.variant }} + sha: ${{ steps.prep.outputs.sha }} + has_project: ${{ steps.prep.outputs.has_project }} + out_base: ${{ steps.prep.outputs.out_base }} + + - name: Upload stats + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: stats-${{ matrix.variant }} + path: stats-${{ matrix.variant }}/ + if-no-files-found: error + + - name: Upload coverage report + if: always() && steps.prep.outputs.has_project == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: coverage-report-${{ matrix.variant }} + path: ${{ steps.prep.outputs.out_base }}/*coverage*/report/ + if-no-files-found: ignore + + # 45-min guard: Fuzz Introspector's analysis is unbounded over /src, so + # projects vendoring large trees (libprotobuf-mutator, fuzzer-test-suite) + # never finish — upstream OSS-Fuzz grants its own introspector build 20h + # and nginx/capnproto STILL time out. More time is futile; ~68% of + # successful C/C++ projects finish under 45 min, so cap there and + # fast-fail the doomed long tail instead of burning 2h × 2 variants. + # Soft signal: continue-on-error, so a cancel never reds the PR. + reachability: + name: Reachability (${{ matrix.variant }}) + runs-on: ubuntu-latest + timeout-minutes: 45 + continue-on-error: true + strategy: + fail-fast: false + matrix: + variant: [baseline, current] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: { fetch-depth: 0, persist-credentials: false, submodules: recursive } + + - id: prep + uses: ./.github/actions/prepare + with: + mode: ${{ inputs.mode }} + variant: ${{ matrix.variant }} + project_name: ${{ inputs.project_name }} + + - name: Install oss-fuzz deps + if: steps.prep.outputs.has_project == 'true' + env: + HELPER_DIR: ${{ steps.prep.outputs.helper_dir }} + run: pip install -r "$HELPER_DIR/infra/ci/requirements.txt" 2>/dev/null || pip install docker + + - name: Build image + id: bi + if: steps.prep.outputs.has_project == 'true' + continue-on-error: true + env: + HELPER_DIR: ${{ steps.prep.outputs.helper_dir }} + PROJECT: ${{ steps.prep.outputs.project }} + run: echo n | python3 "$HELPER_DIR/infra/helper.py" build_image "$PROJECT" + + - name: Working copy (introspector) + id: wc-int + if: steps.prep.outputs.has_project == 'true' && steps.prep.outputs.pristine_dir != '' && steps.bi.outcome == 'success' + env: + PRISTINE_DIR: ${{ steps.prep.outputs.pristine_dir }} + run: | + rsync -a --delete "$PRISTINE_DIR/" /tmp/ws-introspector/ + echo "src=/tmp/ws-introspector" >> "$GITHUB_OUTPUT" + + - name: Build introspector + if: steps.prep.outputs.has_project == 'true' && steps.bi.outcome == 'success' + continue-on-error: true + env: + HELPER_DIR: ${{ steps.prep.outputs.helper_dir }} + PROJECT: ${{ steps.prep.outputs.project }} + WC_SRC: ${{ steps.wc-int.outputs.src }} + run: | + if [ -n "$WC_SRC" ]; then + echo n | python3 "$HELPER_DIR/infra/helper.py" build_fuzzers \ + --sanitizer introspector "$PROJECT" "$WC_SRC" + else + echo n | python3 "$HELPER_DIR/infra/helper.py" build_fuzzers \ + --sanitizer introspector "$PROJECT" + fi + + - name: Extract reachability + if: always() + env: + VARIANT: ${{ matrix.variant }} + OUT_BASE: ${{ steps.prep.outputs.out_base }} + PROJECT: ${{ steps.prep.outputs.project }} + run: | + mkdir -p "reachability-$VARIANT" + SRC="$OUT_BASE/$PROJECT/inspector/all-fuzz-introspector-functions.json" + if [ -f "$SRC" ]; then + cp "$SRC" "reachability-$VARIANT/reachability.json" + else + echo "::notice::No introspector output for $VARIANT — reachability unavailable" + fi + + - name: Upload reachability + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: reachability-${{ matrix.variant }} + path: reachability-${{ matrix.variant }}/ + if-no-files-found: ignore + + report: + name: Report + needs: [measure, reachability] + if: always() + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write # post sticky fuzz-verify comment + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + sparse-checkout: .github/actions/post-fuzz-report + - uses: ./.github/actions/post-fuzz-report + with: + footer: ${{ inputs.footer }} + fuzz_seconds: ${{ inputs.fuzz_seconds }} + github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/fuzz-verify.yml b/.github/workflows/fuzz-verify.yml new file mode 100644 index 0000000000..a3cb1d8022 --- /dev/null +++ b/.github/workflows/fuzz-verify.yml @@ -0,0 +1,15 @@ +# Injected by pr_server. Drop before forwarding upstream. +name: Fuzz Verify +on: [push] +jobs: + fuzz-verify: + uses: ./.github/workflows/_fuzz-verify.yml + permissions: + contents: read + pull-requests: write + with: + mode: upstream + project_name: krb5 + footer: upstream + fuzz_seconds: "300" + secrets: inherit diff --git a/src/tests/fuzzing/Makefile.in b/src/tests/fuzzing/Makefile.in index 15bbbbf1a8..6570b984b0 100644 --- a/src/tests/fuzzing/Makefile.in +++ b/src/tests/fuzzing/Makefile.in @@ -11,10 +11,12 @@ OBJS= \ fuzz_aes.o \ fuzz_asn.o \ fuzz_attrset.o \ + fuzz_ccache.o \ fuzz_chpw.o \ fuzz_crypto.o \ fuzz_des.o \ fuzz_gss.o \ + fuzz_initcreds.o \ fuzz_json.o \ fuzz_kdc.o \ fuzz_krad.o \ @@ -32,10 +34,12 @@ SRCS= \ $(srcdir)/fuzz_aes.c \ $(srcdir)/fuzz_asn.c \ $(srcdir)/fuzz_attrset.c \ + $(srcdir)/fuzz_ccache.c \ $(srcdir)/fuzz_chpw.c \ $(srcdir)/fuzz_crypto.c \ $(srcdir)/fuzz_des.c \ $(srcdir)/fuzz_gss.c \ + $(srcdir)/fuzz_initcreds.c \ $(srcdir)/fuzz_json.c \ $(srcdir)/fuzz_kdc.c \ $(srcdir)/fuzz_krad.c \ @@ -53,10 +57,12 @@ FUZZ_TARGETS= \ fuzz_aes \ fuzz_asn \ fuzz_attrset \ + fuzz_ccache \ fuzz_chpw \ fuzz_crypto \ fuzz_des \ fuzz_gss \ + fuzz_initcreds \ fuzz_json \ fuzz_kdc \ fuzz_krad \ @@ -84,6 +90,9 @@ fuzz_asn: fuzz_asn.o $(KRB5_BASE_DEPLIBS) fuzz_attrset: fuzz_attrset.o $(KRB5_BASE_DEPLIBS) $(CXX_LINK) -o $@ fuzz_attrset.o -lkrad $(KRB5_BASE_LIBS) $(FUZZ_LDFLAGS) +fuzz_ccache: fuzz_ccache.o $(KRB5_BASE_DEPLIBS) + $(CXX_LINK) -o $@ fuzz_ccache.o $(KRB5_BASE_LIBS) $(FUZZ_LDFLAGS) + fuzz_chpw: fuzz_chpw.o $(KRB5_BASE_DEPLIBS) $(CXX_LINK) -o $@ fuzz_chpw.o $(KRB5_BASE_LIBS) $(FUZZ_LDFLAGS) @@ -96,6 +105,9 @@ fuzz_des: fuzz_des.o $(KRB5_BASE_DEPLIBS) fuzz_gss: fuzz_gss.o $(GSS_DEPLIBS) $(KRB5_BASE_DEPLIBS) $(CXX_LINK) -o $@ fuzz_gss.o $(GSS_LIBS) $(KRB5_BASE_LIBS) $(FUZZ_LDFLAGS) +fuzz_initcreds: fuzz_initcreds.o $(KRB5_BASE_DEPLIBS) + $(CXX_LINK) -o $@ fuzz_initcreds.o $(KRB5_BASE_LIBS) $(FUZZ_LDFLAGS) + fuzz_json: fuzz_json.o $(KRB5_BASE_DEPLIBS) $(CXX_LINK) -o $@ fuzz_json.o $(KRB5_BASE_LIBS) $(FUZZ_LDFLAGS) diff --git a/src/tests/fuzzing/fuzz_ccache.c b/src/tests/fuzzing/fuzz_ccache.c new file mode 100644 index 0000000000..82dae9152f --- /dev/null +++ b/src/tests/fuzzing/fuzz_ccache.c @@ -0,0 +1,95 @@ +/* -*- mode: c; c-basic-offset: 4; indent-tabs-mode: nil -*- */ +/* tests/fuzzing/fuzz_ccache.c - fuzzing harness for the FILE ccache parser */ +/* + * Copyright (C) 2026 by Arjun. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * * Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + * COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +/* + * Fuzzing harness for the FILE credential cache parser. + * + * A credential cache is routinely read from an attacker-influenced + * location: the cache path is taken from the KRB5CCNAME environment + * variable, so any process that inherits a hostile environment parses a + * file an attacker controls. This harness writes the fuzz input to a + * temporary file and walks it with the public cache APIs, exercising the + * binary decoder in lib/krb5/ccache/cc_file.c. + */ + +#include "autoconf.h" +#include +#include +#include + +#define kMinInputLength 2 +#define kMaxInputLength 8192 + +extern int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size); + +int +LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) +{ + krb5_context ctx; + krb5_ccache cc = NULL; + krb5_principal princ = NULL; + krb5_cc_cursor cursor; + krb5_creds creds; + char path[256], name[300]; + FILE *fp; + + if (size < kMinInputLength || size > kMaxInputLength) + return 0; + if (krb5_init_context(&ctx) != 0) + return 0; + + snprintf(path, sizeof(path), "/tmp/fuzz_cc.%ld", (long)getpid()); + fp = fopen(path, "wb"); + if (fp == NULL) { + krb5_free_context(ctx); + return 0; + } + fwrite(data, 1, size, fp); + fclose(fp); + + snprintf(name, sizeof(name), "FILE:%s", path); + if (krb5_cc_resolve(ctx, name, &cc) == 0) { + if (krb5_cc_get_principal(ctx, cc, &princ) == 0) + krb5_free_principal(ctx, princ); + + if (krb5_cc_start_seq_get(ctx, cc, &cursor) == 0) { + while (krb5_cc_next_cred(ctx, cc, &cursor, &creds) == 0) + krb5_free_cred_contents(ctx, &creds); + krb5_cc_end_seq_get(ctx, cc, &cursor); + } + + krb5_cc_close(ctx, cc); + } + + unlink(path); + krb5_free_context(ctx); + return 0; +} diff --git a/src/tests/fuzzing/fuzz_ccache_seed_corpus/ccache_v4.bin b/src/tests/fuzzing/fuzz_ccache_seed_corpus/ccache_v4.bin new file mode 100644 index 0000000000..5082d5df8c Binary files /dev/null and b/src/tests/fuzzing/fuzz_ccache_seed_corpus/ccache_v4.bin differ diff --git a/src/tests/fuzzing/fuzz_gss.c b/src/tests/fuzzing/fuzz_gss.c index 3c65f34fd0..034ea2e010 100644 --- a/src/tests/fuzzing/fuzz_gss.c +++ b/src/tests/fuzzing/fuzz_gss.c @@ -30,44 +30,381 @@ */ /* - * Fuzzing harness implementation for gss_accept_sec_context. + * Fuzzing harness for the GSS-API krb5 mechanism. + * + * The previous version of this harness fed the input straight into + * gss_accept_sec_context() with no acceptor credential, so processing + * always stopped at the first credential lookup. This version forges a + * self-contained krb5 service ticket (no KDC required): a fixed service + * key is placed in an in-memory keytab, and a matching ticket encrypted + * with it is stored in an in-memory credential cache. One krb5 context is + * established at startup to provide an acceptor credential and a live + * context; the fuzz input then drives the attacker-controlled GSS-API + * surfaces — the acceptor token decoder, the initiator's reply handling, + * the per-message unwrap/verify decoders and the token import routines. */ #include "autoconf.h" -#include -#include +#include +#include #include #define kMinInputLength 2 -#define kMaxInputLength 1024 +#define kMaxInputLength 4096 extern int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size); -int -LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) +static gss_OID_desc mech_krb5 = { + 9, (void *)"\052\206\110\206\367\022\001\002\002" +}; + +static int setup_done = 0; +static int setup_ok = 0; + +static gss_cred_id_t init_cred = GSS_C_NO_CREDENTIAL; +static gss_cred_id_t accept_cred = GSS_C_NO_CREDENTIAL; +static gss_name_t target_name = GSS_C_NO_NAME; + +/* An established acceptor context kept for per-message fuzzing. */ +static gss_ctx_id_t estab_actx = GSS_C_NO_CONTEXT; + +static const char *client_str = "fuzzclient@KRBTEST.COM"; +static const char *service_str = "host/server.krbtest.com@KRBTEST.COM"; + +/* + * Fill a keyblock with fixed key material. Fixed (rather than random) + * keys keep the forged ticket and the established context deterministic, + * so a crash reachable through the established context is reproducible. + */ +static int +make_fixed_key(krb5_keyblock *kb, krb5_enctype enctype, unsigned int seed) +{ + unsigned int i; + + kb->magic = 0; + kb->enctype = enctype; + kb->length = 32; /* aes256-cts-hmac-sha1-96 */ + kb->contents = malloc(kb->length); + if (kb->contents == NULL) + return 0; + for (i = 0; i < kb->length; i++) + kb->contents[i] = (krb5_octet)(seed + i * 7u); + return 1; +} + +/* + * Build an in-memory keytab and credential cache containing a forged but + * internally consistent service ticket, then import GSS initiator and + * acceptor credentials from them. Returns 1 on success. + */ +static int +build_credentials(void) +{ + krb5_context ctx = NULL; + krb5_principal client = NULL, service = NULL; + krb5_keyblock service_key, session_key; + krb5_keytab kt = NULL; + krb5_keytab_entry ktent; + krb5_ccache cc = NULL; + krb5_enc_tkt_part encpart; + krb5_ticket ticket; + krb5_data *tkt_der = NULL; + krb5_creds creds; + krb5_timestamp now = 0; + OM_uint32 major, minor; + gss_buffer_desc namebuf; + int ok = 0; + + memset(&service_key, 0, sizeof(service_key)); + memset(&session_key, 0, sizeof(session_key)); + memset(&ktent, 0, sizeof(ktent)); + memset(&encpart, 0, sizeof(encpart)); + memset(&ticket, 0, sizeof(ticket)); + memset(&creds, 0, sizeof(creds)); + + if (krb5_init_context(&ctx) != 0) + return 0; + + if (krb5_parse_name(ctx, client_str, &client) != 0) + goto cleanup; + if (krb5_parse_name(ctx, service_str, &service) != 0) + goto cleanup; + + (void)krb5_timeofday(ctx, &now); + if (now == 0) + now = 1700000000; + + if (!make_fixed_key(&service_key, ENCTYPE_AES256_CTS_HMAC_SHA1_96, 1)) + goto cleanup; + if (!make_fixed_key(&session_key, ENCTYPE_AES256_CTS_HMAC_SHA1_96, 2)) + goto cleanup; + + /* In-memory keytab holding the service key. */ + if (krb5_kt_resolve(ctx, "MEMORY:gssfuzz_kt", &kt) != 0) + goto cleanup; + ktent.principal = service; + ktent.timestamp = now; + ktent.vno = 1; + ktent.key = service_key; + if (krb5_kt_add_entry(ctx, kt, &ktent) != 0) + goto cleanup; + (void)krb5_gss_register_acceptor_identity("MEMORY:gssfuzz_kt"); + + /* Encrypted ticket part. */ + encpart.flags = TKT_FLG_INITIAL | TKT_FLG_FORWARDABLE; + encpart.session = &session_key; + encpart.client = client; + encpart.transited.tr_type = KRB5_DOMAIN_X500_COMPRESS; + encpart.transited.tr_contents = empty_data(); + encpart.times.authtime = now; + encpart.times.starttime = now; + encpart.times.endtime = now + 24 * 60 * 60; + encpart.times.renew_till = now + 24 * 60 * 60; + + ticket.server = service; + ticket.enc_part2 = &encpart; + if (krb5_encrypt_tkt_part(ctx, &service_key, &ticket) != 0) + goto cleanup; + if (encode_krb5_ticket(&ticket, &tkt_der) != 0) + goto cleanup; + + /* Credential cache entry referencing that ticket. */ + creds.client = client; + creds.server = service; + creds.keyblock = session_key; + creds.times = encpart.times; + creds.ticket_flags = encpart.flags; + creds.ticket = *tkt_der; + + if (krb5_cc_resolve(ctx, "MEMORY:gssfuzz_cc", &cc) != 0) + goto cleanup; + if (krb5_cc_initialize(ctx, cc, client) != 0) + goto cleanup; + if (krb5_cc_store_cred(ctx, cc, &creds) != 0) + goto cleanup; + + /* Import GSS credentials from the cache and keytab. */ + major = gss_krb5_import_cred(&minor, cc, NULL, NULL, &init_cred); + if (GSS_ERROR(major)) + goto cleanup; + major = gss_krb5_import_cred(&minor, NULL, NULL, kt, &accept_cred); + if (GSS_ERROR(major)) + goto cleanup; + + namebuf.value = (void *)service_str; + namebuf.length = strlen(service_str); + major = gss_import_name(&minor, &namebuf, + (gss_OID)GSS_KRB5_NT_PRINCIPAL_NAME, &target_name); + if (GSS_ERROR(major)) + goto cleanup; + + ok = 1; + +cleanup: + krb5_free_data_contents(ctx, &ticket.enc_part.ciphertext); + if (tkt_der != NULL) + krb5_free_data(ctx, tkt_der); + if (kt != NULL) + krb5_kt_close(ctx, kt); + if (cc != NULL) + krb5_cc_close(ctx, cc); + krb5_free_keyblock_contents(ctx, &service_key); + krb5_free_keyblock_contents(ctx, &session_key); + krb5_free_principal(ctx, client); + krb5_free_principal(ctx, service); + krb5_free_context(ctx); + return ok; +} + +/* + * One-time setup: build credentials and establish one krb5 context, whose + * acceptor side is kept in estab_actx for per-message fuzzing. + */ +static void +do_setup(void) +{ + OM_uint32 minor, imaj, amaj; + gss_ctx_id_t ictx = GSS_C_NO_CONTEXT, actx = GSS_C_NO_CONTEXT; + gss_buffer_desc to_accept = GSS_C_EMPTY_BUFFER; + gss_buffer_desc to_init = GSS_C_EMPTY_BUFFER; + OM_uint32 flags = GSS_C_MUTUAL_FLAG | GSS_C_CONF_FLAG | GSS_C_INTEG_FLAG; + int rounds = 0; + + setup_done = 1; + if (!build_credentials()) + return; + setup_ok = 1; + + imaj = amaj = GSS_S_CONTINUE_NEEDED; + while ((imaj == GSS_S_CONTINUE_NEEDED || amaj == GSS_S_CONTINUE_NEEDED) && + rounds++ < 8) { + if (imaj == GSS_S_CONTINUE_NEEDED) { + gss_release_buffer(&minor, &to_accept); + imaj = gss_init_sec_context(&minor, init_cred, &ictx, target_name, + &mech_krb5, flags, 0, + GSS_C_NO_CHANNEL_BINDINGS, &to_init, + NULL, &to_accept, NULL, NULL); + gss_release_buffer(&minor, &to_init); + if (GSS_ERROR(imaj)) + break; + } + if (amaj == GSS_S_CONTINUE_NEEDED && to_accept.length > 0) { + amaj = gss_accept_sec_context(&minor, &actx, accept_cred, + &to_accept, + GSS_C_NO_CHANNEL_BINDINGS, NULL, + NULL, &to_init, NULL, NULL, NULL); + if (GSS_ERROR(amaj)) + break; + } else if (amaj == GSS_S_CONTINUE_NEEDED) { + break; + } + } + gss_release_buffer(&minor, &to_accept); + gss_release_buffer(&minor, &to_init); + gss_delete_sec_context(&minor, &ictx, GSS_C_NO_BUFFER); + + if (imaj == GSS_S_COMPLETE && amaj == GSS_S_COMPLETE) + estab_actx = actx; + else + gss_delete_sec_context(&minor, &actx, GSS_C_NO_BUFFER); +} + +/* Feed the fuzz input into the acceptor token decoder. */ +static void +fuzz_accept(uint8_t *data, size_t size) { - gss_OID doid; OM_uint32 minor, ret_flags, time_rec; + gss_OID doid = GSS_C_NO_OID; gss_name_t client = GSS_C_NO_NAME; gss_ctx_id_t context = GSS_C_NO_CONTEXT; - gss_cred_id_t deleg_cred = GSS_C_NO_CREDENTIAL; - gss_buffer_desc data_in, data_out = GSS_C_EMPTY_BUFFER; + gss_cred_id_t deleg = GSS_C_NO_CREDENTIAL; + gss_buffer_desc in, out = GSS_C_EMPTY_BUFFER; + + in.length = size; + in.value = (void *)data; + + (void)gss_accept_sec_context(&minor, &context, accept_cred, &in, + GSS_C_NO_CHANNEL_BINDINGS, &client, &doid, + &out, &ret_flags, &time_rec, &deleg); + + gss_release_buffer(&minor, &out); + gss_release_name(&minor, &client); + gss_release_cred(&minor, &deleg); + if (context != GSS_C_NO_CONTEXT) + gss_delete_sec_context(&minor, &context, GSS_C_NO_BUFFER); +} + +/* Feed the fuzz input into the initiator as a spoofed acceptor reply. */ +static void +fuzz_init_reply(uint8_t *data, size_t size) +{ + OM_uint32 major, minor; + gss_ctx_id_t ctx = GSS_C_NO_CONTEXT; + gss_buffer_desc first = GSS_C_EMPTY_BUFFER; + gss_buffer_desc reply, out = GSS_C_EMPTY_BUFFER; + + major = gss_init_sec_context(&minor, init_cred, &ctx, target_name, + &mech_krb5, GSS_C_MUTUAL_FLAG, 0, + GSS_C_NO_CHANNEL_BINDINGS, GSS_C_NO_BUFFER, + NULL, &first, NULL, NULL); + gss_release_buffer(&minor, &first); + + if (major == GSS_S_CONTINUE_NEEDED) { + reply.length = size; + reply.value = (void *)data; + (void)gss_init_sec_context(&minor, init_cred, &ctx, target_name, + &mech_krb5, GSS_C_MUTUAL_FLAG, 0, + GSS_C_NO_CHANNEL_BINDINGS, &reply, NULL, + &out, NULL, NULL); + gss_release_buffer(&minor, &out); + } + + if (ctx != GSS_C_NO_CONTEXT) + gss_delete_sec_context(&minor, &ctx, GSS_C_NO_BUFFER); +} + +/* Feed the fuzz input into the per-message decoders of a live context. */ +static void +fuzz_per_message(uint8_t *data, size_t size) +{ + OM_uint32 minor, qop; + int conf; + gss_buffer_desc in, out = GSS_C_EMPTY_BUFFER; + gss_buffer_desc msg; + + if (estab_actx == GSS_C_NO_CONTEXT) + return; + + in.length = size; + in.value = data; + + /* Tampered confidentiality/integrity token. */ + if (gss_unwrap(&minor, estab_actx, &in, &out, &conf, &qop) == + GSS_S_COMPLETE) + gss_release_buffer(&minor, &out); + + /* Tampered MIC over a fixed message. */ + msg.value = (void *)"fuzz message"; + msg.length = 12; + (void)gss_verify_mic(&minor, estab_actx, &msg, &in, &qop); + + /* Context-deletion / error token. */ + (void)gss_process_context_token(&minor, estab_actx, &in); +} + +/* Feed the fuzz input into the GSS import/deserialization routines. */ +static void +fuzz_import(uint8_t *data, size_t size) +{ + OM_uint32 minor; + gss_buffer_desc in; + gss_ctx_id_t ctx = GSS_C_NO_CONTEXT; + gss_cred_id_t cred = GSS_C_NO_CREDENTIAL; + gss_name_t name = GSS_C_NO_NAME; + + in.length = size; + in.value = (void *)data; + + if (gss_import_sec_context(&minor, &in, &ctx) == GSS_S_COMPLETE) + gss_delete_sec_context(&minor, &ctx, GSS_C_NO_BUFFER); + + if (gss_import_cred(&minor, &in, &cred) == GSS_S_COMPLETE) + gss_release_cred(&minor, &cred); + + if (gss_import_name(&minor, &in, GSS_C_NT_EXPORT_NAME, &name) == + GSS_S_COMPLETE) + gss_release_name(&minor, &name); + + if (gss_import_name(&minor, &in, (gss_OID)GSS_KRB5_NT_PRINCIPAL_NAME, + &name) == GSS_S_COMPLETE) + gss_release_name(&minor, &name); +} + +int +LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) +{ + uint8_t *buf; if (size < kMinInputLength || size > kMaxInputLength) return 0; - data_in.length = size; - data_in.value = (void *)data; - - gss_accept_sec_context(&minor, &context, GSS_C_NO_CREDENTIAL, - &data_in, GSS_C_NO_CHANNEL_BINDINGS, &client, - &doid, &data_out, &ret_flags, &time_rec, - &deleg_cred); + if (!setup_done) + do_setup(); + if (!setup_ok) + return 0; - gss_release_buffer(&minor, &data_out); + /* Work on a private copy; some GSS-API decoders rewrite the token + * buffer in place, which must not touch the const fuzzer input. */ + buf = malloc(size); + if (buf == NULL) + return 0; + memcpy(buf, data, size); - if (context != GSS_C_NO_CONTEXT) - gss_delete_sec_context(&minor, &context, GSS_C_NO_BUFFER); + fuzz_accept(buf, size); + fuzz_init_reply(buf, size); + fuzz_per_message(buf, size); + fuzz_import(buf, size); + free(buf); return 0; } diff --git a/src/tests/fuzzing/fuzz_initcreds.c b/src/tests/fuzzing/fuzz_initcreds.c new file mode 100644 index 0000000000..6412d225ea --- /dev/null +++ b/src/tests/fuzzing/fuzz_initcreds.c @@ -0,0 +1,305 @@ +/* -*- mode: c; c-basic-offset: 4; indent-tabs-mode: nil -*- */ +/* tests/fuzzing/fuzz_initcreds.c - fuzzing harness for KDC reply processing */ +/* + * Copyright (C) 2026 by Arjun. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * * Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + * COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +/* + * Fuzzing harness for the client side of the AS and TGS exchanges. + * + * krb5_get_init_creds_password(), krb5_get_init_creds_keytab() and + * krb5_get_credentials() obtain tickets by sending requests to a KDC and + * processing the replies. The KDC reply is fully attacker controlled (a + * malicious or spoofed KDC, or an on-path attacker). Rather than contact + * a real KDC, this harness installs a KDC send hook (krb5_set_kdc_send_hook) + * that answers every request with the fuzz input, so the input drives the + * AS-REP / TGS-REP / KRB-ERROR decoders, the preauthentication response + * handling and the FAST armor processing in lib/krb5/krb. + */ + +#include "autoconf.h" +#include +#include +#include +#include +#include +#include + +#define kMinInputLength 2 +#define kMaxInputLength 4096 + +extern int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size); + +static const char *client_str = "fuzzclient@KRBTEST.COM"; +static const char *service_str = "host/server.krbtest.com@KRBTEST.COM"; +static const char *tgs_str = "krbtgt/KRBTEST.COM@KRBTEST.COM"; + +static const uint8_t *g_data; +static size_t g_size; + +/* Answer every KDC request with the fuzz input. */ +static krb5_error_code KRB5_CALLCONV +send_hook(krb5_context ctx, void *data, const krb5_data *realm, + const krb5_data *message, krb5_data **new_message_out, + krb5_data **new_reply_out) +{ + krb5_data *reply; + + *new_message_out = NULL; + *new_reply_out = NULL; + + reply = malloc(sizeof(*reply)); + if (reply == NULL) + return ENOMEM; + *reply = empty_data(); + if (g_size > 0 && alloc_data(reply, g_size) == 0) + memcpy(reply->data, g_data, g_size); + *new_reply_out = reply; + return 0; +} + +static char config_path[256]; + +static void +remove_config(void) +{ + if (config_path[0] != '\0') + unlink(config_path); +} + +/* Write a krb5.conf naming a (never-contacted) KDC and select it. */ +static void +write_config(void) +{ + static int done = 0; + FILE *fp; + + if (done) + return; + done = 1; + + snprintf(config_path, sizeof(config_path), "/tmp/fuzz_krb5conf.%ld", + (long)getpid()); + fp = fopen(config_path, "w"); + if (fp == NULL) { + config_path[0] = '\0'; + return; + } + fputs("[libdefaults]\n" + " default_realm = KRBTEST.COM\n" + " dns_lookup_kdc = false\n" + "[realms]\n" + " KRBTEST.COM = {\n" + " kdc = 127.0.0.1:88\n" + " }\n", fp); + fclose(fp); + setenv("KRB5_CONFIG", config_path, 1); + atexit(remove_config); +} + +/* Store a forged TGT for client in ccache so a TGS exchange can be driven. */ +static void +store_tgt(krb5_context ctx, krb5_ccache cc, krb5_principal client) +{ + krb5_principal tgs = NULL; + krb5_keyblock tkt_key, session_key; + krb5_enc_tkt_part encpart; + krb5_ticket ticket; + krb5_data *tkt_der = NULL; + krb5_creds creds; + krb5_timestamp now = 0; + + memset(&tkt_key, 0, sizeof(tkt_key)); + memset(&session_key, 0, sizeof(session_key)); + memset(&encpart, 0, sizeof(encpart)); + memset(&ticket, 0, sizeof(ticket)); + memset(&creds, 0, sizeof(creds)); + + if (krb5_parse_name(ctx, tgs_str, &tgs) != 0) + return; + (void)krb5_timeofday(ctx, &now); + if (now == 0) + now = 1700000000; + if (krb5_c_make_random_key(ctx, ENCTYPE_AES256_CTS_HMAC_SHA1_96, + &tkt_key) != 0) + goto cleanup; + if (krb5_c_make_random_key(ctx, ENCTYPE_AES256_CTS_HMAC_SHA1_96, + &session_key) != 0) + goto cleanup; + + encpart.flags = TKT_FLG_INITIAL | TKT_FLG_FORWARDABLE; + encpart.session = &session_key; + encpart.client = client; + encpart.transited.tr_type = KRB5_DOMAIN_X500_COMPRESS; + encpart.transited.tr_contents = empty_data(); + encpart.times.authtime = now; + encpart.times.starttime = now; + encpart.times.endtime = now + 24 * 60 * 60; + encpart.times.renew_till = now + 24 * 60 * 60; + + ticket.server = tgs; + ticket.enc_part2 = &encpart; + if (krb5_encrypt_tkt_part(ctx, &tkt_key, &ticket) != 0) + goto cleanup; + if (encode_krb5_ticket(&ticket, &tkt_der) != 0) + goto cleanup; + + creds.client = client; + creds.server = tgs; + creds.keyblock = session_key; + creds.times = encpart.times; + creds.ticket_flags = encpart.flags; + creds.ticket = *tkt_der; + (void)krb5_cc_store_cred(ctx, cc, &creds); + +cleanup: + krb5_free_data_contents(ctx, &ticket.enc_part.ciphertext); + if (tkt_der != NULL) + krb5_free_data(ctx, tkt_der); + krb5_free_keyblock_contents(ctx, &tkt_key); + krb5_free_keyblock_contents(ctx, &session_key); + krb5_free_principal(ctx, tgs); +} + +/* Drive the AS exchange with a password. */ +static void +fuzz_as_password(krb5_context ctx, krb5_principal client) +{ + krb5_get_init_creds_opt *opt = NULL; + krb5_creds creds; + + memset(&creds, 0, sizeof(creds)); + if (krb5_get_init_creds_opt_alloc(ctx, &opt) != 0) + return; + if (krb5_get_init_creds_password(ctx, &creds, client, "fuzzpassword", + NULL, NULL, 0, NULL, opt) == 0) + krb5_free_cred_contents(ctx, &creds); + krb5_get_init_creds_opt_free(ctx, opt); +} + +/* Drive the AS exchange with a keytab. */ +static void +fuzz_as_keytab(krb5_context ctx, krb5_principal client) +{ + krb5_get_init_creds_opt *opt = NULL; + krb5_keytab kt = NULL; + krb5_keytab_entry ktent; + krb5_keyblock key; + krb5_creds creds; + krb5_timestamp now = 0; + + memset(&creds, 0, sizeof(creds)); + memset(&ktent, 0, sizeof(ktent)); + memset(&key, 0, sizeof(key)); + + if (krb5_c_make_random_key(ctx, ENCTYPE_AES256_CTS_HMAC_SHA1_96, + &key) != 0) + return; + if (krb5_kt_resolve(ctx, "MEMORY:initcreds_kt", &kt) != 0) + goto cleanup; + (void)krb5_timeofday(ctx, &now); + ktent.principal = client; + ktent.timestamp = now; + ktent.vno = 1; + ktent.key = key; + (void)krb5_kt_add_entry(ctx, kt, &ktent); + + if (krb5_get_init_creds_opt_alloc(ctx, &opt) != 0) + goto cleanup; + if (krb5_get_init_creds_keytab(ctx, &creds, client, kt, 0, NULL, + opt) == 0) + krb5_free_cred_contents(ctx, &creds); + krb5_get_init_creds_opt_free(ctx, opt); + +cleanup: + /* A MEMORY keytab is keyed by name and persists process-wide, so the + * entry must be removed again or it would accumulate every iteration. */ + if (kt != NULL) { + (void)krb5_kt_remove_entry(ctx, kt, &ktent); + krb5_kt_close(ctx, kt); + } + krb5_free_keyblock_contents(ctx, &key); +} + +/* Drive a TGS exchange using a forged TGT. */ +static void +fuzz_tgs(krb5_context ctx, krb5_principal client) +{ + krb5_ccache cc = NULL; + krb5_principal service = NULL; + krb5_creds in_creds, *out_creds = NULL; + + memset(&in_creds, 0, sizeof(in_creds)); + + if (krb5_parse_name(ctx, service_str, &service) != 0) + goto cleanup; + if (krb5_cc_resolve(ctx, "MEMORY:initcreds_cc", &cc) != 0) + goto cleanup; + if (krb5_cc_initialize(ctx, cc, client) != 0) + goto cleanup; + store_tgt(ctx, cc, client); + + in_creds.client = client; + in_creds.server = service; + if (krb5_get_credentials(ctx, 0, cc, &in_creds, &out_creds) == 0) + krb5_free_creds(ctx, out_creds); + +cleanup: + if (cc != NULL) + krb5_cc_destroy(ctx, cc); + krb5_free_principal(ctx, service); +} + +int +LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) +{ + krb5_context ctx; + krb5_principal client = NULL; + + if (size < kMinInputLength || size > kMaxInputLength) + return 0; + + write_config(); + + if (krb5_init_context(&ctx) != 0) + return 0; + + g_data = data; + g_size = size; + krb5_set_kdc_send_hook(ctx, send_hook, NULL); + + if (krb5_parse_name(ctx, client_str, &client) == 0) { + fuzz_as_password(ctx, client); + fuzz_as_keytab(ctx, client); + fuzz_tgs(ctx, client); + krb5_free_principal(ctx, client); + } + + krb5_free_context(ctx); + return 0; +} diff --git a/src/tests/fuzzing/fuzz_initcreds_seed_corpus/as_rep.bin b/src/tests/fuzzing/fuzz_initcreds_seed_corpus/as_rep.bin new file mode 100644 index 0000000000..6cb6811438 Binary files /dev/null and b/src/tests/fuzzing/fuzz_initcreds_seed_corpus/as_rep.bin differ diff --git a/src/tests/fuzzing/fuzz_initcreds_seed_corpus/enc_kdc_rep_part.bin b/src/tests/fuzzing/fuzz_initcreds_seed_corpus/enc_kdc_rep_part.bin new file mode 100644 index 0000000000..3184156386 Binary files /dev/null and b/src/tests/fuzzing/fuzz_initcreds_seed_corpus/enc_kdc_rep_part.bin differ diff --git a/src/tests/fuzzing/fuzz_initcreds_seed_corpus/tgs_rep.bin b/src/tests/fuzzing/fuzz_initcreds_seed_corpus/tgs_rep.bin new file mode 100644 index 0000000000..b46dd44b06 Binary files /dev/null and b/src/tests/fuzzing/fuzz_initcreds_seed_corpus/tgs_rep.bin differ diff --git a/src/tests/fuzzing/oss-fuzz.sh b/src/tests/fuzzing/oss-fuzz.sh index b01d4bcbd2..facff6ac44 100644 --- a/src/tests/fuzzing/oss-fuzz.sh +++ b/src/tests/fuzzing/oss-fuzz.sh @@ -15,10 +15,11 @@ popd # Copy fuzz targets and seed corpus to $OUT. pushd src/tests/fuzzing -fuzzers=("fuzz_aes" "fuzz_asn" "fuzz_attrset" "fuzz_chpw" "fuzz_crypto" - "fuzz_des" "fuzz_gss" "fuzz_json" "fuzz_kdc" "fuzz_krad" "fuzz_krb" - "fuzz_krb5_ticket" "fuzz_marshal_cred" "fuzz_marshal_princ" - "fuzz_ndr" "fuzz_oid" "fuzz_pac" "fuzz_profile" "fuzz_util") +fuzzers=("fuzz_aes" "fuzz_asn" "fuzz_attrset" "fuzz_ccache" "fuzz_chpw" + "fuzz_crypto" "fuzz_des" "fuzz_gss" "fuzz_initcreds" "fuzz_json" + "fuzz_kdc" "fuzz_krad" "fuzz_krb" "fuzz_krb5_ticket" + "fuzz_marshal_cred" "fuzz_marshal_princ" "fuzz_ndr" "fuzz_oid" + "fuzz_pac" "fuzz_profile" "fuzz_util") for fuzzer in "${fuzzers[@]}"; do cp "$fuzzer" "$OUT/$fuzzer"