From 8402771640cc465314db8cc7e2b11e097fe7cff7 Mon Sep 17 00:00:00 2001 From: tc-agent <270634894+tc-agent@users.noreply.github.com> Date: Sat, 11 Apr 2026 01:11:24 +0000 Subject: [PATCH 1/3] fuzzing: add fuzz-imap-parser, fuzz-imap-url, fuzz-message-address harnesses Three new libFuzzer harnesses for core dovecot parsing code that has no existing fuzz coverage: - fuzz-imap-parser: exercises imap_parser_read_tag(), imap_parser_read_command_name(), and imap_parser_finish_line() with both strict and ATOM_ALLCHARS flag combinations. The IMAP argument parser is the primary parsing path for all IMAP client connections and was previously not covered. - fuzz-imap-url: exercises imap_url_parse() for absolute and relative URLs (including URLAUTH), plus imap_url_create() for the write side. IMAP URL parsing is complex (RFC 5092/5593) and was previously not covered. - fuzz-message-address: exercises message_address_parse() with a write/re-parse roundtrip and message_address_parse_path() for Return-Path headers. RFC 5322 address parsing was previously not covered despite being security-critical. Makefile.am is updated in both lib-imap and lib-mail to build these targets under the existing USE_FUZZER conditional. --- src/lib-imap/Makefile.am | 17 ++++++++++++- src/lib-imap/fuzz-imap-parser.c | 38 +++++++++++++++++++++++++++++ src/lib-imap/fuzz-imap-url.c | 25 +++++++++++++++++++ src/lib-mail/Makefile.am | 11 ++++++++- src/lib-mail/fuzz-message-address.c | 29 ++++++++++++++++++++++ 5 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 src/lib-imap/fuzz-imap-parser.c create mode 100644 src/lib-imap/fuzz-imap-url.c create mode 100644 src/lib-mail/fuzz-message-address.c diff --git a/src/lib-imap/Makefile.am b/src/lib-imap/Makefile.am index 0aee66785c..151396ba43 100644 --- a/src/lib-imap/Makefile.am +++ b/src/lib-imap/Makefile.am @@ -109,7 +109,9 @@ test_imap_util_DEPENDENCIES = $(test_deps) if USE_FUZZER noinst_PROGRAMS += \ fuzz-imap-utf7 \ - fuzz-imap-bodystructure + fuzz-imap-bodystructure \ + fuzz-imap-parser \ + fuzz-imap-url nodist_EXTRA_fuzz_imap_utf7_SOURCES = force-cxx-linking.cxx fuzz_imap_utf7_SOURCES = fuzz-imap-utf7.c @@ -125,5 +127,18 @@ fuzz_imap_bodystructure_LDFLAGS = $(FUZZER_LDFLAGS) fuzz_imap_bodystructure_LDADD = libimap.la ../lib-mail/libmail.la $(test_libs) fuzz_imap_bodystructure_DEPENDENCIES = libimap.la $(test_deps) ../lib-mail/libmail.la +nodist_EXTRA_fuzz_imap_parser_SOURCES = force-cxx-linking.cxx +fuzz_imap_parser_SOURCES = fuzz-imap-parser.c +fuzz_imap_parser_CPPFLAGS = $(FUZZER_CPPFLAGS) +fuzz_imap_parser_LDFLAGS = $(FUZZER_LDFLAGS) +fuzz_imap_parser_LDADD = libimap.la $(test_libs) +fuzz_imap_parser_DEPENDENCIES = libimap.la $(test_deps) + +nodist_EXTRA_fuzz_imap_url_SOURCES = force-cxx-linking.cxx +fuzz_imap_url_SOURCES = fuzz-imap-url.c +fuzz_imap_url_CPPFLAGS = $(FUZZER_CPPFLAGS) +fuzz_imap_url_LDFLAGS = $(FUZZER_LDFLAGS) +fuzz_imap_url_LDADD = libimap.la $(test_libs) +fuzz_imap_url_DEPENDENCIES = libimap.la $(test_deps) endif diff --git a/src/lib-imap/fuzz-imap-parser.c b/src/lib-imap/fuzz-imap-parser.c new file mode 100644 index 0000000000..2243786b14 --- /dev/null +++ b/src/lib-imap/fuzz-imap-parser.c @@ -0,0 +1,38 @@ +/* Copyright (c) 2025 Dovecot authors, see the included COPYING file */ + +#include "lib.h" +#include "istream.h" +#include "test-common.h" +#include "fuzzer.h" +#include "imap-parser.h" + +FUZZ_BEGIN_DATA(const unsigned char *data, size_t size) +{ + struct istream *input = test_istream_create_data(data, size); + struct imap_parser *parser = + imap_parser_create(input, NULL, 65536, NULL); + const struct imap_arg *args; + const char *word; + int ret; + + i_stream_read(input); + + /* Try to parse as a full IMAP client command: tag SP cmd [args] CRLF */ + ret = imap_parser_read_tag(parser, &word); + if (ret == 1) { + ret = imap_parser_read_command_name(parser, &word); + if (ret == 1) + (void)imap_parser_finish_line(parser, 0, 0, &args); + } + + /* Also parse the raw data as a flat argument list with permissive flags */ + imap_parser_reset(parser); + i_stream_seek(input, 0); + i_stream_read(input); + (void)imap_parser_finish_line(parser, 0, + IMAP_PARSE_FLAG_ATOM_ALLCHARS, &args); + + imap_parser_unref(&parser); + i_stream_unref(&input); +} +FUZZ_END diff --git a/src/lib-imap/fuzz-imap-url.c b/src/lib-imap/fuzz-imap-url.c new file mode 100644 index 0000000000..0c2f305f09 --- /dev/null +++ b/src/lib-imap/fuzz-imap-url.c @@ -0,0 +1,25 @@ +/* Copyright (c) 2025 Dovecot authors, see the included COPYING file */ + +#include "lib.h" +#include "fuzzer.h" +#include "imap-url.h" + +FUZZ_BEGIN_STR(const char *input) +{ + struct imap_url *url; + const char *error; + + /* Parse as absolute IMAP URL; if valid, regenerate and check round-trip */ + if (imap_url_parse(input, NULL, IMAP_URL_PARSE_ALLOW_URLAUTH, + &url, &error) == 0) + (void)imap_url_create(url); + + /* Parse as relative URL (mailbox path, uid, section etc.) */ + const struct imap_url base = { + .host = { .name = "example.com" }, + .mailbox = "INBOX", + }; + (void)imap_url_parse(input, &base, + IMAP_URL_PARSE_REQUIRE_RELATIVE, &url, &error); +} +FUZZ_END diff --git a/src/lib-mail/Makefile.am b/src/lib-mail/Makefile.am index c02b338817..830d8c4f32 100644 --- a/src/lib-mail/Makefile.am +++ b/src/lib-mail/Makefile.am @@ -127,7 +127,8 @@ if USE_FUZZER fuzz_programs += fuzz-message-parser \ fuzz-qp-decoder \ fuzz-message-date \ - fuzz-message-decoder + fuzz-message-decoder \ + fuzz-message-address nodist_EXTRA_fuzz_message_parser_SOURCES = force-cxx-linking.cxx @@ -161,6 +162,14 @@ fuzz_message_decoder_SOURCES = fuzz-message-decoder.c fuzz_message_decoder_LDADD = $(test_libs) fuzz_message_decoder_DEPENDENCIES = $(test_deps) +nodist_EXTRA_fuzz_message_address_SOURCES = force-cxx-linking.cxx + +fuzz_message_address_CPPFLAGS = $(FUZZER_CPPFLAGS) +fuzz_message_address_LDFLAGS = $(FUZZER_LDFLAGS) +fuzz_message_address_SOURCES = fuzz-message-address.c +fuzz_message_address_LDADD = $(test_libs) +fuzz_message_address_DEPENDENCIES = $(test_deps) + endif noinst_PROGRAMS += $(fuzz_programs) diff --git a/src/lib-mail/fuzz-message-address.c b/src/lib-mail/fuzz-message-address.c new file mode 100644 index 0000000000..71b810a90d --- /dev/null +++ b/src/lib-mail/fuzz-message-address.c @@ -0,0 +1,29 @@ +/* Copyright (c) 2025 Dovecot authors, see the included COPYING file */ + +#include "lib.h" +#include "str.h" +#include "fuzzer.h" +#include "message-address.h" + +FUZZ_BEGIN_DATA(const unsigned char *data, size_t size) +{ + struct message_address *addr; + string_t *str; + + /* Parse as address list and roundtrip through write */ + addr = message_address_parse(pool_datastack_create(), data, size, + UINT_MAX, 0); + if (addr != NULL) { + str = t_str_new(128); + message_address_write(str, addr); + /* Re-parse the written output: must not crash */ + (void)message_address_parse(pool_datastack_create(), + (const unsigned char *)str_c(str), str_len(str), + UINT_MAX, 0); + } + + /* Also exercise Return-Path / path parsing */ + (void)message_address_parse_path(pool_datastack_create(), data, size, + &addr); +} +FUZZ_END From 7b9c65b0948ccfe43c3d8cbc133f0d757ea43f26 Mon Sep 17 00:00:00 2001 From: tc-agent <270634894+tc-agent@users.noreply.github.com> Date: Sat, 11 Apr 2026 03:15:48 +0000 Subject: [PATCH 2/3] =?UTF-8?q?fuzzing:=20fix=20copyright=20year=20in=20ne?= =?UTF-8?q?w=20harnesses=20(2025=20=E2=86=92=202026)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib-imap/fuzz-imap-parser.c | 2 +- src/lib-imap/fuzz-imap-url.c | 2 +- src/lib-mail/fuzz-message-address.c | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib-imap/fuzz-imap-parser.c b/src/lib-imap/fuzz-imap-parser.c index 2243786b14..5705952c2c 100644 --- a/src/lib-imap/fuzz-imap-parser.c +++ b/src/lib-imap/fuzz-imap-parser.c @@ -1,4 +1,4 @@ -/* Copyright (c) 2025 Dovecot authors, see the included COPYING file */ +/* Copyright (c) 2026 Dovecot authors, see the included COPYING file */ #include "lib.h" #include "istream.h" diff --git a/src/lib-imap/fuzz-imap-url.c b/src/lib-imap/fuzz-imap-url.c index 0c2f305f09..02bb57cf32 100644 --- a/src/lib-imap/fuzz-imap-url.c +++ b/src/lib-imap/fuzz-imap-url.c @@ -1,4 +1,4 @@ -/* Copyright (c) 2025 Dovecot authors, see the included COPYING file */ +/* Copyright (c) 2026 Dovecot authors, see the included COPYING file */ #include "lib.h" #include "fuzzer.h" diff --git a/src/lib-mail/fuzz-message-address.c b/src/lib-mail/fuzz-message-address.c index 71b810a90d..e518901e79 100644 --- a/src/lib-mail/fuzz-message-address.c +++ b/src/lib-mail/fuzz-message-address.c @@ -1,4 +1,4 @@ -/* Copyright (c) 2025 Dovecot authors, see the included COPYING file */ +/* Copyright (c) 2026 Dovecot authors, see the included COPYING file */ #include "lib.h" #include "str.h" From dc01350d2ef4aba9ca88b6bdb9be14bcdebdc8e2 Mon Sep 17 00:00:00 2001 From: tc-agent <270634894+tc-agent@users.noreply.github.com> Date: Fri, 24 Apr 2026 18:39:34 +0000 Subject: [PATCH 3/3] [CI] Add fuzzing verification (drop before forwarding upstream) --- .github/actions/collect-fuzz-stats/action.yml | 62 ++++++ .../render_comment.cpython-313.pyc | Bin 0 -> 8638 bytes .github/actions/post-fuzz-report/action.yml | 65 ++++++ .../post-fuzz-report/render_comment.py | 188 ++++++++++++++++++ .github/fuzz/Dockerfile | 23 +++ .github/fuzz/build.sh | 39 ++++ .github/fuzz/project.yaml | 16 ++ .github/fuzz/seeds/fuzz-imap-parser/append | 4 + .github/fuzz/seeds/fuzz-imap-parser/fetch | 1 + .github/fuzz/seeds/fuzz-imap-parser/login | 1 + .github/fuzz/seeds/fuzz-imap-parser/search | 1 + .github/fuzz/seeds/fuzz-imap-parser/select | 1 + .github/fuzz/seeds/fuzz-imap-parser/uid_fetch | 1 + .github/fuzz/seeds/fuzz-imap-url/absolute | 1 + .github/fuzz/seeds/fuzz-imap-url/auth | 1 + .github/fuzz/seeds/fuzz-imap-url/relative | 1 + .github/fuzz/seeds/fuzz-imap-url/section | 1 + .github/fuzz/seeds/fuzz-imap-url/uid | 1 + .../seeds/fuzz-message-address/display_name | 1 + .github/fuzz/seeds/fuzz-message-address/group | 1 + .github/fuzz/seeds/fuzz-message-address/list | 1 + .../fuzz/seeds/fuzz-message-address/quoted | 1 + .github/fuzz/seeds/fuzz-message-address/route | 1 + .../fuzz/seeds/fuzz-message-address/simple | 1 + .github/workflows/fuzz-verify.yml | 186 +++++++++++++++++ 25 files changed, 599 insertions(+) create mode 100644 .github/actions/collect-fuzz-stats/action.yml create mode 100644 .github/actions/post-fuzz-report/__pycache__/render_comment.cpython-313.pyc create mode 100644 .github/actions/post-fuzz-report/action.yml create mode 100644 .github/actions/post-fuzz-report/render_comment.py create mode 100755 .github/fuzz/Dockerfile create mode 100755 .github/fuzz/build.sh create mode 100755 .github/fuzz/project.yaml create mode 100644 .github/fuzz/seeds/fuzz-imap-parser/append create mode 100644 .github/fuzz/seeds/fuzz-imap-parser/fetch create mode 100644 .github/fuzz/seeds/fuzz-imap-parser/login create mode 100644 .github/fuzz/seeds/fuzz-imap-parser/search create mode 100644 .github/fuzz/seeds/fuzz-imap-parser/select create mode 100644 .github/fuzz/seeds/fuzz-imap-parser/uid_fetch create mode 100644 .github/fuzz/seeds/fuzz-imap-url/absolute create mode 100644 .github/fuzz/seeds/fuzz-imap-url/auth create mode 100644 .github/fuzz/seeds/fuzz-imap-url/relative create mode 100644 .github/fuzz/seeds/fuzz-imap-url/section create mode 100644 .github/fuzz/seeds/fuzz-imap-url/uid create mode 100644 .github/fuzz/seeds/fuzz-message-address/display_name create mode 100644 .github/fuzz/seeds/fuzz-message-address/group create mode 100644 .github/fuzz/seeds/fuzz-message-address/list create mode 100644 .github/fuzz/seeds/fuzz-message-address/quoted create mode 100644 .github/fuzz/seeds/fuzz-message-address/route create mode 100644 .github/fuzz/seeds/fuzz-message-address/simple create mode 100644 .github/workflows/fuzz-verify.yml diff --git a/.github/actions/collect-fuzz-stats/action.yml b/.github/actions/collect-fuzz-stats/action.yml new file mode 100644 index 0000000000..2548795902 --- /dev/null +++ b/.github/actions/collect-fuzz-stats/action.yml @@ -0,0 +1,62 @@ +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) + 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 + + echo "---- $OUT ----" + find "$OUT" -type f -printf '%p (%s bytes)\n' diff --git a/.github/actions/post-fuzz-report/__pycache__/render_comment.cpython-313.pyc b/.github/actions/post-fuzz-report/__pycache__/render_comment.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..badc84fe967d923b962f3f20b578bec0806f91b2 GIT binary patch literal 8638 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 TO)Es(uSwG%pNDl-PT+q5Tfk&S literal 0 HcmV?d00001 diff --git a/.github/actions/post-fuzz-report/action.yml b/.github/actions/post-fuzz-report/action.yml new file mode 100644 index 0000000000..7952e882d7 --- /dev/null +++ b/.github/actions/post-fuzz-report/action.yml @@ -0,0 +1,65 @@ +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@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: stats-baseline + path: stats/baseline + continue-on-error: true + + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: stats-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/render_comment.py b/.github/actions/post-fuzz-report/render_comment.py new file mode 100644 index 0000000000..a954f27c2c --- /dev/null +++ b/.github/actions/post-fuzz-report/render_comment.py @@ -0,0 +1,188 @@ +# 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 + +MARKER = "" + +FOOTER_GENERIC = ( + "Per-harness data from `report_target/<fuzzer>/linux/summary.json`. " + "Full HTML reports in the workflow artifacts.") +FOOTER_UPSTREAM = ("Same harness config applied to both sides " + "(baseline = base source + PR harness). " + + FOOTER_GENERIC[5:]) + + +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, + } + + +def _totals(summary): + if not summary: + return None + t = summary["data"][0]["totals"] + return { + "lines": + (t["lines"]["covered"], t["lines"]["count"], t["lines"]["percent"]), + "branches": ( + t["branches"]["covered"], + t["branches"]["count"], + t["branches"]["percent"], + ), + "functions": ( + t["functions"]["covered"], + t["functions"]["count"], + t["functions"]["percent"], + ), + } + + +def _fmt_cov(tot, key): + if not tot: + return "—" + cov, n, pct = tot[key] + return f"{pct:.1f}% ({cov}/{n})" + + +def _fmt_delta(b, a, key): + if not b and not a: + return "—" + if not b: + return "**new**" + if not a: + return "**removed**" + d = a[key][2] - b[key][2] + sign = "+" if d >= 0 else "" + return f"**{sign}{d:.1f} pp**" + + +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"]) + if bt or ct: + out += [ + "| Metric | Before | After | Delta |", + "|---|---|---|---|", + f"| Line coverage | {_fmt_cov(bt, 'lines')} | {_fmt_cov(ct, 'lines')} | {_fmt_delta(bt, ct, 'lines')} |", + f"| Branch coverage | {_fmt_cov(bt, 'branches')} | {_fmt_cov(ct, 'branches')} | {_fmt_delta(bt, ct, 'branches')} |", + f"| Function coverage | {_fmt_cov(bt, 'functions')} | {_fmt_cov(ct, 'functions')} | {_fmt_delta(bt, ct, 'functions')} |", + "", + ] + + 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')} |") + out.append("") + + if not (bt or ct or all_h): + 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/fuzz/Dockerfile b/.github/fuzz/Dockerfile new file mode 100755 index 0000000000..2e7580ba22 --- /dev/null +++ b/.github/fuzz/Dockerfile @@ -0,0 +1,23 @@ +# Copyright 2020 Google Inc. +# +# 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 make autoconf automake libtool wget gettext automake libxml2-dev m4 pkg-config bison flex python3.8-venv libssl-dev zlib1g-dev libtool-bin +RUN git clone --depth 1 --single-branch --branch main https://github.com/dovecot/core dovecot +RUN git clone --depth 1 --single-branch --branch main https://github.com/dovecot/pigeonhole pigeonhole +COPY build.sh $SRC/ +COPY seeds/ $SRC/seeds/ +#COPY fuzz-* $SRC/ diff --git a/.github/fuzz/build.sh b/.github/fuzz/build.sh new file mode 100755 index 0000000000..e80a846361 --- /dev/null +++ b/.github/fuzz/build.sh @@ -0,0 +1,39 @@ +#!/bin/bash -eu +# Copyright 2020 Google Inc. +# +# 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. +# +################################################################################ +cd dovecot +# Patch ldflags +find . -name "Makefile.am" -exec sed -i -e 's,(FUZZER_LDFLAGS),(FUZZER_LDFLAGS) -static-libtool-libs,' {} \; +./autogen.sh +./configure PANDOC=false --with-fuzzer=clang --prefix=$OUT +make -j$(nproc) +# Copy over the fuzzers +find . -name "fuzz-*" -executable -exec libtool install install -m0755 {} $OUT/ \; +cd ../pigeonhole +# Fix typo in upstream: fuzz_suite_LDFLAG -> fuzz_suite_LDFLAGS +find . -name "Makefile.am" -exec sed -i -e 's,fuzz_suite_LDFLAG ,fuzz_suite_LDFLAGS ,' {} \; +find . -name "Makefile.am" -exec sed -i -e 's,(FUZZER_LDFLAGS),(FUZZER_LDFLAGS) -static-libtool-libs,' {} \; +./autogen.sh +./configure --with-dovecot=../dovecot --with-fuzzer=clang --prefix=$OUT +make -j$(nproc) +# Copy over the fuzzers +find . -name "fuzz-*" -executable -exec libtool install install -m0755 {} $OUT/ \; +# Package seed corpora +for harness in fuzz-message-address fuzz-imap-parser fuzz-imap-url; do + if [ -d "$SRC/seeds/$harness" ]; then + zip -j "$OUT/${harness}_seed_corpus.zip" "$SRC/seeds/$harness/"* + fi +done diff --git a/.github/fuzz/project.yaml b/.github/fuzz/project.yaml new file mode 100755 index 0000000000..3c71d55472 --- /dev/null +++ b/.github/fuzz/project.yaml @@ -0,0 +1,16 @@ +homepage: "https://www.dovecot.org/" +language: c +primary_contact: "oss-fuzz@open-xchange.com" +auto_ccs: + - "david@adalogics.com" + - "p.antoine@catenacyber.fr" + - "cmousefi@gmail.com" + - "boschstephan@gmail.com" + - "timo.sirainen@gmail.com" +main_repo: 'https://github.com/dovecot/core' + +fuzzing_engines: + - afl + - honggfuzz + - libfuzzer + diff --git a/.github/fuzz/seeds/fuzz-imap-parser/append b/.github/fuzz/seeds/fuzz-imap-parser/append new file mode 100644 index 0000000000..e7e21dc13f --- /dev/null +++ b/.github/fuzz/seeds/fuzz-imap-parser/append @@ -0,0 +1,4 @@ +A001 APPEND INBOX (\Seen) {10} +Hello + + diff --git a/.github/fuzz/seeds/fuzz-imap-parser/fetch b/.github/fuzz/seeds/fuzz-imap-parser/fetch new file mode 100644 index 0000000000..cbd8128b0f --- /dev/null +++ b/.github/fuzz/seeds/fuzz-imap-parser/fetch @@ -0,0 +1 @@ +A001 FETCH 1:10 (FLAGS BODY[HEADER.FIELDS (FROM TO SUBJECT)]) diff --git a/.github/fuzz/seeds/fuzz-imap-parser/login b/.github/fuzz/seeds/fuzz-imap-parser/login new file mode 100644 index 0000000000..030f275e46 --- /dev/null +++ b/.github/fuzz/seeds/fuzz-imap-parser/login @@ -0,0 +1 @@ +A001 LOGIN user password diff --git a/.github/fuzz/seeds/fuzz-imap-parser/search b/.github/fuzz/seeds/fuzz-imap-parser/search new file mode 100644 index 0000000000..b86e269b5f --- /dev/null +++ b/.github/fuzz/seeds/fuzz-imap-parser/search @@ -0,0 +1 @@ +A001 SEARCH HEADER FROM user@example.com SINCE 1-Jan-2020 diff --git a/.github/fuzz/seeds/fuzz-imap-parser/select b/.github/fuzz/seeds/fuzz-imap-parser/select new file mode 100644 index 0000000000..747649f1e8 --- /dev/null +++ b/.github/fuzz/seeds/fuzz-imap-parser/select @@ -0,0 +1 @@ +A001 SELECT INBOX diff --git a/.github/fuzz/seeds/fuzz-imap-parser/uid_fetch b/.github/fuzz/seeds/fuzz-imap-parser/uid_fetch new file mode 100644 index 0000000000..f1d6256975 --- /dev/null +++ b/.github/fuzz/seeds/fuzz-imap-parser/uid_fetch @@ -0,0 +1 @@ +A001 UID FETCH 1234 (BODY.PEEK[]) diff --git a/.github/fuzz/seeds/fuzz-imap-url/absolute b/.github/fuzz/seeds/fuzz-imap-url/absolute new file mode 100644 index 0000000000..1e68bd2389 --- /dev/null +++ b/.github/fuzz/seeds/fuzz-imap-url/absolute @@ -0,0 +1 @@ +imap://user@example.com/INBOX \ No newline at end of file diff --git a/.github/fuzz/seeds/fuzz-imap-url/auth b/.github/fuzz/seeds/fuzz-imap-url/auth new file mode 100644 index 0000000000..de5b3058f8 --- /dev/null +++ b/.github/fuzz/seeds/fuzz-imap-url/auth @@ -0,0 +1 @@ +imap://user;AUTH=GSSAPI@example.com/ \ No newline at end of file diff --git a/.github/fuzz/seeds/fuzz-imap-url/relative b/.github/fuzz/seeds/fuzz-imap-url/relative new file mode 100644 index 0000000000..69de78fa04 --- /dev/null +++ b/.github/fuzz/seeds/fuzz-imap-url/relative @@ -0,0 +1 @@ +/INBOX/;UID=1/;SECTION=TEXT \ No newline at end of file diff --git a/.github/fuzz/seeds/fuzz-imap-url/section b/.github/fuzz/seeds/fuzz-imap-url/section new file mode 100644 index 0000000000..d0c4ae934a --- /dev/null +++ b/.github/fuzz/seeds/fuzz-imap-url/section @@ -0,0 +1 @@ +imap://user@example.com/INBOX;UIDVALIDITY=12345/;UID=5/;SECTION=1.2 \ No newline at end of file diff --git a/.github/fuzz/seeds/fuzz-imap-url/uid b/.github/fuzz/seeds/fuzz-imap-url/uid new file mode 100644 index 0000000000..eaf7626fc9 --- /dev/null +++ b/.github/fuzz/seeds/fuzz-imap-url/uid @@ -0,0 +1 @@ +imap://example.com/INBOX/;UID=1 \ No newline at end of file diff --git a/.github/fuzz/seeds/fuzz-message-address/display_name b/.github/fuzz/seeds/fuzz-message-address/display_name new file mode 100644 index 0000000000..aba6e25598 --- /dev/null +++ b/.github/fuzz/seeds/fuzz-message-address/display_name @@ -0,0 +1 @@ +"User Name" \ No newline at end of file diff --git a/.github/fuzz/seeds/fuzz-message-address/group b/.github/fuzz/seeds/fuzz-message-address/group new file mode 100644 index 0000000000..90faedb278 --- /dev/null +++ b/.github/fuzz/seeds/fuzz-message-address/group @@ -0,0 +1 @@ +Group: user1@a.com, user2@b.com; \ No newline at end of file diff --git a/.github/fuzz/seeds/fuzz-message-address/list b/.github/fuzz/seeds/fuzz-message-address/list new file mode 100644 index 0000000000..cbc9ee907f --- /dev/null +++ b/.github/fuzz/seeds/fuzz-message-address/list @@ -0,0 +1 @@ +user1@a.com, user2@b.com, user3@c.com \ No newline at end of file diff --git a/.github/fuzz/seeds/fuzz-message-address/quoted b/.github/fuzz/seeds/fuzz-message-address/quoted new file mode 100644 index 0000000000..378bc577eb --- /dev/null +++ b/.github/fuzz/seeds/fuzz-message-address/quoted @@ -0,0 +1 @@ +"User "Quoted" Name" \ No newline at end of file diff --git a/.github/fuzz/seeds/fuzz-message-address/route b/.github/fuzz/seeds/fuzz-message-address/route new file mode 100644 index 0000000000..c86fcba620 --- /dev/null +++ b/.github/fuzz/seeds/fuzz-message-address/route @@ -0,0 +1 @@ +<@route:user@example.com> \ No newline at end of file diff --git a/.github/fuzz/seeds/fuzz-message-address/simple b/.github/fuzz/seeds/fuzz-message-address/simple new file mode 100644 index 0000000000..7046a5bbe8 --- /dev/null +++ b/.github/fuzz/seeds/fuzz-message-address/simple @@ -0,0 +1 @@ +user@example.com \ No newline at end of file diff --git a/.github/workflows/fuzz-verify.yml b/.github/workflows/fuzz-verify.yml new file mode 100644 index 0000000000..2bf7b1f4fc --- /dev/null +++ b/.github/workflows/fuzz-verify.yml @@ -0,0 +1,186 @@ +# Template — injected into upstream repo fork branches by pr_server.py at PR submission time. +# Not used directly in this repo. Injected alongside .github/actions/{collect-fuzz-stats,post-fuzz-report}/. +name: Fuzz Verify +on: [push] + +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 + +env: + PROJECT: dovecot + FUZZ_SECONDS: "300" + +jobs: + measure: + name: Measure (${{ matrix.variant }}) + runs-on: ubuntu-latest + timeout-minutes: 45 + continue-on-error: ${{ matrix.variant == 'baseline' }} + strategy: + fail-fast: false + matrix: + variant: [baseline, current] + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + fetch-depth: 0 + persist-credentials: false + submodules: recursive + + - name: Resolve variant + id: variant + env: + VARIANT: ${{ matrix.variant }} + run: | + if [ "$VARIANT" = "baseline" ]; then + git remote set-head origin -a >/dev/null 2>&1 || true + DEFAULT_BRANCH=$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's|origin/||') + [ -z "$DEFAULT_BRANCH" ] && DEFAULT_BRANCH=main + if ! git rev-parse --verify "origin/$DEFAULT_BRANCH" >/dev/null 2>&1; then + DEFAULT_BRANCH=master + fi + BASE_SHA=$(git merge-base "origin/$DEFAULT_BRANCH" HEAD) + echo "sha=$BASE_SHA" >> "$GITHUB_OUTPUT" + echo "Baseline: $BASE_SHA (merge-base with origin/$DEFAULT_BRANCH)" + + if [ ! -d .github/fuzz ]; then + echo "::error::.github/fuzz not present on PR — cannot overlay onto baseline" + echo "has_project=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + FUZZ_TMP=$(mktemp -d) + cp -r .github/fuzz "$FUZZ_TMP/" + ACTIONS_TMP=$(mktemp -d) + cp -r .github/actions "$ACTIONS_TMP/" + git reset --hard "$BASE_SHA" + git submodule update --init --recursive + rm -rf .github/fuzz .github/actions + mkdir -p .github + cp -r "$FUZZ_TMP/fuzz" .github/ + cp -r "$ACTIONS_TMP/actions" .github/ + echo "has_project=true" >> "$GITHUB_OUTPUT" + else + echo "sha=$GITHUB_SHA" >> "$GITHUB_OUTPUT" + echo "has_project=true" >> "$GITHUB_OUTPUT" + echo "Current: $GITHUB_SHA" + fi + + - name: Clone oss-fuzz + if: steps.variant.outputs.has_project == 'true' + run: git clone --depth 1 https://github.com/google/oss-fuzz.git /tmp/oss-fuzz + + - name: Install oss-fuzz deps + if: steps.variant.outputs.has_project == 'true' + run: pip install -r /tmp/oss-fuzz/infra/ci/requirements.txt 2>/dev/null || pip install docker + + - name: Set up project + if: steps.variant.outputs.has_project == 'true' + run: | + mkdir -p "/tmp/oss-fuzz/projects/$PROJECT" + cp -r .github/fuzz/* "/tmp/oss-fuzz/projects/$PROJECT/" + + - name: Build image + if: steps.variant.outputs.has_project == 'true' + run: echo n | python3 /tmp/oss-fuzz/infra/helper.py build_image "$PROJECT" + + - name: Build fuzzers + if: steps.variant.outputs.has_project == 'true' + run: echo n | python3 /tmp/oss-fuzz/infra/helper.py build_fuzzers --sanitizer address "$PROJECT" "$GITHUB_WORKSPACE" + + - name: Fuzz + if: steps.variant.outputs.has_project == 'true' + run: | + OUT="/tmp/oss-fuzz/build/out/$PROJECT" + + HARNESSES="" + for f in "$OUT"/*; do + [ -f "$f" ] && [ -x "$f" ] || continue + case "$(basename "$f")" in + *.options|*.dict|*_seed_corpus.zip|llvm-symbolizer) continue ;; + esac + HARNESSES="$HARNESSES $(basename "$f")" + done + HARNESSES=$(echo $HARNESSES | xargs) + + if [ -z "$HARNESSES" ]; then + echo "::error::No harness binaries found in $OUT" + exit 1 + fi + + N=$(echo $HARNESSES | wc -w) + TIME_EACH=$(( FUZZ_SECONDS / N )) + [ "$TIME_EACH" -lt 30 ] && TIME_EACH=30 + echo "Harnesses ($N): $HARNESSES — ${TIME_EACH}s each" + + for h in $HARNESSES; do + echo "::group::$h" + mkdir -p "/tmp/oss-fuzz/corpus/$PROJECT/$h" + timeout $((TIME_EACH + 60)) python3 /tmp/oss-fuzz/infra/helper.py run_fuzzer \ + --corpus-dir "/tmp/oss-fuzz/corpus/$PROJECT/$h" \ + "$PROJECT" "$h" -- \ + "-max_total_time=$TIME_EACH" 2>&1 || true + echo "Corpus: $(find "/tmp/oss-fuzz/corpus/$PROJECT/$h" -type f | wc -l) files" + echo "::endgroup::" + done + + - name: Coverage + if: steps.variant.outputs.has_project == 'true' + run: | + echo n | python3 /tmp/oss-fuzz/infra/helper.py build_fuzzers --sanitizer coverage "$PROJECT" "$GITHUB_WORKSPACE" + + mkdir -p "/tmp/oss-fuzz/build/corpus" + ln -sfn "/tmp/oss-fuzz/corpus/$PROJECT" "/tmp/oss-fuzz/build/corpus/$PROJECT" + + python3 /tmp/oss-fuzz/infra/helper.py coverage \ + --no-corpus-download --no-serve \ + "$PROJECT" 2>&1 || true + + - name: Collect stats + if: always() + uses: ./.github/actions/collect-fuzz-stats + with: + project: ${{ env.PROJECT }} + variant: ${{ matrix.variant }} + sha: ${{ steps.variant.outputs.sha }} + has_project: ${{ steps.variant.outputs.has_project }} + out_base: /tmp/oss-fuzz/build/out + + - name: Upload stats + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: stats-${{ matrix.variant }} + path: stats-${{ matrix.variant }}/ + if-no-files-found: error + + - name: Upload coverage report + if: always() && steps.variant.outputs.has_project == 'true' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: coverage-report-${{ matrix.variant }} + path: /tmp/oss-fuzz/build/out/*coverage*/report/ + if-no-files-found: ignore + + report: + name: Report + needs: [measure] + if: always() + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write # post sticky fuzz-verify comment + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + sparse-checkout: .github/actions/post-fuzz-report + - uses: ./.github/actions/post-fuzz-report + with: + footer: upstream + fuzz_seconds: ${{ env.FUZZ_SECONDS }} + github_token: ${{ secrets.GITHUB_TOKEN }}