Skip to content

Docker Image Scanning (Trivy) #270

Docker Image Scanning (Trivy)

Docker Image Scanning (Trivy) #270

Workflow file for this run

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"