Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,13 @@ jobs:
- name: Build (wheel contract tests need dist/)
run: python -m build
- name: Unit, integration, contract, and e2e tests
run: python -m pytest
run: python -m pytest --junitxml=pytest-results.xml
- name: Upload pytest failure report
if: failure()
uses: actions/upload-artifact@v7
with:
name: pytest-${{ matrix.os }}-py${{ matrix.python }}
path: pytest-results.xml

package:
name: Build, package-data, license, smoke, and pip-audit
Expand Down
2 changes: 0 additions & 2 deletions constraints/ci.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,10 @@ build==1.5.0
CacheControl==0.14.4
certifi==2026.6.17
cffi==2.1.0
chardet==5.2.0
charset-normalizer==3.4.9
colorama==0.4.6
coverage==7.15.2
cryptography==49.0.0
cyclonedx-bom==7.3.0
cyclonedx-python-lib==11.11.0
defusedxml==0.7.1
docutils==0.23
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@ classifiers = [
[project.optional-dependencies]
dev = [
"build>=1.2",
"cyclonedx-bom>=7",
"cyclonedx-python-lib>=11",
"hypothesis>=6",
"jsonschema>=4",
"mypy>=1.10",
"pip-audit>=2.7",
"pytest>=8",
Expand Down
131 changes: 116 additions & 15 deletions scripts/build_sbom.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,25 @@
"""Generate a CycloneDX environment SBOM and deterministic artifact checksums."""
"""Generate a reproducible CycloneDX SBOM and deterministic artifact checksums."""

from __future__ import annotations

import argparse
import hashlib
import subprocess
import sys
import json
import tomllib
from importlib.metadata import version
from pathlib import Path
from typing import Iterable
from typing import Any, Iterable

from cyclonedx.model import Property
from cyclonedx.model.bom import Bom, BomMetaData
from cyclonedx.model.component import Component, ComponentType
from cyclonedx.model.dependency import Dependency
from cyclonedx.model.license import DisjunctiveLicense, LicenseAcknowledgement
from cyclonedx.model.tool import ToolRepository
from cyclonedx.output.json import JsonV1Dot6
from packaging.requirements import Requirement
from packaging.utils import canonicalize_name
from packageurl import PackageURL


def sha256_file(path: Path) -> str:
Expand All @@ -19,31 +31,120 @@ def sha256_file(path: Path) -> str:


def write_checksums(paths: Iterable[Path], destination: Path) -> None:
lines = [f"{sha256_file(path)} {path.name}" for path in sorted(paths, key=lambda item: item.name)]
lines = [
f"{sha256_file(path)} {path.name}"
for path in sorted(paths, key=lambda item: item.name)
]
destination.write_text("\n".join(lines) + "\n", encoding="utf-8")


def project_metadata(pyproject: Path) -> dict[str, Any]:
document = tomllib.loads(pyproject.read_text(encoding="utf-8"))
project = document.get("project")
if not isinstance(project, dict):
raise ValueError(f"missing [project] table in {pyproject}")
return project


def declared_licenses(project: dict[str, Any]) -> list[DisjunctiveLicense]:
declared = project.get("license")
if not isinstance(declared, str) or not declared:
return []
return [
DisjunctiveLicense(
id=declared,
acknowledgement=LicenseAcknowledgement.DECLARED,
)
]


def direct_dependency_components(project: dict[str, Any]) -> list[Component]:
raw_dependencies = project.get("dependencies", [])
if not isinstance(raw_dependencies, list):
raise ValueError("project.dependencies must be a list")

components: list[Component] = []
for index, raw in enumerate(raw_dependencies, start=1):
if not isinstance(raw, str):
raise ValueError("project.dependencies entries must be strings")
requirement = Requirement(raw)
components.append(
Component(
name=requirement.name,
type=ComponentType.LIBRARY,
bom_ref=f"requirements-L{index}",
description=f"project dependency: {raw}",
purl=PackageURL(
type="pypi",
name=canonicalize_name(requirement.name),
),
)
)
return components


def build_bom(project: dict[str, Any]) -> Bom:
root = Component(
name=str(project["name"]),
version=str(project["version"]),
type=ComponentType.LIBRARY,
bom_ref="root-component",
description=str(project.get("description", "")) or None,
licenses=declared_licenses(project),
)
components = direct_dependency_components(project)
dependency_nodes = [Dependency(component.bom_ref) for component in components]
tool = Component(
name="cyclonedx-python-lib",
group="CycloneDX",
version=version("cyclonedx-python-lib"),
type=ComponentType.LIBRARY,
bom_ref="tool-cyclonedx-python-lib",
purl=PackageURL(type="pypi", name="cyclonedx-python-lib"),
)
metadata = BomMetaData(
component=root,
tools=ToolRepository(components=[tool]),
properties=[Property(name="cdx:reproducible", value="true")],
)
return Bom(
components=components,
metadata=metadata,
dependencies=[
*dependency_nodes,
Dependency(root.bom_ref, dependencies=dependency_nodes),
],
)


def write_reproducible_sbom(bom: Bom, destination: Path) -> None:
document = json.loads(JsonV1Dot6(bom).output_as_string())
document.pop("serialNumber", None)
metadata = document.get("metadata")
if isinstance(metadata, dict):
metadata.pop("timestamp", None)
destination.write_text(
json.dumps(document, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)


def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("directory", nargs="?", default="dist", type=Path)
args = parser.parse_args()
directory = args.directory.resolve()
artifacts = [
path for path in directory.iterdir()
path
for path in directory.iterdir()
if path.is_file() and (path.suffix == ".whl" or path.name.endswith(".tar.gz"))
]
if not artifacts:
parser.error(f"no wheel or sdist artifacts found in {directory}")

sbom = directory / "hardproof.cdx.json"
subprocess.run(
[
sys.executable, "-m", "cyclonedx_py", "requirements", "-",
"--pyproject", str(Path("pyproject.toml").resolve()),
"--mc-type", "library", "--output-reproducible",
"--output-format", "JSON", "--output-file", str(sbom),
],
input="PyYAML>=6,<7\n", text=True, check=True,
)
project = project_metadata(Path("pyproject.toml").resolve())
write_reproducible_sbom(build_bom(project), sbom)
write_checksums([*artifacts, sbom], directory / "SHA256SUMS")
print(f"SBOM: {sbom}")
print(f"Checksums: {directory / 'SHA256SUMS'}")
Expand Down
69 changes: 69 additions & 0 deletions tests/unit/test_build_sbom.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,28 @@
from __future__ import annotations

import json
import subprocess
import sys
import tomllib
from pathlib import Path

from scripts.build_sbom import sha256_file, write_checksums


ROOT = Path(__file__).resolve().parents[2]
SCRIPT = ROOT / "scripts" / "build_sbom.py"


def run_generator(directory: Path) -> None:
subprocess.run(
[sys.executable, str(SCRIPT), str(directory)],
cwd=ROOT,
check=True,
capture_output=True,
text=True,
)


def test_checksum_manifest_is_sorted_and_reproducible(tmp_path: Path) -> None:
(tmp_path / "b.whl").write_bytes(b"b")
(tmp_path / "a.tar.gz").write_bytes(b"a")
Expand All @@ -13,3 +33,52 @@ def test_checksum_manifest_is_sorted_and_reproducible(tmp_path: Path) -> None:
assert destination.read_bytes() == first
assert destination.read_text(encoding="utf-8").splitlines()[0].endswith(" a.tar.gz")
assert sha256_file(tmp_path / "a.tar.gz") in destination.read_text(encoding="utf-8")


def test_sbom_generation_is_reproducible_and_preserves_project_contract(
tmp_path: Path,
) -> None:
directory = tmp_path / "dist"
directory.mkdir()
(directory / "hardproof-0.3.1-py3-none-any.whl").write_bytes(b"wheel")
(directory / "hardproof-0.3.1.tar.gz").write_bytes(b"sdist")

run_generator(directory)
first_sbom = (directory / "hardproof.cdx.json").read_bytes()
first_checksums = (directory / "SHA256SUMS").read_bytes()
run_generator(directory)

assert (directory / "hardproof.cdx.json").read_bytes() == first_sbom
assert (directory / "SHA256SUMS").read_bytes() == first_checksums

document = json.loads(first_sbom)
assert document["bomFormat"] == "CycloneDX"
assert document["specVersion"] == "1.6"
assert "serialNumber" not in document
assert "timestamp" not in document["metadata"]
assert {item["name"] for item in document["metadata"]["properties"]} == {
"cdx:reproducible"
}

project = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))[
"project"
]
root = document["metadata"]["component"]
assert root["name"] == project["name"]
assert root["version"] == project["version"]
assert {component["name"] for component in document["components"]} == {"PyYAML"}

checksum_text = first_checksums.decode("utf-8")
assert "hardproof.cdx.json" in checksum_text
assert "hardproof-0.3.1-py3-none-any.whl" in checksum_text
assert "hardproof-0.3.1.tar.gz" in checksum_text


def test_sbom_toolchain_does_not_reintroduce_cyclonedx_cli_chardet_cap() -> None:
pyproject = (ROOT / "pyproject.toml").read_text(encoding="utf-8")
constraints = (ROOT / "constraints" / "ci.txt").read_text(encoding="utf-8")

assert "cyclonedx-python-lib>=11" in pyproject
assert "cyclonedx-bom" not in pyproject
assert "cyclonedx-bom==" not in constraints
assert "chardet==" not in constraints