diff --git a/.github/actions/scan-container-images/action.yaml b/.github/actions/scan-container-images/action.yaml new file mode 100644 index 00000000..219ecde0 --- /dev/null +++ b/.github/actions/scan-container-images/action.yaml @@ -0,0 +1,117 @@ +name: "Scan container images" +description: "Scan locally built container images for vulnerabilities with grype" + +inputs: + images: + description: "Newline-separated list of local container image references to scan" + required: true + skip_if_pr_has_label: + description: "Skip failing the container scan when the triggering PR has this label" + required: false + default: "skip-dependencies-check" + +runs: + using: "composite" + steps: + - name: "Check if container scan should be skipped" + id: skip-check + shell: bash + env: + GH_TOKEN: ${{ github.token }} + EVENT_PR_NUMBER: ${{ github.event.pull_request.number }} + REF: ${{ github.ref }} + SKIP_LABEL: ${{ inputs.skip_if_pr_has_label }} + run: | + SHOULD_SKIP="false" + PR_NUMBER="${EVENT_PR_NUMBER:-}" + + # Fall back to branch lookup for workflow_call / push contexts + if [[ -z "${PR_NUMBER}" ]]; then + if [[ "${REF}" == refs/heads/* ]]; then + BRANCH_NAME="${REF#refs/heads/}" + RESULT=$(gh pr list --head "${BRANCH_NAME}" --limit 1 --state open --json number --jq '.[0].number' 2>/dev/null || echo "") + if [[ -n "${RESULT}" && "${RESULT}" != "null" ]]; then + PR_NUMBER="${RESULT}" + fi + elif [[ "${REF}" == refs/pull/* ]]; then + PR_NUMBER="${REF#refs/pull/}" + PR_NUMBER="${PR_NUMBER%%/*}" + fi + fi + + if [[ -n "${SKIP_LABEL}" && -n "${PR_NUMBER}" ]]; then + if gh pr view "${PR_NUMBER}" --json labels --jq '.labels[].name' | grep -Fxq "${SKIP_LABEL}"; then + SHOULD_SKIP="true" + fi + fi + + echo "should_skip=${SHOULD_SKIP}" >> "$GITHUB_OUTPUT" + echo "pr_number=${PR_NUMBER}" >> "$GITHUB_OUTPUT" + + - name: "Get grype database cache key" + id: grype-db-key + shell: bash + run: echo "date=$(date -u +'%Y-%m-%d')" >> "$GITHUB_OUTPUT" + + - name: "Restore grype database cache" + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ runner.temp }}/grype-db + key: grype-db-${{ steps.grype-db-key.outputs.date }} + restore-keys: grype-db- + + - name: "Scan container images" + shell: bash + env: + IMAGES: ${{ inputs.images }} + GRYPE_DB_CACHE_DIR: ${{ runner.temp }}/grype-db + run: | + ACTION_ROOT="$(cd "${GITHUB_ACTION_PATH}/../../.." && pwd)" + export TOOLING_ROOT="${ACTION_ROOT}" + mkdir -p container-scan-reports + while IFS= read -r image; do + [[ -z "${image}" ]] && continue + report="container-scan-reports/$(echo "${image}" | tr -c 'a-zA-Z0-9._-' '-').json" + echo "Scanning ${image}" + IMAGE="${image}" REPORT_FILE="${report}" "${ACTION_ROOT}/scripts/reports/scan-container-image.sh" + { + echo "# ${image}" + "${ACTION_ROOT}/scripts/reports/parse-vulnerabilities.sh" "${report}" + } | tee -a container-scan-summary.md + done <<< "${IMAGES}" + if [[ -n "${GITHUB_STEP_SUMMARY:-}" && -f container-scan-summary.md ]]; then + cat container-scan-summary.md >> "$GITHUB_STEP_SUMMARY" + fi + + - name: "Upload container scan reports as an artefact" + if: ${{ !env.ACT }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: container-scan-reports + path: | + ./container-scan-reports/ + ./container-scan-summary.md + retention-days: 21 + + - name: "Fail if Critical or High vulnerabilities found" + shell: bash + env: + SHOULD_SKIP: ${{ steps.skip-check.outputs.should_skip }} + PR_NUMBER: ${{ steps.skip-check.outputs.pr_number }} + SKIP_LABEL: ${{ inputs.skip_if_pr_has_label }} + run: | + COUNTS=$(jq -s '[.[].matches[] | select(.vulnerability.severity == "Critical" or .vulnerability.severity == "High") | {severity: .vulnerability.severity, id: .vulnerability.id, package: .artifact.name, version: .artifact.version}] | unique | group_by(.severity) | map({(.[0].severity): length}) | add // {}' container-scan-reports/*.json) + CRITICAL_COUNT=$(jq '.Critical // 0' <<< "$COUNTS") + HIGH_COUNT=$(jq '.High // 0' <<< "$COUNTS") + echo "Critical: $CRITICAL_COUNT, High: $HIGH_COUNT" + if [[ "$CRITICAL_COUNT" -gt 0 || "$HIGH_COUNT" -gt 0 ]]; then + if [[ -z "$PR_NUMBER" ]]; then + # Gate is enforced at PR time; post-merge runs only report. + echo "::warning::Found $CRITICAL_COUNT Critical and $HIGH_COUNT High severity vulnerabilities in container images (not blocking outside a pull request)" + elif [[ "$SHOULD_SKIP" == "true" ]]; then + echo "Bypassing blocking container scan failure because PR has label '$SKIP_LABEL'." + else + echo "::error::Found $CRITICAL_COUNT Critical and $HIGH_COUNT High severity vulnerabilities in container images" + exit 1 + fi + fi diff --git a/scripts/reports/scan-container-image.sh b/scripts/reports/scan-container-image.sh new file mode 100755 index 00000000..3cf70c6e --- /dev/null +++ b/scripts/reports/scan-container-image.sh @@ -0,0 +1,86 @@ +#!/bin/bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TOOLING_ROOT="${TOOLING_ROOT:-$(cd "${SCRIPT_DIR}/../.." && pwd)}" + +# Scan a locally built container image for CVEs (Common Vulnerabilities and +# Exposures). This is a grype command wrapper. It will run grype natively if it +# is installed, otherwise it will run it in a Docker container. +# +# Usage: +# $ IMAGE=[local image reference] REPORT_FILE=[report path relative to the repository root] ./scan-container-image.sh +# +# Options: +# GRYPE_DB_CACHE_DIR=/tmp/grype/db # Directory to persist the grype vulnerability database in +# FORCE_USE_DOCKER=true # If set to true the command is run in a Docker container, default is 'false' +# VERBOSE=true # Show all the executed commands, default is 'false' + +# ============================================================================== + +function main() { + + : "${IMAGE:?IMAGE is required}" + : "${REPORT_FILE:?REPORT_FILE is required}" + + cd "$(git rev-parse --show-toplevel)" + + config="$PWD/scripts/config/grype.yaml" + [ -f "$config" ] || config="$TOOLING_ROOT/scripts/config/grype.yaml" + db_cache_dir="${GRYPE_DB_CACHE_DIR:-/tmp/grype/db}" + mkdir -p "$db_cache_dir" "$(dirname "$REPORT_FILE")" + + if command -v grype > /dev/null 2>&1 && ! is-arg-true "${FORCE_USE_DOCKER:-false}"; then + run-grype-natively + else + run-grype-in-docker + fi +} + +function run-grype-natively() { + + GRYPE_DB_CACHE_DIR="$db_cache_dir" grype \ + "docker:${IMAGE}" \ + --config "$config" \ + --output json \ + --file "$PWD/$REPORT_FILE" +} + +function run-grype-in-docker() { + + # shellcheck disable=SC1091 + source "$TOOLING_ROOT/scripts/docker/docker.lib.sh" + + # shellcheck disable=SC2155 + local image=$(name=ghcr.io/anchore/grype docker-get-image-version-and-pull) + + docker run --rm --platform linux/amd64 \ + --volume /var/run/docker.sock:/var/run/docker.sock \ + --volume "$PWD":/workdir \ + --volume "$config":/grype.yaml:ro \ + --volume "$db_cache_dir":/grype-db \ + --env GRYPE_DB_CACHE_DIR=/grype-db \ + "$image" \ + "docker:${IMAGE}" \ + --config /grype.yaml \ + --output json \ + --file "/workdir/$REPORT_FILE" +} + +function is-arg-true() { + + if [[ "$1" =~ ^(true|yes|y|on|1|TRUE|YES|Y|ON)$ ]]; then + return 0 + else + return 1 + fi +} + +# ============================================================================== + +is-arg-true "${VERBOSE:-false}" && set -x + +main "$@" + +exit 0