diff --git a/.github/actions/build-and-scan/action.yml b/.github/actions/build-and-scan/action.yml new file mode 100644 index 0000000..8814794 --- /dev/null +++ b/.github/actions/build-and-scan/action.yml @@ -0,0 +1,75 @@ +name: Build and scan runtime images +description: Build both runtime images for amd64 and arm64, then scan each image +inputs: + nextcloud_version: + description: Nextcloud version passed to the app image build + required: true +runs: + using: composite + steps: + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + with: + platforms: arm64 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build app image (linux/amd64) + uses: docker/build-push-action@v6 + with: + context: .docker/app + platforms: linux/amd64 + load: true + build-args: | + NEXTCLOUD_VERSION=${{ inputs.nextcloud_version }} + tags: scan/app:amd64 + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Build app image (linux/arm64) + uses: docker/build-push-action@v6 + with: + context: .docker/app + platforms: linux/arm64 + load: true + build-args: | + NEXTCLOUD_VERSION=${{ inputs.nextcloud_version }} + tags: scan/app:arm64 + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Build web image (linux/amd64) + uses: docker/build-push-action@v6 + with: + context: .docker/web + platforms: linux/amd64 + load: true + tags: scan/web:amd64 + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Build web image (linux/arm64) + uses: docker/build-push-action@v6 + with: + context: .docker/web + platforms: linux/arm64 + load: true + tags: scan/web:arm64 + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Install Trivy + uses: aquasecurity/setup-trivy@81e514348e19b6112ce2a7e3ecbafe19c1e1f567 # v0.3.1 + with: + version: v0.74.0 + cache: true + + - name: Scan runtime images + shell: bash + run: | + bash scripts/scan-images.sh \ + 'app@linux/amd64=scan/app:amd64' \ + 'app@linux/arm64=scan/app:arm64' \ + 'web@linux/amd64=scan/web:amd64' \ + 'web@linux/arm64=scan/web:arm64' diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 4c7487a..cb477ad 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -8,16 +8,54 @@ on: branches: - main +permissions: + contents: read + env: REGISTRY: ghcr.io jobs: - build: + verify: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Read Nextcloud version + id: nextcloud_version + run: | + version=$(grep -m1 '^NEXTCLOUD_VERSION=' .env.example | cut -d= -f2-) + if [ -z "$version" ]; then + echo "NEXTCLOUD_VERSION is missing from .env.example" >&2 + exit 1 + fi + echo "value=$version" >> "$GITHUB_OUTPUT" + + - name: Build and scan runtime images + uses: ./.github/actions/build-and-scan + with: + nextcloud_version: ${{ steps.nextcloud_version.outputs.value }} + + - name: Save SARIF reports + uses: actions/upload-artifact@v4 + with: + name: trivy-sarif-reports + path: trivy-results/*.sarif + if-no-files-found: warn + + publish: + if: github.event_name == 'push' runs-on: ubuntu-latest permissions: contents: read packages: write + security-events: write steps: - name: Checkout repository @@ -37,35 +75,85 @@ jobs: id: repo_name run: echo "value=${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + - name: Build and scan runtime images + uses: ./.github/actions/build-and-scan + with: + nextcloud_version: ${{ steps.nextcloud_version.outputs.value }} + + - name: Upload app amd64 SARIF to GitHub code scanning + if: hashFiles('trivy-results/app-linux-amd64.sarif') != '' + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: trivy-results/app-linux-amd64.sarif + category: trivy-app-linux-amd64 + + - name: Upload app arm64 SARIF to GitHub code scanning + if: hashFiles('trivy-results/app-linux-arm64.sarif') != '' + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: trivy-results/app-linux-arm64.sarif + category: trivy-app-linux-arm64 + + - name: Upload web amd64 SARIF to GitHub code scanning + if: hashFiles('trivy-results/web-linux-amd64.sarif') != '' + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: trivy-results/web-linux-amd64.sarif + category: trivy-web-linux-amd64 + + - name: Upload web arm64 SARIF to GitHub code scanning + if: hashFiles('trivy-results/web-linux-arm64.sarif') != '' + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: trivy-results/web-linux-arm64.sarif + category: trivy-web-linux-arm64 + + - name: Save SARIF reports + uses: actions/upload-artifact@v4 + with: + name: trivy-sarif-reports + path: trivy-results/*.sarif + if-no-files-found: warn - name: Log in to Container Registry - if: github.event_name == 'push' uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Build app image - uses: docker/build-push-action@v6 - with: - context: .docker/app - platforms: linux/amd64,linux/arm64 - push: ${{ github.event_name == 'push' }} - build-args: | - NEXTCLOUD_VERSION=${{ steps.nextcloud_version.outputs.value }} - tags: ${{ env.REGISTRY }}/${{ steps.repo_name.outputs.value }}-app:latest - cache-from: type=gha - cache-to: type=gha,mode=max - - - name: Build web image - uses: docker/build-push-action@v6 - with: - context: .docker/web - platforms: linux/amd64,linux/arm64 - push: ${{ github.event_name == 'push' }} - tags: ${{ env.REGISTRY }}/${{ steps.repo_name.outputs.value }}-web:latest - cache-from: type=gha - cache-to: type=gha,mode=max + - name: Push scanned architecture images + env: + APP_IMAGE: ${{ env.REGISTRY }}/${{ steps.repo_name.outputs.value }}-app + WEB_IMAGE: ${{ env.REGISTRY }}/${{ steps.repo_name.outputs.value }}-web + IMAGE_VERSION: sha-${{ github.sha }} + run: | + set -euo pipefail + + docker tag scan/app:amd64 "${APP_IMAGE}:${IMAGE_VERSION}-amd64" + docker tag scan/app:arm64 "${APP_IMAGE}:${IMAGE_VERSION}-arm64" + docker tag scan/web:amd64 "${WEB_IMAGE}:${IMAGE_VERSION}-amd64" + docker tag scan/web:arm64 "${WEB_IMAGE}:${IMAGE_VERSION}-arm64" + + docker push "${APP_IMAGE}:${IMAGE_VERSION}-amd64" + docker push "${APP_IMAGE}:${IMAGE_VERSION}-arm64" + docker push "${WEB_IMAGE}:${IMAGE_VERSION}-amd64" + docker push "${WEB_IMAGE}:${IMAGE_VERSION}-arm64" + + - name: Publish multi-platform image tags + env: + APP_IMAGE: ${{ env.REGISTRY }}/${{ steps.repo_name.outputs.value }}-app + WEB_IMAGE: ${{ env.REGISTRY }}/${{ steps.repo_name.outputs.value }}-web + IMAGE_VERSION: sha-${{ github.sha }} + run: | + set -euo pipefail + + docker buildx imagetools create \ + --tag "${APP_IMAGE}:latest" \ + "${APP_IMAGE}:${IMAGE_VERSION}-amd64" \ + "${APP_IMAGE}:${IMAGE_VERSION}-arm64" + + docker buildx imagetools create \ + --tag "${WEB_IMAGE}:latest" \ + "${WEB_IMAGE}:${IMAGE_VERSION}-amd64" \ + "${WEB_IMAGE}:${IMAGE_VERSION}-arm64" diff --git a/.gitignore b/.gitignore index 1539fbc..cd252ac 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ !/backups/.gitkeep .env docker-compose.override.yml +.trivy-cache/ +trivy-results/ diff --git a/Makefile b/Makefile index 29f4e57..7d27b09 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ COMPOSE ?= docker compose GARAGES3_COMPOSE_FILE ?= docker-compose-garages3.yml -.PHONY: up-garages3 down-garages3 bootstrap-garages3 garage-status-garages3 start-garages3 wait-nextcloud-garages3 setup-garages3 test-hooks +.PHONY: up-garages3 down-garages3 bootstrap-garages3 garage-status-garages3 start-garages3 wait-nextcloud-garages3 setup-garages3 test-hooks test-scan-images scan-images up-garages3: $(COMPOSE) -f $(GARAGES3_COMPOSE_FILE) up -d garage @@ -28,3 +28,24 @@ setup-garages3: test-hooks: bash tests/test-hooks.sh + +test-scan-images: + bash tests/test-scan-images.sh + +scan-images: + @set -e; \ + version="$$(sed -n 's/^NEXTCLOUD_VERSION=//p' .env.example | head -n 1)"; \ + test -n "$$version" || { echo 'NEXTCLOUD_VERSION is missing from .env.example' >&2; exit 1; }; \ + docker buildx build --platform linux/amd64 --load --tag nextcloud-app:scan-amd64 \ + --build-arg "NEXTCLOUD_VERSION=$$version" --file .docker/app/Dockerfile .docker/app; \ + docker buildx build --platform linux/arm64 --load --tag nextcloud-app:scan-arm64 \ + --build-arg "NEXTCLOUD_VERSION=$$version" --file .docker/app/Dockerfile .docker/app; \ + docker buildx build --platform linux/amd64 --load --tag nextcloud-web:scan-amd64 \ + --file .docker/web/Dockerfile .docker/web; \ + docker buildx build --platform linux/arm64 --load --tag nextcloud-web:scan-arm64 \ + --file .docker/web/Dockerfile .docker/web; \ + bash scripts/scan-images.sh \ + 'app@linux/amd64=nextcloud-app:scan-amd64' \ + 'app@linux/arm64=nextcloud-app:scan-arm64' \ + 'web@linux/amd64=nextcloud-web:scan-amd64' \ + 'web@linux/arm64=nextcloud-web:scan-arm64' diff --git a/README.md b/README.md index fe8db26..9574476 100644 --- a/README.md +++ b/README.md @@ -237,6 +237,15 @@ docker compose build --pull docker compose up -d ``` +## Vulnerability scanning + +Published `app` and `web` images are scanned for vulnerabilities. Contributors +can run the scan locally with: + +```bash +make scan-images +``` + ## Logs If you want to see the logs, run: diff --git a/scripts/scan-images.sh b/scripts/scan-images.sh new file mode 100644 index 0000000..b115716 --- /dev/null +++ b/scripts/scan-images.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -uo pipefail + +if [[ $# -eq 0 ]]; then + printf 'Usage: %s label@linux/amd64=image [label@linux/arm64=image ...]\n' "$0" >&2 + exit 2 +fi + +trivy_bin="${TRIVY_BIN:-trivy}" +if ! command -v "$trivy_bin" >/dev/null 2>&1; then + if [[ "$trivy_bin" == "trivy" ]] && command -v trivy.exe >/dev/null 2>&1; then + trivy_bin=trivy.exe + else + printf 'Trivy is required. Install the version documented in README.md.\n' >&2 + exit 127 + fi +fi + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd -- "${script_dir}/.." && pwd)" +config="${repo_root}/trivy.yaml" +report_dir="${TRIVY_REPORT_DIR:-${repo_root}/trivy-results}" + +if ! mkdir -p "$report_dir"; then + printf 'Could not create SARIF output directory: %s\n' "$report_dir" >&2 + exit 1 +fi + +trivy_config="$config" +if [[ "$trivy_bin" == "trivy.exe" ]] && command -v wslpath >/dev/null 2>&1; then + trivy_config="$(wslpath -w "$config")" +fi + +scan_status=0 +for image_spec in "$@"; do + if [[ "$image_spec" != *=* ]]; then + printf 'Expected label=image, received: %s\n' "$image_spec" >&2 + exit 2 + fi + + label_platform="${image_spec%%=*}" + label="${label_platform%%@*}" + platform="${label_platform#*@}" + image="${image_spec#*=}" + + if [[ "$label_platform" != *@* || ! "$label" =~ ^[A-Za-z0-9_-]+$ || -z "$image" ]]; then + printf 'Invalid image specification: %s\n' "$image_spec" >&2 + exit 2 + fi + if [[ "$platform" != linux/amd64 && "$platform" != linux/arm64 ]]; then + printf 'Unsupported platform in image specification: %s\n' "$image_spec" >&2 + exit 2 + fi + + printf '\nScanning %s for %s (%s), table output\n' "$label" "$platform" "$image" + if ! "$trivy_bin" image --config "$trivy_config" --platform "$platform" --format table "$image"; then + scan_status=1 + fi + + printf '\nScanning %s for %s (%s), SARIF output\n' "$label" "$platform" "$image" + platform_suffix="${platform//\//-}" + sarif_output="${report_dir}/${label}-${platform_suffix}.sarif" + if [[ "$trivy_bin" == "trivy.exe" ]] && command -v wslpath >/dev/null 2>&1; then + sarif_output="$(wslpath -w "$sarif_output")" + fi + if ! "$trivy_bin" image --config "$trivy_config" --platform "$platform" --format sarif --output "$sarif_output" "$image"; then + scan_status=1 + fi +done + +exit "$scan_status" diff --git a/tests/test-scan-images.sh b/tests/test-scan-images.sh new file mode 100644 index 0000000..f551dfe --- /dev/null +++ b/tests/test-scan-images.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +tmp_root=$(mktemp -d) +trap 'rm -rf "$tmp_root"' EXIT + +bin_dir="$tmp_root/bin" +report_dir="$tmp_root/reports" +log_file="$tmp_root/trivy.log" +mkdir -p "$bin_dir" "$report_dir" + +cat > "$bin_dir/trivy" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +args=("$@") +platform='' +image='' +output='' +format='' +for ((i = 0; i < ${#args[@]}; i++)); do + case "${args[$i]}" in + --platform) + ((i + 1 < ${#args[@]})) || exit 90 + platform="${args[$((i + 1))]}" + ;; + --format) + ((i + 1 < ${#args[@]})) || exit 91 + format="${args[$((i + 1))]}" + ;; + --output) + ((i + 1 < ${#args[@]})) || exit 92 + output="${args[$((i + 1))]}" + ;; + esac +done +image="${args[-1]}" + +printf '%s|%s|%s\n' "$platform" "$format" "$image" >> "$TRIVY_TEST_LOG" + +if [[ "$platform" != "$TRIVY_EXPECTED_PLATFORM" ]]; then + printf 'unexpected platform: %s\n' "$platform" >&2 + exit 93 +fi + +if [[ "$format" == sarif ]]; then + [[ -n "$output" ]] || exit 94 + printf '{"version":"2.1.0"}\n' > "$output" +fi + +if [[ -n "${TRIVY_FAIL_IMAGE:-}" && "$image" == "$TRIVY_FAIL_IMAGE" ]]; then + exit 1 +fi +EOF +chmod +x "$bin_dir/trivy" + +export TRIVY_BIN="$bin_dir/trivy" +export TRIVY_TEST_LOG="$log_file" +export TRIVY_REPORT_DIR="$report_dir" +export TRIVY_EXPECTED_PLATFORM=linux/amd64 + +bash "$repo_root/scripts/scan-images.sh" \ + 'app@linux/amd64=example/app:amd64' +test -f "$report_dir/app-linux-amd64.sarif" +grep -Fqx 'linux/amd64|table|example/app:amd64' "$log_file" +grep -Fqx 'linux/amd64|sarif|example/app:amd64' "$log_file" + +export TRIVY_EXPECTED_PLATFORM=linux/arm64 +bash "$repo_root/scripts/scan-images.sh" \ + 'app@linux/arm64=example/app:arm64' +test -f "$report_dir/app-linux-arm64.sarif" +grep -Fqx 'linux/arm64|table|example/app:arm64' "$log_file" +grep -Fqx 'linux/arm64|sarif|example/app:arm64' "$log_file" + +if TRIVY_EXPECTED_PLATFORM=linux/arm64 TRIVY_FAIL_IMAGE=example/app:arm64 \ + bash "$repo_root/scripts/scan-images.sh" \ + 'app-failure@linux/arm64=example/app:arm64'; then + printf 'expected a Trivy failure to make the helper fail\n' >&2 + exit 1 +fi + +if TRIVY_EXPECTED_PLATFORM=linux/amd64 \ + bash "$repo_root/scripts/scan-images.sh" \ + 'app@linux/s390x=example/app:amd64'; then + printf 'expected an unsupported architecture to be rejected\n' >&2 + exit 1 +fi + +echo 'scan-images tests passed' diff --git a/trivy.yaml b/trivy.yaml new file mode 100644 index 0000000..eda1b11 --- /dev/null +++ b/trivy.yaml @@ -0,0 +1,14 @@ +# Shared vulnerability policy used by CI and `make scan-images`. +scan: + scanners: + - vuln + +vulnerability: + ignore-unfixed: true + +severity: + - HIGH + - CRITICAL + +exit-code: 1 +exit-on-eol: 1