Skip to content

Commit 647e72e

Browse files
authored
Merge pull request #50 from NHSDigital/APM-6390
sbom configuration
2 parents 8c6cd92 + c8a170e commit 647e72e

File tree

9 files changed

+340
-2
lines changed

9 files changed

+340
-2
lines changed

.github/README.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# SBOM & Vulnerability Scanning Automation
2+
3+
This repository uses GitHub Actions to automatically generate a Software Bill of Materials (SBOM), scan for vulnerabilities, and produce package inventory reports.
4+
5+
All reports are named with the repository name for easy identification.
6+
7+
## Features
8+
9+
SBOM Generation: Uses Syft to generate an SPDX JSON SBOM.
10+
SBOM Merging: Merges SBOMs for multiple tools if needed.
11+
SBOM to CSV: Converts SBOM JSON to a CSV report.
12+
Vulnerability Scanning: Uses Grype to scan the SBOM for vulnerabilities and outputs a CSV report.
13+
Package Inventory: Extracts a simple package list (name, type, version) as a CSV.
14+
Artifacts: All reports are uploaded as workflow artifacts with the repository name in the filename.
15+
16+
## Workflow Overview
17+
18+
The main workflow is defined in .github/workflows/sbom.yml
19+
20+
## Scripts
21+
22+
scripts/create-sbom.sh
23+
Generates an SBOM for the repo and for specified tools, merging them as needed.
24+
scripts/update-sbom.py
25+
Merges additional SBOMs into the main SBOM.
26+
.github/scripts/sbom_json_to_csv.py
27+
Converts the SBOM JSON to a detailed CSV report.
28+
.github/scripts/grype_json_to_csv.py
29+
Converts Grype’s vulnerability scan JSON output to a CSV report.
30+
Output columns: REPO, NAME, INSTALLED, FIXED-IN, TYPE, VULNERABILITY, SEVERITY
31+
.github/scripts/sbom_packages_to_csv.py
32+
Extracts a simple package inventory from the SBOM.
33+
Output columns: name, type, version
34+
35+
## Example Reports
36+
37+
Vulnerability Report
38+
grype-report-[RepoName].csv
39+
REPO,NAME,INSTALLED,FIXED-IN,TYPE,VULNERABILITY,SEVERITY
40+
my-repo,Flask,2.1.2,,library,CVE-2022-12345,High
41+
...
42+
43+
Package Inventory
44+
sbom-packages-[RepoName].csv
45+
name,type,version
46+
Flask,library,2.1.2
47+
Jinja2,library,3.1.2
48+
...
49+
50+
## Usage
51+
52+
Push to main branch or run the workflow manually.
53+
Download artifacts from the workflow run summary.
54+
55+
## Customization
56+
57+
Add more tools to scripts/create-sbom.sh as needed.
58+
Modify scripts to adjust report formats or add more metadata.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import json
2+
import csv
3+
import sys
4+
5+
input_file = sys.argv[1] if len(sys.argv) > 1 else "grype-report.json"
6+
output_file = sys.argv[2] if len(sys.argv) > 2 else "grype-report.csv"
7+
8+
with open(input_file, "r", encoding="utf-8") as f:
9+
data = json.load(f)
10+
11+
columns = ["NAME", "INSTALLED", "FIXED-IN", "TYPE", "VULNERABILITY", "SEVERITY"]
12+
13+
with open(output_file, "w", newline="", encoding="utf-8") as csvfile:
14+
writer = csv.DictWriter(csvfile, fieldnames=columns)
15+
writer.writeheader()
16+
for match in data.get("matches", []):
17+
pkg = match.get("artifact", {})
18+
vuln = match.get("vulnerability", {})
19+
row = {
20+
"NAME": pkg.get("name", ""),
21+
"INSTALLED": pkg.get("version", ""),
22+
"FIXED-IN": vuln.get("fix", {}).get("versions", [""])[0] if vuln.get("fix", {}).get("versions") else "",
23+
"TYPE": pkg.get("type", ""),
24+
"VULNERABILITY": vuln.get("id", ""),
25+
"SEVERITY": vuln.get("severity", ""),
26+
}
27+
writer.writerow(row)
28+
print(f"CSV export complete: {output_file}")
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import json
2+
import csv
3+
import sys
4+
from pathlib import Path
5+
from tabulate import tabulate
6+
7+
input_file = sys.argv[1] if len(sys.argv) > 1 else "sbom.json"
8+
output_file = sys.argv[2] if len(sys.argv) > 2 else "sbom.csv"
9+
10+
with open(input_file, "r", encoding="utf-8") as f:
11+
sbom = json.load(f)
12+
13+
packages = sbom.get("packages", [])
14+
15+
columns = [
16+
"name",
17+
"versionInfo",
18+
"type",
19+
"supplier",
20+
"downloadLocation",
21+
"licenseConcluded",
22+
"licenseDeclared",
23+
"externalRefs"
24+
]
25+
26+
def get_type(pkg):
27+
spdxid = pkg.get("SPDXID", "")
28+
if "-" in spdxid:
29+
parts = spdxid.split("-")
30+
if len(parts) > 2:
31+
return parts[2]
32+
refs = pkg.get("externalRefs", [])
33+
for ref in refs:
34+
if ref.get("referenceType") == "purl":
35+
return ref.get("referenceLocator", "").split("/")[0]
36+
return ""
37+
38+
def get_external_refs(pkg):
39+
refs = pkg.get("externalRefs", [])
40+
return ";".join([ref.get("referenceLocator", "") for ref in refs])
41+
42+
with open(output_file, "w", newline="", encoding="utf-8") as csvfile:
43+
writer = csv.DictWriter(csvfile, fieldnames=columns)
44+
writer.writeheader()
45+
for pkg in packages:
46+
row = {
47+
"name": pkg.get("name", ""),
48+
"versionInfo": pkg.get("versionInfo", ""),
49+
"type": get_type(pkg),
50+
"supplier": pkg.get("supplier", ""),
51+
"downloadLocation": pkg.get("downloadLocation", ""),
52+
"licenseConcluded": pkg.get("licenseConcluded", ""),
53+
"licenseDeclared": pkg.get("licenseDeclared", ""),
54+
"externalRefs": get_external_refs(pkg)
55+
}
56+
writer.writerow(row)
57+
58+
print(f"CSV export complete: {output_file}")
59+
60+
with open("sbom_table.txt", "w", encoding="utf-8") as f:
61+
table = []
62+
for pkg in packages:
63+
row = [
64+
pkg.get("name", ""),
65+
pkg.get("versionInfo", ""),
66+
get_type(pkg),
67+
pkg.get("supplier", ""),
68+
pkg.get("downloadLocation", ""),
69+
pkg.get("licenseConcluded", ""),
70+
pkg.get("licenseDeclared", ""),
71+
get_external_refs(pkg)
72+
]
73+
table.append(row)
74+
f.write(tabulate(table, columns, tablefmt="grid"))
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import json
2+
import csv
3+
import sys
4+
import os
5+
6+
input_file = sys.argv[1] if len(sys.argv) > 1 else "sbom.json"
7+
repo_name = sys.argv[2] if len(sys.argv) > 2 else os.getenv("GITHUB_REPOSITORY", "unknown-repo").split("/")[-1]
8+
output_file = f"sbom-packages-{repo_name}.csv"
9+
10+
with open(input_file, "r", encoding="utf-8") as f:
11+
sbom = json.load(f)
12+
13+
packages = sbom.get("packages", [])
14+
15+
columns = ["name", "type", "version"]
16+
17+
with open(output_file, "w", newline="", encoding="utf-8") as csvfile:
18+
writer = csv.DictWriter(csvfile, fieldnames=columns)
19+
writer.writeheader()
20+
for pkg in packages:
21+
row = {
22+
"name": pkg.get("name", ""),
23+
"type": pkg.get("type", ""),
24+
"version": pkg.get("versionInfo", "")
25+
}
26+
writer.writerow(row)
27+
28+
print(f"Package list CSV generated: {output_file}")

