Skip to content

Commit 361cb0b

Browse files
hugovkradarhere
andauthored
Embed SBOM into wheels (#9679)
Co-authored-by: Andrew Murray <radarhere@users.noreply.github.com> Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com>
1 parent 2836057 commit 361cb0b

2 files changed

Lines changed: 144 additions & 27 deletions

File tree

.github/embed-sbom.py

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

.github/workflows/wheels.yml

Lines changed: 48 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ on:
1515
- ".ci/requirements-sbom.txt"
1616
- ".github/compare-dist-sizes.py"
1717
- ".github/dependencies.json"
18+
- ".github/embed-sbom.py"
1819
- ".github/generate-sbom.py"
1920
- ".github/workflows/wheels*"
2021
- "pyproject.toml"
@@ -40,7 +41,35 @@ env:
4041
FORCE_COLOR: 1
4142

4243
jobs:
44+
sbom:
45+
if: github.event_name != 'schedule' || github.event.repository.fork == false
46+
runs-on: ubuntu-latest
47+
name: Generate SBOM
48+
steps:
49+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
50+
with:
51+
persist-credentials: false
52+
53+
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
54+
with:
55+
python-version: "3.x"
56+
57+
- name: Generate CycloneDX SBOM
58+
run: python3 .github/generate-sbom.py
59+
60+
- name: Validate SBOM
61+
run: |
62+
python3 -m pip install -r .ci/requirements-sbom.txt
63+
check-jsonschema --schemafile "https://raw.githubusercontent.com/CycloneDX/specification/1.7/schema/bom-1.7.schema.json" pillow-*.cdx.json
64+
65+
- name: Upload SBOM as workflow artifact
66+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
67+
with:
68+
name: sbom
69+
path: "pillow-*.cdx.json"
70+
4371
build-native-wheels:
72+
needs: sbom
4473
if: github.event_name != 'schedule' || github.event.repository.fork == false
4574
name: ${{ matrix.name }}
4675
runs-on: ${{ matrix.os }}
@@ -128,12 +157,22 @@ jobs:
128157
CIBW_ENVIRONMENT_PASS_LINUX: FORCE_COLOR
129158
MACOSX_DEPLOYMENT_TARGET: ${{ matrix.macosx_deployment_target }}
130159

160+
- name: Download SBOM
161+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
162+
with:
163+
name: sbom
164+
path: sbom
165+
166+
- name: Embed SBOM in wheels
167+
run: python3 .github/embed-sbom.py wheelhouse sbom
168+
131169
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
132170
with:
133171
name: dist-${{ matrix.name }}
134172
path: ./wheelhouse/*.whl
135173

136174
windows:
175+
needs: sbom
137176
if: github.event_name != 'schedule' || github.event.repository.fork == false
138177
name: Windows ${{ matrix.cibw_arch }}
139178
runs-on: ${{ matrix.os }}
@@ -207,6 +246,15 @@ jobs:
207246
powershell C:\pillow\.github\workflows\wheels-test.ps1 %CD%\..\venv-test'
208247
shell: bash
209248

249+
- name: Download SBOM
250+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
251+
with:
252+
name: sbom
253+
path: sbom
254+
255+
- name: Embed SBOM in wheels
256+
run: python3 .github/embed-sbom.py wheelhouse sbom
257+
210258
- name: Upload wheels
211259
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
212260
with:
@@ -299,33 +347,6 @@ jobs:
299347
artifacts_path: dist
300348
anaconda_nightly_upload_token: ${{ secrets.ANACONDA_ORG_UPLOAD_TOKEN }}
301349

302-
sbom:
303-
if: github.event_name != 'schedule' || github.event.repository.fork == false
304-
runs-on: ubuntu-latest
305-
name: Generate SBOM
306-
steps:
307-
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
308-
with:
309-
persist-credentials: false
310-
311-
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
312-
with:
313-
python-version: "3.x"
314-
315-
- name: Generate CycloneDX SBOM
316-
run: python3 .github/generate-sbom.py
317-
318-
- name: Upload SBOM as workflow artifact
319-
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
320-
with:
321-
name: sbom
322-
path: "pillow-*.cdx.json"
323-
324-
- name: Validate SBOM
325-
run: |
326-
python3 -m pip install -r .ci/requirements-sbom.txt
327-
check-jsonschema --schemafile "https://raw.githubusercontent.com/CycloneDX/specification/1.7/schema/bom-1.7.schema.json" pillow-*.cdx.json
328-
329350
sbom-publish:
330351
if: |
331352
github.event.repository.fork == false

0 commit comments

Comments
 (0)