Docker Image Scanning (Trivy) #270
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Docker Image Scanning (Trivy) | |
| # Issue #772 — Add Docker image vulnerability scanning to CI | |
| on: | |
| push: | |
| branches: [main, 'feature/**'] | |
| paths: | |
| - 'backend/Dockerfile' | |
| - 'frontend/Dockerfile' | |
| - 'backend/**' | |
| - 'frontend/**' | |
| - '.trivyignore' | |
| - '.github/workflows/docker-scan.yml' | |
| pull_request: | |
| branches: [main] | |
| schedule: | |
| # Nightly scan to catch newly published CVEs against already-merged images | |
| - cron: '0 3 * * *' | |
| permissions: | |
| contents: read | |
| packages: write # push the scan-tagged image to GHCR so it can be keylessly signed | |
| id-token: write # required for cosign's keyless OIDC signing | |
| jobs: | |
| trivy-backend: | |
| name: Trivy — Backend Image | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232e2 # v3.10.0 | |
| - name: Build backend Docker image | |
| uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0 | |
| with: | |
| context: ./backend | |
| push: false | |
| load: true | |
| tags: future-backend:${{ github.sha }} | |
| cache-from: type=gha,scope=backend | |
| cache-to: type=gha,mode=max,scope=backend | |
| - name: Generate backend SBOM (CycloneDX) | |
| uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 | |
| with: | |
| image: future-backend:${{ github.sha }} | |
| format: cyclonedx-json | |
| output-file: sbom-backend.cyclonedx.json | |
| upload-artifact: false | |
| - name: Upload backend SBOM | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: sbom-backend-${{ github.sha }} | |
| path: sbom-backend.cyclonedx.json | |
| retention-days: 30 | |
| - name: Run Trivy on backend image | |
| uses: aquasecurity/trivy-action@76071ef0d7ec797419534a183b498b4d6366cf37 # v0.31.0 | |
| with: | |
| image-ref: future-backend:${{ github.sha }} | |
| format: sarif | |
| output: trivy-backend.sarif | |
| severity: CRITICAL,HIGH | |
| exit-code: '1' | |
| ignore-unfixed: true | |
| trivyignores: .trivyignore | |
| - name: Upload backend Trivy SARIF report | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: trivy-backend-report-${{ github.sha }} | |
| path: trivy-backend.sarif | |
| retention-days: 30 | |
| # Signing requires pushing to GHCR, which forked PRs can't authenticate | |
| # to — skip on untrusted (fork) pull_request events, run everywhere else. | |
| - name: Log in to GitHub Container Registry | |
| if: success() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) | |
| uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Push scan-tagged backend image to GHCR | |
| id: push_backend | |
| if: success() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) | |
| uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0 | |
| with: | |
| context: ./backend | |
| push: true | |
| tags: ghcr.io/${{ github.repository }}/backend:scan-${{ github.sha }} | |
| cache-from: type=gha,scope=backend | |
| cache-to: type=gha,mode=max,scope=backend | |
| - name: Install cosign | |
| if: success() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) | |
| uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 | |
| - name: Sign backend image (keyless) | |
| if: success() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) | |
| run: cosign sign --yes "ghcr.io/${{ github.repository }}/backend@${{ steps.push_backend.outputs.digest }}" | |
| trivy-frontend: | |
| name: Trivy — Frontend Image | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232e2 # v3.10.0 | |
| - name: Build frontend Docker image | |
| uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0 | |
| with: | |
| context: ./frontend | |
| push: false | |
| load: true | |
| tags: future-frontend:${{ github.sha }} | |
| cache-from: type=gha,scope=frontend | |
| cache-to: type=gha,mode=max,scope=frontend | |
| - name: Generate frontend SBOM (CycloneDX) | |
| uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 | |
| with: | |
| image: future-frontend:${{ github.sha }} | |
| format: cyclonedx-json | |
| output-file: sbom-frontend.cyclonedx.json | |
| upload-artifact: false | |
| - name: Upload frontend SBOM | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: sbom-frontend-${{ github.sha }} | |
| path: sbom-frontend.cyclonedx.json | |
| retention-days: 30 | |
| - name: Run Trivy on frontend image | |
| uses: aquasecurity/trivy-action@76071ef0d7ec797419534a183b498b4d6366cf37 # v0.31.0 | |
| with: | |
| image-ref: future-frontend:${{ github.sha }} | |
| format: sarif | |
| output: trivy-frontend.sarif | |
| severity: CRITICAL,HIGH | |
| exit-code: '1' | |
| ignore-unfixed: true | |
| trivyignores: .trivyignore | |
| - name: Upload frontend Trivy SARIF report | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: trivy-frontend-report-${{ github.sha }} | |
| path: trivy-frontend.sarif | |
| retention-days: 30 | |
| # Signing requires pushing to GHCR, which forked PRs can't authenticate | |
| # to — skip on untrusted (fork) pull_request events, run everywhere else. | |
| - name: Log in to GitHub Container Registry | |
| if: success() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) | |
| uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Push scan-tagged frontend image to GHCR | |
| id: push_frontend | |
| if: success() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) | |
| uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0 | |
| with: | |
| context: ./frontend | |
| push: true | |
| tags: ghcr.io/${{ github.repository }}/frontend:scan-${{ github.sha }} | |
| cache-from: type=gha,scope=frontend | |
| cache-to: type=gha,mode=max,scope=frontend | |
| - name: Install cosign | |
| if: success() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) | |
| uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 | |
| - name: Sign frontend image (keyless) | |
| if: success() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) | |
| run: cosign sign --yes "ghcr.io/${{ github.repository }}/frontend@${{ steps.push_frontend.outputs.digest }}" | |
| docker-scan-gate: | |
| name: Docker Scan Gate | |
| runs-on: ubuntu-latest | |
| needs: [trivy-backend, trivy-frontend, dockerfile-nonroot] | |
| if: always() | |
| steps: | |
| - name: Check scan results | |
| run: | | |
| if [ "${{ needs.trivy-backend.result }}" != "success" ]; then | |
| echo "❌ Backend image scan failed — HIGH/CRITICAL CVEs detected" | |
| exit 1 | |
| fi | |
| if [ "${{ needs.trivy-frontend.result }}" != "success" ]; then | |
| echo "❌ Frontend image scan failed — HIGH/CRITICAL CVEs detected" | |
| exit 1 | |
| fi | |
| if [ "${{ needs.dockerfile-nonroot.result }}" != "success" ]; then | |
| echo "❌ Production Dockerfiles must set a non-root USER" | |
| exit 1 | |
| fi | |
| echo "✅ Docker image scans passed — no unfixed HIGH/CRITICAL CVEs, USER set" | |
| - name: Comment PR on scan failure | |
| if: failure() && github.event_name == 'pull_request' | |
| uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 | |
| with: | |
| script: | | |
| github.rest.issues.createComment({ | |
| issue_number: context.issue.number, | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| body: '🔒 **Docker image scan failed.** HIGH or CRITICAL CVEs were detected in the container image(s), or a production Dockerfile is missing a non-root USER. Download the `trivy-*-report` artifacts for details. Add justified suppressions to `.trivyignore` or update the base image to remediate.' | |
| }) | |
| dockerfile-nonroot: | |
| name: Dockerfiles must run as non-root | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 | |
| - name: Require USER in production Dockerfiles | |
| run: | | |
| failed=0 | |
| for f in backend/Dockerfile frontend/Dockerfile; do | |
| if ! grep -qE '^USER[[:space:]]+' "$f"; then | |
| echo "ERROR: $f has no USER directive — runtime would run as root" | |
| failed=1 | |
| else | |
| echo "OK: $f sets USER $(grep -E '^USER[[:space:]]+' "$f" | awk '{print $2}' | paste -sd, -)" | |
| fi | |
| done | |
| exit "$failed" |