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
50 changes: 50 additions & 0 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,9 @@ jobs:
# 360min = GitHub's hosted job cap. The inner timeout governs the actual
# scheduled (1h) vs dispatched (<=5h, clamped below) duration.
timeout-minutes: 360
permissions:
contents: read
actions: read # list and download the previous run's corpus artifact
strategy:
fail-fast: false
matrix:
Expand Down Expand Up @@ -209,6 +212,43 @@ jobs:
# nightly rustc rejects. Let cargo resolve fresh deps.
run: cargo install cargo-fuzz

- name: Find previous fuzz corpus
id: corpus
# Each run resumes from the corpus the previous run uploaded, so coverage
# accumulates instead of restarting cold. An artifact, not the Actions cache:
# cache entries are evicted under size pressure and after 7 days unused, which
# a weekly run cannot outlast. Only this repository's runs on the same ref
# qualify, so a branch or fork run cannot seed this one.
env:
GH_TOKEN: ${{ github.token }}
TARGET: ${{ matrix.target }}
REF_NAME: ${{ github.ref_name }}
REPO_ID: ${{ github.repository_id }}
run: |
# Two steps, so a failed listing fails this step (set -e) instead of
# reading as "no artifact". --paginate walks every page: newer artifacts
# from other branches must not push this ref's corpus off page one.
artifacts=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/actions/artifacts?name=fuzz-corpus-${TARGET}&per_page=100" \
--jq '.artifacts[]')
run_id=$(jq -rs --arg ref "$REF_NAME" --argjson repo "$REPO_ID" '
[.[]
| select(.expired | not)
| select(.workflow_run.head_branch == $ref and .workflow_run.head_repository_id == $repo)]
| sort_by(.created_at) | last | .workflow_run.id // empty' <<< "$artifacts")
if [ -z "$run_id" ]; then
echo "::warning::No fuzz-corpus-${TARGET} artifact from ${REF_NAME}; starting from an empty corpus"
fi
echo "run_id=${run_id}" >> "$GITHUB_OUTPUT"

- name: Restore fuzz corpus
if: steps.corpus.outputs.run_id != ''
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: fuzz-corpus-${{ matrix.target }}
path: fuzz/corpus/${{ matrix.target }}
run-id: ${{ steps.corpus.outputs.run_id }}
github-token: ${{ github.token }}

- name: Run deep fuzz
# Scheduled runs use 1h/target; a manual workflow_dispatch can request a
# longer duration (up to the 5h clamp below) via fuzz_seconds.
Expand Down Expand Up @@ -237,6 +277,16 @@ jobs:
# killed by timeout) remains tolerated.
timeout "$((FUZZ_SECONDS + 180))" cargo fuzz run ${{ matrix.target }} -- -max_total_time="$FUZZ_SECONDS" || [ $? -eq 124 ]

- name: Save fuzz corpus
# always(): a run that found a crash still grew the corpus.
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: fuzz-corpus-${{ matrix.target }}
path: fuzz/corpus/${{ matrix.target }}/
retention-days: 90
if-no-files-found: warn

- name: Upload crash artifacts
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
Expand Down
25 changes: 18 additions & 7 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,13 +112,24 @@ isolate's floor for every subsequent request it serves. Deployments on
constrained runtimes must bound payload size at the caller. Making these
constants environment-aware or configurable is tracked as a follow-up.

**Test coverage.** The unit tests in `src/byte_storage.rs` call `extract`
directly and are the only merge-time enforcement of the bound. The
`compression_bomb` fuzz target (`fuzz/fuzz_targets/compression_bomb.rs`) is a
build-and-smoke check at pull-request time, and its weekly deep run restarts
from an empty corpus. The Kani harnesses never run on pull requests, never
execute `StorageEnvelope::extract`, and cannot detect a wrong predicate. Treat both as
smoke checks, not as verification of the bound.
**Test coverage.** The three checks live in one private function,
`check_decompression_bound`, which `extract` calls before it allocates. At merge
time, the unit tests in `src/byte_storage.rs` and the integration tests in
`tests/byte_storage_tests.rs` enforce the bound: deleting any one of the three
checks fails at least one of them. The `compression_bomb` fuzz target
(`fuzz/fuzz_targets/compression_bomb.rs`) computes one expected result for each
call it makes to `extract` and `retrieve` and requires that exact result. It
builds envelopes at the 512 MiB limits that only one check rejects, so deleting
any one check makes it fail. At pull-request time the target is only built and
smoke-run. Each scheduled or on-demand deep run uploads its corpus as an
artifact kept for 90 days, and the next run on the same branch starts from the
newest one. With no such artifact it warns and starts from an empty corpus. Two
Kani proofs, `verify_decompression_bound_size_caps` and
`verify_decompression_bound_ratio`, check `check_decompression_bound` over every
(compressed length, `original_size`) pair against limits written as literals, so
an inverted comparison or a changed constant fails them. Kani runs on the
schedule and on manual dispatch, not on pull requests. These Kani statements
cover only those two proofs.

### Envelope decode bounds

Expand Down
1 change: 1 addition & 0 deletions fuzz/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ cargo-fuzz = true
libfuzzer-sys = "0.4"
arbitrary = { version = "1", features = ["derive"] }
rmp-serde = "1"
lz4_flex = "0.12"

[dependencies.cachekit-core]
path = ".."
Expand Down
Loading
Loading