From 5028f4e1ee333f9f77eab051ae0bbf13c59c6bc7 Mon Sep 17 00:00:00 2001 From: AlxcBTW <187763517+AlxcBTW@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:03:50 +0200 Subject: [PATCH] ci: pin actions by SHA, least-privilege tokens, Dependabot, advisories schedule, supply-chain doc --- .github/dependabot.yml | 45 +++++++++++++++++ .github/workflows/advisories.yml | 33 +++++++++++++ .github/workflows/ci.yml | 84 +++++++++++++++++++++++++++----- .github/workflows/fuzz.yml | 14 +++--- .github/workflows/kubernetes.yml | 7 ++- README.md | 2 +- deploy/Dockerfile | 13 +++-- docs/supply-chain.md | 45 +++++++++++++++++ 8 files changed, 220 insertions(+), 23 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/advisories.yml create mode 100644 docs/supply-chain.md diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..cc0fc2c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,45 @@ +# Keeps the pins in docs/supply-chain.md from rotting: one pull request a week per ecosystem, +# everything of a kind grouped, and nothing newer than a week (a release that is pulled or found +# malicious within days never reaches us; security updates ignore the wait). +version: 2 +updates: + # Actions are pinned by full commit SHA with a `# vX.Y.Z` comment; Dependabot moves both. + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + cooldown: + default-days: 7 + groups: + actions: + patterns: ["*"] + commit-message: + prefix: "ci" + # The workspace names ../network/crates/api by path (see launcher above): Dependabot may fail to + # resolve it. The fuzz crate has the same dependency. + - package-ecosystem: cargo + directory: / + schedule: + interval: weekly + day: monday + cooldown: + default-days: 7 + groups: + cargo-minor-and-patch: + update-types: [minor, patch] + commit-message: + prefix: "deps" + # Base images of deploy/Dockerfile: digest and tag move together. + - package-ecosystem: docker + directory: /deploy + schedule: + interval: weekly + day: monday + cooldown: + default-days: 7 + groups: + images: + patterns: ["*"] + commit-message: + prefix: "deploy" diff --git a/.github/workflows/advisories.yml b/.github/workflows/advisories.yml new file mode 100644 index 0000000..44d872c --- /dev/null +++ b/.github/workflows/advisories.yml @@ -0,0 +1,33 @@ +name: advisories + +# New security advisories appear without a commit, so the check that every pull request gets from +# the shared rules workflow (cargo-deny: advisories, bans, licences, sources) also runs every +# Monday and on demand. A red run here means a dependency of the lockfile on main has a new +# advisory; the fix is an update, or an entry in deny.toml with the reason it does not apply. + +on: + schedule: + - cron: "17 5 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + deny: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + path: repo + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + repository: anyplay-gg/network + path: network + - uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2.1.1 + with: + manifest-path: repo/Cargo.toml + arguments: --all-features + command: check diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4d166c..68da2e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,10 @@ permissions: env: CARGO_TERM_COLOR: always + # Trivy for the image report, checked against its published SHA-256 before it runs + # (trivy__checksums.txt of the release). + TRIVY_VERSION: 0.75.0 + TRIVY_SHA256: c6e65abddb348e25f10549df887045629cf28cc72453cd1c63acb717316b3f3f # The cloud depends on the network's API crate by relative path (`../network/crates/api`), so # every job checks out both repositories side by side. @@ -25,18 +29,20 @@ jobs: run: working-directory: cloud steps: - - uses: actions/checkout@v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false path: cloud - - uses: actions/checkout@v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false repository: anyplay-gg/network path: network - - uses: dtolnay/rust-toolchain@v1 + - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # v1 with: toolchain: stable components: rustfmt, clippy - - uses: Swatinem/rust-cache@v2.9.3 + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: workspaces: cloud - name: Format @@ -56,7 +62,8 @@ jobs: runs-on: ubuntu-latest services: postgres: - image: postgres:18-alpine + # postgres:18-alpine, pinned by digest. + image: postgres:18-alpine@sha256:898c60cb390d1085bc97bdd6a1ae968757421df394b071848b52b1c09c89d6a5 env: POSTGRES_PASSWORD: test ports: @@ -67,7 +74,8 @@ jobs: --health-timeout 5s --health-retries 30 nats: - image: nats:2.15-alpine + # nats:2.15-alpine, pinned by digest. + image: nats:2.15-alpine@sha256:7ae34c25dfd6fe2e01c6483eabef20d796690fdcac6c4d94ca780a6cf9877fde ports: - 4222:4222 defaults: @@ -77,17 +85,19 @@ jobs: ANYPLAY_TEST_DATABASE_URL: postgres://postgres:test@127.0.0.1:5432/postgres ANYPLAY_TEST_NATS_URL: nats://127.0.0.1:4222 steps: - - uses: actions/checkout@v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false path: cloud - - uses: actions/checkout@v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false repository: anyplay-gg/network path: network - - uses: dtolnay/rust-toolchain@v1 + - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # v1 with: toolchain: stable - - uses: Swatinem/rust-cache@v2.9.3 + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: workspaces: cloud - name: Test on PostgreSQL, and as a cluster of two nodes @@ -96,11 +106,13 @@ jobs: docker: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false path: cloud - - uses: actions/checkout@v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false repository: anyplay-gg/network path: network - name: Build the cloud image @@ -140,6 +152,54 @@ jobs: docker logs smoke-cloud || true docker logs smoke-relay || true + # Nothing pushes these images, so there is no registry to attach an SBOM or a provenance + # attestation to; the first workflow that pushes one adds both (docs/supply-chain.md). What + # CI can do today is look at what it built: a CycloneDX SBOM of each image (kept as an + # artifact) and a Trivy report of fixable HIGH and CRITICAL findings in the job summary. + # + # Report only, on purpose: a new CVE in a Debian package would turn every unrelated pull + # request red, and nobody has seen the baseline yet. To make it block, set TRIVY_EXIT_CODE + # to "1" below, drop `continue-on-error`, and accept known findings in a `.trivyignore` + # with a reason for each. + - name: Install Trivy + run: | + curl -fsSL -o trivy.tar.gz \ + "https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz" + echo "${TRIVY_SHA256} trivy.tar.gz" | sha256sum -c - + tar -xzf trivy.tar.gz trivy + sudo install trivy /usr/local/bin/trivy + - name: SBOM and vulnerability report of the images + continue-on-error: true + env: + TRIVY_EXIT_CODE: "0" + run: | + mkdir -p image-reports + status=0 + { + echo "## Image scan (report only)" + echo + echo "HIGH and CRITICAL findings that have a fix. Not blocking yet: docs/supply-chain.md." + } >> "$GITHUB_STEP_SUMMARY" + for image in anyplay-cloud anyplay-relay anyplay-caddy; do + trivy image --quiet --format cyclonedx --output "image-reports/$image.cdx.json" "$image" + trivy image --quiet --severity HIGH,CRITICAL --ignore-unfixed \ + --exit-code "$TRIVY_EXIT_CODE" --format table --output "image-reports/$image.txt" "$image" || status=$? + { + echo + echo "### $image" + echo '```' + cat "image-reports/$image.txt" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + done + exit "$status" + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + if: always() + with: + name: image-reports + path: image-reports/* + if-no-files-found: ignore + # Links, title IDs in code, licences and advisories: the rules every AnyPlay repository shares. rules: uses: anyplay-gg/.github/.github/workflows/rules.yml@main diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 7093c8f..106b2c6 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -39,22 +39,24 @@ jobs: run: working-directory: cloud steps: - - uses: actions/checkout@v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false path: cloud - - uses: actions/checkout@v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false repository: anyplay-gg/network path: network - - uses: dtolnay/rust-toolchain@v1 + - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # v1 with: toolchain: nightly - - uses: Swatinem/rust-cache@v2.9.3 + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: workspaces: cloud/fuzz - name: Install cargo-fuzz run: cargo install cargo-fuzz --version 0.13.2 --locked - - uses: actions/cache@v6.1.0 + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: cloud/fuzz/corpus/${{ matrix.target }} key: fuzz-corpus-${{ matrix.target }}-${{ github.run_id }} @@ -67,7 +69,7 @@ jobs: if: github.event_name != 'pull_request' run: cargo fuzz cmin ${{ matrix.target }} - if: failure() - uses: actions/upload-artifact@v7.0.2 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: crash-${{ matrix.target }} path: cloud/fuzz/artifacts/${{ matrix.target }} diff --git a/.github/workflows/kubernetes.yml b/.github/workflows/kubernetes.yml index a4197f7..0a22cd5 100644 --- a/.github/workflows/kubernetes.yml +++ b/.github/workflows/kubernetes.yml @@ -31,7 +31,8 @@ env: KIND_VERSION: v0.33.0 # The official NATS chart, https://github.com/nats-io/k8s (appVersion 2.15.1). NATS_CHART_VERSION: 2.15.1 - CURL_IMAGE: curlimages/curl:8.21.0 + # curlimages/curl:8.21.0, pinned by the digest of its multi-platform index. + CURL_IMAGE: curlimages/curl:8.21.0@sha256:7c12af72ceb38b7432ab85e1a265cff6ae58e06f95539d539b654f2cfa64bb13 # The cloud depends on the network's API crate by relative path, so both repositories are # checked out side by side, like in ci.yml. The chart lives in cloud/deploy/helm. @@ -41,6 +42,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1 with: version: ${{ env.HELM_VERSION }} @@ -93,9 +96,11 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false path: cloud - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false repository: anyplay-gg/network path: network - uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1 diff --git a/README.md b/README.md index 0f981fb..511585d 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ The services behind the [AnyPlay network](https://github.com/anyplay-gg/network) | `deploy/` | Dockerfile, `compose.yaml`, Caddyfile and an example `.env` | | `deploy/helm/` | the [Helm chart](docs/kubernetes.md) that runs the cloud as a cluster on Kubernetes, with an example CloudNativePG database and the files for the kind test | | `fuzz/` | [fuzz targets](fuzz/README.md) for the token check, run every night | -| `docs/` | [architecture](docs/ARCHITECTURE.md), [settings](docs/configuration.md), [decisions](docs/adr) and [EU compliance drafts](docs/compliance/README.md) | +| `docs/` | [architecture](docs/ARCHITECTURE.md), [settings](docs/configuration.md), [decisions](docs/adr), [EU compliance drafts](docs/compliance/README.md) and [supply chain](docs/supply-chain.md) | ## Host your own diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 9a4b1b7..b736ea0 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -7,15 +7,21 @@ # docker build -f cloud/deploy/Dockerfile --target relay -t anyplay-relay . # # `compose.yaml` does this for you. Dockerfile.dockerignore keeps everything else out of the context. +# +# Every base image is pinned by the digest of its multi-platform index, with the tag it was taken +# from in the comment above it; Dependabot (the `docker` ecosystem in .github/dependabot.yml) moves +# both together. Nothing here pushes an image: CI only builds and scans them (docs/supply-chain.md). -FROM rust:1-bookworm AS build +# rust:1-bookworm +FROM rust:1-bookworm@sha256:48b7f435b8fc6378ce43b44f15e3a47fd8919b2825f835c39bd4bd3da50af5b8 AS build WORKDIR /src COPY network ./network COPY cloud ./cloud WORKDIR /src/cloud RUN cargo build --release --locked -p anyplay-cloud -p anyplay-relay -FROM debian:bookworm-slim AS runtime +# debian:bookworm-slim +FROM debian:bookworm-slim@sha256:7ff6ba31527b9e336162fa061746c366c4b2032f4d2cff2078bea43f78efe3f8 AS runtime RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates \ && rm -rf /var/lib/apt/lists/* \ @@ -43,7 +49,8 @@ ENTRYPOINT ["anyplay-relay"] # Caddy as user 10001, so that the relay, which runs as the same user, can read the certificates # Caddy fetches. Caddy keeps its files in /data and /config. -FROM caddy:2 AS caddy +# caddy:2 +FROM caddy:2@sha256:504a44d7d74bd2bc28a66afc4774912fc21223ca8a67734af1c54ab2f999b489 AS caddy RUN mkdir -p /data /config \ && chown -R 10001:10001 /data /config USER 10001:10001 diff --git a/docs/supply-chain.md b/docs/supply-chain.md new file mode 100644 index 0000000..a0a204b --- /dev/null +++ b/docs/supply-chain.md @@ -0,0 +1,45 @@ +# Supply chain + +What is pinned in this repository, how updates arrive, what CI says about the container images, and what is not covered yet. The cloud has no public release or registry image yet ([ARCHITECTURE.md](ARCHITECTURE.md)); the images are built in CI and by operators with [deploy/Dockerfile](../deploy/Dockerfile). + +## What is pinned + +| What | How | +|---|---| +| GitHub Actions | Every third-party action is pinned by full commit SHA with the release it was taken from in a `# vX.Y.Z` comment. The SHA is looked up from the release tag with `gh api repos///git/ref/tags/`. | +| Workflow permissions | `contents: read` for every workflow; nothing here writes to the repository. | +| Checkouts | `persist-credentials: false`. Nothing in these workflows pushes. | +| Base images | `rust`, `debian` and `caddy` in the Dockerfile, and `postgres`, `nats` and `curl` in the workflows, are pinned by the digest of their multi-platform index, with the tag in a comment. The image build uses `cargo build --locked`. | +| Tools downloaded in CI | `helm`, `kubeconform` and Trivy are installed at an exact version; kubeconform and Trivy are checked against a SHA-256 in the workflow before they run. `cargo-fuzz` is installed with `--locked` at an exact version. | +| Licences, advisories, sources | `cargo deny` with [deny.toml](../deny.toml) on every pull request (the shared `rules` workflow) and every Monday (`advisories.yml`). The licences must allow use under AGPL-3.0; unknown registries and git sources are refused. | + +The shared `rules` workflow comes from `anyplay-gg/.github@main`, which is the organisation's own code and is not pinned to a commit. The Helm chart's test fixtures under `deploy/helm` (a plain PostgreSQL and the NATS chart) are not digest-pinned yet. + +## How updates arrive + +[Dependabot](../.github/dependabot.yml) opens one pull request a week for the actions (all in one), one for the crates of the workspace and of `fuzz/` (minor and patch updates in one; a major update by itself) and one for the base images of the Dockerfile, where it changes tag and digest together. It waits a week after a release before it proposes it, so a release that is pulled or found malicious in its first days never reaches us. Security updates ignore that wait. + +The workspace names the network's API crate by path (`../network/crates/api`). Dependabot sees only this repository, so it may not be able to resolve the crates and report an error instead of a pull request; update the crates by hand (`cargo update`, then the checks CI runs) until that dependency is a version. + +## The images + +CI builds the three images (`cloud`, `relay`, `caddy`) and does not push them, so there is no registry to attach an SBOM or a provenance attestation to. What the `docker` job does instead: + +- It writes a CycloneDX SBOM of each built image and keeps them as the artifact `image-reports`. +- It runs [Trivy](https://trivy.dev/) on each image and writes the HIGH and CRITICAL findings that have a fix into the job summary. + +**The scan does not block, on purpose.** Nobody has seen yet what the baseline of three Debian-based images looks like, and a new CVE in a Debian package would turn every unrelated pull request red until someone updated the base image. Look at the first reports, update the base images (Dependabot does), and accept what remains with a reason. Then make it block: in `.github/workflows/ci.yml` set `TRIVY_EXIT_CODE` of the step "SBOM and vulnerability report of the images" to `"1"`, remove its `continue-on-error`, and put the findings you accept in a `.trivyignore` file with a comment for each. + +**When a workflow starts to push an image**, it should build with `docker/build-push-action` with `sbom: true` and `provenance: mode=max`, so the registry holds the SBOM and a SLSA provenance next to the image, and attest the pushed digest with `actions/attest-build-provenance` (`subject-name` the image name, `subject-digest` the build's digest, `push-to-registry: true`). That job needs `packages: write`, `id-token: write` and `attestations: write`, and nothing else. Pin those actions like all others. Operators then verify with `gh attestation verify oci:// --repo anyplay-gg/cloud`. + +## Verify + +Nothing is released yet. To check a source tree: `cargo deny --all-features check`; to check an image you built, `trivy image `. + +## What is not covered yet + +- **Pushed images, their SBOM, provenance and signature**, as above: there is no pushing workflow. +- **Reproducible builds.** The build stage compiles on `rust:1-bookworm` (pinned by digest, which Dependabot moves) and the runtime stage runs `apt-get install ca-certificates`, which takes whatever Debian serves that day. +- **`cargo auditable` in the images**: the binaries do not carry their dependency list yet (the launcher's do). +- **Commit and tag signing**, and branch protection, are repository settings and are not switched on. +- **The Rust toolchain** in CI is `stable` as installed by `dtolnay/rust-toolchain`, not a pinned version.