feat(ci): scan images for vulnerabilities with Trivy #841
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: Verify Images | |
| on: | |
| pull_request: | |
| branches: | |
| - main | |
| permissions: | |
| contents: read | |
| env: | |
| # sha256sum format: <hash><space><format (space for text)><file name> | |
| MODSECURITY_RECOMMENDED: "ccff8ba1f12428b34ff41960d8bf773dd9f62b9a7c77755247a027cb01896d4f modsecurity.conf-recommended" | |
| GO_FTW_VERSION: '2.4.0' | |
| # renovate: datasource=docker depName=aquasec/trivy | |
| TRIVY_IMAGE: "aquasec/trivy:0.74.0@sha256:62b1e65e8869bc4b4c6aa4fa2b21595256c7c2f6018a9d9ad61caf87187c1969" | |
| jobs: | |
| prepare: | |
| runs-on: ubuntu-latest | |
| outputs: | |
| matrix: ${{ steps.generate.outputs.matrix }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # ratchet:actions/checkout@v7.0.0 | |
| with: | |
| persist-credentials: false | |
| - name: Generate matrix | |
| id: generate | |
| uses: docker/bake-action/subaction/matrix@d3418bd7d0e9324001bca92fa8ba175ea7e6dc9b # ratchet:docker/bake-action/subaction/matrix@v7.3.0 | |
| with: | |
| target: default | |
| fields: platforms | |
| - name: Check modsecurity recommended | |
| run: | | |
| curl -sSL https://raw.githubusercontent.com/owasp-modsecurity/ModSecurity/v3/master/modsecurity.conf-recommended -o modsecurity.conf-recommended | |
| echo '${{ env.MODSECURITY_RECOMMENDED }}' > sha256sum.txt | |
| sha256sum -c sha256sum.txt | |
| build: | |
| runs-on: ${{ startsWith(matrix.platforms, 'linux/arm') && 'ubuntu-24.04-arm' || 'ubuntu-latest' }} | |
| permissions: | |
| contents: read | |
| packages: write | |
| needs: | |
| - prepare | |
| strategy: | |
| # One image failing its scan should not hide the state of the others. | |
| fail-fast: false | |
| matrix: | |
| include: ${{ fromJson(needs.prepare.outputs.matrix) }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # ratchet:actions/checkout@v7.0.0 | |
| with: | |
| fetch-depth: 1 | |
| persist-credentials: false | |
| # https://github.com/docker/setup-qemu-action | |
| - name: Set up QEMU | |
| uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # ratchet:docker/setup-qemu-action@v4.2.0 | |
| with: | |
| image: tonistiigi/binfmt:qemu-v9.2.0 | |
| # https://github.com/docker/setup-buildx-action | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # ratchet:docker/setup-buildx-action@v4.2.0 | |
| with: | |
| driver-opts: image=moby/buildkit:master | |
| - name: Login to GitHub Container Registry | |
| uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # ratchet:docker/login-action@v4.4.0 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Build ${{ matrix.target }}-verification | |
| uses: docker/bake-action@d3418bd7d0e9324001bca92fa8ba175ea7e6dc9b # ratchet:docker/bake-action@v7.3.0 | |
| with: | |
| files: | | |
| ./docker-bake.hcl | |
| targets: ${{ matrix.target }} | |
| # Build only the specified platform and tag as verification build. | |
| # Create a tar archive and load the image into Docker. | |
| set: | | |
| *.platform=${{ matrix.platforms }} | |
| ${{ matrix.target }}.tags=${{ matrix.target }}-verification | |
| *.output=type=docker,dest=${{ matrix.target }}-verification.tar | |
| *.output=type=docker | |
| push: false | |
| - name: Upload image artifact | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # ratchet:actions/upload-artifact@v7.0.1 | |
| with: | |
| name: ${{ matrix.target }}-verification.tar | |
| path: ${{ matrix.target }}-verification.tar | |
| retention-days: 7 | |
| overwrite: true | |
| - name: Run ${{ matrix.target }} | |
| env: | |
| TARGET: ${{ matrix.target }} | |
| run: | | |
| # get the major CRS version from the matrix.target | |
| # Targets ending with "previous" are v3; all others are v4. | |
| CRS_VERSION="$([[ "${TARGET}" == *-previous ]] && echo v3 || echo v4)" | |
| . .github/workflows/configure-rules-for-test.sh \ | |
| "src/opt/modsecurity/configure-rules.${CRS_VERSION}.conf" \ | |
| README.md \ | |
| "${TARGET}.env" | |
| echo "Starting container ${TARGET}-verification" | |
| # The backend is a dummy | |
| docker run \ | |
| --pull "never" \ | |
| -d \ | |
| --name "${TARGET}-test" \ | |
| --env-file "${TARGET}.env" \ | |
| -p 8080:8080 \ | |
| -p 8443:8443 \ | |
| -e BACKEND=http://localhost:9999 \ | |
| "${TARGET}-verification" | |
| - name: Verify ${{ matrix.target }} | |
| env: | |
| TARGET: ${{ matrix.target }} | |
| run: | | |
| HOST_AND_PORT="localhost:8080" | |
| counter=10 | |
| while true; do | |
| printf "Checking whether container is running..." | |
| if [ "$(docker inspect "${TARGET}-test" --format='{{.State.Running}}')" = 'true' ]; then | |
| echo "ok" | |
| printf "Trying to connect..." | |
| if curl -s --connect-timeout 5 --max-time 1 "${HOST_AND_PORT}" > /dev/null 2>&1; then | |
| echo "ok" | |
| break | |
| fi | |
| fi | |
| echo "failed" | |
| ((counter--)) | |
| if [ ${counter} -gt 0 ]; then | |
| echo "Will retry in 5 seconds" | |
| sleep 5 | |
| else | |
| printf "No more retries. Here's the log output from the container\n\n\n" | |
| docker logs "${TARGET}-test" | |
| exit 1 | |
| fi | |
| done | |
| if grep -q "nginx" <<< "${TARGET}"; then | |
| printf "\n\n### nginx tests ###\n\n" | |
| printf "Storing headers for sample attack..." | |
| curl -s -D headers.txt -o /dev/null "${HOST_AND_PORT}/?test=../../etc/passwd" | |
| printf "...done\n\n\n" | |
| printf "Check status 403..." | |
| grep -q "HTTP/1.1 403 Forbidden" headers.txt | |
| printf "yes\n\n\n" | |
| printf "Check 'Content-Type' header on 403..." | |
| grep -qi "^content-type: text/html" headers.txt | |
| printf "yes\n\n\n" | |
| printf "Check 'Access-Control-Allow-Origin' header..." | |
| grep -q "Access-Control-Allow-Origin: *" headers.txt | |
| printf "yes\n\n\n" | |
| printf "Check 'Access-Control-Max-Age' header..." | |
| grep -q "Access-Control-Max-Age: 3600" headers.txt | |
| printf "yes\n\n\n" | |
| printf "Check 'Access-Control-Allow-Methods' header..." | |
| grep -q "Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS" headers.txt | |
| printf "yes\n\n\n" | |
| printf "Check 'Access-Control-Allow-Headers'..." | |
| grep -q "Access-Control-Allow-Headers: *" headers.txt | |
| printf "yes\n\n\n" | |
| printf "Check HTTP/3 is off by default (no Alt-Svc header on SSL port)..." | |
| curl -s -k -D headers-ssl.txt -o /dev/null "https://localhost:8443/" | |
| if grep -qi "^alt-svc:" headers-ssl.txt; then | |
| echo "FAIL: Alt-Svc header present with HTTP3 unset (should default to off)" | |
| exit 1 | |
| fi | |
| printf "yes\n\n\n" | |
| echo "### nginx tests - done ###" | |
| fi | |
| printf "\n\n### generic tests ###\n\n" | |
| echo "Test HTTP/2 upgrade..." | |
| curl -v --http2 --no-progress-meter -o /dev/null "${HOST_AND_PORT}" 2>&1 | tee out.txt | |
| grep -iP "< 101 Switching Protocols|< HTTP/2" < out.txt | tee out2.txt | |
| wc -l out2.txt | grep -qoP "\d+" | xargs -I % test % -eq 2 | |
| rm out*.txt | |
| printf "...yes\n\n\n" | |
| echo "Test HTTP/2 prior knowledge..." | |
| curl -v --http2-prior-knowledge --no-progress-meter -o /dev/null "${HOST_AND_PORT}" 2>&1 | tee out.txt | |
| grep -iq "< HTTP/2" out.txt | |
| grep -iqv "< 101 Switching Protocols" out.txt | |
| printf "...yes\n\n\n" | |
| echo "### generic tests - done ###" | |
| - name: Verify HTTP/3 opt-in | |
| if: ${{ matrix.target == 'nginx-debian-writable-latest' && matrix.platforms == 'linux/amd64' }} | |
| run: | | |
| echo "Starting container ${{ matrix.target }}-http3-test with HTTP3=on" | |
| docker run \ | |
| --pull "never" \ | |
| -d \ | |
| --name ${{ matrix.target }}-http3-test \ | |
| --env-file "${{ matrix.target }}.env" \ | |
| -e BACKEND=http://localhost:9999 \ | |
| -e HTTP3=on \ | |
| -e HTTP3_ALT_SVC_PORT=8444 \ | |
| -p 8444:8443 \ | |
| -p 8444:8443/udp \ | |
| "${{ matrix.target }}-verification" | |
| counter=10 | |
| while true; do | |
| if [ "$(docker inspect ${{ matrix.target }}-http3-test --format='{{.State.Running}}')" = 'true' ] && \ | |
| curl -s -k --connect-timeout 5 --max-time 2 -D headers-http3.txt -o /dev/null "https://localhost:8444/"; then | |
| break | |
| fi | |
| ((counter--)) | |
| if [ ${counter} -le 0 ]; then | |
| echo "No more retries. Here's the log output from the container" | |
| docker logs ${{ matrix.target }}-http3-test | |
| exit 1 | |
| fi | |
| echo "Not ready yet, retrying in 5 seconds" | |
| sleep 5 | |
| done | |
| printf "Check Alt-Svc header advertises h3 on the remapped port (8444)..." | |
| grep -qi '^alt-svc:.*h3=":8444"' headers-http3.txt | |
| printf "yes\n\n\n" | |
| docker rm -f ${{ matrix.target }}-http3-test | |
| - name: Checkout CRS | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # ratchet:actions/checkout@v7.0.0 | |
| with: | |
| fetch-depth: 1 | |
| repository: coreruleset/coreruleset | |
| path: crs | |
| persist-credentials: false | |
| - name: "Install go-ftw" | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| cd crs | |
| # ftw runs on the runner, so its architecture decides the asset. Asking the | |
| # runner keeps this from drifting apart from the runs-on mapping, and an | |
| # architecture with no release fails here rather than on the first exec. | |
| case "$(uname -m)" in | |
| x86_64) arch="amd64" ;; | |
| aarch64 | arm64) arch="arm64" ;; | |
| *) echo "No go-ftw release for runner architecture $(uname -m)" >&2; exit 1 ;; | |
| esac | |
| # The release CDN resets connections now and then. Download to a file so a | |
| # truncated transfer fails here instead of reaching tar, and retry it. | |
| archive="ftw_${GO_FTW_VERSION}_linux_${arch}.tar.gz" | |
| for attempt in 1 2 3; do | |
| if gh release download -R coreruleset/go-ftw "v${GO_FTW_VERSION}" \ | |
| -p "${archive}" --clobber; then | |
| break | |
| fi | |
| if [ "${attempt}" -eq 3 ]; then | |
| echo "Giving up on ${archive} after ${attempt} attempts" | |
| exit 1 | |
| fi | |
| delay=$((attempt * 5)) | |
| echo "Download failed, retrying in ${delay} seconds" | |
| sleep "${delay}" | |
| done | |
| tar -xzf "${archive}" ftw | |
| rm "${archive}" | |
| - name: Patch CRS compose file to use verification image | |
| run: | | |
| sed -i \ | |
| 's#image: owasp/modsecurity-crs:${{ contains(matrix.target, 'apache') && 'apache' || 'nginx' }}.*#image: ${{ matrix.target }}-verification#' \ | |
| crs/tests/docker-compose.yml | |
| - name: Patch nginx overrides for nginx 1.30.0 (920100-4 now returns 405) | |
| run: | | |
| yq e -i '(.test_overrides[] | select(.rule_id == 920100 and .test_ids[0] == 4 and (.test_ids | length) == 1) | .output.status) = 405' \ | |
| crs/tests/regression/nginx-overrides.yaml | |
| - name: Pin the CRS test container to a single nginx worker | |
| # go-ftw bounds the log output of a request with markers. Several workers | |
| # flush their buffers independently, so lines interleave and tests such as | |
| # 956100 fail at random (https://github.com/coreruleset/go-ftw/issues/473). | |
| # This applies to the test setup only; the images still default to "auto". | |
| run: | | |
| yq e -i '.services.modsec3-nginx.environment.NGINX_WORKER_PROCESSES = "1"' \ | |
| crs/tests/docker-compose.yml | |
| - name: Run CRS tests for ${{ matrix.target }} | |
| # Log flushing isn't reliable enough for go-ftw (https://github.com/coreruleset/go-ftw/issues/473) | |
| if: ${{!contains(matrix.target, 'nginx-alpine')}} | |
| run: | | |
| cd crs | |
| modsec_version="${{ contains(matrix.target, 'apache') && 'modsec2-apache' || 'modsec3-nginx' }}" | |
| backend="${{ contains(matrix.target, 'apache') && 'httpd' || 'nginx' }}" | |
| mkdir -p "tests/logs/${modsec_version}/{nginx,apache2}" | |
| docker compose -f ./tests/docker-compose.yml up -d "${modsec_version}" | |
| sleep 10 | |
| docker compose -f ./tests/docker-compose.yml logs | |
| if ! [ "$(docker inspect ${modsec_version} --format='{{.State.Running}}')" = "true" ]; then | |
| echo "Web server failed to start. Aborting." | |
| exit 1 | |
| fi | |
| ./ftw check -d tests/regression/tests | |
| ./ftw run \ | |
| -d tests/regression/tests \ | |
| --log-file "tests/logs/${modsec_version}/error.log" \ | |
| --overrides "tests/regression/${backend}-overrides.yaml" \ | |
| --show-failures-only | |
| - name: Upload logs | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # ratchet:actions/upload-artifact@v7.0.1 | |
| if: always() | |
| with: | |
| name: ${{ matrix.target }}-error.log | |
| path: "crs/tests/logs/${{ contains(matrix.target, 'apache') && 'modsec2-apache' || 'modsec3-nginx' }}/error.log" | |
| retention-days: 7 | |
| overwrite: true | |
| # The scanner runs as a container rather than through the action: it parses | |
| # attacker-supplied image contents, so it gets the database and the image and | |
| # nothing else. Downloading the database is the only step that needs a network. | |
| # One cache entry per day: the database is refreshed below anyway, and a | |
| # per-run key would fill the repository cache with copies of it. | |
| - name: Trivy cache key | |
| id: trivy-cache-key | |
| run: echo "day=$(date -u +%F)" >> "${GITHUB_OUTPUT}" | |
| - name: Cache the Trivy database | |
| uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # ratchet:actions/cache@v6.1.0 | |
| with: | |
| path: .trivy-cache | |
| key: trivy-db-${{ steps.trivy-cache-key.outputs.day }} | |
| restore-keys: trivy-db- | |
| - name: Update the Trivy database | |
| run: | | |
| docker run --rm -v "${PWD}/.trivy-cache:/cache" "${TRIVY_IMAGE}" \ | |
| image --cache-dir /cache --download-db-only | |
| # Runs last so a vulnerable base image doesn't hide functional test results. | |
| # Only fixed vulnerabilities fail the build: those are actionable (bump the base | |
| # image), while the unfixed ones the distributions carry would block every PR. | |
| # The image comes from the tar the build already wrote, so the scan needs no | |
| # registry access and runs with the network switched off. | |
| - name: Scan ${{ matrix.target }} for vulnerabilities | |
| env: | |
| TARGET: ${{ matrix.target }} | |
| run: | | |
| docker run --rm --network none \ | |
| -v "${PWD}/.trivy-cache:/cache" \ | |
| -v "${PWD}/${TARGET}-verification.tar:/scan/image.tar:ro" \ | |
| "${TRIVY_IMAGE}" \ | |
| image \ | |
| --cache-dir /cache \ | |
| --skip-db-update \ | |
| --input /scan/image.tar \ | |
| --scanners vuln \ | |
| --pkg-types os,library \ | |
| --ignore-unfixed \ | |
| --severity HIGH,CRITICAL \ | |
| --exit-code 1 \ | |
| --format table \ | |
| --skip-dirs /usr/share/java \ | |
| --skip-dirs /usr/share/maven-repo |