Skip to content

feat(ci): scan images for vulnerabilities with Trivy #841

feat(ci): scan images for vulnerabilities with Trivy

feat(ci): scan images for vulnerabilities with Trivy #841

Workflow file for this run

---
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