.github/workflows/pr.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ jobs:
2727
echo ::set-output name=candidate::$(grep version pr/pyproject.toml | awk -F\" '{print $2}')
2828
2929
- name: Install Python 3.8
30-
uses: actions/setup-python@v1
30+
uses: actions/setup-python@v5
3131
with:
3232
python-version: 3.8
3333

.github/workflows/sbom.yml

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
name: SBOM Vulnerability Scanning
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
environment:
7+
description: "Run SBOM check"
8+
required: true
9+
type: choice
10+
options:
11+
- yes
12+
- no
13+
14+
env:
15+
SYFT_VERSION: "1.27.1"
16+
TF_VERSION: "1.12.2"
17+
18+
jobs:
19+
deploy:
20+
name: Software Bill of Materials
21+
runs-on: ubuntu-latest
22+
permissions:
23+
actions: read
24+
contents: write
25+
steps:
26+
- name: Checkout
27+
uses: actions/checkout@v5
28+
29+
- name: Setup Python 3.13
30+
uses: actions/setup-python@v5
31+
with:
32+
python-version: "3.13"
33+
34+
- name: Setup Terraform
35+
uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd
36+
37+
- uses: terraform-linters/setup-tflint@ae78205cfffec9e8d93fd2b3115c7e9d3166d4b6
38+
name: Setup TFLint
39+
40+
- name: Set architecture variable
41+
id: os-arch
42+
run: |
43+
case "${{ runner.arch }}" in
44+
X64) ARCH="amd64" ;;
45+
ARM64) ARCH="arm64" ;;
46+
esac
47+
echo "arch=${ARCH}" >> $GITHUB_OUTPUT
48+
49+
- name: Download and setup Syft
50+
run: |
51+
DOWNLOAD_URL="https://github.com/anchore/syft/releases/download/v${{ env.SYFT_VERSION }}/syft_${{ env.SYFT_VERSION }}_linux_${{ steps.os-arch.outputs.arch }}.tar.gz"
52+
echo "Downloading: ${DOWNLOAD_URL}"
53+
54+
curl -L -o syft.tar.gz "${DOWNLOAD_URL}"
55+
tar -xzf syft.tar.gz
56+
chmod +x syft
57+
58+
# Add to PATH for subsequent steps
59+
echo "$(pwd)" >> $GITHUB_PATH
60+
61+
- name: Create SBOM
62+
run: bash scripts/create-sbom.sh terraform python tflint
63+
64+
- name: Convert SBOM JSON to CSV
65+
run: |
66+
pip install --upgrade pip
67+
pip install tabulate
68+
REPO_NAME=$(basename $GITHUB_REPOSITORY)
69+
python .github/scripts/sbom_json_to_csv.py sbom.json SBOM_${REPO_NAME}.csv
70+
71+
- name: Upload SBOM CSV as artifact
72+
uses: actions/upload-artifact@v4
73+
with:
74+
name: sbom-csv
75+
path: SBOM_${{ github.event.repository.name }}.csv
76+
77+
- name: Install Grype
78+
run: |
79+
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin
80+
81+
- name: Scan SBOM for Vulnerabilities (JSON)
82+
run: |
83+
grype sbom:sbom.json -o json > grype-report.json
84+
85+
86+
87+
- name: Convert Grype JSON to CSV
88+
run: |
89+
pip install --upgrade pip
90+
REPO_NAME=$(basename $GITHUB_REPOSITORY)
91+
python .github/scripts/grype_json_to_csv.py grype-report.json grype-report-${REPO_NAME}.csv
92+
93+
94+
- name: Upload Vulnerability Report
95+
uses: actions/upload-artifact@v4
96+
with:
97+
name: grype-report
98+
path: grype-report-${{ github.event.repository.name }}.csv
99+
100+
- name: Generate Package Inventory CSV
101+
run: |
102+
pip install --upgrade pip
103+
REPO_NAME=$(basename $GITHUB_REPOSITORY)
104+
python .github/scripts/sbom_packages_to_csv.py sbom.json $REPO_NAME
105+
106+
- name: Upload Package Inventory CSV
107+
uses: actions/upload-artifact@v4
108+
with:
109+
name: sbom-packages
110+
path: sbom-packages-${{ github.event.repository.name }}.csv

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "proxygen-cli"
3-
version = "2.1.19"
3+
version = "2.1.20"
44
description = "CLI for interacting with NHSD APIM's proxygen service"
55
authors = ["Ben Strutt <ben.strutt1@nhs.net>"]
66
readme = "README.md"

