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
47 changes: 47 additions & 0 deletions .github/workflows/image-scan.yml
Original file line number Diff line number Diff line change
@@ -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
62 changes: 59 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
22 changes: 18 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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 ./
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
23 changes: 23 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
Loading