From ee0db8bbe281d53f0511092fe0f13e3bb008aee2 Mon Sep 17 00:00:00 2001 From: ahmad Date: Wed, 16 Sep 2026 23:51:53 +0300 Subject: [PATCH 1/4] ci(security): scan app and web images with Trivy Signed-off-by: ahmad --- .github/workflows/docker-image.yml | 41 ++++++++++++++++++- .gitignore | 2 + Makefile | 11 +++++- README.md | 16 ++++++++ scripts/scan-images.sh | 63 ++++++++++++++++++++++++++++++ trivy.yaml | 17 ++++++++ 6 files changed, 147 insertions(+), 3 deletions(-) create mode 100644 scripts/scan-images.sh create mode 100644 trivy.yaml diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 4c7487a..6f5b51c 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -18,6 +18,7 @@ jobs: permissions: contents: read packages: write + security-events: write steps: - name: Checkout repository @@ -52,8 +53,9 @@ jobs: uses: docker/build-push-action@v6 with: context: .docker/app - platforms: linux/amd64,linux/arm64 + platforms: ${{ github.event_name == 'push' && 'linux/amd64,linux/arm64' || 'linux/amd64' }} push: ${{ github.event_name == 'push' }} + load: ${{ github.event_name == 'pull_request' }} build-args: | NEXTCLOUD_VERSION=${{ steps.nextcloud_version.outputs.value }} tags: ${{ env.REGISTRY }}/${{ steps.repo_name.outputs.value }}-app:latest @@ -64,8 +66,43 @@ jobs: uses: docker/build-push-action@v6 with: context: .docker/web - platforms: linux/amd64,linux/arm64 + platforms: ${{ github.event_name == 'push' && 'linux/amd64,linux/arm64' || 'linux/amd64' }} push: ${{ github.event_name == 'push' }} + load: ${{ github.event_name == 'pull_request' }} tags: ${{ env.REGISTRY }}/${{ steps.repo_name.outputs.value }}-web:latest 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 + env: + APP_IMAGE: ${{ env.REGISTRY }}/${{ steps.repo_name.outputs.value }}-app:latest + WEB_IMAGE: ${{ env.REGISTRY }}/${{ steps.repo_name.outputs.value }}-web:latest + run: bash scripts/scan-images.sh "app=$APP_IMAGE" "web=$WEB_IMAGE" + + - name: Upload app SARIF to GitHub code scanning + if: always() && github.event_name == 'push' && hashFiles('trivy-results/app.sarif') != '' + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: trivy-results/app.sarif + category: trivy-app-image + + - name: Upload web SARIF to GitHub code scanning + if: always() && github.event_name == 'push' && hashFiles('trivy-results/web.sarif') != '' + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: trivy-results/web.sarif + category: trivy-web-image + + - name: Save SARIF reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: trivy-sarif-reports + path: trivy-results/*.sarif + if-no-files-found: warn 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..977f770 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 scan-images up-garages3: $(COMPOSE) -f $(GARAGES3_COMPOSE_FILE) up -d garage @@ -28,3 +28,12 @@ setup-garages3: test-hooks: bash tests/test-hooks.sh + +scan-images: + @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 \ + --build-arg "NEXTCLOUD_VERSION=$$version" --file .docker/app/Dockerfile .docker/app + @docker buildx build --platform linux/amd64 --load --tag nextcloud-web:scan \ + --file .docker/web/Dockerfile .docker/web + @bash scripts/scan-images.sh app=nextcloud-app:scan web=nextcloud-web:scan diff --git a/README.md b/README.md index fe8db26..6a73ba4 100644 --- a/README.md +++ b/README.md @@ -237,6 +237,22 @@ docker compose build --pull docker compose up -d ``` +## Scan runtime images + +The image workflow scans the `app` and `web` images it builds. To run the same +vulnerability policy locally, install Docker with Buildx, GNU Make, Bash, and +Trivy `v0.74.0`, then run: + +```bash +make scan-images +``` + +This builds the same app and web Dockerfiles for `linux/amd64`, using the +`NEXTCLOUD_VERSION` from `.env.example`, then applies the shared policy in +[`trivy.yaml`](trivy.yaml). The table reports are printed to the terminal and +SARIF reports are written to `trivy-results/`. High and critical vulnerabilities +fail the command only when a fix is available. An end-of-life base OS also fails. + ## 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..d61bf6d --- /dev/null +++ b/scripts/scan-images.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +set -uo pipefail + +if [[ $# -eq 0 ]]; then + printf 'Usage: %s label=image [label=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="${image_spec%%=*}" + image="${image_spec#*=}" + if [[ ! "$label" =~ ^[A-Za-z0-9_-]+$ || -z "$image" ]]; then + printf 'Invalid image specification: %s\n' "$image_spec" >&2 + exit 2 + fi + + printf '\nScanning %s (%s), table output\n' "$label" "$image" + if ! "$trivy_bin" image --config "$trivy_config" --format table "$image"; then + scan_status=1 + fi + + printf '\nScanning %s (%s), SARIF output\n' "$label" "$image" + sarif_output="${report_dir}/${label}.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" --format sarif --output "$sarif_output" "$image"; then + scan_status=1 + fi +done + +exit "$scan_status" diff --git a/trivy.yaml b/trivy.yaml new file mode 100644 index 0000000..78da611 --- /dev/null +++ b/trivy.yaml @@ -0,0 +1,17 @@ +# Shared vulnerability policy used by CI and `make scan-images`. +image: + platform: linux/amd64 + +scan: + scanners: + - vuln + +vulnerability: + ignore-unfixed: true + +severity: + - HIGH + - CRITICAL + +exit-code: 1 +exit-on-eol: 1 From 01126ce0ca7491f06d62190871c5d7f9683f8564 Mon Sep 17 00:00:00 2001 From: ahmad Date: Thu, 17 Sep 2026 09:52:39 +0300 Subject: [PATCH 2/4] ci: gate multi-arch image publication on Trivy scans Signed-off-by: ahmad --- .github/workflows/docker-image.yml | 135 +++++++++++++++++++++++------ Makefile | 26 ++++-- README.md | 11 +-- scripts/scan-images.sh | 21 +++-- tests/test-scan-images.sh | 91 +++++++++++++++++++ trivy.yaml | 3 - 6 files changed, 237 insertions(+), 50 deletions(-) create mode 100644 tests/test-scan-images.sh diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 6f5b51c..068cece 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -38,38 +38,55 @@ jobs: id: repo_name run: echo "value=${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" + - 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: Log in to Container Registry - if: github.event_name == 'push' - uses: docker/login-action@v3 + - name: Build app image (linux/amd64) + uses: docker/build-push-action@v6 with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + context: .docker/app + platforms: linux/amd64 + load: true + build-args: | + NEXTCLOUD_VERSION=${{ steps.nextcloud_version.outputs.value }} + tags: scan/app:amd64 + cache-from: type=gha + cache-to: type=gha,mode=max - - name: Build app image + - name: Build app image (linux/arm64) uses: docker/build-push-action@v6 with: context: .docker/app - platforms: ${{ github.event_name == 'push' && 'linux/amd64,linux/arm64' || 'linux/amd64' }} - push: ${{ github.event_name == 'push' }} - load: ${{ github.event_name == 'pull_request' }} + platforms: linux/arm64 + load: true build-args: | NEXTCLOUD_VERSION=${{ steps.nextcloud_version.outputs.value }} - tags: ${{ env.REGISTRY }}/${{ steps.repo_name.outputs.value }}-app:latest + 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 + - name: Build web image (linux/arm64) uses: docker/build-push-action@v6 with: context: .docker/web - platforms: ${{ github.event_name == 'push' && 'linux/amd64,linux/arm64' || 'linux/amd64' }} - push: ${{ github.event_name == 'push' }} - load: ${{ github.event_name == 'pull_request' }} - tags: ${{ env.REGISTRY }}/${{ steps.repo_name.outputs.value }}-web:latest + platforms: linux/arm64 + load: true + tags: scan/web:arm64 cache-from: type=gha cache-to: type=gha,mode=max @@ -80,24 +97,40 @@ jobs: cache: true - name: Scan runtime images - env: - APP_IMAGE: ${{ env.REGISTRY }}/${{ steps.repo_name.outputs.value }}-app:latest - WEB_IMAGE: ${{ env.REGISTRY }}/${{ steps.repo_name.outputs.value }}-web:latest - run: bash scripts/scan-images.sh "app=$APP_IMAGE" "web=$WEB_IMAGE" + run: | + bash scripts/scan-images.sh \ + 'app-amd64@linux/amd64=scan/app:amd64' \ + 'app-arm64@linux/arm64=scan/app:arm64' \ + 'web-amd64@linux/amd64=scan/web:amd64' \ + 'web-arm64@linux/arm64=scan/web:arm64' + + - name: Upload app amd64 SARIF to GitHub code scanning + if: always() && github.event_name == 'push' && hashFiles('trivy-results/app-amd64.sarif') != '' + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: trivy-results/app-amd64.sarif + category: trivy-app-linux-amd64 - - name: Upload app SARIF to GitHub code scanning - if: always() && github.event_name == 'push' && hashFiles('trivy-results/app.sarif') != '' + - name: Upload app arm64 SARIF to GitHub code scanning + if: always() && github.event_name == 'push' && hashFiles('trivy-results/app-arm64.sarif') != '' uses: github/codeql-action/upload-sarif@v3 with: - sarif_file: trivy-results/app.sarif - category: trivy-app-image + sarif_file: trivy-results/app-arm64.sarif + category: trivy-app-linux-arm64 - - name: Upload web SARIF to GitHub code scanning - if: always() && github.event_name == 'push' && hashFiles('trivy-results/web.sarif') != '' + - name: Upload web amd64 SARIF to GitHub code scanning + if: always() && github.event_name == 'push' && hashFiles('trivy-results/web-amd64.sarif') != '' uses: github/codeql-action/upload-sarif@v3 with: - sarif_file: trivy-results/web.sarif - category: trivy-web-image + sarif_file: trivy-results/web-amd64.sarif + category: trivy-web-linux-amd64 + + - name: Upload web arm64 SARIF to GitHub code scanning + if: always() && github.event_name == 'push' && hashFiles('trivy-results/web-arm64.sarif') != '' + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: trivy-results/web-arm64.sarif + category: trivy-web-linux-arm64 - name: Save SARIF reports if: always() @@ -106,3 +139,49 @@ jobs: 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: Push scanned architecture images + if: github.event_name == 'push' + 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 + if: github.event_name == 'push' + 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/Makefile b/Makefile index 977f770..f77c09e 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 scan-images +.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 @@ -29,11 +29,23 @@ setup-garages3: test-hooks: bash tests/test-hooks.sh +test-scan-images: + bash tests/test-scan-images.sh + scan-images: - @version="$$(sed -n 's/^NEXTCLOUD_VERSION=//p' .env.example | head -n 1)"; \ + @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 \ - --build-arg "NEXTCLOUD_VERSION=$$version" --file .docker/app/Dockerfile .docker/app - @docker buildx build --platform linux/amd64 --load --tag nextcloud-web:scan \ - --file .docker/web/Dockerfile .docker/web - @bash scripts/scan-images.sh app=nextcloud-app:scan web=nextcloud-web:scan + 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-amd64@linux/amd64=nextcloud-app:scan-amd64' \ + 'app-arm64@linux/arm64=nextcloud-app:scan-arm64' \ + 'web-amd64@linux/amd64=nextcloud-web:scan-amd64' \ + 'web-arm64@linux/arm64=nextcloud-web:scan-arm64' diff --git a/README.md b/README.md index 6a73ba4..66294be 100644 --- a/README.md +++ b/README.md @@ -247,11 +247,12 @@ Trivy `v0.74.0`, then run: make scan-images ``` -This builds the same app and web Dockerfiles for `linux/amd64`, using the -`NEXTCLOUD_VERSION` from `.env.example`, then applies the shared policy in -[`trivy.yaml`](trivy.yaml). The table reports are printed to the terminal and -SARIF reports are written to `trivy-results/`. High and critical vulnerabilities -fail the command only when a fix is available. An end-of-life base OS also fails. +This builds the app and web Dockerfiles for both `linux/amd64` and `linux/arm64`, +using the `NEXTCLOUD_VERSION` from `.env.example`, then applies the shared policy +in [`trivy.yaml`](trivy.yaml) to each architecture. The table reports are printed +to the terminal and separate SARIF reports for each image and architecture are +written to `trivy-results/`. High and critical vulnerabilities fail the command +only when a fix is available. An end-of-life base OS also fails. ## Logs diff --git a/scripts/scan-images.sh b/scripts/scan-images.sh index d61bf6d..7608bc3 100644 --- a/scripts/scan-images.sh +++ b/scripts/scan-images.sh @@ -2,7 +2,7 @@ set -uo pipefail if [[ $# -eq 0 ]]; then - printf 'Usage: %s label=image [label=image ...]\n' "$0" >&2 + printf 'Usage: %s label@linux/amd64=image [label@linux/arm64=image ...]\n' "$0" >&2 exit 2 fi @@ -38,24 +38,31 @@ for image_spec in "$@"; do exit 2 fi - label="${image_spec%%=*}" + label_platform="${image_spec%%=*}" + label="${label_platform%%@*}" + platform="${label_platform#*@}" image="${image_spec#*=}" - if [[ ! "$label" =~ ^[A-Za-z0-9_-]+$ || -z "$image" ]]; then + + 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 (%s), table output\n' "$label" "$image" - if ! "$trivy_bin" image --config "$trivy_config" --format table "$image"; then + 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 (%s), SARIF output\n' "$label" "$image" + printf '\nScanning %s for %s (%s), SARIF output\n' "$label" "$platform" "$image" sarif_output="${report_dir}/${label}.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" --format sarif --output "$sarif_output" "$image"; then + if ! "$trivy_bin" image --config "$trivy_config" --platform "$platform" --format sarif --output "$sarif_output" "$image"; then scan_status=1 fi done diff --git a/tests/test-scan-images.sh b/tests/test-scan-images.sh new file mode 100644 index 0000000..e7add98 --- /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-amd64@linux/amd64=example/app:amd64' +test -f "$report_dir/app-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-arm64@linux/arm64=example/app:arm64' +test -f "$report_dir/app-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-arm64-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-invalid@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 index 78da611..eda1b11 100644 --- a/trivy.yaml +++ b/trivy.yaml @@ -1,7 +1,4 @@ # Shared vulnerability policy used by CI and `make scan-images`. -image: - platform: linux/amd64 - scan: scanners: - vuln From 529852050975f7d1460f297b36a582015fd1791e Mon Sep 17 00:00:00 2001 From: ahmad Date: Thu, 17 Sep 2026 10:15:52 +0300 Subject: [PATCH 3/4] ci: isolate image verification from publishing Signed-off-by: ahmad --- .github/actions/build-and-scan/action.yml | 75 +++++++++++++ .github/workflows/docker-image.yml | 126 +++++++++------------- Makefile | 8 +- README.md | 3 + scripts/scan-images.sh | 3 +- tests/test-scan-images.sh | 12 +-- 6 files changed, 139 insertions(+), 88 deletions(-) create mode 100644 .github/actions/build-and-scan/action.yml 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 068cece..cb477ad 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -8,11 +8,48 @@ 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: @@ -38,102 +75,40 @@ jobs: id: repo_name run: echo "value=${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + - name: Build and scan runtime images + uses: ./.github/actions/build-and-scan 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=${{ steps.nextcloud_version.outputs.value }} - 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=${{ steps.nextcloud_version.outputs.value }} - 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 - run: | - bash scripts/scan-images.sh \ - 'app-amd64@linux/amd64=scan/app:amd64' \ - 'app-arm64@linux/arm64=scan/app:arm64' \ - 'web-amd64@linux/amd64=scan/web:amd64' \ - 'web-arm64@linux/arm64=scan/web:arm64' + nextcloud_version: ${{ steps.nextcloud_version.outputs.value }} - name: Upload app amd64 SARIF to GitHub code scanning - if: always() && github.event_name == 'push' && hashFiles('trivy-results/app-amd64.sarif') != '' + if: hashFiles('trivy-results/app-linux-amd64.sarif') != '' uses: github/codeql-action/upload-sarif@v3 with: - sarif_file: trivy-results/app-amd64.sarif + sarif_file: trivy-results/app-linux-amd64.sarif category: trivy-app-linux-amd64 - name: Upload app arm64 SARIF to GitHub code scanning - if: always() && github.event_name == 'push' && hashFiles('trivy-results/app-arm64.sarif') != '' + if: hashFiles('trivy-results/app-linux-arm64.sarif') != '' uses: github/codeql-action/upload-sarif@v3 with: - sarif_file: trivy-results/app-arm64.sarif + sarif_file: trivy-results/app-linux-arm64.sarif category: trivy-app-linux-arm64 - name: Upload web amd64 SARIF to GitHub code scanning - if: always() && github.event_name == 'push' && hashFiles('trivy-results/web-amd64.sarif') != '' + if: hashFiles('trivy-results/web-linux-amd64.sarif') != '' uses: github/codeql-action/upload-sarif@v3 with: - sarif_file: trivy-results/web-amd64.sarif + sarif_file: trivy-results/web-linux-amd64.sarif category: trivy-web-linux-amd64 - name: Upload web arm64 SARIF to GitHub code scanning - if: always() && github.event_name == 'push' && hashFiles('trivy-results/web-arm64.sarif') != '' + if: hashFiles('trivy-results/web-linux-arm64.sarif') != '' uses: github/codeql-action/upload-sarif@v3 with: - sarif_file: trivy-results/web-arm64.sarif + sarif_file: trivy-results/web-linux-arm64.sarif category: trivy-web-linux-arm64 - name: Save SARIF reports - if: always() uses: actions/upload-artifact@v4 with: name: trivy-sarif-reports @@ -141,7 +116,6 @@ jobs: 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 }} @@ -149,7 +123,6 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Push scanned architecture images - if: github.event_name == 'push' env: APP_IMAGE: ${{ env.REGISTRY }}/${{ steps.repo_name.outputs.value }}-app WEB_IMAGE: ${{ env.REGISTRY }}/${{ steps.repo_name.outputs.value }}-web @@ -168,7 +141,6 @@ jobs: docker push "${WEB_IMAGE}:${IMAGE_VERSION}-arm64" - name: Publish multi-platform image tags - if: github.event_name == 'push' env: APP_IMAGE: ${{ env.REGISTRY }}/${{ steps.repo_name.outputs.value }}-app WEB_IMAGE: ${{ env.REGISTRY }}/${{ steps.repo_name.outputs.value }}-web diff --git a/Makefile b/Makefile index f77c09e..7d27b09 100644 --- a/Makefile +++ b/Makefile @@ -45,7 +45,7 @@ scan-images: docker buildx build --platform linux/arm64 --load --tag nextcloud-web:scan-arm64 \ --file .docker/web/Dockerfile .docker/web; \ bash scripts/scan-images.sh \ - 'app-amd64@linux/amd64=nextcloud-app:scan-amd64' \ - 'app-arm64@linux/arm64=nextcloud-app:scan-arm64' \ - 'web-amd64@linux/amd64=nextcloud-web:scan-amd64' \ - 'web-arm64@linux/arm64=nextcloud-web:scan-arm64' + '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 66294be..51c0cfe 100644 --- a/README.md +++ b/README.md @@ -254,6 +254,9 @@ to the terminal and separate SARIF reports for each image and architecture are written to `trivy-results/`. High and critical vulnerabilities fail the command only when a fix is available. An end-of-life base OS also fails. +The local arm64 builds require QEMU/binfmt support on non-arm64 hosts. The GitHub +workflow sets up QEMU before building both architectures. + ## Logs If you want to see the logs, run: diff --git a/scripts/scan-images.sh b/scripts/scan-images.sh index 7608bc3..b115716 100644 --- a/scripts/scan-images.sh +++ b/scripts/scan-images.sh @@ -58,7 +58,8 @@ for image_spec in "$@"; do fi printf '\nScanning %s for %s (%s), SARIF output\n' "$label" "$platform" "$image" - sarif_output="${report_dir}/${label}.sarif" + 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 diff --git a/tests/test-scan-images.sh b/tests/test-scan-images.sh index e7add98..f551dfe 100644 --- a/tests/test-scan-images.sh +++ b/tests/test-scan-images.sh @@ -62,28 +62,28 @@ export TRIVY_REPORT_DIR="$report_dir" export TRIVY_EXPECTED_PLATFORM=linux/amd64 bash "$repo_root/scripts/scan-images.sh" \ - 'app-amd64@linux/amd64=example/app:amd64' -test -f "$report_dir/app-amd64.sarif" + '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-arm64@linux/arm64=example/app:arm64' -test -f "$report_dir/app-arm64.sarif" + '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-arm64-failure@linux/arm64=example/app:arm64'; then + '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-invalid@linux/s390x=example/app:amd64'; then + 'app@linux/s390x=example/app:amd64'; then printf 'expected an unsupported architecture to be rejected\n' >&2 exit 1 fi From 03d0299c61274da0912e665560695793009f3384 Mon Sep 17 00:00:00 2001 From: ahmad Date: Fri, 18 Sep 2026 22:04:54 +0300 Subject: [PATCH 4/4] docs: simplify image scanning README Signed-off-by: ahmad --- README.md | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 51c0cfe..9574476 100644 --- a/README.md +++ b/README.md @@ -237,26 +237,15 @@ docker compose build --pull docker compose up -d ``` -## Scan runtime images +## Vulnerability scanning -The image workflow scans the `app` and `web` images it builds. To run the same -vulnerability policy locally, install Docker with Buildx, GNU Make, Bash, and -Trivy `v0.74.0`, then run: +Published `app` and `web` images are scanned for vulnerabilities. Contributors +can run the scan locally with: ```bash make scan-images ``` -This builds the app and web Dockerfiles for both `linux/amd64` and `linux/arm64`, -using the `NEXTCLOUD_VERSION` from `.env.example`, then applies the shared policy -in [`trivy.yaml`](trivy.yaml) to each architecture. The table reports are printed -to the terminal and separate SARIF reports for each image and architecture are -written to `trivy-results/`. High and critical vulnerabilities fail the command -only when a fix is available. An end-of-life base OS also fails. - -The local arm64 builds require QEMU/binfmt support on non-arm64 hosts. The GitHub -workflow sets up QEMU before building both architectures. - ## Logs If you want to see the logs, run: