Skip to content

Commit 426eaaa

Browse files
committed
Embed SBOM into wheels
1 parent fb895da commit 426eaaa

2 files changed

Lines changed: 116 additions & 5 deletions

File tree

.github/embed-sbom.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
"""Embed Pillow's CycloneDX SBOM into each wheel's `.dist-info/sboms/`
2+
directory, as specified by PEP 770.
3+
4+
The SBOM (produced by `generate-sbom.py`) is injected into every `.whl` in
5+
the given directory, updating each wheel's `RECORD` so the result remains a
6+
valid, installable wheel.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import argparse
12+
import base64
13+
import hashlib
14+
import sys
15+
import zipfile
16+
from pathlib import Path
17+
18+
19+
def record_entry(path: str, data: bytes) -> str:
20+
"""Build a RECORD line: `path,sha256=<base64url-nopad>,<size>`."""
21+
digest = base64.urlsafe_b64encode(hashlib.sha256(data).digest())
22+
return f"{path},sha256={digest.rstrip(b'=').decode()},{len(data)}"
23+
24+
25+
def embed(wheel: Path, sbom_name: str, sbom_bytes: bytes) -> None:
26+
with zipfile.ZipFile(wheel) as zf:
27+
infos = zf.infolist()
28+
contents = {info.filename: zf.read(info.filename) for info in infos}
29+
30+
record_name = next(
31+
name
32+
for name in contents
33+
if name.endswith(".dist-info/RECORD") and name.count("/") == 1
34+
)
35+
dist_info = record_name.rsplit("/", 1)[0]
36+
sbom_path = f"{dist_info}/sboms/{sbom_name}"
37+
38+
# Append a matching RECORD line for the SBOM (RECORD's own line has no hash).
39+
lines = contents[record_name].decode("utf-8").splitlines()
40+
lines.append(record_entry(sbom_path, sbom_bytes))
41+
contents[record_name] = ("\n".join(lines) + "\n").encode("utf-8")
42+
43+
tmp = wheel.with_name(wheel.name + ".tmp")
44+
with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as zf:
45+
# Re-use each original ZipInfo to preserve timestamps, mode bits and
46+
# compression; only RECORD's contents change.
47+
for info in infos:
48+
zf.writestr(info, contents[info.filename])
49+
zf.writestr(sbom_path, sbom_bytes)
50+
tmp.replace(wheel)
51+
52+
53+
def load_sbom(path: Path) -> tuple[str, bytes]:
54+
"""Read the SBOM; `path` may be the file or a directory containing one."""
55+
if path.is_dir():
56+
candidates = list(path.glob("*.cdx.json"))
57+
if len(candidates) != 1:
58+
msg = f"expected exactly one *.cdx.json in {path}, found {len(candidates)}"
59+
raise SystemExit(msg)
60+
path = candidates[0]
61+
return path.name, path.read_bytes()
62+
63+
64+
def main() -> None:
65+
parser = argparse.ArgumentParser(
66+
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
67+
)
68+
parser.add_argument(
69+
"wheelhouse", type=Path, help="directory of wheels to embed the SBOM into"
70+
)
71+
parser.add_argument(
72+
"sbom",
73+
type=Path,
74+
help="SBOM file, or a directory containing a single `.cdx.json`",
75+
)
76+
args = parser.parse_args()
77+
78+
sbom_name, sbom_bytes = load_sbom(args.sbom)
79+
80+
wheels = sorted(args.wheelhouse.glob("*.whl"))
81+
if not wheels:
82+
print(f"error: no wheels found in {args.wheelhouse}", file=sys.stderr)
83+
raise SystemExit(1)
84+
85+
for wheel in wheels:
86+
embed(wheel, sbom_name, sbom_bytes)
87+
print(f"Embedded {sbom_name} in {wheel.name}")
88+
89+
90+
if __name__ == "__main__":
91+
main()

.github/workflows/wheels.yml

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ env:
4141

4242
jobs:
4343
build-native-wheels:
44+
needs: sbom
4445
if: github.event_name != 'schedule' || github.event.repository.fork == false
4546
name: ${{ matrix.name }}
4647
runs-on: ${{ matrix.os }}
@@ -127,12 +128,22 @@ jobs:
127128
CIBW_ENABLE: cpython-prerelease pypy
128129
MACOSX_DEPLOYMENT_TARGET: ${{ matrix.macosx_deployment_target }}
129130

131+
- name: Download SBOM
132+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
133+
with:
134+
name: sbom
135+
path: sbom
136+
137+
- name: Embed SBOM in wheels
138+
run: python3 .github/embed-sbom.py wheelhouse sbom
139+
130140
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
131141
with:
132142
name: dist-${{ matrix.name }}
133143
path: ./wheelhouse/*.whl
134144

135145
windows:
146+
needs: sbom
136147
if: github.event_name != 'schedule' || github.event.repository.fork == false
137148
name: Windows ${{ matrix.cibw_arch }}
138149
runs-on: ${{ matrix.os }}
@@ -206,6 +217,15 @@ jobs:
206217
powershell C:\pillow\.github\workflows\wheels-test.ps1 %CD%\..\venv-test'
207218
shell: bash
208219

220+
- name: Download SBOM
221+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
222+
with:
223+
name: sbom
224+
path: sbom
225+
226+
- name: Embed SBOM in wheels
227+
run: python .github/embed-sbom.py wheelhouse sbom
228+
209229
- name: Upload wheels
210230
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
211231
with:
@@ -314,17 +334,17 @@ jobs:
314334
- name: Generate CycloneDX SBOM
315335
run: python3 .github/generate-sbom.py
316336

337+
- name: Validate SBOM
338+
run: |
339+
python3 -m pip install -r .ci/requirements-sbom.txt
340+
check-jsonschema --schemafile "https://raw.githubusercontent.com/CycloneDX/specification/1.7/schema/bom-1.7.schema.json" pillow-*.cdx.json
341+
317342
- name: Upload SBOM as workflow artifact
318343
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
319344
with:
320345
name: sbom
321346
path: "pillow-*.cdx.json"
322347

323-
- name: Validate SBOM
324-
run: |
325-
python3 -m pip install -r .ci/requirements-sbom.txt
326-
check-jsonschema --schemafile "https://raw.githubusercontent.com/CycloneDX/specification/1.7/schema/bom-1.7.schema.json" pillow-*.cdx.json
327-
328348
sbom-publish:
329349
if: |
330350
github.event.repository.fork == false

0 commit comments

Comments
 (0)