scripts/create-sbom.sh

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
#!/bin/bash
2+
set -euo pipefail
3+
IFS=$'\n\t'
4+
5+
REPO_ROOT=$(git rev-parse --show-toplevel)
6+
7+
# Generate SBOM for current directory
8+
syft -o spdx-json . > "$REPO_ROOT/sbom.json"
9+
10+
# Generate and merge SBOMs for each tool passed as argument
11+
for tool in "$@"; do
12+
echo "Creating SBOM for $tool and merging"
13+
tool_path=$(command -v "$tool")
14+
if [[ -z "$tool_path" ]]; then
15+
echo "Warning: '$tool' not found in PATH. Skipping." >&2
16+
continue
17+
fi
18+
syft -q -o spdx-json "$tool_path" | python "$REPO_ROOT/scripts/update-sbom.py"
19+
done

scripts/update-sbom.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import json
2+
import sys
3+
from pathlib import Path
4+
5+
6+
def main() -> None:
7+
with Path("sbom.json").open("r") as f:
8+
sbom = json.load(f)
9+
10+
tool = json.loads(sys.stdin.read())
11+
12+
sbom.setdefault("packages", []).extend(tool.setdefault("packages", []))
13+
sbom.setdefault("files", []).extend(tool.setdefault("files", []))
14+
sbom.setdefault("relationships", []).extend(tool.setdefault("relationships", []))
15+
16+
with Path("sbom.json").open("w") as f:
17+
json.dump(sbom, f)
18+
19+
20+
if __name__ == "__main__":
21+
main()

0 commit comments

Comments
 (0)