diff --git a/.github/actions/scan-dependencies/action.yaml b/.github/actions/scan-dependencies/action.yaml index 53ec460b..30feeb12 100644 --- a/.github/actions/scan-dependencies/action.yaml +++ b/.github/actions/scan-dependencies/action.yaml @@ -20,7 +20,7 @@ inputs: 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" + description: "Skip failing the dependency scan when the triggering PR has this label" required: false default: "skip-dependencies-check" runs: @@ -68,13 +68,7 @@ runs: 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)" @@ -82,26 +76,24 @@ runs: export BUILD_DATETIME=${{ inputs.build_datetime }} "${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 && steps.skip-check.outputs.should_skip != 'true' }} + if: ${{ !env.ACT }} 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 }} + export GRYPE_FAIL_ON_SEVERITY=none "${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)" @@ -110,48 +102,49 @@ runs: 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 && steps.skip-check.outputs.should_skip != 'true' }} + if: ${{ !env.ACT }} 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' }} + if: ${{ !env.ACT }} 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") | {id: .vulnerability.id, package: .artifact.name, version: .artifact.version}] | unique_by([.id, .package, .version]) | length' vulnerabilities-repository-report.json) HIGH_COUNT=$(jq '[.matches[] | select(.vulnerability.severity == "High") | {id: .vulnerability.id, package: .artifact.name, version: .artifact.version}] | unique_by([.id, .package, .version]) | 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 + if [[ "${{ steps.skip-check.outputs.should_skip }}" == "true" ]]; then + echo "Bypassing blocking dependency scan failure because PR has label '${{ inputs.skip_if_pr_has_label }}'." + else + echo "::error::Found $CRITICAL_COUNT Critical and $HIGH_COUNT High severity vulnerabilities" + exit 1 + fi 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.skip-check.outputs.should_skip != 'true' && steps.check.outputs.secrets_exist == 'true' + if: 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.skip-check.outputs.should_skip != 'true' && steps.check.outputs.secrets_exist == 'true' + if: steps.check.outputs.secrets_exist == 'true' run: | aws s3 cp \ ./sbom-repository-report.json.zip \ diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index c0b496da..08663561 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -51,3 +51,10 @@ entry: .github/actions/check-todo-usage/check-todos.sh language: script pass_filenames: false + +- id: scan-dependencies + name: Scan dependencies + entry: scripts/githooks/scan-dependencies.sh + language: script + pass_filenames: false + stages: [manual] diff --git a/docs/github-actions/scan-dependencies.md b/docs/github-actions/scan-dependencies.md index 71c92535..befc70b2 100644 --- a/docs/github-actions/scan-dependencies.md +++ b/docs/github-actions/scan-dependencies.md @@ -13,6 +13,7 @@ Scans project dependencies for known vulnerabilities. ### Description This composite action scans project dependencies to identify known security vulnerabilities. +It always produces the SBOM, raw Grype JSON report, and markdown summary, and it fails the workflow when High or Critical vulnerabilities are present unless the triggering PR carries a configured skip label. ### Usage @@ -20,16 +21,44 @@ This composite action scans project dependencies to identify known security vuln jobs: scan-deps: runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read steps: - uses: actions/checkout@v4 - name: Scan dependencies uses: NHSDigital/nhs-notify-shared-modules/.github/actions/scan-dependencies@v1.0.0 + with: + build_datetime: "${{ needs.metadata.outputs.build_datetime }}" + build_timestamp: "${{ needs.metadata.outputs.build_timestamp }}" + skip_if_pr_has_label: grype-ignore-high-and-critical ``` ### Details - Scans: npm packages, Ruby gems, Python packages -- Checks: Known vulnerabilities (CVEs) -- Reports: Security advisories -- Alerts: High-risk dependencies +- Checks: Known vulnerabilities or CVE +- Reports: SBOM JSON, Grype JSON, and markdown summary +- Alerts: High and Critical vulnerabilities fail by default + +### Ignore File + +If a repository contains a root-level `.grypeignore`, each non-comment line is treated as a Grype vulnerability ID to suppress. For example: + +```text +CVE-2026-25128 # Ticket to review: CCM-14317 +GHSA-xxxx-yyyy-zzzz +``` + +The workflow summary now includes the vulnerability ID in each row so exemptions can be copied directly into `.grypeignore`. + +### Skip Label + +Set `skip_if_pr_has_label` to a PR label name if you need to skip the blocking failure for known High or Critical findings that are already being handled elsewhere. + +- The scan still runs. +- Reports and summaries are still uploaded. +- Only the final blocking failure is skipped when the label is present. + +The default label is `skip-dependencies-check`. diff --git a/docs/pre-commit-hooks.md b/docs/pre-commit-hooks.md index a60c2b7c..18913d0e 100644 --- a/docs/pre-commit-hooks.md +++ b/docs/pre-commit-hooks.md @@ -115,6 +115,27 @@ Validates TODO comments follow the required format with Jira ticket IDs. - id: check-todo-usage ``` +#### scan-dependencies + +Runs the same SBOM and Grype dependency scan used in CI and prints the markdown vulnerability summary locally. + +**Usage:** + +```yaml +- repo: https://github.com/NHSDigital/nhs-notify-shared-modules + rev: vX.Y.Z + hooks: + - id: scan-dependencies +``` + +Run it manually when needed: + +```bash +pre-commit run --config scripts/config/pre-commit.yaml --hook-stage manual scan-dependencies +``` + +If the repository contains a root `.grypeignore`, the hook will honor it automatically. + ## Setup in Your Repository Add to your `.pre-commit-config.yaml`: @@ -128,6 +149,7 @@ repos: - id: check-file-format - id: check-markdown-format - id: lint-terraform + - id: scan-dependencies # Add other hooks as needed ``` @@ -142,3 +164,4 @@ pre-commit install - All hooks run with `pass_filenames: false` - they operate on the entire repository - The `scan-secrets` hook checks the entire Git history for security - Hooks reference scripts in either `scripts/githooks/` or `.github/actions/` +- `scan-dependencies` uses the same Grype-based workflow as CI, respects a root `.grypeignore` when present, and is configured as a manual-only hook diff --git a/scripts/config/pre-commit.yaml b/scripts/config/pre-commit.yaml index 861a12a0..afc0f2a8 100644 --- a/scripts/config/pre-commit.yaml +++ b/scripts/config/pre-commit.yaml @@ -70,3 +70,11 @@ repos: entry: /usr/bin/env check=branch ./.github/actions/check-todo-usage/check-todos.sh language: script pass_filenames: false + - repo: local + hooks: + - id: scan-dependencies + name: Scan dependencies + entry: ./scripts/githooks/scan-dependencies.sh + language: script + pass_filenames: false + stages: [manual] diff --git a/scripts/config/syft.yaml b/scripts/config/syft.yaml index e9f5f580..f47f61e1 100644 --- a/scripts/config/syft.yaml +++ b/scripts/config/syft.yaml @@ -4,6 +4,8 @@ # - "./out/**/*.json" exclude: - ./.git/** + - ./**/terraform/**/.terraform/** + - ./**/terraform/plugin-cache/** # maximum number of workers used to process the list of package catalogers in parallel parallelism: 3 diff --git a/scripts/githooks/scan-dependencies.sh b/scripts/githooks/scan-dependencies.sh new file mode 100755 index 00000000..6c4fcef6 --- /dev/null +++ b/scripts/githooks/scan-dependencies.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TOOLING_ROOT="${TOOLING_ROOT:-$(cd "${SCRIPT_DIR}/../.." && pwd)}" + +cd "$(git rev-parse --show-toplevel)" + +export BUILD_DATETIME="${BUILD_DATETIME:-$(date -u +'%Y-%m-%dT%H:%M:%S%z')}" +export GRYPE_FAIL_ON_SEVERITY="${GRYPE_FAIL_ON_SEVERITY:-high}" + +echo "Step 1: Creating SBOM..." +"${TOOLING_ROOT}/scripts/reports/create-sbom-report.sh" > /dev/null 2>&1 +[[ -f sbom-repository-report.json ]] && echo " ✓ SBOM created" || echo " ✗ SBOM not found" + +echo "Step 2: Scanning vulnerabilities..." +"${TOOLING_ROOT}/scripts/reports/scan-vulnerabilities.sh" || true + +echo "Step 3: Parsing vulnerabilities..." +REPORT_FILE="" +if [[ -f vulnerabilities-repository-report.json ]]; then + REPORT_FILE="vulnerabilities-repository-report.json" +elif [[ -f vulnerabilities-repository-report.tmp.json ]]; then + REPORT_FILE="vulnerabilities-repository-report.tmp.json" +fi + +if [[ -n "$REPORT_FILE" ]]; then + "${TOOLING_ROOT}/scripts/reports/parse-vulnerabilities.sh" "$REPORT_FILE" "true" + + # Check if Critical or High vulnerabilities exist and fail + CRITICAL_COUNT=$(jq '[.matches[] | select(.vulnerability.severity == "Critical") | {id: .vulnerability.id, package: .artifact.name, version: .artifact.version}] | unique_by([.id, .package, .version]) | length' "$REPORT_FILE") + HIGH_COUNT=$(jq '[.matches[] | select(.vulnerability.severity == "High") | {id: .vulnerability.id, package: .artifact.name, version: .artifact.version}] | unique_by([.id, .package, .version]) | length' "$REPORT_FILE") + + if [[ "$CRITICAL_COUNT" -gt 0 || "$HIGH_COUNT" -gt 0 ]]; then + echo "" + echo "❌ Found $CRITICAL_COUNT Critical and $HIGH_COUNT High severity vulnerabilities" + exit 1 + fi +fi diff --git a/scripts/reports/parse-vulnerabilities.sh b/scripts/reports/parse-vulnerabilities.sh index db930968..181d086a 100755 --- a/scripts/reports/parse-vulnerabilities.sh +++ b/scripts/reports/parse-vulnerabilities.sh @@ -4,17 +4,19 @@ # Local changes may diverge from the template source of truth. # # Parse vulnerability report JSON and output a human-readable summary -# Usage: ./parse-vulnerabilities.sh -# +# Usage: ./parse-vulnerabilities.sh [critical-high-only] +# Options: +# critical-high-only Only print Critical and High severity sections set -euo pipefail if [[ $# -lt 1 ]]; then - echo "Usage: $0 " + echo "Usage: $0 [critical-high-only]" exit 1 fi REPORT_FILE="$1" +FILTER_CRITICAL_HIGH_ONLY="${2:-false}" if [[ ! -f "$REPORT_FILE" ]]; then echo "Error: File not found: $REPORT_FILE" @@ -64,23 +66,22 @@ print_severity_section() { echo "### $severity ($count)" echo "" - echo "| Package | Language | Version | Fix | Description |" - echo "|---------|---------|---------|-----|-------------|" + echo "| ID | 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 + description: ((.vulnerability.description // "N/A") | .[0:70] + "...") }] | unique_by([.id, .package, .version]) - | sort_by(.package) + | sort_by(.id) | .[] - | "| \(.package) | \(.language) | \(.version) | \(.fix) | \(.description[0:70])... |" + | "| \(.id) | \(.package) | \(.language) | \(.version) | \(.fix) | \(.description) |" ' "$REPORT_FILE" echo "" @@ -88,20 +89,8 @@ print_severity_section() { 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 "" +if [[ "$FILTER_CRITICAL_HIGH_ONLY" != "true" ]]; then + print_severity_section "Medium" "$MEDIUM_COUNT" + print_severity_section "Low" "$LOW_COUNT" +fi diff --git a/scripts/reports/scan-vulnerabilities.sh b/scripts/reports/scan-vulnerabilities.sh index 544a3f66..488b08fc 100755 --- a/scripts/reports/scan-vulnerabilities.sh +++ b/scripts/reports/scan-vulnerabilities.sh @@ -44,10 +44,18 @@ function create-report() { function run-grype-natively() { + local fail_on_severity=${GRYPE_FAIL_ON_SEVERITY:-} + local fail_on_opt="" + if [[ -n "$fail_on_severity" && "$fail_on_severity" != "none" ]]; then + fail_on_opt="--fail-on $fail_on_severity" + fi + + # shellcheck disable=SC2086 grype \ sbom:"$PWD/sbom-repository-report.json" \ - --config "$TOOLING_ROOT/scripts/config/grype.yaml" \ + --config "$PWD/scripts/config/grype.yaml" \ --output json \ + $fail_on_opt \ --file "$PWD/vulnerabilities-repository-report.tmp.json" } @@ -58,19 +66,33 @@ function run-grype-in-docker() { # shellcheck disable=SC2155 local image=$(name=ghcr.io/anchore/grype docker-get-image-version-and-pull) + + local fail_on_severity=${GRYPE_FAIL_ON_SEVERITY:-} + local fail_on_opt="" + if [[ -n "$fail_on_severity" && "$fail_on_severity" != "none" ]]; then + fail_on_opt="--fail-on $fail_on_severity" + fi + + # shellcheck disable=SC2086 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 /tooling/scripts/config/grype.yaml \ + --config /workdir/scripts/config/grype.yaml \ --output json \ + $fail_on_opt \ --file /workdir/vulnerabilities-repository-report.tmp.json } function enrich-report() { + if [[ ! -f vulnerabilities-repository-report.tmp.json ]]; then + echo "Grype did not produce vulnerabilities-repository-report.tmp.json" >&2 + return 1 + fi + build_datetime=${BUILD_DATETIME:-$(date -u +'%Y-%m-%dT%H:%M:%S%z')} git_url=$(git config --get remote.origin.url) git_branch=$(git rev-parse --abbrev-ref HEAD) @@ -88,8 +110,6 @@ function enrich-report() { rm -f vulnerabilities-repository-report.tmp.json } -# ============================================================================== - function is-arg-true() { if [[ "$1" =~ ^(true|yes|y|on|1|TRUE|YES|Y|ON)$ ]]; then