diff --git a/.github/workflows/image-scan.yml b/.github/workflows/image-scan.yml new file mode 100644 index 0000000..ad3c265 --- /dev/null +++ b/.github/workflows/image-scan.yml @@ -0,0 +1,47 @@ +name: Image scan + +# Vulnerabilities are published after an image is released: every week, scan +# `latest` (its Debian packages and the crates in the binary); findings show in +# the repository's Security tab. +on: + schedule: + - cron: "17 5 * * 1" + workflow_dispatch: + +permissions: + contents: read + +env: + IMAGE: ghcr.io/${{ github.repository }} + # moved by hand, with the one in release.yml + TRIVY: ghcr.io/aquasecurity/trivy:0.76.2@sha256:8f6bb947dbc528902e11f2f5be52f7b519f6a39d5d148f41166f2040499768fb + +jobs: + trivy: + runs-on: ubuntu-latest + permissions: + contents: read + packages: read + security-events: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # the login above: credentials for ghcr.io and nothing else + - name: Scan the latest image + run: | + mkdir -m 777 "$RUNNER_TEMP/trivy" + docker run --rm -e DOCKER_CONFIG=/docker -v "$HOME/.docker/config.json:/docker/config.json:ro" \ + -v "$RUNNER_TEMP/trivy:/out" "$TRIVY" image \ + --image-src remote --scanners vuln --ignore-unfixed \ + --format sarif --output /out/image.sarif "$IMAGE:latest" + + - uses: github/codeql-action/upload-sarif@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3 + with: + sarif_file: ${{ runner.temp }}/trivy/image.sarif + category: image diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2a3c3e6..d625b86 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,6 +3,9 @@ name: Release # Pushing a tag vX.Y.Z publishes ghcr.io/forgust-dev/forgust:X.Y.Z (and X.Y, # latest) for amd64 and arm64, then opens a draft GitHub release with notes # grouped from the commit subjects since the last tag (.github/release-notes.sh). +# The image carries an SBOM and BuildKit provenance, is scanned for known +# vulnerabilities before it gets its tags, and is signed: a build provenance +# attestation that `gh attestation verify` checks (see SECURITY.md). # Each architecture builds on its own runner: a Rust release build under # emulation would take hours. on: @@ -15,6 +18,8 @@ permissions: env: IMAGE: ghcr.io/${{ github.repository }} DESCRIPTION: A fast, self-hosted code forge written in Rust + # moved by hand, with the one in image-scan.yml + TRIVY: ghcr.io/aquasecurity/trivy:0.76.2@sha256:8f6bb947dbc528902e11f2f5be52f7b519f6a39d5d148f41166f2040499768fb jobs: check: @@ -94,13 +99,52 @@ jobs: if-no-files-found: error retention-days: 1 - publish: - # one multi-arch image under the version tags + scan: + # the Debian packages and the crates in the binary: a high or critical + # vulnerability that has a fix stops the release before the image gets a tag. + # A job of its own, so that the scanner only ever sees a token that can read. + name: scan (${{ matrix.arch }}) needs: build + strategy: + matrix: + arch: [amd64, arm64] + runs-on: ubuntu-latest + permissions: + packages: read + steps: + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + path: /tmp/digests + name: digests-${{ matrix.arch }} + + - uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # the login above, read-only: credentials for ghcr.io and nothing else + - name: Scan for vulnerabilities + working-directory: /tmp/digests + run: | + docker run --rm -e DOCKER_CONFIG=/docker -v "$HOME/.docker/config.json:/docker/config.json:ro" "$TRIVY" image \ + --image-src remote --platform "linux/$ARCH" --scanners vuln \ + --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 "$IMAGE@sha256:$(ls)" + env: + ARCH: ${{ matrix.arch }} + + publish: + # one multi-arch image under the version tags, signed + needs: scan runs-on: ubuntu-latest permissions: contents: read packages: write + # the signature: a Sigstore certificate for this workflow run, stored with + # the repository's attestations and next to the image + id-token: write + attestations: write + artifact-metadata: write steps: - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: @@ -130,17 +174,29 @@ jobs: org.opencontainers.image.description=${{ env.DESCRIPTION }} - name: Create the multi-arch image + id: image working-directory: /tmp/digests run: | # tags and the index's annotations (source, version, licenses...) from metadata-action mapfile -t args < <(jq -r '(.tags[] | "-t", .), (.annotations[] | "--annotation", .)' <<< "$DOCKER_METADATA_OUTPUT_JSON") - docker buildx imagetools create "${args[@]}" $(printf "$IMAGE@sha256:%s " *) + docker buildx imagetools create --metadata-file "$RUNNER_TEMP/image.json" "${args[@]}" $(printf "$IMAGE@sha256:%s " *) + # the digest of what was just pushed, for the signature: not read back from a tag + digest=$(jq -er '."containerimage.descriptor".digest' "$RUNNER_TEMP/image.json") + echo "digest=$digest" >> "$GITHUB_OUTPUT" - name: Inspect run: docker buildx imagetools inspect "$IMAGE:$VERSION" env: VERSION: ${{ steps.meta.outputs.version }} + # signed build provenance for the multi-arch image: which commit and which + # workflow run built this digest + - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-name: ${{ env.IMAGE }} + subject-digest: ${{ steps.image.outputs.digest }} + push-to-registry: true + release: # a draft: read the notes, then publish it needs: publish diff --git a/Dockerfile b/Dockerfile index a7c9c1f..289efbe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,26 @@ # syntax=docker/dockerfile:1.7 # Forgust: one binary + the web UI. Runtime needs git >= 2.45 (trixie ships 2.47). +# +# Base images are pinned by digest (Dependabot moves the tag and the digest +# together). The image's SBOM (`docker buildx build --sbom=true`) lists the +# Debian packages of the final stage and the crates in the binary, which +# cargo-auditable records there; BUILDKIT_SBOM_SCAN_STAGE adds one more SBOM +# per build stage, for the npm packages and the toolchains. -FROM node:26.12-bookworm-slim AS web +FROM node:26.12-bookworm-slim@sha256:88e480776c6149ff1d098cd304f44bf18057fb5d284a3329b78162d29821e28c AS web +ARG BUILDKIT_SBOM_SCAN_STAGE=true WORKDIR /src/web COPY web/package.json web/package-lock.json ./ RUN npm ci --no-audit --no-fund COPY web/ ./ RUN npm run build -FROM rust:1.99-trixie AS build +FROM rust:1.99-trixie@sha256:8a4e90c7946b74626488b1446f712c0fe0ed1a596cbd3d871684bc9c5433ae07 AS build +ARG BUILDKIT_SBOM_SCAN_STAGE=true +# writes the dependency list into the binary: SBOM and vulnerability scanners read it +ARG CARGO_AUDITABLE=0.7.7 +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + cargo install --locked cargo-auditable@${CARGO_AUDITABLE} WORKDIR /src ENV SQLX_OFFLINE=true CARGO_TERM_COLOR=never COPY Cargo.toml Cargo.lock ./ @@ -17,11 +29,13 @@ COPY crates crates COPY bench bench RUN --mount=type=cache,target=/usr/local/cargo/registry \ --mount=type=cache,target=/src/target \ - cargo build --release --locked -p forgust-server \ + cargo auditable build --release --locked -p forgust-server \ && cp target/release/forgust /usr/local/bin/forgust -FROM debian:trixie-slim +FROM debian:trixie-slim@sha256:a72b4433de1cf7d6bae0c4cee74d0547f58601a643b3375f070c51600d63b6b2 +# upgrade: security fixes Debian published after the pinned base image was built RUN apt-get update \ + && apt-get upgrade -y \ && apt-get install -y --no-install-recommends git ca-certificates postgresql-client tini \ && rm -rf /var/lib/apt/lists/* \ && useradd --system --home /var/lib/forgust --create-home --uid 10001 forgust diff --git a/README.md b/README.md index 578779c..9eac125 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,10 @@ services: forgust: image: ghcr.io/forgust-dev/forgust:0.1 restart: unless-stopped + read_only: true + tmpfs: [/tmp] + cap_drop: [ALL] + security_opt: ["no-new-privileges:true"] ports: ["8080:8080", "2222:2222"] environment: FORGUST_DATABASE__URL: postgres://forgust:change-me@db:5432/forgust @@ -87,6 +91,8 @@ services: volumes: { forgust-data: {}, db-data: {} } ``` +- **Verify the image:** releases are signed and carry an SBOM, see + [SECURITY.md](SECURITY.md#verifying-a-release). - **First administrator:** the log prints a one-time link on first start (`docker compose logs forgust | grep setup`). - **Configuration:** every key in [`forgust.example.toml`](forgust.example.toml) can diff --git a/SECURITY.md b/SECURITY.md index 4736042..251bfe7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -14,6 +14,29 @@ published, unless you prefer not to be named. Forgust is before 1.0: only the latest release gets security fixes. +## Verifying a release + +The release workflow builds `ghcr.io/forgust-dev/forgust` on GitHub's runners and +signs it with a build provenance attestation: a Sigstore certificate that names the +repository, the tag and the workflow run that built the image. To check an image +before running it, with the [GitHub CLI](https://cli.github.com): + +```sh +gh attestation verify oci://ghcr.io/forgust-dev/forgust:0.2.0 \ + --repo forgust-dev/forgust --signer-workflow forgust-dev/forgust/.github/workflows/release.yml +``` + +Each image also carries an SBOM (SPDX): the Debian packages of the image, the crates +compiled into the binary, and the packages of the build stages. + +```sh +docker buildx imagetools inspect ghcr.io/forgust-dev/forgust:0.2.0 --format '{{ json .SBOM }}' +``` + +The image is scanned for known vulnerabilities at each release, and `latest` every +week. Releases up to 0.1.0 were published before signing and have no attestation +to verify. + ## Scope Anything in this repository: the server and its APIs (`/api/v1`, `/api/v4`, the diff --git a/compose.yml b/compose.yml index e8b244d..a8cd1b2 100644 --- a/compose.yml +++ b/compose.yml @@ -17,6 +17,11 @@ services: image: forgust:local build: . restart: unless-stopped + # the server only writes to its volume and /tmp, and needs no capability + read_only: true + tmpfs: [/tmp] + cap_drop: [ALL] + security_opt: ["no-new-privileges:true"] depends_on: db: { condition: service_healthy } s3: { condition: service_healthy }