diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..bbf16ee --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,21 @@ +# Supply-chain hardening: deterministic builds +# +# Path remapping eliminates local filesystem paths from binaries. +# CI sets RUSTFLAGS env var which OVERRIDES build.rustflags below. +# SOURCE_DATE_EPOCH=0 ensures no build timestamps leak. +# +# NOTE: Local dev builds use build.rustflags for path remapping. +# CI builds use the RUSTFLAGS env var instead (takes precedence). +# This means local builds get local remapping, CI gets CI remapping. + +[env] +SOURCE_DATE_EPOCH = "0" + +[build] +# Remap common local paths in release builds. +# These are best-effort for local dev — CI RUSTFLAGS override this entirely. +# The $HOME/.cargo/registry/src prefix covers most dependency crate paths. +rustflags = [ + "--remap-path-prefix", "/home/lateuf/.cargo/registry/src=crate", + "--remap-path-prefix", "/home/lateuf/Projects/Karapace=src", +] diff --git a/.github/actions/karapace-build/action.yml b/.github/actions/karapace-build/action.yml new file mode 100644 index 0000000..6b899e1 --- /dev/null +++ b/.github/actions/karapace-build/action.yml @@ -0,0 +1,62 @@ +name: 'Karapace Build' +description: 'Build a Karapace environment from a manifest file' +inputs: + manifest: + description: 'Path to the karapace.toml manifest file' + required: true + default: 'karapace.toml' + name: + description: 'Optional name for the environment' + required: false + karapace-version: + description: 'Version of Karapace to install (or "latest")' + required: false + default: 'latest' + store-path: + description: 'Path to the Karapace store directory' + required: false + default: '/tmp/karapace-store' +outputs: + env-id: + description: 'The environment ID of the built environment' + value: ${{ steps.build.outputs.env_id }} + short-id: + description: 'The short ID of the built environment' + value: ${{ steps.build.outputs.short_id }} +runs: + using: 'composite' + steps: + - name: Install prerequisites + shell: bash + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq fuse-overlayfs curl + sudo sysctl -w kernel.unprivileged_userns_clone=1 || true + + - name: Install Karapace + shell: bash + run: | + if [ "${{ inputs.karapace-version }}" = "latest" ]; then + cargo install --path crates/karapace-cli --root /usr/local 2>/dev/null || \ + cargo install karapace-cli --root /usr/local 2>/dev/null || \ + echo "::warning::Could not install karapace; using local build" + fi + + - name: Build environment + id: build + shell: bash + run: | + ARGS="--store ${{ inputs.store-path }} --json" + if [ -n "${{ inputs.name }}" ]; then + ARGS="$ARGS --name ${{ inputs.name }}" + fi + OUTPUT=$(karapace $ARGS build "${{ inputs.manifest }}" 2>&1) || { + echo "::error::Karapace build failed" + echo "$OUTPUT" + exit 1 + } + echo "$OUTPUT" + ENV_ID=$(echo "$OUTPUT" | jq -r '.env_id // empty') + SHORT_ID=$(echo "$OUTPUT" | jq -r '.short_id // empty') + echo "env_id=$ENV_ID" >> "$GITHUB_OUTPUT" + echo "short_id=$SHORT_ID" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8d98d46 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,559 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: >- + -D warnings + --remap-path-prefix /home/runner/work=src + --remap-path-prefix /home/runner/.cargo/registry/src=crate + --remap-path-prefix /home/runner/.rustup=rustup + RUST_TOOLCHAIN: "1.82" + SOURCE_DATE_EPOCH: "0" + CARGO_INCREMENTAL: "0" + +jobs: + fmt: + name: Format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + components: rustfmt + - run: cargo fmt --all --check + + clippy: + name: Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + components: clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo clippy --workspace --all-targets -- -D warnings + + test: + name: Test (${{ matrix.os }}) + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu + runner: ubuntu-latest + - os: fedora + runner: ubuntu-latest + container: fedora:latest + - os: opensuse + runner: ubuntu-latest + container: opensuse/tumbleweed:latest + container: ${{ matrix.container || '' }} + steps: + - uses: actions/checkout@v4 + - name: Install build deps (Fedora) + if: matrix.os == 'fedora' + run: dnf install -y gcc make curl + - name: Install build deps (openSUSE) + if: matrix.os == 'opensuse' + run: zypper install -y gcc make curl gzip tar + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + - uses: Swatinem/rust-cache@v2 + - run: cargo test --workspace + + e2e: + name: E2E Tests + runs-on: ubuntu-latest + needs: [test] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + - uses: Swatinem/rust-cache@v2 + - name: Install prerequisites + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq fuse-overlayfs curl crun + sudo sysctl -w kernel.unprivileged_userns_clone=1 || true + - name: Verify prerequisites + run: | + echo "--- Prerequisite verification ---" + unshare --version + fuse-overlayfs --version || echo "fuse-overlayfs: $(which fuse-overlayfs)" + curl --version | head -1 + crun --version | head -1 + echo "User namespace test:" + unshare --user --map-root-user --fork true && echo "OK" || { echo "FAIL"; exit 1; } + echo "--- All prerequisites verified ---" + - name: Run E2E tests + run: cargo test --test e2e -- --ignored --test-threads=1 + - name: Check for mount leaks + if: always() + run: | + if grep -q fuse-overlayfs /proc/mounts; then + echo "FATAL: stale fuse-overlayfs mounts detected" + grep fuse-overlayfs /proc/mounts + exit 1 + fi + + enospc: + name: ENOSPC Tests + runs-on: ubuntu-latest + needs: [test] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + - uses: Swatinem/rust-cache@v2 + - name: Run ENOSPC tests (requires sudo for tmpfs) + run: sudo -E cargo test --test enospc -- --ignored --test-threads=1 + + e2e-resolve: + name: E2E Resolver (${{ matrix.os }}) + runs-on: ubuntu-latest + needs: [test] + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu + container: ubuntu:24.04 + setup: "apt-get update -qq && apt-get install -y -qq build-essential curl fuse-overlayfs crun" + - os: fedora + container: fedora:latest + setup: "dnf install -y gcc make curl fuse-overlayfs fuse3 crun" + - os: opensuse + container: opensuse/tumbleweed:latest + setup: "zypper install -y gcc make curl fuse-overlayfs gzip tar crun" + container: ${{ matrix.container }} + steps: + - uses: actions/checkout@v4 + - name: Install prerequisites + run: ${{ matrix.setup }} + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + - uses: Swatinem/rust-cache@v2 + - name: Enable user namespaces + run: sysctl -w kernel.unprivileged_userns_clone=1 || true + - name: Verify prerequisites + run: | + echo "--- Prerequisite verification (${{ matrix.os }}) ---" + which unshare && unshare --version || true + which fuse-overlayfs && (fuse-overlayfs --version || true) + curl --version | head -1 + unshare --user --map-root-user --fork true && echo "Namespaces: OK" || { echo "Namespaces: FAIL"; exit 1; } + echo "--- All prerequisites verified ---" + - name: Run resolver E2E tests + run: cargo test --test e2e e2e_resolve -- --ignored --test-threads=1 + + build-release: + name: Release Build (${{ matrix.target }}) + runs-on: ubuntu-latest + needs: [fmt, clippy, test] + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-gnu + artifact: karapace-linux-x86_64-gnu + - target: x86_64-unknown-linux-musl + artifact: karapace-linux-x86_64-musl + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + targets: ${{ matrix.target }} + - uses: Swatinem/rust-cache@v2 + with: + key: ${{ matrix.target }} + - name: Install musl-tools + if: contains(matrix.target, 'musl') + run: sudo apt-get update -qq && sudo apt-get install -y -qq musl-tools + - name: Clean before release build + run: cargo clean + - run: cargo build --release --workspace --target ${{ matrix.target }} + - name: Install cargo-cyclonedx + run: cargo install cargo-cyclonedx@0.5.5 --locked + - name: Generate SBOM + run: cargo cyclonedx --format json --all + - name: Compute and verify checksums + run: | + cd target/${{ matrix.target }}/release + sha256sum karapace karapace-dbus > SHA256SUMS + sha256sum -c SHA256SUMS + echo "SHA256 checksum verification: OK" + - name: Validate SBOM is valid JSON with components + run: | + SBOM=$(find crates/karapace-cli -name '*.cdx.json' | head -1) + python3 -c " + import json, sys + with open('$SBOM') as f: + bom = json.load(f) + assert 'components' in bom, 'SBOM missing components key' + assert len(bom['components']) > 0, 'SBOM has zero components' + print(f'SBOM valid: {len(bom[\"components\"])} components') + " + - name: Verify static linking (musl only) + if: contains(matrix.target, 'musl') + run: | + set -euo pipefail + if ldd target/${{ matrix.target }}/release/karapace 2>&1 | grep -q 'not a dynamic executable'; then + echo "PASS: karapace is statically linked" + else + echo "FAIL: karapace is NOT statically linked" + ldd target/${{ matrix.target }}/release/karapace + exit 1 + fi + if ldd target/${{ matrix.target }}/release/karapace-dbus 2>&1 | grep -q 'not a dynamic executable'; then + echo "PASS: karapace-dbus is statically linked" + else + echo "FAIL: karapace-dbus is NOT statically linked" + ldd target/${{ matrix.target }}/release/karapace-dbus + exit 1 + fi + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: | + target/${{ matrix.target }}/release/karapace + target/${{ matrix.target }}/release/karapace-dbus + target/${{ matrix.target }}/release/SHA256SUMS + + smoke-test: + name: Smoke Test Release (${{ matrix.target }}) + runs-on: ubuntu-latest + needs: [build-release] + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-gnu + artifact: karapace-linux-x86_64-gnu + binary_path: target/x86_64-unknown-linux-gnu/release + - target: x86_64-unknown-linux-musl + artifact: karapace-linux-x86_64-musl + binary_path: target/x86_64-unknown-linux-musl/release + steps: + - uses: actions/download-artifact@v4 + with: + name: ${{ matrix.artifact }} + - name: Verify binary + run: | + chmod +x ${{ matrix.binary_path }}/karapace + ${{ matrix.binary_path }}/karapace --version + ${{ matrix.binary_path }}/karapace doctor + ${{ matrix.binary_path }}/karapace migrate + + reproducibility-check: + name: Reproducibility Check (same-run) + runs-on: ubuntu-latest + needs: [fmt, clippy, test] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + - name: Build A + run: cargo build --release -p karapace-cli -p karapace-dbus + - name: Record hashes A + run: | + sha256sum target/release/karapace target/release/karapace-dbus > /tmp/hashes-a.txt + cat /tmp/hashes-a.txt + - name: Clean and rebuild B + run: | + cargo clean + cargo build --release -p karapace-cli -p karapace-dbus + - name: Record hashes B + run: | + sha256sum target/release/karapace target/release/karapace-dbus > /tmp/hashes-b.txt + cat /tmp/hashes-b.txt + - name: Compare A vs B + run: | + set -euo pipefail + echo "=== Build A ===" + cat /tmp/hashes-a.txt + echo "=== Build B ===" + cat /tmp/hashes-b.txt + HASH_A_CLI=$(awk '/karapace$/{print $1}' /tmp/hashes-a.txt) + HASH_B_CLI=$(awk '/karapace$/{print $1}' /tmp/hashes-b.txt) + HASH_A_DBUS=$(awk '/karapace-dbus$/{print $1}' /tmp/hashes-a.txt) + HASH_B_DBUS=$(awk '/karapace-dbus$/{print $1}' /tmp/hashes-b.txt) + if [ "$HASH_A_CLI" != "$HASH_B_CLI" ]; then + echo "FATAL: karapace binary not reproducible" + echo " A: $HASH_A_CLI" + echo " B: $HASH_B_CLI" + exit 1 + fi + if [ "$HASH_A_DBUS" != "$HASH_B_DBUS" ]; then + echo "FATAL: karapace-dbus binary not reproducible" + echo " A: $HASH_A_DBUS" + echo " B: $HASH_B_DBUS" + exit 1 + fi + echo "Reproducibility check PASSED: both binaries byte-identical across builds" + + cross-run-reproducibility: + name: Cross-Run Reproducibility (${{ matrix.label }}) + runs-on: ${{ matrix.runner }} + needs: [fmt, clippy, test] + strategy: + fail-fast: false + matrix: + include: + - label: ubuntu-latest + runner: ubuntu-latest + - label: ubuntu-22.04 + runner: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + - name: Build release + run: cargo build --release -p karapace-cli -p karapace-dbus + - name: Compute hashes + run: | + sha256sum target/release/karapace target/release/karapace-dbus > hashes.txt + cat hashes.txt + - uses: actions/upload-artifact@v4 + with: + name: repro-hashes-${{ matrix.label }} + path: hashes.txt + - uses: actions/upload-artifact@v4 + with: + name: repro-binary-${{ matrix.label }} + path: | + target/release/karapace + target/release/karapace-dbus + + verify-cross-reproducibility: + name: Verify Cross-Run Reproducibility + runs-on: ubuntu-latest + needs: [cross-run-reproducibility] + steps: + - uses: actions/download-artifact@v4 + with: + name: repro-hashes-ubuntu-latest + path: build-latest + - uses: actions/download-artifact@v4 + with: + name: repro-hashes-ubuntu-22.04 + path: build-2204 + - uses: actions/download-artifact@v4 + with: + name: repro-binary-ubuntu-latest + path: bin-latest + - uses: actions/download-artifact@v4 + with: + name: repro-binary-ubuntu-22.04 + path: bin-2204 + - name: Compare cross-run hashes + run: | + set -euo pipefail + echo "=== ubuntu-latest ===" + cat build-latest/hashes.txt + echo "=== ubuntu-22.04 ===" + cat build-2204/hashes.txt + if diff -u build-latest/hashes.txt build-2204/hashes.txt; then + echo "Cross-run reproducibility PASSED: builds are byte-identical" + else + echo "WARNING: Cross-run builds differ — generating diffoscope report" + sudo apt-get update -qq && sudo apt-get install -y -qq diffoscope + diffoscope bin-latest/target/release/karapace bin-2204/target/release/karapace \ + --text /tmp/diffoscope-karapace.txt || true + diffoscope bin-latest/target/release/karapace-dbus bin-2204/target/release/karapace-dbus \ + --text /tmp/diffoscope-dbus.txt || true + echo "=== Diffoscope report (karapace) ===" + cat /tmp/diffoscope-karapace.txt 2>/dev/null || echo "(empty)" + echo "=== Diffoscope report (karapace-dbus) ===" + cat /tmp/diffoscope-dbus.txt 2>/dev/null || echo "(empty)" + echo "FATAL: Cross-run builds are NOT reproducible" + exit 1 + fi + + reproducibility-check-musl: + name: Reproducibility Check (musl, same-run) + runs-on: ubuntu-latest + needs: [fmt, clippy, test] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + targets: x86_64-unknown-linux-musl + - name: Install musl-tools + run: sudo apt-get update -qq && sudo apt-get install -y -qq musl-tools + - name: Build A (musl) + run: | + cargo clean + cargo build --release -p karapace-cli -p karapace-dbus --target x86_64-unknown-linux-musl + - name: Record hashes A + run: | + sha256sum target/x86_64-unknown-linux-musl/release/karapace target/x86_64-unknown-linux-musl/release/karapace-dbus > /tmp/hashes-a.txt + cat /tmp/hashes-a.txt + - name: Clean and rebuild B + run: | + cargo clean + cargo build --release -p karapace-cli -p karapace-dbus --target x86_64-unknown-linux-musl + - name: Record hashes B + run: | + sha256sum target/x86_64-unknown-linux-musl/release/karapace target/x86_64-unknown-linux-musl/release/karapace-dbus > /tmp/hashes-b.txt + cat /tmp/hashes-b.txt + - name: Compare A vs B + run: | + set -euo pipefail + echo "=== Build A (musl) ===" + cat /tmp/hashes-a.txt + echo "=== Build B (musl) ===" + cat /tmp/hashes-b.txt + HASH_A_CLI=$(awk '/karapace$/{print $1}' /tmp/hashes-a.txt) + HASH_B_CLI=$(awk '/karapace$/{print $1}' /tmp/hashes-b.txt) + HASH_A_DBUS=$(awk '/karapace-dbus$/{print $1}' /tmp/hashes-a.txt) + HASH_B_DBUS=$(awk '/karapace-dbus$/{print $1}' /tmp/hashes-b.txt) + if [ "$HASH_A_CLI" != "$HASH_B_CLI" ]; then + echo "FATAL: karapace musl binary not reproducible" + echo " A: $HASH_A_CLI" + echo " B: $HASH_B_CLI" + exit 1 + fi + if [ "$HASH_A_DBUS" != "$HASH_B_DBUS" ]; then + echo "FATAL: karapace-dbus musl binary not reproducible" + echo " A: $HASH_A_DBUS" + echo " B: $HASH_B_DBUS" + exit 1 + fi + echo "Musl reproducibility check PASSED: both binaries byte-identical across builds" + + cross-run-reproducibility-musl: + name: Cross-Run Reproducibility musl (${{ matrix.label }}) + runs-on: ${{ matrix.runner }} + needs: [fmt, clippy, test] + strategy: + fail-fast: false + matrix: + include: + - label: ubuntu-latest + runner: ubuntu-latest + - label: ubuntu-22.04 + runner: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + targets: x86_64-unknown-linux-musl + - name: Install musl-tools + run: sudo apt-get update -qq && sudo apt-get install -y -qq musl-tools + - name: Build release (musl) + run: | + cargo clean + cargo build --release -p karapace-cli -p karapace-dbus --target x86_64-unknown-linux-musl + - name: Compute hashes + run: | + sha256sum target/x86_64-unknown-linux-musl/release/karapace target/x86_64-unknown-linux-musl/release/karapace-dbus > hashes.txt + cat hashes.txt + - uses: actions/upload-artifact@v4 + with: + name: repro-musl-hashes-${{ matrix.label }} + path: hashes.txt + + verify-cross-reproducibility-musl: + name: Verify Cross-Run Reproducibility (musl) + runs-on: ubuntu-latest + needs: [cross-run-reproducibility-musl] + steps: + - uses: actions/download-artifact@v4 + with: + name: repro-musl-hashes-ubuntu-latest + path: build-latest + - uses: actions/download-artifact@v4 + with: + name: repro-musl-hashes-ubuntu-22.04 + path: build-2204 + - name: Compare musl cross-run hashes + run: | + set -euo pipefail + echo "=== ubuntu-latest (musl) ===" + cat build-latest/hashes.txt + echo "=== ubuntu-22.04 (musl) ===" + cat build-2204/hashes.txt + if diff -u build-latest/hashes.txt build-2204/hashes.txt; then + echo "Musl cross-run reproducibility PASSED: builds are byte-identical" + else + echo "WARNING: Musl cross-run builds differ" + echo "NOTE: Musl static builds should be runner-independent" + echo "FATAL: Musl cross-run builds are NOT reproducible" + exit 1 + fi + + lockfile-check: + name: Lockfile Integrity + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + - name: Verify Cargo.lock is up-to-date + run: | + cp Cargo.lock /tmp/Cargo.lock.before + cargo update + if ! diff -u /tmp/Cargo.lock.before Cargo.lock; then + echo "FATAL: Cargo.lock is not up-to-date or has drifted" + echo "Run 'cargo update' locally and commit the result" + exit 1 + fi + echo "Cargo.lock integrity: OK (no drift detected)" + + cargo-deny: + name: Dependency Policy (cargo-deny) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: EmbarkStudios/cargo-deny-action@v2 + with: + command: check + arguments: --all-features + + ci-contract: + name: CI Contract + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Verify all required jobs exist + run: | + set -euo pipefail + REQUIRED_JOBS="fmt clippy test e2e enospc e2e-resolve build-release smoke-test ci-contract reproducibility-check reproducibility-check-musl cross-run-reproducibility verify-cross-reproducibility cross-run-reproducibility-musl verify-cross-reproducibility-musl lockfile-check cargo-deny" + MISSING="" + for job in $REQUIRED_JOBS; do + if ! grep -qE "^ ${job}:" .github/workflows/ci.yml; then + MISSING="$MISSING $job" + fi + done + if [ -n "$MISSING" ]; then + echo "FATAL: Missing required CI jobs:$MISSING" + echo "See CI_CONTRACT.md for the full list of required jobs." + exit 1 + fi + echo "All required CI jobs present: $REQUIRED_JOBS" + - name: Verify CI_CONTRACT.md exists + run: | + if [ ! -f CI_CONTRACT.md ]; then + echo "FATAL: CI_CONTRACT.md missing" + exit 1 + fi + echo "CI_CONTRACT.md present" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..55ab31f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,270 @@ +name: Release + +on: + push: + tags: ['v*'] + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: >- + -D warnings + --remap-path-prefix /home/runner/work=src + --remap-path-prefix /home/runner/.cargo/registry/src=crate + --remap-path-prefix /home/runner/.rustup=rustup + RUST_TOOLCHAIN: "1.82" + SOURCE_DATE_EPOCH: "0" + CARGO_INCREMENTAL: "0" + +permissions: + contents: read + +jobs: + build: + name: Build Release (${{ matrix.target }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-gnu + - target: x86_64-unknown-linux-musl + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + targets: ${{ matrix.target }} + - uses: Swatinem/rust-cache@v2 + with: + key: ${{ matrix.target }} + - name: Install musl-tools + if: contains(matrix.target, 'musl') + run: sudo apt-get update -qq && sudo apt-get install -y -qq musl-tools + - name: Clean before release build + run: cargo clean + - name: Build release binaries + run: cargo build --release --workspace --target ${{ matrix.target }} + - name: Install cargo-cyclonedx + run: cargo install cargo-cyclonedx@0.5.5 --locked + - name: Generate SBOM + run: cargo cyclonedx --format json --all + - name: Compute and verify checksums + run: | + cd target/${{ matrix.target }}/release + sha256sum karapace karapace-dbus > SHA256SUMS + sha256sum -c SHA256SUMS + - name: Validate SBOM + run: | + SBOM=$(find crates/karapace-cli -name '*.cdx.json' | head -1) + python3 -c " + import json + with open('$SBOM') as f: + bom = json.load(f) + assert 'components' in bom and len(bom['components']) > 0 + print(f'SBOM valid: {len(bom[\"components\"])} components') + " + - name: Verify static linking (musl only) + if: contains(matrix.target, 'musl') + run: | + set -euo pipefail + for bin in karapace karapace-dbus; do + if ldd target/${{ matrix.target }}/release/$bin 2>&1 | grep -q 'not a dynamic executable'; then + echo "PASS: $bin is statically linked" + else + echo "FAIL: $bin is NOT statically linked" + ldd target/${{ matrix.target }}/release/$bin + exit 1 + fi + done + - uses: actions/upload-artifact@v4 + with: + name: release-${{ matrix.target }} + path: | + target/${{ matrix.target }}/release/karapace + target/${{ matrix.target }}/release/karapace-dbus + target/${{ matrix.target }}/release/SHA256SUMS + + sign: + name: Sign Release (${{ matrix.target }}) + runs-on: ubuntu-latest + needs: [build] + permissions: + id-token: write + strategy: + matrix: + include: + - target: x86_64-unknown-linux-gnu + suffix: gnu + - target: x86_64-unknown-linux-musl + suffix: musl + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: release-${{ matrix.target }} + path: artifacts + - uses: sigstore/cosign-installer@v3 + - name: Sign binaries + run: | + cosign sign-blob --yes \ + artifacts/target/${{ matrix.target }}/release/karapace \ + --output-signature artifacts/karapace-${{ matrix.suffix }}.sig \ + --output-certificate artifacts/karapace-${{ matrix.suffix }}.crt + cosign sign-blob --yes \ + artifacts/target/${{ matrix.target }}/release/karapace-dbus \ + --output-signature artifacts/karapace-dbus-${{ matrix.suffix }}.sig \ + --output-certificate artifacts/karapace-dbus-${{ matrix.suffix }}.crt + - name: Generate and sign provenance attestation + run: | + cat > artifacts/provenance-${{ matrix.suffix }}.json << EOF + { + "_type": "https://in-toto.io/Statement/v0.1", + "predicateType": "https://slsa.dev/provenance/v0.2", + "subject": [ + { + "name": "karapace-${{ matrix.suffix }}", + "digest": { + "sha256": "$(sha256sum artifacts/target/${{ matrix.target }}/release/karapace | awk '{print $1}')" + } + }, + { + "name": "karapace-dbus-${{ matrix.suffix }}", + "digest": { + "sha256": "$(sha256sum artifacts/target/${{ matrix.target }}/release/karapace-dbus | awk '{print $1}')" + } + } + ], + "predicate": { + "builder": { + "id": "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + }, + "buildType": "https://github.com/actions/runner", + "invocation": { + "configSource": { + "uri": "git+https://github.com/${{ github.repository }}@${{ github.ref }}", + "digest": { + "sha1": "${{ github.sha }}" + }, + "entryPoint": ".github/workflows/release.yml" + } + }, + "metadata": { + "buildInvocationId": "${{ github.run_id }}", + "completeness": { + "parameters": true, + "environment": true, + "materials": true + }, + "reproducible": true + }, + "materials": [ + { + "uri": "git+https://github.com/${{ github.repository }}@${{ github.ref }}", + "digest": { + "sha1": "${{ github.sha }}" + } + } + ] + } + } + EOF + cosign sign-blob --yes \ + artifacts/provenance-${{ matrix.suffix }}.json \ + --output-signature artifacts/provenance-${{ matrix.suffix }}.json.sig \ + --output-certificate artifacts/provenance-${{ matrix.suffix }}.json.crt + - uses: actions/upload-artifact@v4 + with: + name: signatures-${{ matrix.suffix }} + path: | + artifacts/*.sig + artifacts/*.crt + artifacts/provenance-${{ matrix.suffix }}.json + + verify: + name: Verify Release (${{ matrix.target }}) + runs-on: ubuntu-latest + needs: [sign] + strategy: + matrix: + include: + - target: x86_64-unknown-linux-gnu + suffix: gnu + - target: x86_64-unknown-linux-musl + suffix: musl + steps: + - uses: actions/download-artifact@v4 + with: + name: release-${{ matrix.target }} + path: release + - uses: actions/download-artifact@v4 + with: + name: signatures-${{ matrix.suffix }} + path: sigs + - uses: sigstore/cosign-installer@v3 + - name: Verify checksums + run: | + cd release/target/${{ matrix.target }}/release + sha256sum -c SHA256SUMS + - name: Verify binary signatures + run: | + cosign verify-blob release/target/${{ matrix.target }}/release/karapace \ + --signature sigs/artifacts/karapace-${{ matrix.suffix }}.sig \ + --certificate sigs/artifacts/karapace-${{ matrix.suffix }}.crt \ + --certificate-identity-regexp 'https://github.com/marcoallegretti/karapace' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com + cosign verify-blob release/target/${{ matrix.target }}/release/karapace-dbus \ + --signature sigs/artifacts/karapace-dbus-${{ matrix.suffix }}.sig \ + --certificate sigs/artifacts/karapace-dbus-${{ matrix.suffix }}.crt \ + --certificate-identity-regexp 'https://github.com/marcoallegretti/karapace' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com + - name: Verify provenance attestation + run: | + cosign verify-blob sigs/artifacts/provenance-${{ matrix.suffix }}.json \ + --signature sigs/artifacts/provenance-${{ matrix.suffix }}.json.sig \ + --certificate sigs/artifacts/provenance-${{ matrix.suffix }}.json.crt \ + --certificate-identity-regexp 'https://github.com/marcoallegretti/karapace' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com + + publish: + name: Publish GitHub Release + runs-on: ubuntu-latest + needs: [verify] + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + name: release-x86_64-unknown-linux-gnu + path: release-gnu + - uses: actions/download-artifact@v4 + with: + name: release-x86_64-unknown-linux-musl + path: release-musl + - uses: actions/download-artifact@v4 + with: + name: signatures-gnu + path: sigs-gnu + - uses: actions/download-artifact@v4 + with: + name: signatures-musl + path: sigs-musl + - name: Stage release files + run: | + mkdir -p dist + # GNU binaries (renamed with -gnu suffix) + cp release-gnu/target/x86_64-unknown-linux-gnu/release/karapace dist/karapace-linux-x86_64-gnu + cp release-gnu/target/x86_64-unknown-linux-gnu/release/karapace-dbus dist/karapace-dbus-linux-x86_64-gnu + cp release-gnu/target/x86_64-unknown-linux-gnu/release/SHA256SUMS dist/SHA256SUMS-gnu + # Musl binaries (renamed with -musl suffix) + cp release-musl/target/x86_64-unknown-linux-musl/release/karapace dist/karapace-linux-x86_64-musl + cp release-musl/target/x86_64-unknown-linux-musl/release/karapace-dbus dist/karapace-dbus-linux-x86_64-musl + cp release-musl/target/x86_64-unknown-linux-musl/release/SHA256SUMS dist/SHA256SUMS-musl + # Signatures + cp sigs-gnu/artifacts/*.sig sigs-gnu/artifacts/*.crt dist/ + cp sigs-gnu/artifacts/provenance-gnu.json dist/ + cp sigs-musl/artifacts/*.sig sigs-musl/artifacts/*.crt dist/ + cp sigs-musl/artifacts/provenance-musl.json dist/ + - name: Create release + uses: softprops/action-gh-release@v2 + with: + files: dist/* diff --git a/.github/workflows/supply-chain-test.yml b/.github/workflows/supply-chain-test.yml new file mode 100644 index 0000000..10d3d37 --- /dev/null +++ b/.github/workflows/supply-chain-test.yml @@ -0,0 +1,794 @@ +name: Supply Chain Test + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: >- + -D warnings + --remap-path-prefix /home/runner/work=src + --remap-path-prefix /home/runner/.cargo/registry/src=crate + --remap-path-prefix /home/runner/.rustup=rustup + RUST_TOOLCHAIN: "1.82" + SOURCE_DATE_EPOCH: "0" + CARGO_INCREMENTAL: "0" + +permissions: + contents: read + +jobs: + build-and-sign: + name: Build, Sign & Attest + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + attestations: write + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + - uses: Swatinem/rust-cache@v2 + + - name: Clean before release build + run: cargo clean + - name: Build release binaries + run: cargo build --release -p karapace-cli -p karapace-dbus + + - name: Install cargo-cyclonedx + run: cargo install cargo-cyclonedx@0.5.5 --locked + + - name: Generate SBOM + run: cargo cyclonedx --format json --output-prefix karapace + + - name: Compute checksums + run: | + cd target/release + sha256sum karapace karapace-dbus > SHA256SUMS + sha256sum -c SHA256SUMS + echo "Checksums verified" + + - name: Verify no local paths in binary + run: | + set -euo pipefail + if strings target/release/karapace | grep -qE '/home/runner'; then + echo "FATAL: binary leaks CI runner paths" + strings target/release/karapace | grep '/home/runner' | head -5 + exit 1 + fi + echo "No CI runner path leakage detected" + + # --- Cosign signing --- + - uses: sigstore/cosign-installer@v3 + + - name: Sign karapace binary + run: | + cosign sign-blob --yes \ + target/release/karapace \ + --output-signature target/release/karapace.sig \ + --output-certificate target/release/karapace.crt + + - name: Sign karapace-dbus binary + run: | + cosign sign-blob --yes \ + target/release/karapace-dbus \ + --output-signature target/release/karapace-dbus.sig \ + --output-certificate target/release/karapace-dbus.crt + + - name: Sign SBOM + run: | + cosign sign-blob --yes \ + karapace_bom.json \ + --output-signature karapace_bom.json.sig \ + --output-certificate karapace_bom.json.crt + + # --- Provenance attestation --- + - name: Generate provenance attestation + run: | + cat > /tmp/provenance.json << EOF + { + "_type": "https://in-toto.io/Statement/v0.1", + "predicateType": "https://slsa.dev/provenance/v0.2", + "subject": [ + { + "name": "karapace", + "digest": { + "sha256": "$(sha256sum target/release/karapace | awk '{print $1}')" + } + }, + { + "name": "karapace-dbus", + "digest": { + "sha256": "$(sha256sum target/release/karapace-dbus | awk '{print $1}')" + } + } + ], + "predicate": { + "builder": { + "id": "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + }, + "buildType": "https://github.com/actions/runner", + "invocation": { + "configSource": { + "uri": "git+https://github.com/${{ github.repository }}@${{ github.ref }}", + "digest": { + "sha1": "${{ github.sha }}" + }, + "entryPoint": "${{ github.workflow_ref }}" + } + }, + "metadata": { + "buildInvocationId": "${{ github.run_id }}", + "completeness": { + "parameters": true, + "environment": true, + "materials": true + }, + "reproducible": true + }, + "materials": [ + { + "uri": "git+https://github.com/${{ github.repository }}@${{ github.ref }}", + "digest": { + "sha1": "${{ github.sha }}" + } + } + ] + } + } + EOF + echo "Provenance attestation generated" + python3 -c "import json; json.load(open('/tmp/provenance.json')); print('Valid JSON')" + + - name: Sign provenance attestation + run: | + cosign sign-blob --yes \ + /tmp/provenance.json \ + --output-signature /tmp/provenance.json.sig \ + --output-certificate /tmp/provenance.json.crt + + - name: Upload all artifacts + uses: actions/upload-artifact@v4 + with: + name: supply-chain-artifacts + path: | + target/release/karapace + target/release/karapace-dbus + target/release/SHA256SUMS + target/release/karapace.sig + target/release/karapace.crt + target/release/karapace-dbus.sig + target/release/karapace-dbus.crt + karapace_bom.json + karapace_bom.json.sig + karapace_bom.json.crt + /tmp/provenance.json + /tmp/provenance.json.sig + /tmp/provenance.json.crt + + verify-signatures: + name: Verify Signatures & Provenance + runs-on: ubuntu-latest + needs: [build-and-sign] + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: supply-chain-artifacts + path: artifacts + + - uses: sigstore/cosign-installer@v3 + + # --- SHA256 verification --- + - name: Verify SHA256 checksums + run: | + cd artifacts/target/release + sha256sum -c SHA256SUMS + echo "SHA256 verification: PASSED" + + # --- Cosign verification (karapace) --- + - name: Verify karapace signature + run: | + cosign verify-blob \ + artifacts/target/release/karapace \ + --signature artifacts/target/release/karapace.sig \ + --certificate artifacts/target/release/karapace.crt \ + --certificate-identity-regexp 'https://github.com/marcoallegretti/karapace' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com + echo "karapace signature: VERIFIED" + + # --- Cosign verification (karapace-dbus) --- + - name: Verify karapace-dbus signature + run: | + cosign verify-blob \ + artifacts/target/release/karapace-dbus \ + --signature artifacts/target/release/karapace-dbus.sig \ + --certificate artifacts/target/release/karapace-dbus.crt \ + --certificate-identity-regexp 'https://github.com/marcoallegretti/karapace' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com + echo "karapace-dbus signature: VERIFIED" + + # --- SBOM signature verification --- + - name: Verify SBOM signature + run: | + cosign verify-blob \ + artifacts/karapace_bom.json \ + --signature artifacts/karapace_bom.json.sig \ + --certificate artifacts/karapace_bom.json.crt \ + --certificate-identity-regexp 'https://github.com/marcoallegretti/karapace' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com + echo "SBOM signature: VERIFIED" + + # --- Provenance verification --- + - name: Verify provenance attestation signature + run: | + cosign verify-blob \ + artifacts/tmp/provenance.json \ + --signature artifacts/tmp/provenance.json.sig \ + --certificate artifacts/tmp/provenance.json.crt \ + --certificate-identity-regexp 'https://github.com/marcoallegretti/karapace' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com + echo "Provenance attestation signature: VERIFIED" + + # --- Provenance content verification --- + - name: Verify provenance content matches this build + run: | + set -euo pipefail + PROV=$(cat artifacts/tmp/provenance.json) + + # Verify commit SHA + PROV_SHA=$(echo "$PROV" | python3 -c "import sys,json; print(json.load(sys.stdin)['predicate']['invocation']['configSource']['digest']['sha1'])") + if [ "$PROV_SHA" != "${{ github.sha }}" ]; then + echo "FATAL: Provenance commit SHA mismatch" + echo " Expected: ${{ github.sha }}" + echo " Got: $PROV_SHA" + exit 1 + fi + echo "Commit SHA in provenance: MATCHES ($PROV_SHA)" + + # Verify builder identity contains expected repo + BUILDER_ID=$(echo "$PROV" | python3 -c "import sys,json; print(json.load(sys.stdin)['predicate']['builder']['id'])") + if ! echo "$BUILDER_ID" | grep -q "${{ github.repository }}"; then + echo "FATAL: Builder identity does not match expected repository" + echo " Expected to contain: ${{ github.repository }}" + echo " Got: $BUILDER_ID" + exit 1 + fi + echo "Builder identity: MATCHES ($BUILDER_ID)" + + # Verify workflow ref + WORKFLOW_REF=$(echo "$PROV" | python3 -c "import sys,json; print(json.load(sys.stdin)['predicate']['invocation']['configSource']['entryPoint'])") + if ! echo "$WORKFLOW_REF" | grep -q "supply-chain-test"; then + echo "FATAL: Workflow path mismatch" + echo " Expected to contain: supply-chain-test" + echo " Got: $WORKFLOW_REF" + exit 1 + fi + echo "Workflow path: MATCHES ($WORKFLOW_REF)" + + # Verify binary hashes in attestation match actual binaries + ACTUAL_CLI_HASH=$(sha256sum artifacts/target/release/karapace | awk '{print $1}') + PROV_CLI_HASH=$(echo "$PROV" | python3 -c "import sys,json; subj=json.load(sys.stdin)['subject']; print([s['digest']['sha256'] for s in subj if s['name']=='karapace'][0])") + if [ "$ACTUAL_CLI_HASH" != "$PROV_CLI_HASH" ]; then + echo "FATAL: Provenance hash mismatch for karapace" + echo " Actual: $ACTUAL_CLI_HASH" + echo " Attestation: $PROV_CLI_HASH" + exit 1 + fi + echo "Provenance hash for karapace: MATCHES" + + echo "=== ALL PROVENANCE CHECKS PASSED ===" + + # --- Tamper detection tests --- + tamper-binary: + name: "Tamper Test: Binary" + runs-on: ubuntu-latest + needs: [build-and-sign] + steps: + - uses: actions/download-artifact@v4 + with: + name: supply-chain-artifacts + path: artifacts + - uses: sigstore/cosign-installer@v3 + + - name: Tamper binary and verify detection + run: | + set -euo pipefail + # Modify 1 byte in the binary + cp artifacts/target/release/karapace /tmp/karapace-tampered + python3 -c " + data = bytearray(open('/tmp/karapace-tampered', 'rb').read()) + data[100] ^= 0xFF # flip one byte + open('/tmp/karapace-tampered', 'wb').write(data) + " + + # SHA256 must not match + ORIGINAL_HASH=$(awk '/karapace$/{print $1}' artifacts/target/release/SHA256SUMS) + TAMPERED_HASH=$(sha256sum /tmp/karapace-tampered | awk '{print $1}') + if [ "$ORIGINAL_HASH" = "$TAMPERED_HASH" ]; then + echo "FATAL: SHA256 did not detect binary tampering" + exit 1 + fi + echo "SHA256 tamper detection: PASSED (hashes differ)" + + # Cosign verification must fail + if cosign verify-blob \ + /tmp/karapace-tampered \ + --signature artifacts/target/release/karapace.sig \ + --certificate artifacts/target/release/karapace.crt \ + --certificate-identity-regexp 'https://github.com/marcoallegretti/karapace' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com 2>/dev/null; then + echo "FATAL: Cosign did NOT detect binary tampering" + exit 1 + fi + echo "Cosign tamper detection: PASSED (verification correctly failed)" + + tamper-sbom: + name: "Tamper Test: SBOM" + runs-on: ubuntu-latest + needs: [build-and-sign] + steps: + - uses: actions/download-artifact@v4 + with: + name: supply-chain-artifacts + path: artifacts + - uses: sigstore/cosign-installer@v3 + + - name: Tamper SBOM and verify detection + run: | + set -euo pipefail + # Modify SBOM + cp artifacts/karapace_bom.json /tmp/sbom-tampered.json + python3 -c " + import json + with open('/tmp/sbom-tampered.json') as f: + bom = json.load(f) + bom['components'][0]['name'] = 'TAMPERED-PACKAGE' + with open('/tmp/sbom-tampered.json', 'w') as f: + json.dump(bom, f) + " + + # Cosign verification must fail + if cosign verify-blob \ + /tmp/sbom-tampered.json \ + --signature artifacts/karapace_bom.json.sig \ + --certificate artifacts/karapace_bom.json.crt \ + --certificate-identity-regexp 'https://github.com/marcoallegretti/karapace' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com 2>/dev/null; then + echo "FATAL: Cosign did NOT detect SBOM tampering" + exit 1 + fi + echo "SBOM tamper detection: PASSED (verification correctly failed)" + + tamper-signature-removal: + name: "Tamper Test: Signature Removal" + runs-on: ubuntu-latest + needs: [build-and-sign] + steps: + - uses: actions/download-artifact@v4 + with: + name: supply-chain-artifacts + path: artifacts + - uses: sigstore/cosign-installer@v3 + + - name: Remove signature and verify detection + run: | + set -euo pipefail + # Delete .sig file + rm artifacts/target/release/karapace.sig + + # Verification must fail without signature + if cosign verify-blob \ + artifacts/target/release/karapace \ + --signature artifacts/target/release/karapace.sig \ + --certificate artifacts/target/release/karapace.crt \ + --certificate-identity-regexp 'https://github.com/marcoallegretti/karapace' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com 2>/dev/null; then + echo "FATAL: Cosign did NOT detect missing signature" + exit 1 + fi + echo "Signature removal detection: PASSED (verification correctly failed)" + + adversarial-env-injection: + name: "Adversarial: Environment Injection" + runs-on: ubuntu-latest + needs: [build-and-sign] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + - uses: Swatinem/rust-cache@v2 + - uses: actions/download-artifact@v4 + with: + name: supply-chain-artifacts + path: baseline + + - name: "Test: Malicious RUSTFLAGS cannot inject code" + run: | + set -euo pipefail + # Attempt to inject a cfg flag via RUSTFLAGS — build must still succeed + # and produce a binary that matches baseline (because our CI RUSTFLAGS override) + RUSTFLAGS="${RUSTFLAGS} --cfg=malicious_flag" \ + cargo build --release -p karapace-cli -p karapace-dbus 2>&1 || true + + # The key check: the binary must NOT contain our malicious string as executable code + # (cfg flags only affect conditional compilation — no matching cfg! blocks means no effect) + echo "Malicious RUSTFLAGS injection: build completed (cfg flag ignored if no matching code)" + + - name: "Test: PATH manipulation cannot alter build" + run: | + set -euo pipefail + BASELINE_HASH=$(sha256sum baseline/target/release/karapace | awk '{print $1}') + + # Create a fake rustc wrapper in a temp dir + mkdir -p /tmp/fake-bin + cat > /tmp/fake-bin/rustc-wrapper << 'SCRIPT' + #!/bin/bash + echo "ATTACK: fake rustc intercepted" >&2 + exit 1 + SCRIPT + chmod +x /tmp/fake-bin/rustc-wrapper + + # Verify that RUSTC_WRAPPER pointing to malicious binary is detected + # (cargo will fail because the wrapper returns exit 1) + if RUSTC_WRAPPER=/tmp/fake-bin/rustc-wrapper cargo build --release -p karapace-cli 2>/dev/null; then + echo "FATAL: Malicious RUSTC_WRAPPER was NOT detected — build should have failed" + exit 1 + fi + echo "PASS: Malicious RUSTC_WRAPPER correctly caused build failure" + + - name: "Test: HTTP_PROXY/HTTPS_PROXY do not leak into build" + run: | + set -euo pipefail + # Set network proxy vars — they should not affect a from-cache build + HTTP_PROXY=http://evil-proxy:8080 \ + HTTPS_PROXY=http://evil-proxy:8080 \ + NO_PROXY="" \ + cargo build --release -p karapace-cli -p karapace-dbus 2>&1 | tail -5 + echo "PASS: Build completed with proxy env vars (no network needed for cached build)" + + # Verify no proxy strings in the binary + if strings target/release/karapace | grep -qi 'evil-proxy'; then + echo "FATAL: Proxy string leaked into binary" + exit 1 + fi + echo "PASS: No proxy strings in binary" + + adversarial-artifact-tampering: + name: "Adversarial: Intermediate Artifact Tampering" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + + - name: "Test: Multi-rlib tampering detected by clean-build comparison" + run: | + set -euo pipefail + # Build baseline from clean state + cargo clean + cargo build --release -p karapace-cli -p karapace-dbus + BASELINE_HASH=$(sha256sum target/release/karapace | awk '{print $1}') + echo "Baseline binary hash: $BASELINE_HASH" + + # Tamper ALL karapace rlibs + for RLIB in $(find target/release/deps -name 'libkarapace_*.rlib'); do + python3 -c " + data = bytearray(open('$RLIB', 'rb').read()) + data[100] ^= 0xFF + open('$RLIB', 'wb').write(data) + " + echo "Tampered: $(basename $RLIB)" + done + + # Rebuild — cargo should detect and rebuild from source + cargo build --release -p karapace-cli -p karapace-dbus 2>&1 | tail -3 + REBUILD_HASH=$(sha256sum target/release/karapace | awk '{print $1}') + echo "Post-tamper rebuild hash: $REBUILD_HASH" + + # Clean rebuild must match baseline + cargo clean + cargo build --release -p karapace-cli -p karapace-dbus + CLEAN_HASH=$(sha256sum target/release/karapace | awk '{print $1}') + echo "Clean rebuild hash: $CLEAN_HASH" + + if [ "$CLEAN_HASH" != "$BASELINE_HASH" ]; then + echo "FATAL: Clean rebuild does not match baseline" + exit 1 + fi + echo "PASS: Clean rebuild matches baseline after multi-rlib tampering" + + - name: "Test: .rmeta tampering triggers rebuild" + run: | + set -euo pipefail + BASELINE_HASH=$(sha256sum target/release/karapace | awk '{print $1}') + for RMETA in $(find target/release/deps -name 'libkarapace_*.rmeta'); do + python3 -c " + data = bytearray(open('$RMETA', 'rb').read()) + data[50] ^= 0xFF + open('$RMETA', 'wb').write(data) + " + done + cargo build --release -p karapace-cli -p karapace-dbus 2>&1 | tail -3 + RMETA_HASH=$(sha256sum target/release/karapace | awk '{print $1}') + if [ "$RMETA_HASH" = "$BASELINE_HASH" ]; then + echo "PASS: Cargo rebuilt from source, ignoring tampered .rmeta" + else + echo "FATAL: .rmeta tampering changed binary hash" + exit 1 + fi + + - name: "Test: .d file tampering does not affect binary" + run: | + set -euo pipefail + BASELINE_HASH=$(sha256sum target/release/karapace | awk '{print $1}') + for D_FILE in $(find target/release/deps -name 'karapace_*.d' | head -5); do + echo "TAMPERED_MARKER" >> "$D_FILE" + done + cargo build --release -p karapace-cli -p karapace-dbus 2>&1 | tail -2 + D_HASH=$(sha256sum target/release/karapace | awk '{print $1}') + if [ "$D_HASH" = "$BASELINE_HASH" ]; then + echo "PASS: .d file tampering did not affect binary" + else + echo "FATAL: .d file tampering changed binary hash" + exit 1 + fi + + - name: "Test: .fingerprint tampering triggers rebuild" + run: | + set -euo pipefail + BASELINE_HASH=$(sha256sum target/release/karapace | awk '{print $1}') + FP_DIR=$(find target/release/.fingerprint -name 'karapace-cli-*' -type d | head -1) + if [ -n "$FP_DIR" ]; then + for FP_FILE in "$FP_DIR"/*; do + [ -f "$FP_FILE" ] && echo "TAMPER" >> "$FP_FILE" + done + cargo build --release -p karapace-cli -p karapace-dbus 2>&1 | tail -3 + FP_HASH=$(sha256sum target/release/karapace | awk '{print $1}') + if [ "$FP_HASH" = "$BASELINE_HASH" ]; then + echo "PASS: Cargo detected fingerprint tampering and rebuilt correctly" + else + echo "FATAL: Fingerprint tampering changed binary hash" + exit 1 + fi + else + echo "SKIP: No .fingerprint directory found" + fi + + adversarial-build-script: + name: "Adversarial: Build Script Injection" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + + - name: "Test: Rogue marker build.rs detected by hash change" + run: | + set -euo pipefail + cargo clean + cargo build --release -p karapace-cli -p karapace-dbus + BASELINE_HASH=$(sha256sum target/release/karapace | awk '{print $1}') + echo "Baseline binary hash: $BASELINE_HASH" + + cat > crates/karapace-cli/build.rs << 'ROGUE' + fn main() { + println!("cargo:rustc-env=ROGUE_MARKER=SUPPLY_CHAIN_ATTACK"); + } + ROGUE + cargo build --release -p karapace-cli -p karapace-dbus + ROGUE_HASH=$(sha256sum target/release/karapace | awk '{print $1}') + if [ "$BASELINE_HASH" = "$ROGUE_HASH" ]; then + echo "FATAL: Rogue build.rs did NOT change binary hash" + exit 1 + fi + echo "PASS: Rogue marker build.rs detected ($BASELINE_HASH != $ROGUE_HASH)" + rm crates/karapace-cli/build.rs + + - name: "Test: HOME-reading build.rs detected by hash change" + run: | + set -euo pipefail + cargo clean + cargo build --release -p karapace-cli -p karapace-dbus + BASELINE_HASH=$(sha256sum target/release/karapace | awk '{print $1}') + + cat > crates/karapace-cli/build.rs << 'ROGUE' + fn main() { + let home = std::env::var("HOME").unwrap_or_else(|_| "unknown".to_string()); + println!("cargo:rustc-env=BUILD_HOME={}", home); + } + ROGUE + cargo build --release -p karapace-cli -p karapace-dbus + HOME_HASH=$(sha256sum target/release/karapace | awk '{print $1}') + if [ "$BASELINE_HASH" = "$HOME_HASH" ]; then + echo "FATAL: HOME-reading build.rs did NOT change binary hash" + exit 1 + fi + echo "PASS: HOME-reading build.rs detected ($BASELINE_HASH != $HOME_HASH)" + rm crates/karapace-cli/build.rs + + - name: "Test: Hostname-leaking build.rs detected by hash change" + run: | + set -euo pipefail + cargo clean + cargo build --release -p karapace-cli -p karapace-dbus + BASELINE_HASH=$(sha256sum target/release/karapace | awk '{print $1}') + + cat > crates/karapace-cli/build.rs << 'ROGUE' + fn main() { + let hostname = std::process::Command::new("hostname") + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_else(|_| "unknown".to_string()); + println!("cargo:rustc-env=BUILD_HOST={}", hostname); + } + ROGUE + cargo build --release -p karapace-cli -p karapace-dbus + HOST_HASH=$(sha256sum target/release/karapace | awk '{print $1}') + if [ "$BASELINE_HASH" = "$HOST_HASH" ]; then + echo "FATAL: Hostname-leaking build.rs did NOT change binary hash" + exit 1 + fi + echo "PASS: Hostname-leaking build.rs detected ($BASELINE_HASH != $HOST_HASH)" + rm crates/karapace-cli/build.rs + + - name: "Test: Removing rogue build.rs restores baseline" + run: | + set -euo pipefail + # After all rogue scripts removed, clean rebuild must match + cargo clean + cargo build --release -p karapace-cli -p karapace-dbus + RESTORED=$(sha256sum target/release/karapace | awk '{print $1}') + # Build again to confirm reproducibility + cargo clean + cargo build --release -p karapace-cli -p karapace-dbus + RESTORED2=$(sha256sum target/release/karapace | awk '{print $1}') + if [ "$RESTORED" = "$RESTORED2" ]; then + echo "PASS: Clean rebuilds are reproducible after build.rs removal ($RESTORED)" + else + echo "FATAL: Clean rebuilds not reproducible" + exit 1 + fi + + adversarial-credential-injection: + name: "Adversarial: Credential Injection" + runs-on: ubuntu-latest + needs: [build-and-sign] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + - uses: actions/download-artifact@v4 + with: + name: supply-chain-artifacts + path: baseline + + - name: "Test: Fake credentials do not affect binary" + run: | + set -euo pipefail + BASELINE_HASH=$(sha256sum baseline/target/release/karapace | awk '{print $1}') + API_KEY="sk-FAKE-SUPER-SECRET-KEY-12345" \ + SECRET_TOKEN="ghp_FAKE_GITHUB_TOKEN_abcdef123456" \ + AWS_SECRET_ACCESS_KEY="wJalrXUtnFEMI/K7MDENG/FAKE" \ + CARGO_REGISTRY_TOKEN="cio_FAKE_REGISTRY_TOKEN_xyz" \ + cargo build --release -p karapace-cli -p karapace-dbus + CRED_HASH=$(sha256sum target/release/karapace | awk '{print $1}') + if [ "$CRED_HASH" != "$BASELINE_HASH" ]; then + echo "FATAL: Credential env vars changed binary hash" + exit 1 + fi + echo "PASS: Fake credentials did not affect binary hash" + + - name: "Test: No credential strings in binary" + run: | + set -euo pipefail + for TERM in "sk-FAKE" "SUPER-SECRET" "ghp_FAKE" "GITHUB_TOKEN" "wJalrXUtnFEMI" "AWS_SECRET" "cio_FAKE"; do + if strings target/release/karapace | grep -qi "$TERM"; then + echo "FATAL: $TERM leaked into binary" + exit 1 + fi + done + echo "PASS: No credential strings leaked into binary" + + adversarial-rustflags-bypass: + name: "Adversarial: RUSTFLAGS Bypass" + runs-on: ubuntu-latest + needs: [build-and-sign] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + - uses: actions/download-artifact@v4 + with: + name: supply-chain-artifacts + path: baseline + + - name: "Test: RUSTFLAGS override changes hash (detected)" + run: | + set -euo pipefail + BASELINE_HASH=$(sha256sum baseline/target/release/karapace | awk '{print $1}') + RUSTFLAGS="-C debuginfo=2" cargo build --release -p karapace-cli -p karapace-dbus 2>&1 | tail -3 + OVERRIDE_HASH=$(sha256sum target/release/karapace | awk '{print $1}') + if [ "$OVERRIDE_HASH" = "$BASELINE_HASH" ]; then + echo "INFO: RUSTFLAGS override did not change hash (strip=true may remove debuginfo)" + else + echo "PASS: RUSTFLAGS override detected via hash change" + echo "DEFENSE: CI RUSTFLAGS are authoritative; local overrides cannot affect release artifacts" + fi + + - name: "Test: SOURCE_DATE_EPOCH does not affect binary" + run: | + set -euo pipefail + cargo clean + SOURCE_DATE_EPOCH=0 cargo build --release -p karapace-cli -p karapace-dbus + EPOCH0=$(sha256sum target/release/karapace | awk '{print $1}') + cargo clean + SOURCE_DATE_EPOCH=9999999999 cargo build --release -p karapace-cli -p karapace-dbus + EPOCH_FUTURE=$(sha256sum target/release/karapace | awk '{print $1}') + if [ "$EPOCH0" = "$EPOCH_FUTURE" ]; then + echo "PASS: SOURCE_DATE_EPOCH does not affect binary (strip=true removes timestamps)" + else + echo "FATAL: SOURCE_DATE_EPOCH affects binary hash" + exit 1 + fi + + verify-docs-executable: + name: "Verify docs/verification.md Commands" + runs-on: ubuntu-latest + needs: [build-and-sign] + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: supply-chain-artifacts + path: artifacts + - uses: sigstore/cosign-installer@v3 + + - name: Execute verification.md commands + run: | + set -euo pipefail + + # Flatten artifacts to match docs layout + cp artifacts/target/release/karapace . + cp artifacts/target/release/karapace-dbus . + cp artifacts/target/release/SHA256SUMS . + cp artifacts/target/release/karapace.sig . + cp artifacts/target/release/karapace.crt . + cp artifacts/target/release/karapace-dbus.sig . + cp artifacts/target/release/karapace-dbus.crt . + cp artifacts/karapace_bom.json . + + echo "=== Step 1: Verify SHA256 Checksums ===" + sha256sum -c SHA256SUMS + echo "PASSED" + + echo "=== Step 2: Verify Cosign Signatures ===" + cosign verify-blob karapace \ + --signature karapace.sig \ + --certificate karapace.crt \ + --certificate-identity-regexp 'https://github.com/marcoallegretti/karapace' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com + echo "karapace: VERIFIED" + + cosign verify-blob karapace-dbus \ + --signature karapace-dbus.sig \ + --certificate karapace-dbus.crt \ + --certificate-identity-regexp 'https://github.com/marcoallegretti/karapace' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com + echo "karapace-dbus: VERIFIED" + + echo "=== Step 3: Inspect SBOM ===" + python3 -m json.tool karapace_bom.json | head -50 + echo "SBOM is valid JSON" + + echo "=== ALL verification.md commands executed successfully ===" diff --git a/CI_CONTRACT.md b/CI_CONTRACT.md new file mode 100644 index 0000000..547f56c --- /dev/null +++ b/CI_CONTRACT.md @@ -0,0 +1,90 @@ +# CI Contract + +This document defines the required CI jobs that must exist in `.github/workflows/ci.yml`. +The `ci-contract` job enforces this list automatically — if any job is missing, CI fails. + +## Required Jobs (ci.yml) + +| Job Name | Purpose | Enforced | +|----------|---------|----------| +| `fmt` | Rustfmt check | Yes | +| `clippy` | Clippy lint | Yes | +| `test` | `cargo test --workspace` on 3 OS matrix | Yes | +| `e2e` | E2E namespace backend tests (`--ignored`) | Yes | +| `enospc` | ENOSPC simulation with tmpfs (requires `sudo`) | Yes | +| `e2e-resolve` | Package resolver E2E on Ubuntu/Fedora/openSUSE | Yes | +| `build-release` | Release build + SBOM + checksums (gnu + musl matrix) | Yes | +| `smoke-test` | Verify release binary runs (gnu + musl matrix) | Yes | +| `reproducibility-check` | Same-run glibc build reproducibility (build A vs B) | Yes | +| `reproducibility-check-musl` | Same-run musl build reproducibility (build A vs B) | Yes | +| `cross-run-reproducibility` | Cross-run glibc reproducibility (ubuntu-latest vs ubuntu-22.04) | Yes | +| `verify-cross-reproducibility` | Compare glibc cross-run build hashes (with diffoscope) | Yes | +| `cross-run-reproducibility-musl` | Cross-run musl reproducibility (ubuntu-latest vs ubuntu-22.04) | Yes | +| `verify-cross-reproducibility-musl` | Compare musl cross-run build hashes | Yes | +| `lockfile-check` | Verify Cargo.lock has not drifted | Yes | +| `cargo-deny` | Dependency policy (licenses, registries, advisories) | Yes | +| `ci-contract` | Self-check: all required jobs exist | Yes | + +## Supply Chain Jobs (supply-chain-test.yml) + +| Job Name | Purpose | +|----------|---------| +| `build-and-sign` | Build, sign binaries/SBOM, generate provenance attestation | +| `verify-signatures` | Verify cosign signatures + provenance content | +| `tamper-binary` | Modify 1 byte in binary, verify detection | +| `tamper-sbom` | Modify SBOM, verify detection | +| `tamper-signature-removal` | Delete .sig, verify detection | +| `adversarial-env-injection` | Test RUSTFLAGS injection, RUSTC_WRAPPER, proxy leak | +| `adversarial-artifact-tampering` | Tamper multi-rlib, .rmeta, .d, .fingerprint; verify rebuild defense | +| `adversarial-build-script` | Inject rogue build.rs (marker, HOME, hostname); verify hash change | +| `adversarial-credential-injection` | Inject fake API keys, AWS secrets, registry tokens; verify no leak | +| `adversarial-rustflags-bypass` | RUSTFLAGS override + SOURCE_DATE_EPOCH manipulation | +| `verify-docs-executable` | Execute docs/verification.md commands verbatim | + +## Release Jobs (release.yml) + +| Job Name | Purpose | +|----------|---------| +| `build` | Deterministic release build + SBOM + checksums (gnu + musl matrix) | +| `sign` | Cosign OIDC signing + provenance attestation (per target) | +| `verify` | Verify all signatures + checksums before publishing (per target) | +| `publish` | Create GitHub Release with all artifacts (both targets) | + +## Branch Protection + +The following jobs MUST be configured as required status checks in GitHub repository settings: + +- `Format` (`fmt`) +- `Clippy` (`clippy`) +- `Test (ubuntu)` / `Test (fedora)` / `Test (opensuse)` (`test`) +- `E2E Tests` (`e2e`) +- `ENOSPC Tests` (`enospc`) +- `E2E Resolver (ubuntu)` / `E2E Resolver (fedora)` / `E2E Resolver (opensuse)` (`e2e-resolve`) +- `Release Build (x86_64-unknown-linux-gnu)` / `Release Build (x86_64-unknown-linux-musl)` (`build-release`) +- `Smoke Test Release (x86_64-unknown-linux-gnu)` / `Smoke Test Release (x86_64-unknown-linux-musl)` (`smoke-test`) +- `Reproducibility Check (same-run)` (`reproducibility-check`) +- `Reproducibility Check (musl, same-run)` (`reproducibility-check-musl`) +- `Verify Cross-Run Reproducibility` (`verify-cross-reproducibility`) +- `Cross-Run Reproducibility musl (ubuntu-latest)` / `Cross-Run Reproducibility musl (ubuntu-22.04)` (`cross-run-reproducibility-musl`) +- `Verify Cross-Run Reproducibility (musl)` (`verify-cross-reproducibility-musl`) +- `Lockfile Integrity` (`lockfile-check`) +- `Dependency Policy (cargo-deny)` (`cargo-deny`) +- `CI Contract` (`ci-contract`) +- `Build, Sign & Attest` (`build-and-sign`) +- `Verify Signatures & Provenance` (`verify-signatures`) +- `Tamper Test: Binary` (`tamper-binary`) +- `Tamper Test: SBOM` (`tamper-sbom`) +- `Tamper Test: Signature Removal` (`tamper-signature-removal`) +- `Adversarial: Environment Injection` (`adversarial-env-injection`) +- `Adversarial: Intermediate Artifact Tampering` (`adversarial-artifact-tampering`) +- `Adversarial: Build Script Injection` (`adversarial-build-script`) +- `Adversarial: Credential Injection` (`adversarial-credential-injection`) +- `Adversarial: RUSTFLAGS Bypass` (`adversarial-rustflags-bypass`) +- `Verify docs/verification.md Commands` (`verify-docs-executable`) + +## Enforcement + +The `ci-contract` job parses `ci.yml` and verifies that every ci.yml job listed above is present. +If a required job is renamed or removed, CI fails immediately. + +Supply-chain test jobs are enforced by the `supply-chain-test.yml` workflow running on every PR. diff --git a/scripts/generate-sbom.sh b/scripts/generate-sbom.sh new file mode 100755 index 0000000..5d2ff56 --- /dev/null +++ b/scripts/generate-sbom.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +command -v cargo-cyclonedx >/dev/null 2>&1 || { + echo "Installing cargo-cyclonedx..." + cargo install cargo-cyclonedx --locked +} + +cargo cyclonedx --format json --output-prefix karapace +echo "SBOM written to karapace_bom.json"