-
-
Notifications
You must be signed in to change notification settings - Fork 30
fix(release): use subset check for SBOM/lock comparison #148
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
1bccd70
ad0b4e1
c129329
1672fed
d45f8c9
e39df2e
9f8f6fb
483f7fa
85f85fd
7b681ff
96ff9b6
358053b
38e1a65
f5c7aa7
71079ff
2c10864
ce7d070
7d5e003
0c1ee69
273a2f1
2e0a677
88bc16e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,6 +20,15 @@ | |
| except ImportError: # pragma: no cover - supported Python 3.9/3.10 | ||
| tomllib = None | ||
|
|
||
| try: # Prefer the installed packaging module when available. | ||
| from packaging.markers import InvalidMarker, Marker | ||
| from packaging.specifiers import InvalidSpecifier, SpecifierSet | ||
| from packaging.version import InvalidVersion, Version | ||
| except ImportError: # pragma: no cover - fallback for environments without top-level packaging | ||
| from pip._vendor.packaging.markers import InvalidMarker, Marker | ||
| from pip._vendor.packaging.specifiers import InvalidSpecifier, SpecifierSet | ||
| from pip._vendor.packaging.version import InvalidVersion, Version | ||
|
|
||
|
|
||
| FORMAT = "engraphis-release-evidence/3" | ||
| PACKAGE = "engraphis" | ||
|
|
@@ -30,12 +39,27 @@ | |
| _PACKAGE_LOCK_LINE = re.compile(r"([A-Za-z0-9][A-Za-z0-9_.-]*)==([^\s]+)\Z") | ||
| _IMAGE_DIGEST = re.compile(r"sha256:[0-9a-f]{64}\Z") | ||
| _BUILDER_IMAGE = "github-hosted:ubuntu-latest/python-3.11" | ||
| # Extras installed by the release workflow (`.github/workflows/release.yml` runs | ||
| # `pip install ... ".[all,test]"` before capturing the SBOM), so the captured | ||
| # closure must include every marker-applicable requirement they declare. | ||
| _RELEASE_EXTRAS = ("all", "test") | ||
| _BUILDER_TOOLCHAIN = { | ||
| "build": "1.5.0", | ||
| "pip": "26.2", | ||
| "setuptools": "83.0.0", | ||
| "wheel": "0.47.0", | ||
| } | ||
|
|
||
|
|
||
| def _purl_matches(purl: str, name: str, version: str) -> bool: | ||
| """Return True when a pkg:pypi PURL names *name* at *version*.""" | ||
| if not purl.startswith("pkg:pypi/"): | ||
| return False | ||
| remainder = purl[len("pkg:pypi/"):].split("?", 1)[0] | ||
| if "@" not in remainder: | ||
| return False | ||
| purl_name, purl_version = remainder.split("@", 1) | ||
| return _canonical_package_name(purl_name) == name and purl_version == version | ||
| _GRYPE_VERSION = "0.110.0" | ||
| _SECRET_NAME = re.compile( | ||
| r"(?:secret|token|password|credential|api[-_]?key|private[-_]?key)", re.IGNORECASE | ||
|
|
@@ -121,6 +145,8 @@ def project_version(root: Path) -> str: | |
| return version | ||
|
|
||
|
|
||
|
|
||
|
|
||
| def git_commit(root: Path) -> str: | ||
| try: | ||
| commit = subprocess.check_output( | ||
|
|
@@ -218,6 +244,113 @@ def _canonical_package_name(value: str) -> str: | |
| return re.sub(r"[-_.]+", "-", value).lower() | ||
|
|
||
|
|
||
| def _parse_requirement(requirement: str) -> tuple[str, str | None]: | ||
| """Extract (name, specifier) from a PEP 508 requirement string.""" | ||
| requirement = requirement.strip() | ||
| if not requirement: | ||
| return ("", None) | ||
| match = re.match( | ||
| r'^([A-Za-z0-9][A-Za-z0-9._-]*)' | ||
| r'(?:\[.*?\])?' | ||
| r'\s*' | ||
| r'((?:[<>=!~]=?[^;,\s]+(?:\s*,\s*[<>=!~]=?[^;,\s]+)*)?)', | ||
| requirement, | ||
| ) | ||
| if not match: | ||
| name = re.split(r"[\s;<(>=!~\[]", requirement, maxsplit=1)[0] | ||
| return (name, None) | ||
| name = match.group(1) | ||
| specifier = match.group(2) if match.group(2) else None | ||
| return (name, specifier) | ||
|
|
||
|
|
||
| def _version_satisfies(version: str, specifier: str) -> bool: | ||
| """Check if *version* satisfies a PEP 440 specifier.""" | ||
| if not specifier: | ||
| return True | ||
| try: | ||
| candidate = Version(version) | ||
| spec = SpecifierSet(specifier) | ||
| except (InvalidVersion, InvalidSpecifier): | ||
| return False | ||
| return candidate in spec | ||
|
|
||
|
|
||
| def _declared_dependencies(root: Path) -> dict[str, str | None]: | ||
| """Return {canonical_name: specifier} required in the captured SBOM closure. | ||
|
|
||
| Covers [project].dependencies plus every requirement declared by the extras | ||
| the release workflow installs (``_RELEASE_EXTRAS``). PEP 508 environment | ||
| markers are evaluated against the running interpreter, which in the release | ||
| workflow is the same environment that captures the SBOM; requirements whose | ||
| markers do not apply are not required. | ||
| """ | ||
| pyproject = root / "pyproject.toml" | ||
| try: | ||
| raw = pyproject.read_text(encoding="utf-8") | ||
| except OSError: | ||
| return {} | ||
| requirements: list[str] = [] | ||
| if tomllib is not None: | ||
| try: | ||
| parsed = tomllib.loads(raw) | ||
| except (KeyError, ValueError): | ||
| parsed = {} | ||
| project = parsed.get("project", {}) if isinstance(parsed, dict) else {} | ||
| if isinstance(project, dict): | ||
| core = project.get("dependencies", []) | ||
| if isinstance(core, list): | ||
| requirements.extend(item for item in core if isinstance(item, str)) | ||
| extras = project.get("optional-dependencies", {}) | ||
| if isinstance(extras, dict): | ||
| for extra in _RELEASE_EXTRAS: | ||
| group = extras.get(extra) | ||
| if isinstance(group, list): | ||
| requirements.extend( | ||
| item for item in group if isinstance(item, str) | ||
| ) | ||
| else: | ||
| project = re.search(r"(?ms)^\[project\]\s*(.*?)(?=^\[|\Z)", raw) | ||
| if project is not None: | ||
| deps_block = re.search( | ||
| r'(?m)^dependencies\s*=\s*\[(.*?)\]', project.group(1), re.DOTALL, | ||
| ) | ||
| if deps_block is not None: | ||
| requirements.extend(re.findall(r'"([^"]+)"', deps_block.group(1))) | ||
| extras_table = re.search( | ||
| r"(?ms)^\[project\.optional-dependencies\]\s*(.*?)(?=^\[|\Z)", raw, | ||
| ) | ||
| if extras_table is not None: | ||
| for extra in _RELEASE_EXTRAS: | ||
| group = re.search( | ||
| r"(?m)^" + re.escape(extra) + r"\s*=\s*\[(.*?)\]", | ||
| extras_table.group(1), re.DOTALL, | ||
| ) | ||
| if group is not None: | ||
| requirements.extend(re.findall(r'"([^"]+)"', group.group(1))) | ||
| deps: dict[str, str | None] = {} | ||
| for requirement in requirements: | ||
| if not isinstance(requirement, str) or not requirement.strip(): | ||
| continue | ||
| name, specifier = _parse_requirement(requirement) | ||
| canonical = _canonical_package_name(name) | ||
| if not canonical or canonical == PACKAGE: | ||
| continue | ||
| marker_text = requirement.split(";", 1)[1].strip() if ";" in requirement else "" | ||
| if marker_text: | ||
| try: | ||
| applies = Marker(marker_text).evaluate() | ||
| except InvalidMarker as exc: | ||
| raise EvidenceError( | ||
| "pyproject.toml declares an unparsable environment marker: " | ||
| + marker_text | ||
| ) from exc | ||
| if not applies: | ||
| continue | ||
| if canonical not in deps or deps[canonical] is None: | ||
| deps[canonical] = specifier | ||
| return deps | ||
|
|
||
| def _python_sbom_packages(document: dict[str, Any]) -> set[tuple[str, str]]: | ||
| packages = set() | ||
| metadata_component = document.get("metadata", {}).get("component") | ||
|
|
@@ -238,13 +371,19 @@ def _python_sbom_packages(document: dict[str, Any]) -> set[tuple[str, str]]: | |
| purl = component.get("purl") | ||
| name = component.get("name") | ||
| version = component.get("version") | ||
| if ( | ||
| isinstance(purl, str) | ||
| and purl.startswith("pkg:pypi/") | ||
| and isinstance(name, str) | ||
| and isinstance(version, str) | ||
| ): | ||
| packages.add((_canonical_package_name(name), version)) | ||
| if not isinstance(name, str) or not isinstance(version, str): | ||
| continue | ||
| if not isinstance(purl, str) or not purl.startswith("pkg:pypi/"): | ||
| raise EvidenceError( | ||
| "SBOM component lacks a valid PyPI PURL: " | ||
| + name + "@" + version | ||
| ) | ||
| if not _purl_matches(purl, _canonical_package_name(name), version): | ||
| raise EvidenceError( | ||
| "SBOM component PURL does not match its name/version: " | ||
| + name + "@" + version + " vs " + purl | ||
| ) | ||
| packages.add((_canonical_package_name(name), version)) | ||
| return packages | ||
|
|
||
|
|
||
|
|
@@ -272,12 +411,14 @@ def sbom_artifact(root: Path, path: Path) -> dict[str, Any]: | |
| } | ||
|
|
||
|
|
||
| def environment_lock_artifact(root: Path, path: Path, sbom: Path) -> dict[str, Any]: | ||
| def environment_lock_artifact( | ||
| root: Path, path: Path, sbom: Path, version: str) -> dict[str, Any]: | ||
| """Require the exact build freeze to equal the Python SBOM package closure.""" | ||
| if not path.is_file() or path.is_symlink(): | ||
| raise EvidenceError("build environment lock is missing") | ||
| relative = _relative_path(root, path) | ||
| packages: set[tuple[str, str]] = set() | ||
| seen_names: set[str] = set() | ||
| try: | ||
| lines = path.read_text(encoding="utf-8").splitlines() | ||
| except (OSError, UnicodeDecodeError) as exc: | ||
|
|
@@ -288,12 +429,71 @@ def environment_lock_artifact(root: Path, path: Path, sbom: Path) -> dict[str, A | |
| match = _PACKAGE_LOCK_LINE.fullmatch(line) | ||
| if match is None: | ||
| raise EvidenceError("build environment lock must contain exact name==version lines") | ||
| package = (_canonical_package_name(match.group(1)), match.group(2)) | ||
| canonical = _canonical_package_name(match.group(1)) | ||
| package = (canonical, match.group(2)) | ||
| if package in packages: | ||
| raise EvidenceError("build environment lock contains a duplicate package") | ||
| if canonical in seen_names: | ||
| raise EvidenceError( | ||
| "build environment lock contains conflicting versions of " + canonical | ||
| ) | ||
| seen_names.add(canonical) | ||
| packages.add(package) | ||
| sbom_packages = _python_sbom_packages(_json_object(sbom, "SBOM")) | ||
| if packages != sbom_packages: | ||
| document = _json_object(sbom, "SBOM") | ||
| sbom_packages = _python_sbom_packages(document) | ||
| if not sbom_packages: | ||
| raise EvidenceError("SBOM contains no Python package components") | ||
| metadata_component = document.get("metadata", {}).get("component") | ||
| if not isinstance(metadata_component, dict): | ||
|
Coding-Dev-Tools marked this conversation as resolved.
|
||
| raise EvidenceError( | ||
| "SBOM metadata.component does not identify the " + PACKAGE + " root" | ||
| ) | ||
| root_name = metadata_component.get("name") | ||
| root_version = metadata_component.get("version") | ||
| root_purl = metadata_component.get("purl") | ||
| if ( | ||
| not isinstance(root_name, str) | ||
| or _canonical_package_name(root_name) != PACKAGE | ||
| or not isinstance(root_version, str) | ||
| or root_version != version | ||
| or not isinstance(root_purl, str) | ||
| or not _purl_matches(root_purl, PACKAGE, version) | ||
| ): | ||
| raise EvidenceError( | ||
| "SBOM metadata.component does not identify the " + PACKAGE | ||
| + " root at version " + version | ||
| ) | ||
| declared = _declared_dependencies(root) | ||
| dependency_packages = { | ||
| pkg for pkg in sbom_packages | ||
| if pkg != (_canonical_package_name(PACKAGE), version) | ||
| } | ||
| declared_names = {name for name in declared if name != PACKAGE} | ||
| sbom_dependency_names = { | ||
| name for name, _ in dependency_packages | ||
|
Coding-Dev-Tools marked this conversation as resolved.
|
||
| } | ||
| missing_declared = declared_names - sbom_dependency_names | ||
|
Comment on lines
+471
to
+475
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a truncated SBOM retains the root and every direct requirement from the selected Useful? React with 👍 / 👎. |
||
| if missing_declared: | ||
| raise EvidenceError( | ||
| "SBOM is missing declared dependencies: " | ||
| + ", ".join(sorted(missing_declared)) | ||
| ) | ||
| # Validate version constraints for declared dependencies | ||
| sbom_versions = {name: ver for name, ver in dependency_packages} | ||
| for name, specifier in declared.items(): | ||
| if name == PACKAGE or not specifier or name not in sbom_versions: | ||
| continue | ||
| sbom_ver = sbom_versions[name] | ||
| if not _version_satisfies(sbom_ver, specifier): | ||
| raise EvidenceError( | ||
| f"SBOM version {name}=={sbom_ver} does not satisfy " | ||
| f"declared constraint {specifier}" | ||
| ) | ||
| if not dependency_packages: | ||
|
Coding-Dev-Tools marked this conversation as resolved.
|
||
| raise EvidenceError( | ||
| "SBOM contains no dependency components beyond the " + PACKAGE + " root" | ||
| ) | ||
| if not sbom_packages.issubset(packages): | ||
|
Coding-Dev-Tools marked this conversation as resolved.
Coding-Dev-Tools marked this conversation as resolved.
Coding-Dev-Tools marked this conversation as resolved.
Coding-Dev-Tools marked this conversation as resolved.
|
||
| raise EvidenceError("build environment lock and Python SBOM package closure differ") | ||
|
Coding-Dev-Tools marked this conversation as resolved.
|
||
| return { | ||
| "filename": path.name, | ||
|
|
@@ -675,7 +875,7 @@ def build_evidence( | |
| artifacts = distribution_artifacts(distribution_directory, version) | ||
| artifact_digests = {item["filename"]: item["sha256"] for item in artifacts} | ||
| python_sbom = sbom_artifact(root, sbom) | ||
| environment = environment_lock_artifact(root, environment_lock, sbom) | ||
| environment = environment_lock_artifact(root, environment_lock, sbom, version) | ||
| container_sbom = container_sbom_artifact(root, image_sbom, image_digest) | ||
| container_scan = container_scan_artifact(root, image_scan) | ||
| reproducibility_record = reproducibility_artifact( | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.