Skip to content

Commit c16f95b

Browse files
authored
feat(build): attested sbom (#199)
* feat(build): attested sbom * feat(build): pinned python for sbom build
1 parent 5335aac commit c16f95b

3 files changed

Lines changed: 202 additions & 0 deletions

File tree

.github/workflows/matrix.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,8 @@ jobs:
150150
KERNEL_VERSION: "${{ matrix.merges.version }}"
151151
KERNEL_FLAVOR: "${{ matrix.merges.flavor }}"
152152
KERNEL_PRODUCES: "${{ join(matrix.merges.produces, ',') }}"
153+
KERNEL_SRC_URL: "${{ matrix.merges.source }}"
154+
FIRMWARE_URL: "${{ matrix.merges.firmware_url }}"
153155
DIGESTS_DIR: digests
154156
steps:
155157
- name: Harden the runner (Audit all outbound calls)
@@ -158,6 +160,12 @@ jobs:
158160
egress-policy: audit
159161
- name: checkout repository
160162
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4
163+
- name: set up Python
164+
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
165+
with:
166+
python-version: '3.13'
167+
- name: install python deps
168+
run: pip3 install -r requirements.txt
161169
- name: install cosign
162170
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
163171
- name: docker setup buildx
@@ -188,3 +196,23 @@ jobs:
188196
compression-level: 0
189197
- name: run merge script
190198
run: sh -x merge.sh
199+
- name: generate and attest CycloneDX SBOM
200+
env:
201+
COSIGN_EXPERIMENTAL: "true"
202+
run: |
203+
set -euo pipefail
204+
python3 hack/build/generate-sbom.py
205+
jq -e '.bomFormat == "CycloneDX" and ((.components // []) | length > 0)' \
206+
sbom.cdx.json >/dev/null \
207+
|| { echo "::error::kernel SBOM is empty or invalid"; exit 1; }
208+
# Attest the SBOM to each published manifest (kernel + SDK)
209+
attested=""
210+
IFS=',' read -ra refs <<< "${KERNEL_PRODUCES}"
211+
for ref in "${refs[@]}"; do
212+
[ -n "${ref}" ] || continue
213+
image="${ref%:*}"
214+
case " ${attested} " in *" ${image} "*) continue ;; esac
215+
attested="${attested} ${image}"
216+
echo "attesting SBOM to ${ref}"
217+
cosign attest --yes --type cyclonedx --predicate sbom.cdx.json "${ref}"
218+
done

