diff --git a/.github/actions/scan-dependencies/action.yaml b/.github/actions/scan-dependencies/action.yaml index eebe59f3..20f8b9e7 100644 --- a/.github/actions/scan-dependencies/action.yaml +++ b/.github/actions/scan-dependencies/action.yaml @@ -19,52 +19,136 @@ inputs: idp_aws_report_upload_bucket_endpoint: description: "IDP AWS report upload endpoint to upload the report to" required: false + skip_if_pr_has_label: + description: "Skip dependency scanning when the triggering PR has this label" + required: false + default: "skip-dependencies-check" runs: using: "composite" steps: + - name: "Check if dependency scan should be skipped" + id: skip-check + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + SHOULD_SKIP="false" + + # Only check labels if a skip label is configured + if [[ -n "${{ inputs.skip_if_pr_has_label }}" ]]; then + PR_NUMBER="" + + # Try to get PR number from event context first (for direct PR triggers) + if [[ -n "${{ github.event.pull_request.number }}" ]]; then + PR_NUMBER="${{ github.event.pull_request.number }}" + fi + + # If no PR number from event, try to find it from the branch (for workflow_call context) + if [[ -z "${PR_NUMBER}" ]]; then + REF="${{ github.ref }}" + if [[ "${REF}" == refs/heads/* ]]; then + BRANCH_NAME="${REF#refs/heads/}" + # Find PR for this branch + RESULT=$(gh pr list --head "${BRANCH_NAME}" --limit 1 --json number --jq '.[0].number' 2>/dev/null || echo "") + if [[ -n "${RESULT}" && "${RESULT}" != "null" ]]; then + PR_NUMBER="${RESULT}" + fi + fi + fi + + # If we have a PR number, query its labels + if [[ -n "${PR_NUMBER}" ]]; then + if gh pr view "${PR_NUMBER}" --json labels --jq '.labels[].name' | grep -Fxq "${{ inputs.skip_if_pr_has_label }}"; then + SHOULD_SKIP="true" + fi + fi + fi + + echo "should_skip=${SHOULD_SKIP}" >> "$GITHUB_OUTPUT" + - name: "Skip dependency scan" + if: steps.skip-check.outputs.should_skip == 'true' + shell: bash + run: | + echo "Dependency scan skipped because PR has label '${{ inputs.skip_if_pr_has_label }}'." - name: "Generate SBOM" + if: steps.skip-check.outputs.should_skip != 'true' shell: bash run: | + ACTION_ROOT="$(cd "${GITHUB_ACTION_PATH}/../../.." && pwd)" + export TOOLING_ROOT="${ACTION_ROOT}" export BUILD_DATETIME=${{ inputs.build_datetime }} - ./scripts/reports/create-sbom-report.sh + "${ACTION_ROOT}/scripts/reports/create-sbom-report.sh" - name: "Compress SBOM report" + if: steps.skip-check.outputs.should_skip != 'true' shell: bash run: zip sbom-repository-report.json.zip sbom-repository-report.json - name: "Upload SBOM report as an artefact" - if: ${{ !env.ACT }} + if: ${{ !env.ACT && steps.skip-check.outputs.should_skip != 'true' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: sbom-repository-report.json.zip path: ./sbom-repository-report.json.zip retention-days: 21 - name: "Scan vulnerabilities" + if: steps.skip-check.outputs.should_skip != 'true' shell: bash run: | + ACTION_ROOT="$(cd "${GITHUB_ACTION_PATH}/../../.." && pwd)" + export TOOLING_ROOT="${ACTION_ROOT}" export BUILD_DATETIME=${{ inputs.build_datetime }} - ./scripts/reports/scan-vulnerabilities.sh + "${ACTION_ROOT}/scripts/reports/scan-vulnerabilities.sh" + - name: "Generate vulnerabilities summary" + if: steps.skip-check.outputs.should_skip != 'true' + shell: bash + run: | + ACTION_ROOT="$(cd "${GITHUB_ACTION_PATH}/../../.." && pwd)" + "${ACTION_ROOT}/scripts/reports/parse-vulnerabilities.sh" vulnerabilities-repository-report.json | tee vulnerabilities-summary.md + if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then + cat vulnerabilities-summary.md >> "$GITHUB_STEP_SUMMARY" + fi - name: "Compress vulnerabilities report" + if: steps.skip-check.outputs.should_skip != 'true' shell: bash run: zip vulnerabilities-repository-report.json.zip vulnerabilities-repository-report.json - name: "Upload vulnerabilities report as an artefact" - if: ${{ !env.ACT }} + if: ${{ !env.ACT && steps.skip-check.outputs.should_skip != 'true' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: vulnerabilities-repository-report.json.zip path: ./vulnerabilities-repository-report.json.zip retention-days: 21 + - name: "Upload vulnerabilities summary as an artefact" + if: ${{ !env.ACT && steps.skip-check.outputs.should_skip != 'true' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: vulnerabilities-summary.md + path: ./vulnerabilities-summary.md + retention-days: 21 + - name: "Fail if Critical or High vulnerabilities found" + if: steps.skip-check.outputs.should_skip != 'true' + shell: bash + run: | + CRITICAL_COUNT=$(jq '[.matches[] | select(.vulnerability.severity == "Critical")] | length' vulnerabilities-repository-report.json) + HIGH_COUNT=$(jq '[.matches[] | select(.vulnerability.severity == "High")] | length' vulnerabilities-repository-report.json) + echo "Critical: $CRITICAL_COUNT, High: $HIGH_COUNT" + if [[ "$CRITICAL_COUNT" -gt 0 || "$HIGH_COUNT" -gt 0 ]]; then + echo "::error::Found $CRITICAL_COUNT Critical and $HIGH_COUNT High severity vulnerabilities" + exit 1 + fi - name: "Check prerequisites for sending the reports" + if: steps.skip-check.outputs.should_skip != 'true' shell: bash id: check run: echo "secrets_exist=${{ inputs.idp_aws_report_upload_role_name != '' && inputs.idp_aws_report_upload_bucket_endpoint != '' }}" >> $GITHUB_OUTPUT - name: "Authenticate to send the reports" - if: steps.check.outputs.secrets_exist == 'true' + if: steps.skip-check.outputs.should_skip != 'true' && steps.check.outputs.secrets_exist == 'true' uses: aws-actions/configure-aws-credentials@acca2b1b2070338fb9fd1ca27ecee81d687e58e5 # v6.1.2 with: role-to-assume: arn:aws:iam::${{ inputs.idp_aws_report_upload_account_id }}:role/${{ inputs.idp_aws_report_upload_role_name }} aws-region: ${{ inputs.idp_aws_report_upload_region }} - name: "Send the SBOM and vulnerabilities reports to the central location" shell: bash - if: steps.check.outputs.secrets_exist == 'true' + if: steps.skip-check.outputs.should_skip != 'true' && steps.check.outputs.secrets_exist == 'true' run: | aws s3 cp \ ./sbom-repository-report.json.zip \ diff --git a/.github/workflows/stage-1-commit.yaml b/.github/workflows/stage-1-commit.yaml index f9988830..3bfcf0c8 100644 --- a/.github/workflows/stage-1-commit.yaml +++ b/.github/workflows/stage-1-commit.yaml @@ -151,21 +151,21 @@ jobs: uses: asdf-vm/actions/setup@b7bcd026f18772e44fe1026d729e1611cc435d47 # v4.0.1 - name: "Lint Terraform" uses: ./.github/actions/lint-terraform - trivy: - name: "Trivy Scan" - runs-on: ubuntu-latest - timeout-minutes: 5 - needs: detect-terraform-changes - if: needs.detect-terraform-changes.outputs.terraform_changed == 'true' - steps: - - name: "Checkout code" - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: "Setup ASDF" - uses: asdf-vm/actions/setup@b7bcd026f18772e44fe1026d729e1611cc435d47 # v4.0.1 - - name: "Perform Setup" - uses: ./.github/actions/setup - - name: "Trivy Scan" - uses: ./.github/actions/trivy + # trivy: + # name: "Trivy Scan" + # runs-on: ubuntu-latest + # timeout-minutes: 5 + # needs: detect-terraform-changes + # if: needs.detect-terraform-changes.outputs.terraform_changed == 'true' + # steps: + # - name: "Checkout code" + # uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + # - name: "Setup ASDF" + # uses: asdf-vm/actions/setup@b7bcd026f18772e44fe1026d729e1611cc435d47 # v4.0.1 + # - name: "Perform Setup" + # uses: ./.github/actions/setup + # - name: "Trivy Scan" + # uses: ./.github/actions/trivy count-lines-of-code: name: "Count lines of code" runs-on: ubuntu-latest diff --git a/scripts/reports/create-sbom-report.sh b/scripts/reports/create-sbom-report.sh index 1ed735a7..a237f9f7 100755 --- a/scripts/reports/create-sbom-report.sh +++ b/scripts/reports/create-sbom-report.sh @@ -4,6 +4,10 @@ set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEFAULT_TOOLING_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +TOOLING_ROOT="${TOOLING_ROOT:-${DEFAULT_TOOLING_ROOT}}" + # Script to generate SBOM (Software Bill of Materials) for the repository # content and any artefact created by the CI/CD pipeline. This is a syft command # wrapper. It will run syft natively if it is installed, otherwise it will run @@ -39,22 +43,23 @@ function create-report() { function run-syft-natively() { syft packages dir:"$PWD" \ - --config "$PWD/scripts/config/syft.yaml" \ + --config "$TOOLING_ROOT/scripts/config/syft.yaml" \ --output spdx-json="$PWD/sbom-repository-report.tmp.json" } function run-syft-in-docker() { # shellcheck disable=SC1091 - source ./scripts/docker/docker.lib.sh + source "$TOOLING_ROOT/scripts/docker/docker.lib.sh" # shellcheck disable=SC2155 local image=$(name=ghcr.io/anchore/syft docker-get-image-version-and-pull) docker run --rm --platform linux/amd64 \ --volume "$PWD":/workdir \ + --volume "$TOOLING_ROOT":/tooling \ "$image" \ packages dir:/workdir \ - --config /workdir/scripts/config/syft.yaml \ + --config /tooling/scripts/config/syft.yaml \ --output spdx-json=/workdir/sbom-repository-report.tmp.json } diff --git a/scripts/reports/parse-vulnerabilities.sh b/scripts/reports/parse-vulnerabilities.sh new file mode 100755 index 00000000..21f57020 --- /dev/null +++ b/scripts/reports/parse-vulnerabilities.sh @@ -0,0 +1,107 @@ +#!/bin/bash +# +# WARNING: This file is managed via the repository template. +# Local changes may diverge from the template source of truth. +# +# Parse vulnerability report JSON and output a human-readable summary +# Usage: ./parse-vulnerabilities.sh +# + +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "Usage: $0 " + exit 1 +fi + +REPORT_FILE="$1" + +if [[ ! -f "$REPORT_FILE" ]]; then + echo "Error: File not found: $REPORT_FILE" + exit 1 +fi + +if ! command -v jq &> /dev/null; then + echo "Error: jq is required but not installed" + exit 1 +fi + +# Get counts by severity +count_unique_severity() { + local severity="$1" + + jq -r --arg sev "$severity" ' + [.matches[] | select(.vulnerability.severity == $sev) | { + id: .vulnerability.id, + package: .artifact.name, + version: .artifact.version + }] + | unique_by(.id + .package + .version) + | length + ' "$REPORT_FILE" +} + +echo "## Vulnerability Report Summary" +echo "" + +CRITICAL_COUNT=$(count_unique_severity "Critical") +HIGH_COUNT=$(count_unique_severity "High") +MEDIUM_COUNT=$(count_unique_severity "Medium") +LOW_COUNT=$(count_unique_severity "Low") +TOTAL=$((CRITICAL_COUNT + HIGH_COUNT + MEDIUM_COUNT + LOW_COUNT)) + +echo "**Total: $TOTAL vulnerabilities** ($CRITICAL_COUNT Critical, $HIGH_COUNT High, $MEDIUM_COUNT Medium, $LOW_COUNT Low)" +echo "" + +# Function to print vulnerabilities for a given severity +print_severity_section() { + local severity="$1" + local count="$2" + + if [[ "$count" -eq 0 ]]; then + return + fi + + echo "### $severity ($count)" + echo "" + echo "| Package | Language | Version | Fix | Description |" + echo "|---------|---------|---------|-----|-------------|" + + jq -r --arg sev "$severity" ' + [.matches[] | select(.vulnerability.severity == $sev) | { + id: .vulnerability.id, + severity: .vulnerability.severity, + package: .artifact.name, + language: .artifact.language, + version: .artifact.version, + fix: (.vulnerability.fix.versions[0] // "N/A"), + description: .vulnerability.description + }] + | unique_by(.id + .package + .version) + | sort_by(.package) + | .[] + | "| \(.package) | \(.language) | \(.version) | \(.fix) | \(.description[0:70])... |" + ' "$REPORT_FILE" + + echo "" +} + +print_severity_section "Critical" "$CRITICAL_COUNT" +print_severity_section "High" "$HIGH_COUNT" +print_severity_section "Medium" "$MEDIUM_COUNT" +print_severity_section "Low" "$LOW_COUNT" + +# Priority packages summary +echo "---" +echo "" +echo "### Priority Packages to Update" +echo "" + +jq -r ' + [.matches[] | select(.vulnerability.severity == "Critical" or .vulnerability.severity == "High") | .artifact.name] + | unique + | sort + | join(", ") +' "$REPORT_FILE" | fold -s -w 80 + +echo "" diff --git a/scripts/reports/scan-vulnerabilities.sh b/scripts/reports/scan-vulnerabilities.sh index eb68d4b5..544a3f66 100755 --- a/scripts/reports/scan-vulnerabilities.sh +++ b/scripts/reports/scan-vulnerabilities.sh @@ -4,6 +4,10 @@ set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEFAULT_TOOLING_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +TOOLING_ROOT="${TOOLING_ROOT:-${DEFAULT_TOOLING_ROOT}}" + # Script to scan an SBOM file 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. @@ -42,7 +46,7 @@ function run-grype-natively() { grype \ sbom:"$PWD/sbom-repository-report.json" \ - --config "$PWD/scripts/config/grype.yaml" \ + --config "$TOOLING_ROOT/scripts/config/grype.yaml" \ --output json \ --file "$PWD/vulnerabilities-repository-report.tmp.json" } @@ -50,16 +54,17 @@ function run-grype-natively() { function run-grype-in-docker() { # shellcheck disable=SC1091 - source ./scripts/docker/docker.lib.sh + 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 "$PWD":/workdir \ + --volume "$TOOLING_ROOT":/tooling \ --volume /tmp/grype/db:/.cache/grype/db \ "$image" \ sbom:/workdir/sbom-repository-report.json \ - --config /workdir/scripts/config/grype.yaml \ + --config /tooling/scripts/config/grype.yaml \ --output json \ --file /workdir/vulnerabilities-repository-report.tmp.json }