diff --git a/common/common.sh b/common/common.sh index 1d2e703b..5db891e0 100755 --- a/common/common.sh +++ b/common/common.sh @@ -1,5 +1,7 @@ #!/usr/bin/env bash +set -euo pipefail + # The output appears in a dedicated emphasized GitHub step box. # Multiple lines are not supported. # Markdown is not supported. diff --git a/release/scan-image-vulnerabilities/README.md b/release/scan-image-vulnerabilities/README.md new file mode 100644 index 00000000..37944afb --- /dev/null +++ b/release/scan-image-vulnerabilities/README.md @@ -0,0 +1,85 @@ +# Scan Image Vulnerabilities + +Scan a container image in Quay.io for vulnerabilities using `roxctl image scan` and fail if fixable critical or important vulnerabilities are found. + +This action waits for an image to be available in Quay.io, scans it using roxctl, and generates a detailed vulnerability report in the GitHub step summary, while making the raw report available as JSON in the workspace as `scan-result.json`. + +## Required permissions + +```yaml +permissions: + # Needed for stackrox/central-login to create the JWT token. + id-token: write +``` + +## All options + +| Input | Description | Required | Default | +| ----------------------------------------- | -------------------------------------------------- | -------- | --------- | +| [image](#image) | Image name (without registry prefix) | Yes | | +| [version](#version) | Image version tag | Yes | | +| [wait-limit](#wait-limit) | Maximum time to wait for image (seconds) | No | `"7200"` | +| [summary-prefix](#summary-prefix) | Title prefix for the GitHub step summary | Yes | | +| [quay-bearer-token](#quay-bearer-token) | Quay.io bearer token for wait-for-image | Yes | | +| [central-url](#central-url) | ACS Central URL | Yes | | + +### Detailed options + +#### image + +Image name without the registry prefix. The action will automatically prepend `quay.io/` to construct the full image reference. + +Example: `"rhacs-eng/main"` + +#### version + +Image version tag to scan. + +Example: `"3.76.1"` + +#### wait-limit + +Maximum time in seconds to wait for the image to be available on Quay.io before failing. + +Default: `"7200"` (2 hours) + +#### summary-prefix + +Prefix for the vulnerability report in the GitHub step summary. Use this to help users of the action classify images into groups when multiple matrix scans are performed in a workflow. + +Example: `"Upstream Image Scan Results"` + +#### quay-bearer-token + +Bearer token for authenticating with the Quay.io API. This is required by the wait-for-image action to check if the image is available. + +#### central-url + +URL of the ACS/RHACS Central instance to use for scanning. + +Example: `"https://central.example.com"` + +## Usage + +The action integrates with the [stackrox/central-login](https://github.com/stackrox/central-login) action, which uses OIDC login for authentication of the `roxctl` CLI. +The ACS Central needs to be configured to allow exchanging tokens from GitHub Actions workflow runs. + +Additionally, an image integration for Quay.io must be configured in the ACS Central so that it can pull images requested for scanning. + +```yaml +name: Scan image for vulnerabilities + +jobs: + scan: + runs-on: ubuntu-latest + permissions: + id-token: write # Required for ACS Central OIDC login + steps: + - uses: stackrox/actions/release/scan-image-vulnerabilities@v1 + with: + image: rhacs-eng/main + version: 3.76.1 + summary-prefix: "Main Image Scan" + quay-bearer-token: ${{ secrets.QUAY_BEARER_TOKEN }} + central-url: https://central.example.com +``` diff --git a/release/scan-image-vulnerabilities/action.yml b/release/scan-image-vulnerabilities/action.yml new file mode 100644 index 00000000..7314fc31 --- /dev/null +++ b/release/scan-image-vulnerabilities/action.yml @@ -0,0 +1,53 @@ +name: "Scan Image Vulnerabilities" +description: "Scan an image for vulnerabilities and fail if fixable critical or important ones are found" + +inputs: + image: + description: "Image name (without registry prefix)" + required: true + version: + description: "Image version tag" + required: true + wait-limit: + description: "Maximum time to wait for image (seconds)" + required: false + default: "7200" + summary-prefix: + description: "Title prefix for the GitHub step summary" + required: true + quay-bearer-token: + description: "Quay.io bearer token for wait-for-image" + required: true + central-url: + description: "ACS Central URL" + required: true + +runs: + using: composite + steps: + - name: "Wait for image to be built: quay.io/${{ inputs.image }}:${{ inputs.version }}" + uses: stackrox/actions/release/wait-for-image@v1 + with: + token: ${{ inputs.quay-bearer-token }} + image: ${{ inputs.image }}:${{ inputs.version }} + limit: ${{ inputs.wait-limit }} + + - name: Central login + uses: stackrox/central-login@v1 + with: + endpoint: ${{ inputs.central-url }} + + - name: Install roxctl + uses: stackrox/roxctl-installer-action@v1 + with: + central-endpoint: ${{ inputs.central-url }} + central-token: ${{ env.ROX_API_TOKEN }} + + - name: Scan image for fixable vulnerabilities + shell: bash + run: | + set -uo pipefail + "${{ github.action_path }}/../../common/common.sh" \ + "${{ github.action_path }}/scan-image-vulnerabilities.sh" \ + "quay.io/${{ inputs.image }}:${{ inputs.version }}" \ + "${{ inputs.summary-prefix }}" diff --git a/release/scan-image-vulnerabilities/scan-image-vulnerabilities.sh b/release/scan-image-vulnerabilities/scan-image-vulnerabilities.sh new file mode 100755 index 00000000..3e54594f --- /dev/null +++ b/release/scan-image-vulnerabilities/scan-image-vulnerabilities.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# +# Scans an image for vulnerabilities and prints a summary of the findings. +# For the purpose of this script, a finding is an image component in a version that is affected by a CVE. +# There may be multiple findings for the same image component in a version, if the component has multiple CVEs. +# +# Local run: +# +# test/local-env.sh release/scan-image-vulnerabilities/scan-image-vulnerabilities.sh +# +set -euo pipefail + +function main() { + local IMAGE="${1:-}" + local SUMMARY_PREFIX="${2:-}" + local result_path="scan-result.json" + + check_not_empty \ + IMAGE \ + SUMMARY_PREFIX + + scan_image "$IMAGE" "$result_path" + + # Count the total and fixable number of findings for each severity. + # Use associative arrays to store counts by severity. + # Arrays are used via nameref parameters in print_summary function. + # shellcheck disable=SC2034 + declare -A total_counts + # shellcheck disable=SC2034 + declare -A fixable_counts + + # shellcheck disable=SC2034 + for severity in CRITICAL IMPORTANT MODERATE LOW; do + total_counts[$severity]="$(count_total_findings "$severity" "$result_path")" + fixable_counts[$severity]="$(count_fixable_findings "$severity" "$result_path")" + done + + gh_summary "### $SUMMARY_PREFIX $IMAGE" + print_summary total_counts fixable_counts + + # Print the findings table in a collapsible section. + # For the table to render correctly, we need to add a newline after the summary. + gh_summary "
Click to expand details\n" + gh_summary "$(print_details_table "$result_path")" + gh_summary "
" + + # Fail the build if any fixable findings of relevant severity are present. + if are_blocking_vulns_present fixable_counts; then + exit 1 + fi +} + +# Scans the image for vulnerabilities. +function scan_image() { + local image="$1" + local result_path="$2" + roxctl image scan --output=json --force \ + --image="${image}" | tee "$result_path" + gh_output result-path "$result_path" +} + +# Counts the number of findings for a given severity. +function count_total_findings() { + local severity="$1" + local result_path="$2" + jq "[.result.vulnerabilities // [] | .[] | select(.cveSeverity == \"$severity\")] | length" "$result_path" +} + +# Counts the number of fixable findings for a given severity. +function count_fixable_findings() { + local severity="$1" + local result_path="$2" + jq "[.result.vulnerabilities // [] | .[] | select(.cveSeverity == \"$severity\" and .componentFixedVersion != \"\")] | length" "$result_path" +} + +# Prints the vulnerability status and an overview table of the findings counts. +function print_summary() { + local -n total_counts_ref=$1 + local -n fixable_counts_ref=$2 + + if are_blocking_vulns_present fixable_counts_ref; then + local message="Found fixable critical or important vulnerabilities." + + gh_log "error" "$message See the step summary for details." + gh_summary "Status: ❌" + gh_summary "> $message" + else + gh_summary "Status: ✅" + gh_summary "> No fixable critical or important vulnerabilities found." + fi + + gh_summary "" + gh_summary "| Severity | Total | Fixable |" + gh_summary "| --- | --- | --- |" + for severity in CRITICAL IMPORTANT MODERATE LOW; do + gh_summary "| $severity | ${total_counts_ref[$severity]} | ${fixable_counts_ref[$severity]} |" + done +} + +# Checks if any fixable findings of relevant severity are present. +function are_blocking_vulns_present() { + local -n fixable_counts_map="$1" + (( fixable_counts_map[CRITICAL] > 0 || fixable_counts_map[IMPORTANT] > 0 )) +} + +# Prints a markdown table of the findings, sorted by severity with fixable findings first. +# Each row contains left, right and column separators added by jq's join function. +function print_details_table() { + local result_path="$1" + echo "| COMPONENT | VERSION | CVE | SEVERITY | FIXED_VERSION | LINK |" + echo "| --- | --- | --- | --- | --- | --- |" + jq -r ' + .result.vulnerabilities // [] + | sort_by([ + (if ((.componentFixedVersion // "") != "") then 0 else 1 end), + ({"CRITICAL":0,"IMPORTANT":1,"MODERATE":2,"LOW":3}[.cveSeverity] // 4) + ]) + | (.[] | [.componentName // "", .componentVersion // "", .cveId // "", .cveSeverity // "", .componentFixedVersion // "", .cveInfo // ""] | "| " + join(" | ") + " |") + ' "$result_path" +} + +main "$@"