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
2 changes: 2 additions & 0 deletions docs/changelog/3269.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Add ``metadata.timestamp`` (derived from ``SOURCE_DATE_EPOCH`` for reproducibility) and ``metadata.tools`` naming the
generator to the embedded SBOM, and validate both fields in the packaging checks that already run on every pull request.
14 changes: 14 additions & 0 deletions hatch_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
import json
import tempfile
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Final

from hatchling.builders.hooks.plugin.interface import BuildHookInterface
from hatchling.builders.utils import get_reproducible_timestamp

_ROOT: Final[Path] = Path(__file__).resolve().parent
_EMBED_INIT: Final[Path] = _ROOT / "src" / "virtualenv" / "seed" / "wheels" / "embed" / "__init__.py"
Expand Down Expand Up @@ -71,6 +73,12 @@ def _cyclonedx_document(version: str, name: str) -> dict[str, Any]:
"serialNumber": _serial_number(name, version, wheel_sha256),
"version": 1,
"metadata": {
"timestamp": _timestamp(),
"tools": {
"components": [
{"type": "application", "name": "hatch_build.py", "vendor": "pypa"},
],
},
"component": {
"type": "application",
"name": name,
Expand Down Expand Up @@ -104,3 +112,9 @@ def _serial_number(name: str, version: str, wheel_sha256: dict[str, str]) -> str
# of the same commit against the same bundled wheels produce a byte-identical document
payload = f"{name}@{version}+{','.join(f'{k}:{v}' for k, v in sorted(wheel_sha256.items()))}"
return f"urn:uuid:{uuid.uuid5(_SBOM_NAMESPACE, payload)}"


def _timestamp() -> str:
# honors SOURCE_DATE_EPOCH through hatchling's own helper, the same value it uses for the wheel's zip entry
# timestamps, so setting it for a reproducible build keeps the SBOM byte-identical too
return datetime.fromtimestamp(get_reproducible_timestamp(), tz=timezone.utc).isoformat()
20 changes: 18 additions & 2 deletions tasks/validate_sbom.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import sys
import zipfile
from pathlib import Path
from typing import Any

_SERIAL_PATTERN = re.compile(r"^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")

Expand Down Expand Up @@ -48,10 +49,26 @@ def validate(wheel: Path) -> list[str]:
# the document as CycloneDX at all, and silently rejects anything missing it as an unknown format
problems.append(f"serialNumber must match {_SERIAL_PATTERN.pattern}, got {serial!r}")

root_ref = document.get("metadata", {}).get("component", {}).get("bom-ref")
metadata_problems, root_ref = _validate_metadata(document.get("metadata", {}))
problems += metadata_problems
problems += _validate_dependency_graph(document, root_ref)
return problems


def _validate_metadata(metadata: dict[str, Any]) -> tuple[list[str], str | None]:
problems = []
if not metadata.get("timestamp"):
problems.append("metadata.timestamp is missing")
if not metadata.get("tools", {}).get("components"):
problems.append("metadata.tools.components is missing or empty")
root_ref = metadata.get("component", {}).get("bom-ref")
if not root_ref:
problems.append("metadata.component.bom-ref is missing")
return problems, root_ref


def _validate_dependency_graph(document: dict[str, Any], root_ref: str | None) -> list[str]:
problems = []
component_refs = {component.get("bom-ref") for component in document.get("components", [])}
dependency_entries = {
entry.get("ref"): set(entry.get("dependsOn", [])) for entry in document.get("dependencies", [])
Expand All @@ -65,7 +82,6 @@ def validate(wheel: Path) -> list[str]:
problems.append(f"no dependencies entry for the root component {root_ref!r}")
elif root_depends_on != component_refs:
problems.append(f"root dependsOn {sorted(root_depends_on)} does not match components {sorted(component_refs)}")

return problems


Expand Down