Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 62 additions & 24 deletions .github/workflows/fuzz-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,20 +27,44 @@ jobs:
fuzz-smoke:
name: Fuzz Smoke Test (60s per target)
runs-on: cachekit
timeout-minutes: 20
# Budget: ~2 min rustup + ~10 min cargo-fuzz install (CARGO_HOME=/tmp/cargo,
# so no cross-run cache) + ~10 min one-shot ASAN build of all targets
# + ~1 min per fuzz target (count derived from `cargo fuzz list`, ~14 min
# today) + slack.
timeout-minutes: 45

steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6

- name: Install Rust nightly
run: |
# Pin nightly: cargo-fuzz 0.13.1 → rustix uses rustc_layout_scalar_valid_range_*
# attributes reserved after nightly-2026-04-27. Last known-good date.
# Pin nightly: cargo-fuzz's rustix dependency uses
# rustc_layout_scalar_valid_range_* attributes reserved after
# nightly-2026-04-27. Last known-good date.
rustup toolchain install nightly-2026-04-27
rustup default nightly-2026-04-27

- name: Install cargo-fuzz
run: cargo install --locked cargo-fuzz
# Version-pinned: a floating `cargo install` hands a hijacked cargo-fuzz
# release immediate code exec on the self-hosted runner.
run: cargo install --locked cargo-fuzz --version 0.13.2

- name: Verify fuzz lockfile is current
run: |
cd rust/fuzz
# `cargo fuzz build/run` have no --locked passthrough, so the committed
# Cargo.lock is advisory to them: on any manifest/lock drift cargo would
# silently re-resolve every transitive to newest-on-crates.io, on a
# self-hosted runner. This is the only step that enforces the lock —
# it fails loudly on drift, and warms the empty CARGO_HOME besides.
cargo +nightly-2026-04-27 fetch --locked

- name: Build all fuzz targets
run: |
cd rust/fuzz
# One cargo invocation builds every [[bin]] target, sharing dependency
# compilation. A target that fails to compile fails the job here.
cargo +nightly-2026-04-27 fuzz build

- name: Run fuzzing smoke tests
id: fuzz
Expand All @@ -50,28 +74,38 @@ jobs:
# Create artifacts directory
mkdir -p artifacts

# Fuzz targets that compile against cachekit-core 0.1.1.
# 9 encryption/advanced targets are disabled — cachekit-core API
# changed (encrypt_aes_gcm → encrypt_with_keys etc.) and the
# fuzz targets haven't been updated. See #114.
FUZZ_TARGETS=(
byte_storage_compress
byte_storage_decompress
byte_storage_format_injection
encryption_key_derivation
)

for target in "${FUZZ_TARGETS[@]}"; do
echo "Fuzzing $target..."
# Every target `cargo fuzz list` reports runs — no hand-maintained
# allow-list in this workflow to drift out of date.
# Resolve the list in a standalone assignment: `for t in $(cmd)` does
# not propagate a failing $(cmd) under `set -e`, and an empty list
# would loop zero times and go green having fuzzed nothing.
TARGETS=$(cargo +nightly-2026-04-27 fuzz list)
if [ -z "$TARGETS" ]; then
echo "::error::cargo fuzz list returned no targets"
exit 1
fi

# `cargo fuzz list` enumerates the [[bin]] stanzas in Cargo.toml, NOT the
# files in fuzz_targets/. So a new fuzz_targets/*.rs added without its
# stanza is never built and never run, and this job still goes green —
# the same silent-dark mode the old hardcoded array caused. Nothing in
# cargo enforces that the two agree, so assert it here.
SRC_COUNT=$(find fuzz_targets -maxdepth 1 -name '*.rs' | wc -l)
LIST_COUNT=$(printf '%s\n' "$TARGETS" | wc -l)
if [ "$SRC_COUNT" -ne "$LIST_COUNT" ]; then
echo "::error::fuzz_targets/ holds $SRC_COUNT sources but Cargo.toml declares $LIST_COUNT [[bin]] targets — add the missing [[bin]] stanza so the new target actually runs"
exit 1
fi

if ! cargo +nightly-2026-04-27 fuzz run "$target" -- -max_total_time=60; then
echo "::warning::Fuzz target '$target' found potential issues"
# Continue to test other targets even if one fails
touch artifacts/.fuzz_failures
fi
# A non-zero exit (crash found, or target failed to run) fails the
# job immediately: green must mean "fuzzed and clean".
for target in $TARGETS; do
echo "Fuzzing $target..."
cargo +nightly-2026-04-27 fuzz run "$target" -- -max_total_time=60
done

# Check if any crashes were found
# Belt-and-braces: fail on any crash artifact even if the runs above
# all exited zero.
if find artifacts -name 'crash-*' -o -name 'timeout-*' -o -name 'oom-*' | grep -q .; then
echo "::error::Fuzzing discovered crashes or errors. See artifacts for details."
exit 1
Expand All @@ -85,5 +119,9 @@ jobs:
with:
name: fuzz-crash-artifacts
path: rust/fuzz/artifacts/
retention-days: 30
# Short window deliberately: this repo is public and this job runs on
# pull_request, so the artifact is a working libFuzzer reproducer for an
# unfixed defect in the shipped compression/AES-GCM path, downloadable by
# anyone. 3 days is enough to triage; 30 is a month-long public window.
retention-days: 3
if-no-files-found: warn
84 changes: 54 additions & 30 deletions .github/workflows/security-deep.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,10 @@ jobs:
fuzzing:
name: Extended Fuzzing (3 targets × 1h)
runs-on: cachekit
timeout-minutes: 200
# Budget: ~2 min rustup + ~10 min cargo-fuzz install (CARGO_HOME=/tmp/cargo,
# no cross-run cache) + ~10 min ASAN builds + 3 x 60 min fuzz time
# (-max_total_time is pure fuzz time, excluding builds) + slack.
timeout-minutes: 240
env:
# Avoid EXDEV "cross-device link" errors when rustup stages a nightly
# toolchain across overlay/hostPath boundaries on the ARC runner pod
Expand All @@ -61,51 +64,72 @@ jobs:

