Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
c21d1a0
CCM-17525: Updating grype checks
aidenvaines-cgi Jul 7, 2026
b7db224
CCM-17525: Updating grype checks
aidenvaines-cgi Jul 7, 2026
098defe
CCM-17525: Updating grype checks
aidenvaines-cgi Jul 7, 2026
7dedf8b
CCM-17525: Updating grype checks
aidenvaines-cgi Jul 7, 2026
e1158b0
CCM-17525: Updating grype checks
aidenvaines-cgi Jul 7, 2026
bbfd8de
CCM-17525: Updating grype checks
aidenvaines-cgi Jul 7, 2026
9ea5e5a
CCM-17525: Updating grype checks
aidenvaines-cgi Jul 7, 2026
50be418
CCM-17525: Updating grype checks
aidenvaines-cgi Jul 7, 2026
48faf5c
CCM-17525: Updating grype checks
aidenvaines-cgi Jul 7, 2026
12c5edf
CCM-17525: Updating grype checks
aidenvaines-cgi Jul 7, 2026
71c2e02
CCM-17525: Updating grype checks
aidenvaines-cgi Jul 7, 2026
448528f
CCM-17525: Updating grype checks
aidenvaines-cgi Jul 7, 2026
cb79fe4
CCM-17525: Updating grype checks
aidenvaines-cgi Jul 7, 2026
2eea47c
CCM-17525: Updating grype checks
aidenvaines-cgi Jul 7, 2026
a04e6bf
CCM-17525: Updating grype checks
aidenvaines-cgi Jul 7, 2026
5639349
CCM-17525: Updating grype checks
aidenvaines-cgi Jul 7, 2026
c911552
CCM-17525: Updating grype checks
aidenvaines-cgi Jul 7, 2026
8db1449
CCM-17525: Updating grype checks
aidenvaines-cgi Jul 7, 2026
f38eee4
CCM-17525: Updating grype checks
aidenvaines-cgi Jul 7, 2026
190f741
CCM-17525: Updating grype checks
aidenvaines-cgi Jul 7, 2026
5af9304
CCM-17525: Updating grype checks
aidenvaines-cgi Jul 8, 2026
85a0904
CCM-17525: Updating grype checks
aidenvaines-cgi Jul 8, 2026
136878f
Merge branch 'main' into CCM-17525_useGrypeProperly
aidenvaines-cgi Jul 8, 2026
f7e0096
Merge branch 'main' into CCM-17525_useGrypeProperly
aidenvaines-cgi Jul 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 13 additions & 20 deletions .github/actions/scan-dependencies/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -68,40 +68,32 @@ 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)"
export TOOLING_ROOT="${ACTION_ROOT}"
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)"
Expand All @@ -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 \
Expand Down
7 changes: 7 additions & 0 deletions .pre-commit-hooks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
35 changes: 32 additions & 3 deletions docs/github-actions/scan-dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,52 @@ 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

```yaml
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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this still correct ? - Thought grype.yaml file is used


```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`.
23 changes: 23 additions & 0 deletions docs/pre-commit-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand All @@ -128,6 +149,7 @@ repos:
- id: check-file-format
- id: check-markdown-format
- id: lint-terraform
- id: scan-dependencies
# Add other hooks as needed
```

Expand All @@ -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
8 changes: 8 additions & 0 deletions scripts/config/pre-commit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
2 changes: 2 additions & 0 deletions scripts/config/syft.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions scripts/githooks/scan-dependencies.sh
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +28 to +40
39 changes: 14 additions & 25 deletions scripts/reports/parse-vulnerabilities.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path-to-report.json>
#
# Usage: ./parse-vulnerabilities.sh <path-to-report.json> [critical-high-only]
# Options:
# critical-high-only Only print Critical and High severity sections

set -euo pipefail

if [[ $# -lt 1 ]]; then
echo "Usage: $0 <vulnerability-report.json>"
echo "Usage: $0 <vulnerability-report.json> [critical-high-only]"
exit 1
fi

REPORT_FILE="$1"
FILTER_CRITICAL_HIGH_ONLY="${2:-false}"
Comment on lines 18 to +19

if [[ ! -f "$REPORT_FILE" ]]; then
echo "Error: File not found: $REPORT_FILE"
Expand Down Expand Up @@ -64,44 +66,31 @@ 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 ""
}

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
Loading