hack/build/generate-sbom.py

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
#!/usr/bin/env python3
2+
"""Generate a curated CycloneDX SBOM for a published kernel manifest.
3+
4+
The kernel / kernel-SDK images are `FROM scratch` containing only compiled
5+
artifacts, so scanning them yields nothing, and scanning the debian build
6+
container yields hundreds of toolchain/base-OS packages that have no bearing on
7+
the kernel that ships. Instead we describe what actually defines the kernel:
8+
9+
- the upstream linux source version it was built from,
10+
- the patches applied to that source (the authoritative, arch-union list from
11+
patchlist.py), and
12+
- for GPU flavors, the firmware / nvidia module versions baked in.
13+
14+
All of this is architecture-independent -- the same source, patches, and module
15+
versions apply to every arch in the manifest -- so a single SBOM correctly
16+
describes the whole multi-arch image and stays correct as new arches (e.g.
17+
arm64) are added. There are deliberately NO per-arch / package filter rules.
18+
19+
Reads from the environment (set by the merge job):
20+
KERNEL_VERSION e.g. "6.18.35" or "6.18.35+nvidia-610.43.02"
21+
KERNEL_FLAVOR e.g. "zone", "host", "zone-amdgpu", "zone-nvidiagpu"
22+
KERNEL_SRC_URL upstream linux source tarball URL
23+
FIRMWARE_URL linux-firmware tarball URL (only used for zone-amdgpu)
24+
25+
Writes sbom.cdx.json (CycloneDX 1.6) in the current directory.
26+
27+
This was created with Claude.
28+
"""
29+
import json
30+
import os
31+
import re
32+
import subprocess
33+
import sys
34+
35+
36+
def applied_patches(version, flavor):
37+
"""The patch files applied to this (version, flavor).
38+
39+
Delegates to patchlist.py so the SBOM lists exactly the patches the build
40+
applies. patchlist.py matches constraints WITHOUT an arch argument, so this
41+
is the union across architectures -- correct for a manifest-level SBOM and
42+
forward-compatible with future arch-specific patches.
43+
"""
44+
out = subprocess.run(
45+
[sys.executable, "hack/build/patchlist.py", version, flavor],
46+
check=True,
47+
stdout=subprocess.PIPE,
48+
text=True,
49+
).stdout
50+
return [line.strip() for line in out.splitlines() if line.strip()]
51+
52+
53+
def firmware_version_from_url(url):
54+
# .../linux-firmware-<ver>.tar.xz
55+
match = re.search(r"linux-firmware-(.+?)\.tar\.", url or "")
56+
return match.group(1) if match else None
57+
58+
59+
def main():
60+
version = os.environ["KERNEL_VERSION"]
61+
flavor = os.environ["KERNEL_FLAVOR"]
62+
src_url = os.environ.get("KERNEL_SRC_URL", "")
63+
firmware_url = os.environ.get("FIRMWARE_URL", "")
64+
65+
# Strip any "+nvidia-<ver>" local suffix to get the upstream kernel version.
66+
kernel_version = version.split("+")[0]
67+
68+
kernel_ref = "pkg:generic/linux@%s" % kernel_version
69+
linux_component = {
70+
"bom-ref": kernel_ref,
71+
"type": "operating-system",
72+
"name": "linux",
73+
"version": kernel_version,
74+
"purl": kernel_ref,
75+
}
76+
if src_url:
77+
linux_component["externalReferences"] = [
78+
{"type": "distribution", "url": src_url}
79+
]
80+
81+
patches = applied_patches(version, flavor)
82+
if patches:
83+
linux_component["pedigree"] = {
84+
"patches": [
85+
{"type": "unofficial", "diff": {"url": patch}} for patch in patches
86+
]
87+
}
88+
89+
components = [linux_component]
90+
depends_on = [kernel_ref]
91+
92+
# GPU flavors bake in extra, separately-versioned artifacts.
93+
if flavor == "zone-amdgpu":
94+
fw_version = firmware_version_from_url(firmware_url)
95+
if fw_version:
96+
fw_ref = "pkg:generic/linux-firmware@%s" % fw_version
97+
fw_component = {
98+
"bom-ref": fw_ref,
99+
"type": "firmware",
100+
"name": "linux-firmware",
101+
"version": fw_version,
102+
"purl": fw_ref,
103+
}
104+
if firmware_url:
105+
fw_component["externalReferences"] = [
106+
{"type": "distribution", "url": firmware_url}
107+
]
108+
components.append(fw_component)
109+
depends_on.append(fw_ref)
110+
111+
if flavor == "zone-nvidiagpu" and "+nvidia-" in version:
112+
nv_version = version.split("+nvidia-", 1)[1]
113+
nv_url = (
114+
"https://github.com/NVIDIA/open-gpu-kernel-modules/archive/refs/tags/%s.tar.gz"
115+
% nv_version
116+
)
117+
nv_ref = "pkg:github/NVIDIA/open-gpu-kernel-modules@%s" % nv_version
118+
components.append(
119+
{
120+
"bom-ref": nv_ref,
121+
"type": "library",
122+
"name": "nvidia-open-gpu-kernel-modules",
123+
"version": nv_version,
124+
"purl": nv_ref,
125+
"externalReferences": [{"type": "distribution", "url": nv_url}],
126+
}
127+
)
128+
depends_on.append(nv_ref)
129+
130+
image_ref = "%s-kernel@%s" % (flavor, version)
131+
document = {
132+
"bomFormat": "CycloneDX",
133+
"specVersion": "1.6",
134+
"version": 1,
135+
"metadata": {
136+
"component": {
137+
"bom-ref": image_ref,
138+
"type": "container",
139+
"name": "%s-kernel" % flavor,
140+
"version": version,
141+
},
142+
"properties": [
143+
{"name": "dev.edera.kernel.flavor", "value": flavor},
144+
],
145+
},
146+
"components": components,
147+
"dependencies": [{"ref": image_ref, "dependsOn": depends_on}],
148+
}
149+
150+
with open("sbom.cdx.json", "w") as out:
151+
json.dump(document, out, indent=2)
152+
out.write("\n")
153+
154+
# Human-readable summary for the build log.
155+
print(
156+
json.dumps(
157+
{
158+
"flavor": flavor,
159+
"version": version,
160+
"kernel": kernel_version,
161+
"patches": len(patches),
162+
"components": len(components),
163+
},
164+
indent=2,
165+
)
166+
)
167+
168+
169+
if __name__ == "__main__":
170+
main()

hack/build/matrix.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,10 @@ def generate_merges(builds: list[dict[str, any]]) -> list[dict[str, any]]:
487487
"tags": list(build["tags"]),
488488
"produces": list(build["produces"]),
489489
"archs": [build["arch"]],
490+
# Carried for SBOM generation in the merge job; identical across
491+
# archs for a given (version, flavor).
492+
"source": build["source"],
493+
"firmware_url": build["firmware_url"],
490494
}
491495
else:
492496
if build["arch"] not in merges[key]["archs"]:

0 commit comments

Comments
 (0)