- name: Install nightly Rust
run: |
# Pin nightly: cargo-fuzz 0.13.1 → rustix uses rustc_layout_scalar_valid_range_*
# attributes reserved after nightly-2026-04-27. Last known-good date.
# Same pin as fuzz-smoke.yml.
# Pin nightly: cargo-fuzz's rustix dependency uses
# rustc_layout_scalar_valid_range_* attributes reserved after
# nightly-2026-04-27. Last known-good date. Same pin as fuzz-smoke.yml.
rustup toolchain install nightly-2026-04-27
rustup default nightly-2026-04-27

- name: Install cargo-fuzz
run: cargo install --locked cargo-fuzz
# Version-pinned: a floating `cargo install` hands a hijacked cargo-fuzz
# release immediate code exec on the self-hosted runner. Same pattern as
# kani-verifier above and fuzz-smoke.yml.
run: cargo install --locked cargo-fuzz --version 0.13.2

- name: Verify fuzz lockfile is current
run: |
cd rust/fuzz
# See fuzz-smoke.yml: cargo fuzz has no --locked passthrough, so this is
# the only step enforcing the committed lock against manifest drift.
cargo +nightly-2026-04-27 fetch --locked

- name: Build fuzz targets
run: |
cd rust/fuzz
# Build before fuzzing so a compile error surfaces in ~10 min as a build
# failure, not three hours in as a confusing fuzz-step error.
cargo +nightly-2026-04-27 fuzz build

# The fuzz crate (cachekit-storage-fuzz) declares no [features] table — its
# cachekit-core feature set is fixed in rust/fuzz/Cargo.toml. Passing
# --features here makes cargo error out before any fuzzing happens, and the
# old `|| true` swallowed exactly that for three core versions. A non-zero
# exit (build failure or crash) must fail the job; a clean hour exits 0 via
# -max_total_time.
#
# Deliberate subset for the time budget: these three cover the highest-value
# attack surfaces (compression bombs, envelope decode, key derivation); every
# target gets 60 s on every PR via fuzz-smoke.yml.
- name: Fuzz byte_storage_compress (1 hour)
run: |
cd rust
timeout 3600 cargo fuzz run byte_storage_compress --no-default-features --features compression,checksum || true
cargo fuzz run byte_storage_compress -- -max_total_time=3600

- name: Fuzz byte_storage_decompress (1 hour)
run: |
cd rust
timeout 3600 cargo fuzz run byte_storage_decompress --no-default-features --features compression,checksum || true
cargo fuzz run byte_storage_decompress -- -max_total_time=3600

# NOTE: encryption_roundtrip and 8 other encryption targets are stale against
# cachekit-core 0.1.1 (encrypt_aes_gcm → encrypt_with_keys). See #114. Using
# encryption_key_derivation, which compiles, until those targets are migrated.
- name: Fuzz encryption_key_derivation (1 hour)
run: |
cd rust
timeout 3600 cargo fuzz run encryption_key_derivation --no-default-features --features encryption || true

- name: Check for crashes
run: |
cd rust/fuzz
CRASHES=$(find artifacts -name "crash-*" 2>/dev/null | wc -l)
if [ "$CRASHES" -gt 0 ]; then
echo "❌ Found $CRASHES crashes during fuzzing"
find artifacts -name "crash-*" -exec echo "Crash: {}" \;
exit 1
fi
echo "✅ No crashes found during fuzzing"

- name: Generate coverage report
run: |
cd rust
for target in byte_storage_compress byte_storage_decompress encryption_key_derivation; do
echo "=== Coverage for $target ==="
cargo fuzz coverage $target || true
done
cargo fuzz run encryption_key_derivation -- -max_total_time=3600

# No "Check for crashes" step: now that `|| true` is gone, a crash fails its
# own fuzz step and kills the job, so a trailing check could only ever run in
# the no-crash case and print ✅ — a named green step incapable of failing,
# which is the exact manufactured-evidence pattern this PR removes. (It also
# ran `find artifacts` without creating the dir, swallowing the error.) The
# fuzz steps' exit codes are the signal; this upload preserves the evidence,
# which would otherwise die with the ephemeral runner.
- name: Upload crash artifacts
if: failure()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
with:
name: extended-fuzz-crash-artifacts
path: rust/fuzz/artifacts/
retention-days: 30
if-no-files-found: warn

atheris-fuzzing:
name: Atheris Python-Rust Fuzzing
Expand Down
1 change: 0 additions & 1 deletion rust/fuzz/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ coverage/

# Build artifacts
target/
Cargo.lock

# Crash reports and triage output
crash-*.txt
Expand Down
Loading
Loading