Skip to content

Commit 4bba52d

Browse files
committed
Add NCS SBOM pipeline for workspace scanning and CVE reporting.
Syft generates full-workspace and per-west-project SPDX and CycloneDX SBOMs on main branch pushes. Grype produces CVE reports published as separate CycloneDX and SPDX zip artifacts plus a full sbom/ bundle.
1 parent de66a86 commit 4bba52d

5 files changed

Lines changed: 479 additions & 0 deletions

File tree

.github/workflows/sbom.yml

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
name: NCS SBOM Pipeline
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
workflow_dispatch:
9+
inputs:
10+
grype_fail_gate:
11+
description: Enable Grype fail gate for this run (Stage 1b)
12+
type: boolean
13+
default: false
14+
15+
# Stage 3 — enable weekly CVE sweep:
16+
# schedule:
17+
# - cron: '0 3 * * 1'
18+
19+
concurrency:
20+
group: sbom-${{ github.ref }}
21+
cancel-in-progress: true
22+
23+
permissions:
24+
contents: read
25+
actions: read
26+
security-events: write
27+
28+
env:
29+
SYFT_VERSION: v1.49.0
30+
GRYPE_VERSION: v0.116.0
31+
FAIL_ON_SEVERITY: high
32+
# Stage 1a: report-only. Set to true after tuning .grype.yaml (Stage 1b).
33+
GRYPE_FAIL_GATE: false
34+
GRYPE_CONFIG: ncs/nrf/.grype.yaml
35+
36+
jobs:
37+
sbom:
38+
runs-on: ubuntu-24.04
39+
timeout-minutes: 180
40+
steps:
41+
- name: Checkout west workspace
42+
uses: nrfconnect/action-checkout-west-update@b3257f7035e900ae50c280fe5759508f547f872d # 22 Jan 2026
43+
with:
44+
git-fetch-depth: 0
45+
path: ncs/nrf
46+
git-ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
47+
rebase: ${{ github.event_name == 'pull_request' }}
48+
49+
- name: Resolve NCS version
50+
id: ncs_version
51+
working-directory: ncs/nrf
52+
run: |
53+
major=$(grep '^VERSION_MAJOR' VERSION | awk -F' = ' '{print $2}')
54+
minor=$(grep '^VERSION_MINOR' VERSION | awk -F' = ' '{print $2}')
55+
patch=$(grep '^PATCHLEVEL' VERSION | awk -F' = ' '{print $2}')
56+
version="v${major}.${minor}.${patch}"
57+
short_sha="${GITHUB_SHA:0:8}"
58+
dt_version="${version}-${short_sha}"
59+
echo "version=${version}" >> "$GITHUB_OUTPUT"
60+
echo "dt_version=${dt_version}" >> "$GITHUB_OUTPUT"
61+
echo "NCS ${version} (DT version ${dt_version})"
62+
63+
- name: Extract west manifest graph
64+
working-directory: ncs
65+
run: |
66+
mkdir -p sbom sbom/projects
67+
west list -f "{name}|{revision}|{path}|{url}" > sbom/west-projects.csv
68+
west list -f "{name:30} {revision:12} {path}" > sbom/west-manifest-snapshot.txt
69+
west manifest --resolve > sbom/west-manifest-resolved.yml
70+
echo "----- Manifest snapshot -----"
71+
cat sbom/west-manifest-snapshot.txt
72+
73+
- name: Install Syft
74+
run: |
75+
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh \
76+
| sh -s -- -b /usr/local/bin "${SYFT_VERSION}"
77+
syft version
78+
79+
- name: Install Grype
80+
run: |
81+
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh \
82+
| sh -s -- -b /usr/local/bin "${GRYPE_VERSION}"
83+
grype version
84+
85+
- name: Full workspace SBOM — SPDX + CycloneDX (Mode A)
86+
working-directory: ncs
87+
run: |
88+
syft "dir:${GITHUB_WORKSPACE}/ncs" \
89+
-o spdx-json=sbom/ncs-full.spdx.json \
90+
-o spdx-tag-value=sbom/ncs-full.spdx \
91+
-o cyclonedx-json=sbom/ncs-full.cdx.json \
92+
--exclude './**/build/**' \
93+
--exclude './**/.git/**' \
94+
--exclude './**/twister-out/**'
95+
96+
- name: Per-project SBOMs mapped to west revisions (Mode B)
97+
run: bash ncs/nrf/scripts/sbom/scan-projects.sh "${GITHUB_WORKSPACE}/ncs"
98+
99+
- name: Annotate per-project SBOMs with west revisions
100+
working-directory: ncs
101+
run: bash nrf/scripts/sbom/annotate.sh
102+
103+
- name: Generate license SBOM (west ncs-sbom)
104+
working-directory: ncs
105+
run: |
106+
pip3 install -U pip
107+
pip3 install -r nrf/scripts/requirements-west-ncs-sbom.txt
108+
west ncs-sbom \
109+
--input-dir nrf/samples/zephyr/basic/blinky \
110+
--output-spdx sbom/license-blinky.spdx \
111+
--package-supplier "Nordic Semiconductor ASA"
112+
113+
- name: Vulnerability scan reports (Grype)
114+
working-directory: ncs
115+
run: |
116+
grype sbom:sbom/ncs-full.cdx.json -c "${GITHUB_WORKSPACE}/${GRYPE_CONFIG}" -o table > sbom/grype-report.txt || true
117+
grype sbom:sbom/ncs-full.cdx.json -c "${GITHUB_WORKSPACE}/${GRYPE_CONFIG}" -o json > sbom/grype-report.json || true
118+
grype sbom:sbom/ncs-full.cdx.json -c "${GITHUB_WORKSPACE}/${GRYPE_CONFIG}" -o sarif > sbom/grype-results.sarif || true
119+
cat sbom/grype-report.txt
120+
121+
- name: Grype CVE gate
122+
if: >-
123+
env.GRYPE_FAIL_GATE == 'true' ||
124+
(github.event_name == 'workflow_dispatch' && github.event.inputs.grype_fail_gate == 'true')
125+
working-directory: ncs
126+
run: grype sbom:sbom/ncs-full.cdx.json -c "${GITHUB_WORKSPACE}/${GRYPE_CONFIG}" --fail-on "${FAIL_ON_SEVERITY}"
127+
128+
- name: Upload SARIF to GitHub Security tab
129+
if: always() && hashFiles('ncs/sbom/grype-results.sarif') != ''
130+
continue-on-error: true
131+
uses: github/codeql-action/upload-sarif@f1f6e5f6af878fb37288ce1c627459e94dbf7d01 # v3.30.1
132+
with:
133+
sarif_file: ncs/sbom/grype-results.sarif
134+
category: grype-ncs-sbom
135+
136+
- name: Package CycloneDX and SPDX zips
137+
id: package_zips
138+
if: always()
139+
working-directory: ncs/sbom
140+
run: |
141+
set -euo pipefail
142+
shopt -s nullglob
143+
144+
cdx_files=(ncs-full.cdx.json projects/*.cdx.json)
145+
spdx_files=(ncs-full.spdx ncs-full.spdx.json license-blinky.spdx projects/*.spdx.json)
146+
147+
if ((${#cdx_files[@]})); then
148+
zip -r "../ncs-sbom-cyclonedx.zip" "${cdx_files[@]}"
149+
echo "cyclonedx_zip=ncs/ncs-sbom-cyclonedx.zip" >> "$GITHUB_OUTPUT"
150+
echo "cyclonedx_size=$(du -h ../ncs-sbom-cyclonedx.zip | cut -f1)" >> "$GITHUB_OUTPUT"
151+
fi
152+
153+
if ((${#spdx_files[@]})); then
154+
zip -r "../ncs-sbom-spdx.zip" "${spdx_files[@]}"
155+
echo "spdx_zip=ncs/ncs-sbom-spdx.zip" >> "$GITHUB_OUTPUT"
156+
echo "spdx_size=$(du -h ../ncs-sbom-spdx.zip | cut -f1)" >> "$GITHUB_OUTPUT"
157+
fi
158+
159+
- name: Upload CycloneDX zip
160+
if: always() && steps.package_zips.outputs.cyclonedx_zip != ''
161+
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4
162+
with:
163+
name: ncs-sbom-cyclonedx-${{ steps.ncs_version.outputs.dt_version }}
164+
path: ncs/ncs-sbom-cyclonedx.zip
165+
retention-days: 90
166+
167+
- name: Upload SPDX zip
168+
if: always() && steps.package_zips.outputs.spdx_zip != ''
169+
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4
170+
with:
171+
name: ncs-sbom-spdx-${{ steps.ncs_version.outputs.dt_version }}
172+
path: ncs/ncs-sbom-spdx.zip
173+
retention-days: 90
174+
175+
- name: Upload SBOM artifacts
176+
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4
177+
if: always()
178+
with:
179+
name: ncs-sbom-${{ steps.ncs_version.outputs.dt_version }}
180+
path: ncs/sbom/
181+
retention-days: 90
182+
if-no-files-found: error
183+
184+
- name: SBOM artifact summary
185+
if: always()
186+
working-directory: ncs/sbom
187+
run: |
188+
fmt_size() { du -h "$1" 2>/dev/null | cut -f1; }
189+
190+
cdx_size="$(fmt_size ncs-full.cdx.json)"
191+
spdx_size="$(fmt_size ncs-full.spdx)"
192+
spdx_json_size="$(fmt_size ncs-full.spdx.json)"
193+
194+
{
195+
echo "## SBOM download bundles"
196+
echo ""
197+
echo "| Bundle | Contents | Size | Artifact |"
198+
echo "|--------|----------|------|----------|"
199+
if [ -n "${{ steps.package_zips.outputs.cyclonedx_size }}" ]; then
200+
echo "| **CycloneDX** | \`ncs-full.cdx.json\` + \`projects/*.cdx.json\` | ${{ steps.package_zips.outputs.cyclonedx_size }} | **ncs-sbom-cyclonedx-${{ steps.ncs_version.outputs.dt_version }}** |"
201+
fi
202+
if [ -n "${{ steps.package_zips.outputs.spdx_size }}" ]; then
203+
echo "| **SPDX** | \`ncs-full.spdx\`, \`ncs-full.spdx.json\`, \`license-blinky.spdx\`, \`projects/*.spdx.json\` | ${{ steps.package_zips.outputs.spdx_size }} | **ncs-sbom-spdx-${{ steps.ncs_version.outputs.dt_version }}** |"
204+
fi
205+
echo ""
206+
echo "### Full workspace files"
207+
echo ""
208+
echo "| Format | File | Size |"
209+
echo "|--------|------|------|"
210+
[ -f ncs-full.cdx.json ] && \
211+
echo "| CycloneDX (JSON) | \`ncs-full.cdx.json\` | ${cdx_size} |"
212+
[ -f ncs-full.spdx ] && \
213+
echo "| SPDX (tag-value) | \`ncs-full.spdx\` | ${spdx_size} |"
214+
[ -f ncs-full.spdx.json ] && \
215+
echo "| SPDX (JSON) | \`ncs-full.spdx.json\` | ${spdx_json_size} |"
216+
echo ""
217+
echo "> Download the **zip bundles** from the **Artifacts** section on this run page."
218+
echo ""
219+
echo "## All SBOM outputs"
220+
echo ""
221+
echo "<details>"
222+
echo "<summary>Complete file listing ($(find . -type f | wc -l) files)</summary>"
223+
echo ""
224+
echo "| File | Size |"
225+
echo "|------|------|"
226+
find . -type f | sort | while read -r f; do
227+
echo "| \`${f#./}\` | $(fmt_size "$f") |"
228+
done
229+
echo ""
230+
echo "</details>"
231+
echo ""
232+
echo "Full bundle artifact: \`ncs-sbom-${{ steps.ncs_version.outputs.dt_version }}\`"
233+
} >> "$GITHUB_STEP_SUMMARY"

.grype.yaml

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Grype configuration for NCS workspace SBOM scans.
2+
# Tune ignore rules after reviewing initial report-only runs.
3+
# See: https://github.com/anchore/grype#specifying-ignore-rules
4+
5+
ignore:
6+
# Precompiled Nordic libraries — no package metadata for CVE matching
7+
- path: "**/lib/*.a"
8+
reason: "Nordic precompiled blob — track via PSIRT advisories"
9+
- path: "**/lib/**/*.a"
10+
reason: "Nordic precompiled blob — track via PSIRT advisories"
11+
- path: "**/lib/*.lib"
12+
reason: "Nordic precompiled blob — track via PSIRT advisories"
13+
14+
# SoftDevice Controller binary blobs
15+
- path: "**/softdevice_controller/**/*.bin"
16+
reason: "SDC binary blob — opaque to Grype"
17+
- path: "**/sdc/**/*.bin"
18+
reason: "SDC binary blob — opaque to Grype"
19+
20+
# Build artifacts and CI output — not shipped firmware
21+
- path: "**/build/**"
22+
reason: "Build output directory"
23+
- path: "**/twister-out/**"
24+
reason: "Twister test output"
25+
- path: "**/.git/**"
26+
reason: "Git metadata"
27+
28+
# Host-only tooling in workspace — not part of firmware image
29+
- path: "**/tools/**"
30+
reason: "Host development tools"
31+
- path: "**/scripts/ci/**"
32+
reason: "CI scripts — not shipped"
33+
34+
# Test harnesses — high false-positive rate for CVEs in test-only deps
35+
- path: "**/tests/**"
36+
reason: "Test code — tune per-project after baseline review"
37+
38+
# Uncomment and fill after first report-only run identifies noisy CVEs:
39+
# ignore:
40+
# - vulnerability: CVE-XXXX-YYYY
41+
# reason: "Not compiled into shipped firmware — west project X unused"

scripts/sbom/annotate.sh

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
#!/usr/bin/env bash
2+
# Post-process per-project CycloneDX SBOMs with west manifest revisions.
3+
# Expects to run from the west workspace root (ncs/) with sbom/ present.
4+
set -euo pipefail
5+
6+
SBOM_DIR="${SBOM_DIR:-sbom/projects}"
7+
CSV="${SBOM_DIR%/projects}/west-projects.csv"
8+
9+
if ! command -v jq >/dev/null 2>&1; then
10+
echo "error: jq is required" >&2
11+
exit 1
12+
fi
13+
14+
if [[ ! -d "${SBOM_DIR}" ]]; then
15+
echo "error: ${SBOM_DIR} not found" >&2
16+
exit 1
17+
fi
18+
19+
declare -A REVISIONS=()
20+
if [[ -f "${CSV}" ]]; then
21+
while IFS='|' read -r name revision _path _url; do
22+
[[ -z "${name}" || "${name}" == \#* ]] && continue
23+
safe_name="$(echo "${name}" | tr '/:' '_')"
24+
REVISIONS["${safe_name}"]="${revision}"
25+
done < "${CSV}"
26+
fi
27+
28+
for cdx in "${SBOM_DIR}"/*.cdx.json; do
29+
[[ -f "${cdx}" ]] || continue
30+
base="$(basename "${cdx}" .cdx.json)"
31+
revision="${REVISIONS[${base}]:-unknown}"
32+
33+
echo "annotate ${base} → revision ${revision}"
34+
35+
tmp="$(mktemp)"
36+
jq --arg rev "${revision}" --arg name "${base}" '
37+
.metadata.component.version = $rev |
38+
.metadata.component.name = (.metadata.component.name // $name) |
39+
.metadata.properties = (
40+
(.metadata.properties // []) |
41+
map(select(.name != "west:revision")) +
42+
[{"name": "west:revision", "value": $rev}]
43+
)
44+
' "${cdx}" > "${tmp}"
45+
mv "${tmp}" "${cdx}"
46+
done
47+
48+
echo "annotation complete"

scripts/sbom/scan-projects.sh

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
#!/usr/bin/env bash
2+
# Per-west-project Syft scan (Mode B).
3+
# Reads west-projects.csv produced by the workflow and emits SPDX + CycloneDX
4+
# per manifest project under sbom/projects/.
5+
set -euo pipefail
6+
7+
WORKSPACE_ROOT="${1:?usage: scan-projects.sh WORKSPACE_ROOT}"
8+
CSV="${WORKSPACE_ROOT}/sbom/west-projects.csv"
9+
OUT_DIR="${WORKSPACE_ROOT}/sbom/projects"
10+
11+
if [[ ! -f "${CSV}" ]]; then
12+
echo "error: ${CSV} not found — run west list step first" >&2
13+
exit 1
14+
fi
15+
16+
mkdir -p "${OUT_DIR}"
17+
18+
# Syft exclude patterns shared across per-project scans
19+
SYFT_EXCLUDES=(
20+
'./**/build/**'
21+
'./**/.git/**'
22+
'./**/twister-out/**'
23+
)
24+
25+
scan_project() {
26+
local name="$1"
27+
local revision="$2"
28+
local relpath="$3"
29+
local target="${WORKSPACE_ROOT}/${relpath}"
30+
local safe_name
31+
32+
safe_name="$(echo "${name}" | tr '/:' '_')"
33+
34+
if [[ ! -d "${target}" ]]; then
35+
echo "skip ${name}: path ${relpath} not present"
36+
return 0
37+
fi
38+
39+
echo "scan ${name} @ ${revision} (${relpath})"
40+
41+
local -a exclude_args=()
42+
for pattern in "${SYFT_EXCLUDES[@]}"; do
43+
exclude_args+=(--exclude "${pattern}")
44+
done
45+
46+
syft "dir:${target}" \
47+
--source-name "${name}" \
48+
--source-version "${revision}" \
49+
-o "spdx-json=${OUT_DIR}/${safe_name}.spdx.json" \
50+
-o "cyclonedx-json=${OUT_DIR}/${safe_name}.cdx.json" \
51+
"${exclude_args[@]}"
52+
}
53+
54+
while IFS='|' read -r name revision relpath _url; do
55+
[[ -z "${name}" || "${name}" == \#* ]] && continue
56+
scan_project "${name}" "${revision}" "${relpath}"
57+
done < "${CSV}"
58+
59+
echo "per-project SBOMs written to ${OUT_DIR}"

0 commit comments

Comments
 (0)