python-sbom 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- python_sbom/__init__.py +46 -0
- python_sbom/__main__.py +10 -0
- python_sbom/_cli/__init__.py +1 -0
- python_sbom/_cli/_archive.py +38 -0
- python_sbom/_cli/_embed.py +28 -0
- python_sbom/_cli/_main.py +28 -0
- python_sbom/_cli/_parser.py +144 -0
- python_sbom/_cli/_spdx.py +61 -0
- python_sbom/_cli/_validate.py +40 -0
- python_sbom/_collect/__init__.py +1 -0
- python_sbom/_collect/_archive.py +94 -0
- python_sbom/_collect/_content.py +109 -0
- python_sbom/_collect/_facts.py +155 -0
- python_sbom/_collect/_revision.py +93 -0
- python_sbom/_collect/_settings.py +82 -0
- python_sbom/_collect/_source.py +54 -0
- python_sbom/_collect/_wheel.py +55 -0
- python_sbom/_errors.py +15 -0
- python_sbom/_formats/__init__.py +1 -0
- python_sbom/_formats/_json.py +18 -0
- python_sbom/_formats/_spdx.py +263 -0
- python_sbom/_generate/__init__.py +1 -0
- python_sbom/_generate/_archive.py +84 -0
- python_sbom/_generate/_embed.py +74 -0
- python_sbom/_model/__init__.py +1 -0
- python_sbom/_model/_archive.py +117 -0
- python_sbom/_model/_bom.py +137 -0
- python_sbom/_model/_components.py +182 -0
- python_sbom/_model/_content.py +128 -0
- python_sbom/_model/_document.py +100 -0
- python_sbom/_model/_graph.py +64 -0
- python_sbom/_model/_identity.py +53 -0
- python_sbom/_model/_inputs.py +141 -0
- python_sbom/_model/_iri.py +96 -0
- python_sbom/_model/_licenses.py +58 -0
- python_sbom/_model/_markers.py +61 -0
- python_sbom/_model/_metadata.py +100 -0
- python_sbom/_model/_names.py +90 -0
- python_sbom/_model/_tools.py +48 -0
- python_sbom/_model/_types.py +281 -0
- python_sbom/_model/_wheel.py +89 -0
- python_sbom/_validate/__init__.py +1 -0
- python_sbom/_validate/_artifact.py +55 -0
- python_sbom/_validate/_document.py +221 -0
- python_sbom/_validate/_licenses.py +104 -0
- python_sbom/_validate/_limits.py +27 -0
- python_sbom/_validate/_schemas.py +124 -0
- python_sbom/_validate/schemas/LICENSE +201 -0
- python_sbom/_validate/schemas/bom-1.6.schema.json +5699 -0
- python_sbom/_validate/schemas/jsf-0.82.schema.json +240 -0
- python_sbom/_validate/schemas/spdx.schema.json +1662 -0
- python_sbom/_wheel/__init__.py +1 -0
- python_sbom/_wheel/_bundled.py +142 -0
- python_sbom/_wheel/_reader.py +59 -0
- python_sbom/_wheel/_record.py +55 -0
- python_sbom/_wheel/_write.py +103 -0
- python_sbom/_wheel/_zip.py +94 -0
- python_sbom/hatch/__init__.py +9 -0
- python_sbom/hatch/_hook.py +150 -0
- python_sbom/hatch/hooks.py +18 -0
- python_sbom/py.typed +0 -0
- python_sbom/version.py +24 -0
- python_sbom-0.1.1.dist-info/METADATA +95 -0
- python_sbom-0.1.1.dist-info/RECORD +69 -0
- python_sbom-0.1.1.dist-info/WHEEL +4 -0
- python_sbom-0.1.1.dist-info/entry_points.txt +5 -0
- python_sbom-0.1.1.dist-info/licenses/LICENSE +18 -0
- python_sbom-0.1.1.dist-info/licenses/src/python_sbom/_validate/schemas/LICENSE +201 -0
- python_sbom-0.1.1.dist-info/sboms/python-sbom.cdx.json +13548 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from typing import TYPE_CHECKING, Final, NotRequired, TypedDict
|
|
8
|
+
|
|
9
|
+
from python_sbom._model._graph import walk_components
|
|
10
|
+
from python_sbom._model._names import SBOM_FILE, SBOM_REPOSITORY, property_prefix, sbom_property
|
|
11
|
+
from python_sbom.version import __version__
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from python_sbom._model._types import (
|
|
15
|
+
Component,
|
|
16
|
+
Document,
|
|
17
|
+
ExternalReference,
|
|
18
|
+
License,
|
|
19
|
+
Metadata,
|
|
20
|
+
OrganizationalEntity,
|
|
21
|
+
Tools,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# SPDX 2.3 JSON names every CycloneDX 1.6 hash algorithm, but by names of its own
|
|
25
|
+
_HASH_ALGORITHMS: Final[dict[str, str]] = {
|
|
26
|
+
"MD5": "MD5",
|
|
27
|
+
"SHA-1": "SHA1",
|
|
28
|
+
"SHA-256": "SHA256",
|
|
29
|
+
"SHA-384": "SHA384",
|
|
30
|
+
"SHA-512": "SHA512",
|
|
31
|
+
"SHA3-256": "SHA3-256",
|
|
32
|
+
"SHA3-384": "SHA3-384",
|
|
33
|
+
"SHA3-512": "SHA3-512",
|
|
34
|
+
"BLAKE2b-256": "BLAKE2b-256",
|
|
35
|
+
"BLAKE2b-384": "BLAKE2b-384",
|
|
36
|
+
"BLAKE2b-512": "BLAKE2b-512",
|
|
37
|
+
"BLAKE3": "BLAKE3",
|
|
38
|
+
}
|
|
39
|
+
_LICENSE_REF: Final[re.Pattern[str]] = re.compile(r"LicenseRef-[A-Za-z0-9.-]+")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _LegacyTool(TypedDict):
|
|
43
|
+
vendor: NotRequired[str]
|
|
44
|
+
name: NotRequired[str]
|
|
45
|
+
version: NotRequired[str]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _Checksum(TypedDict):
|
|
49
|
+
algorithm: str
|
|
50
|
+
checksumValue: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class _ExternalRef(TypedDict):
|
|
54
|
+
referenceCategory: str
|
|
55
|
+
referenceType: str
|
|
56
|
+
referenceLocator: str
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class _Package(TypedDict):
|
|
60
|
+
SPDXID: str
|
|
61
|
+
name: str
|
|
62
|
+
downloadLocation: str
|
|
63
|
+
filesAnalyzed: bool
|
|
64
|
+
licenseConcluded: str
|
|
65
|
+
licenseDeclared: str
|
|
66
|
+
primaryPackagePurpose: str
|
|
67
|
+
versionInfo: NotRequired[str]
|
|
68
|
+
supplier: NotRequired[str]
|
|
69
|
+
checksums: NotRequired[list[_Checksum]]
|
|
70
|
+
homepage: NotRequired[str]
|
|
71
|
+
copyrightText: NotRequired[str]
|
|
72
|
+
summary: NotRequired[str]
|
|
73
|
+
comment: NotRequired[str]
|
|
74
|
+
externalRefs: NotRequired[list[_ExternalRef]]
|
|
75
|
+
licenseComments: NotRequired[str]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class _Relationship(TypedDict):
|
|
79
|
+
spdxElementId: str
|
|
80
|
+
relationshipType: str
|
|
81
|
+
relatedSpdxElement: str
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class _CreationInfo(TypedDict):
|
|
85
|
+
created: str
|
|
86
|
+
creators: list[str]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class _ExtractedLicense(TypedDict):
|
|
90
|
+
licenseId: str
|
|
91
|
+
extractedText: str
|
|
92
|
+
name: str
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class Spdx(TypedDict):
|
|
96
|
+
spdxVersion: str
|
|
97
|
+
dataLicense: str
|
|
98
|
+
SPDXID: str
|
|
99
|
+
name: str
|
|
100
|
+
documentNamespace: str
|
|
101
|
+
comment: str
|
|
102
|
+
creationInfo: _CreationInfo
|
|
103
|
+
packages: list[_Package]
|
|
104
|
+
relationships: list[_Relationship]
|
|
105
|
+
hasExtractedLicensingInfos: NotRequired[list[_ExtractedLicense]]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def render_spdx(document: Document) -> Spdx:
|
|
109
|
+
metadata: Final[Metadata] = document["metadata"]
|
|
110
|
+
root: Final[Component] = metadata["component"]
|
|
111
|
+
project: Final[str] = property_prefix(root["name"])
|
|
112
|
+
recorded: Final[dict[str, str]] = {prop["name"]: prop.get("value", "") for prop in metadata.get("properties", [])}
|
|
113
|
+
name: Final[str] = f"{root['name']}-{root['version']}" if "version" in root else root["name"]
|
|
114
|
+
# without a serial number the content names the document, so rendering it twice gives the same namespace
|
|
115
|
+
source: Final[str] = (
|
|
116
|
+
document["serialNumber"]
|
|
117
|
+
if "serialNumber" in document
|
|
118
|
+
else f"sha256:{hashlib.sha256(json.dumps(document, sort_keys=True).encode()).hexdigest()}"
|
|
119
|
+
)
|
|
120
|
+
repository: Final[str] = recorded.get(sbom_property(project, SBOM_REPOSITORY), "https://spdx.org/spdxdocs")
|
|
121
|
+
sbom_file: Final[str] = recorded.get(sbom_property(project, SBOM_FILE), "CycloneDX JSON")
|
|
122
|
+
tools, tool_creators = _tools(metadata)
|
|
123
|
+
# file components mirror wheel RECORD entries, which carry no SHA-1 and so cannot become SPDX files
|
|
124
|
+
components: Final[list[Component]] = [
|
|
125
|
+
component
|
|
126
|
+
for component in walk_components([root, *document.get("components", []), *tools])
|
|
127
|
+
if component["type"] != "file"
|
|
128
|
+
]
|
|
129
|
+
packages: Final[list[_Package]] = [_package(component, project) for component in components]
|
|
130
|
+
spdx: Final[Spdx] = {
|
|
131
|
+
"spdxVersion": "SPDX-2.3",
|
|
132
|
+
"dataLicense": "CC0-1.0",
|
|
133
|
+
"SPDXID": "SPDXRef-DOCUMENT",
|
|
134
|
+
"name": name,
|
|
135
|
+
"documentNamespace": f"{repository}/sboms/{name}-{source.removeprefix('urn:uuid:')}",
|
|
136
|
+
"comment": f"Rendered from the CycloneDX SBOM {source} ({sbom_file}), which also carries per-file hashes and"
|
|
137
|
+
" the build formulation.",
|
|
138
|
+
"creationInfo": _creation_info(metadata, tool_creators),
|
|
139
|
+
"packages": packages,
|
|
140
|
+
"relationships": _relationships(document, components, tools),
|
|
141
|
+
}
|
|
142
|
+
# SPDX requires the text of every LicenseRef a package declares; the metadata names them, it holds no text
|
|
143
|
+
references: Final[set[str]] = set()
|
|
144
|
+
for package in packages:
|
|
145
|
+
references.update(_LICENSE_REF.findall(package["licenseDeclared"]))
|
|
146
|
+
if references:
|
|
147
|
+
spdx["hasExtractedLicensingInfos"] = [
|
|
148
|
+
{"licenseId": token, "extractedText": f"{token}, as named in package metadata", "name": token}
|
|
149
|
+
for token in sorted(references)
|
|
150
|
+
]
|
|
151
|
+
return spdx
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _tools(metadata: Metadata) -> tuple[list[Component], list[str]]:
|
|
155
|
+
# CycloneDX 1.5 lists the tools as components; earlier versions as a bare array, whose entries carry no bom-ref, so
|
|
156
|
+
# they become creators alone
|
|
157
|
+
tools: Final[Tools | list[_LegacyTool] | None] = metadata.get("tools")
|
|
158
|
+
if isinstance(tools, list):
|
|
159
|
+
return [], [_creator(name, tool.get("version")) for tool in tools if (name := tool.get("name"))]
|
|
160
|
+
components: Final[list[Component]] = [] if tools is None else tools.get("components", [])
|
|
161
|
+
return components, [
|
|
162
|
+
_creator(tool["name"], tool.get("version")) for tool in components if tool["type"] == "application"
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _creator(name: str, version: str | None) -> str:
|
|
167
|
+
return f"Tool: {name}" if version is None else f"Tool: {name}-{version}"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _package(component: Component, project: str) -> _Package:
|
|
171
|
+
# a component carries either one SPDX expression, or the free-form names from License/classifier metadata plus the
|
|
172
|
+
# SPDX ids concluded from them
|
|
173
|
+
licenses: Final[list[License]] = component.get("licenses", [])
|
|
174
|
+
concluded: Final[list[str]] = [
|
|
175
|
+
entry["license"]["id"] for entry in licenses if "license" in entry and "id" in entry["license"]
|
|
176
|
+
]
|
|
177
|
+
package: Final[_Package] = {
|
|
178
|
+
"SPDXID": _spdx_id(component["bom-ref"]),
|
|
179
|
+
"name": component["name"],
|
|
180
|
+
"downloadLocation": "NOASSERTION",
|
|
181
|
+
"filesAnalyzed": False,
|
|
182
|
+
"licenseConcluded": " AND ".join(concluded) or "NOASSERTION",
|
|
183
|
+
"licenseDeclared": next((entry["expression"] for entry in licenses if "expression" in entry), "NOASSERTION"),
|
|
184
|
+
"primaryPackagePurpose": {"application": "APPLICATION", "library": "LIBRARY"}.get(component["type"], "OTHER"),
|
|
185
|
+
}
|
|
186
|
+
if version := component.get("version"):
|
|
187
|
+
package["versionInfo"] = version
|
|
188
|
+
if (supplier := component.get("supplier")) and (organization := supplier.get("name")):
|
|
189
|
+
package["supplier"] = f"Organization: {organization}"
|
|
190
|
+
checksums: Final[list[_Checksum]] = [
|
|
191
|
+
{"algorithm": _HASH_ALGORITHMS[entry["alg"]], "checksumValue": entry["content"]}
|
|
192
|
+
for entry in component.get("hashes", [])
|
|
193
|
+
if entry["alg"] in _HASH_ALGORITHMS
|
|
194
|
+
]
|
|
195
|
+
if checksums:
|
|
196
|
+
package["checksums"] = checksums
|
|
197
|
+
references: Final[list[ExternalReference]] = component.get("externalReferences", [])
|
|
198
|
+
if homepage := next((reference["url"] for reference in references if reference["type"] == "website"), None):
|
|
199
|
+
package["homepage"] = homepage
|
|
200
|
+
if copyright_text := component.get("copyright"):
|
|
201
|
+
package["copyrightText"] = copyright_text
|
|
202
|
+
if description := component.get("description"):
|
|
203
|
+
package["summary"] = description
|
|
204
|
+
if comment := "\n".join(
|
|
205
|
+
f"{prop['name']}: {prop.get('value', '')}"
|
|
206
|
+
for prop in component.get("properties", [])
|
|
207
|
+
if prop["name"].startswith(f"{project}:")
|
|
208
|
+
):
|
|
209
|
+
package["comment"] = comment
|
|
210
|
+
if purl := component.get("purl"):
|
|
211
|
+
package["externalRefs"] = [
|
|
212
|
+
{"referenceCategory": "PACKAGE-MANAGER", "referenceType": "purl", "referenceLocator": purl}
|
|
213
|
+
]
|
|
214
|
+
if package["licenseDeclared"] == "NOASSERTION" and (
|
|
215
|
+
names := [entry["license"]["name"] for entry in licenses if "license" in entry and "name" in entry["license"]]
|
|
216
|
+
):
|
|
217
|
+
package["licenseComments"] = f"Declared in package metadata as: {'; '.join(names)}"
|
|
218
|
+
return package
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _spdx_id(bom_ref: str) -> str:
|
|
222
|
+
# the readable part drops every other character, so refs that differ only in those share it; only the full digest,
|
|
223
|
+
# not a prefix of it, keeps their ids apart
|
|
224
|
+
readable: Final[str] = re.sub(r"[^A-Za-z0-9.]+", "-", bom_ref).strip("-")
|
|
225
|
+
return f"SPDXRef-{readable}-{hashlib.sha256(bom_ref.encode()).hexdigest()}"
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _creation_info(metadata: Metadata, tool_creators: list[str]) -> _CreationInfo:
|
|
229
|
+
timestamp: Final[str | None] = metadata.get("timestamp")
|
|
230
|
+
# SPDX requires a creation time; without the CycloneDX one, the time of this rendering is the true one
|
|
231
|
+
created: Final[datetime] = datetime.now(UTC) if timestamp is None else datetime.fromisoformat(timestamp)
|
|
232
|
+
supplier: Final[OrganizationalEntity | None] = metadata.get("supplier")
|
|
233
|
+
organization: Final[str] = "" if supplier is None else supplier.get("name", "")
|
|
234
|
+
creators: Final[list[str]] = [f"Organization: {organization}"] if organization else []
|
|
235
|
+
creators.extend(tool_creators)
|
|
236
|
+
creators.append(f"Tool: python-sbom-{__version__}")
|
|
237
|
+
return {"created": created.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), "creators": creators}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _relationships(document: Document, components: list[Component], tools: list[Component]) -> list[_Relationship]:
|
|
241
|
+
root_id: Final[str] = _spdx_id(document["metadata"]["component"]["bom-ref"])
|
|
242
|
+
relationships: Final[list[_Relationship]] = [_relationship("SPDXRef-DOCUMENT", "DESCRIBES", root_id)]
|
|
243
|
+
for parent in components:
|
|
244
|
+
parent_id = _spdx_id(parent["bom-ref"])
|
|
245
|
+
children = (child for child in parent.get("components", []) if child["type"] != "file")
|
|
246
|
+
relationships.extend(_relationship(parent_id, "CONTAINS", _spdx_id(child["bom-ref"])) for child in children)
|
|
247
|
+
for entry in document.get("dependencies", []):
|
|
248
|
+
dependent_id = _spdx_id(entry["ref"])
|
|
249
|
+
relationships.extend(
|
|
250
|
+
_relationship(dependent_id, "DEPENDS_ON", _spdx_id(dependency)) for dependency in entry.get("dependsOn", [])
|
|
251
|
+
)
|
|
252
|
+
relationships.extend(_relationship(_spdx_id(tool["bom-ref"]), "BUILD_TOOL_OF", root_id) for tool in tools)
|
|
253
|
+
return relationships
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _relationship(element: str, kind: str, related: str) -> _Relationship:
|
|
257
|
+
return {"spdxElementId": element, "relationshipType": kind, "relatedSpdxElement": related}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
__all__ = [
|
|
261
|
+
"Spdx",
|
|
262
|
+
"render_spdx",
|
|
263
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The pipelines that build a wheel's and an archive's SBOM from what _collect gathers, and write it."""
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from python_sbom._collect._archive import read_archive
|
|
6
|
+
from python_sbom._collect._content import resolve_provider
|
|
7
|
+
from python_sbom._collect._facts import collect_facts
|
|
8
|
+
from python_sbom._collect._revision import revision_reader
|
|
9
|
+
from python_sbom._collect._settings import settings_from_pyproject
|
|
10
|
+
from python_sbom._collect._source import copyright_notice, hash_build_scripts
|
|
11
|
+
from python_sbom._errors import SbomError
|
|
12
|
+
from python_sbom._formats._json import dumps
|
|
13
|
+
from python_sbom._model._archive import archive_artifact, bundled_paths
|
|
14
|
+
from python_sbom._model._bom import assemble
|
|
15
|
+
from python_sbom._model._content import ProviderContext
|
|
16
|
+
from python_sbom._model._document import checked_contributions
|
|
17
|
+
from python_sbom._model._identity import project_identity
|
|
18
|
+
from python_sbom._model._names import archive_sbom_file_name
|
|
19
|
+
from python_sbom._wheel._reader import open_artifact
|
|
20
|
+
from python_sbom._wheel._write import append_member
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from collections.abc import Sequence
|
|
24
|
+
from pathlib import Path, PurePosixPath
|
|
25
|
+
|
|
26
|
+
from python_sbom._model._content import ContentProvider
|
|
27
|
+
from python_sbom._model._inputs import BuildFacts
|
|
28
|
+
from python_sbom._model._types import Document
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def archive_sbom( # ruff: ignore[too-many-arguments] # every command-line option reaches the build
|
|
32
|
+
root: Path,
|
|
33
|
+
archive: Path,
|
|
34
|
+
*,
|
|
35
|
+
provider: ContentProvider | str | None,
|
|
36
|
+
commit: str | None,
|
|
37
|
+
build_env: Sequence[str] | None,
|
|
38
|
+
build_scripts: Sequence[PurePosixPath],
|
|
39
|
+
output: Path | None,
|
|
40
|
+
embed: bool,
|
|
41
|
+
) -> tuple[Path, str | None]:
|
|
42
|
+
name = archive_sbom_file_name(archive.name)
|
|
43
|
+
if (target := output or archive.with_name(name)).exists() and target.samefile(archive):
|
|
44
|
+
msg = f"{target} is the archive {archive} itself, refusing to overwrite it with the SBOM"
|
|
45
|
+
raise SbomError(msg)
|
|
46
|
+
facts = collect_facts(root, revision_reader(commit), path=build_env)
|
|
47
|
+
document = describe_archive(root, archive, facts=facts, provider=provider, build_scripts=build_scripts)
|
|
48
|
+
target.write_bytes(content := dumps(document))
|
|
49
|
+
if not embed:
|
|
50
|
+
return target, None
|
|
51
|
+
append_member(archive, name, content, facts.epoch)
|
|
52
|
+
return target, name
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def describe_archive(
|
|
56
|
+
root: Path,
|
|
57
|
+
archive: Path,
|
|
58
|
+
*,
|
|
59
|
+
facts: BuildFacts,
|
|
60
|
+
provider: ContentProvider | str | None,
|
|
61
|
+
build_scripts: Sequence[PurePosixPath],
|
|
62
|
+
) -> Document:
|
|
63
|
+
settings = settings_from_pyproject(root)
|
|
64
|
+
content, provider_scripts = resolve_provider(provider, settings, root)
|
|
65
|
+
with open_artifact(archive) as artifact:
|
|
66
|
+
tree = read_archive(artifact)
|
|
67
|
+
project = project_identity(tree.metadata[""], settings)
|
|
68
|
+
context = ProviderContext(root=root, settings=settings, identity=project, read=artifact.provider_read)
|
|
69
|
+
built = archive_artifact(
|
|
70
|
+
archive.name,
|
|
71
|
+
tree,
|
|
72
|
+
project=project,
|
|
73
|
+
bundled_info={str(path): content.bundled_wheel(context, path) for path in bundled_paths(tree)},
|
|
74
|
+
build_scripts=hash_build_scripts(root, [*build_scripts, *provider_scripts]),
|
|
75
|
+
copyright_line=copyright_notice(root, tree.metadata[""].values("license_files")),
|
|
76
|
+
)
|
|
77
|
+
contributions = checked_contributions(content.contributions(context), content)
|
|
78
|
+
return assemble(built, project, settings, facts, contributions)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
__all__ = [
|
|
82
|
+
"archive_sbom",
|
|
83
|
+
"describe_archive",
|
|
84
|
+
]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import PurePosixPath
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from python_sbom._collect._content import resolve_provider
|
|
7
|
+
from python_sbom._collect._facts import collect_facts
|
|
8
|
+
from python_sbom._collect._revision import revision_reader
|
|
9
|
+
from python_sbom._collect._settings import settings_from_pyproject
|
|
10
|
+
from python_sbom._collect._wheel import read_wheel
|
|
11
|
+
from python_sbom._formats._json import dumps
|
|
12
|
+
from python_sbom._model._bom import assemble
|
|
13
|
+
from python_sbom._model._content import ProviderContext
|
|
14
|
+
from python_sbom._model._document import checked_contributions
|
|
15
|
+
from python_sbom._model._identity import project_identity
|
|
16
|
+
from python_sbom._model._wheel import wheel_artifact
|
|
17
|
+
from python_sbom._wheel._reader import open_artifact
|
|
18
|
+
from python_sbom._wheel._write import write_sbom
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from collections.abc import Sequence
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from python_sbom._model._content import ContentProvider
|
|
25
|
+
from python_sbom._model._inputs import BuildFacts, Settings
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def embed_sbom(
|
|
29
|
+
wheel: Path,
|
|
30
|
+
root: Path,
|
|
31
|
+
*,
|
|
32
|
+
provider: ContentProvider | str | None,
|
|
33
|
+
commit: str | None,
|
|
34
|
+
build_env: Sequence[str] | None,
|
|
35
|
+
) -> str:
|
|
36
|
+
settings = settings_from_pyproject(root)
|
|
37
|
+
content, scripts = resolve_provider(provider, settings, root)
|
|
38
|
+
facts = collect_facts(root, revision_reader(commit), path=build_env)
|
|
39
|
+
return embed_into(wheel, root, settings=settings, content=content, build_scripts=scripts, facts=facts)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def embed_into( # ruff: ignore[too-many-arguments] # what the hook and the command line each gather their own way
|
|
43
|
+
wheel: Path,
|
|
44
|
+
root: Path,
|
|
45
|
+
*,
|
|
46
|
+
settings: Settings,
|
|
47
|
+
content: ContentProvider,
|
|
48
|
+
build_scripts: Sequence[PurePosixPath],
|
|
49
|
+
facts: BuildFacts,
|
|
50
|
+
) -> str:
|
|
51
|
+
# the wheel stays open while its document is built, for its metadata, its bundled wheels and the provider to read,
|
|
52
|
+
# and is closed before it is rewritten
|
|
53
|
+
with open_artifact(wheel) as artifact:
|
|
54
|
+
tree = read_wheel(artifact, root, settings, build_scripts)
|
|
55
|
+
project = project_identity(tree.metadata, settings)
|
|
56
|
+
context = ProviderContext(root=root, settings=settings, identity=project, read=artifact.provider_read)
|
|
57
|
+
built = wheel_artifact(
|
|
58
|
+
tree,
|
|
59
|
+
project=project,
|
|
60
|
+
settings=settings,
|
|
61
|
+
bundled_info={
|
|
62
|
+
bundled.path: content.bundled_wheel(context, PurePosixPath(bundled.path)) for bundled in tree.bundled
|
|
63
|
+
},
|
|
64
|
+
)
|
|
65
|
+
contributions = checked_contributions(content.contributions(context), content)
|
|
66
|
+
return write_sbom(
|
|
67
|
+
wheel, built.sbom_file, dumps(assemble(built, project, settings, facts, contributions)), facts.epoch
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
__all__ = [
|
|
72
|
+
"embed_into",
|
|
73
|
+
"embed_sbom",
|
|
74
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The CycloneDX document model: its types, its inputs and the pure functions that build it."""
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import PurePosixPath
|
|
5
|
+
from typing import TYPE_CHECKING, Final
|
|
6
|
+
|
|
7
|
+
from packageurl import PackageURL
|
|
8
|
+
|
|
9
|
+
from ._components import (
|
|
10
|
+
bundled_wheel_component,
|
|
11
|
+
component_from_metadata,
|
|
12
|
+
file_component,
|
|
13
|
+
mark_bundled,
|
|
14
|
+
vendored_dependencies,
|
|
15
|
+
)
|
|
16
|
+
from ._graph import artifact_contents
|
|
17
|
+
from ._inputs import Artifact
|
|
18
|
+
from ._names import archive_sbom_file_name
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from collections.abc import Mapping
|
|
22
|
+
|
|
23
|
+
from ._content import BundledWheelInfo
|
|
24
|
+
from ._identity import ProjectIdentity
|
|
25
|
+
from ._inputs import ArchiveTree, BuildScript
|
|
26
|
+
from ._types import Component
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def archive_artifact( # ruff: ignore[too-many-arguments] # keyword-only, each a separate fact the edge read
|
|
30
|
+
name: str,
|
|
31
|
+
tree: ArchiveTree,
|
|
32
|
+
*,
|
|
33
|
+
project: ProjectIdentity,
|
|
34
|
+
bundled_info: Mapping[str, BundledWheelInfo],
|
|
35
|
+
build_scripts: list[BuildScript],
|
|
36
|
+
copyright_line: str | None,
|
|
37
|
+
) -> Artifact:
|
|
38
|
+
# the archive's top-level .dist-info names the project; every other .dist-info marks a wheel bundled unpacked into
|
|
39
|
+
# its parent directory and every .whl member a bundled wheel; other files belong to the deepest distribution that
|
|
40
|
+
# holds them, or, at the top level, to the archive
|
|
41
|
+
version: Final[str] = tree.metadata[""].version
|
|
42
|
+
ref: Final[str] = PackageURL(type="generic", name=name, version=version).to_string()
|
|
43
|
+
distributions = _Distributions(tree=tree, project=project, bundled_info=bundled_info)
|
|
44
|
+
root: Final[Component] = {
|
|
45
|
+
"type": "application",
|
|
46
|
+
"bom-ref": ref,
|
|
47
|
+
"name": name,
|
|
48
|
+
"version": version,
|
|
49
|
+
"purl": ref,
|
|
50
|
+
"components": [
|
|
51
|
+
distributions.wheel(member) if member.endswith(".whl") else file_component(ref, tree.files[member])
|
|
52
|
+
for member in tree.loose
|
|
53
|
+
],
|
|
54
|
+
}
|
|
55
|
+
components: Final[list[Component]] = [distributions.component(prefix) for prefix in sorted(tree.metadata)]
|
|
56
|
+
return Artifact(
|
|
57
|
+
root=root,
|
|
58
|
+
components=components,
|
|
59
|
+
dependencies=vendored_dependencies(artifact_contents(root, components)),
|
|
60
|
+
build_scripts=build_scripts,
|
|
61
|
+
version=version,
|
|
62
|
+
sbom_file=archive_sbom_file_name(name),
|
|
63
|
+
scope=(
|
|
64
|
+
"Components are the distributions the archive holds, as wheels or unpacked, each with its files; the"
|
|
65
|
+
" archive's own top-level files sit under the root component."
|
|
66
|
+
),
|
|
67
|
+
workflow=("archive-build", f"build {name} and this SBOM"),
|
|
68
|
+
license_file=next(iter(tree.metadata[""].values("license_files")), None),
|
|
69
|
+
copyright=copyright_line,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
74
|
+
class _Distributions:
|
|
75
|
+
tree: ArchiveTree
|
|
76
|
+
project: ProjectIdentity
|
|
77
|
+
bundled_info: Mapping[str, BundledWheelInfo]
|
|
78
|
+
|
|
79
|
+
def component(self, prefix: str) -> Component:
|
|
80
|
+
component = component_from_metadata(self.tree.metadata[prefix])
|
|
81
|
+
if prefix:
|
|
82
|
+
# a wheel bundled unpacked, named by its directory as a .whl is by its path
|
|
83
|
+
directory = prefix.removesuffix("/")
|
|
84
|
+
mark_bundled(component, directory, self.project.property_prefix, self.bundled_info[directory])
|
|
85
|
+
component["components"] = [
|
|
86
|
+
self.wheel(name) if name.endswith(".whl") else file_component(component["bom-ref"], self.tree.files[name])
|
|
87
|
+
for name in self.tree.owned[prefix]
|
|
88
|
+
]
|
|
89
|
+
return component
|
|
90
|
+
|
|
91
|
+
def wheel(self, name: str) -> Component:
|
|
92
|
+
return bundled_wheel_component(self.tree.bundled[name], self.project.property_prefix, self.bundled_info[name])
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def bundled_paths(tree: ArchiveTree) -> list[PurePosixPath]:
|
|
96
|
+
return [PurePosixPath(member.removesuffix("/")) for member in bundled_members(tree.loose, tree.owned)]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def bundled_members(loose: list[str], owned: Mapping[str, list[str]]) -> list[str]:
|
|
100
|
+
# the bundled wheels in the order the components list them, which is the order they are read and a provider is asked
|
|
101
|
+
# about them: the archive's own top-level .whl members, then each wheel bundled unpacked, by its directory ending in
|
|
102
|
+
# "/", followed by the .whl members it holds
|
|
103
|
+
return [
|
|
104
|
+
*(name for name in loose if name.endswith(".whl")),
|
|
105
|
+
*(
|
|
106
|
+
member
|
|
107
|
+
for prefix in sorted(owned)
|
|
108
|
+
for member in ([prefix] if prefix else []) + [name for name in owned[prefix] if name.endswith(".whl")]
|
|
109
|
+
),
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
__all__ = [
|
|
114
|
+
"archive_artifact",
|
|
115
|
+
"bundled_members",
|
|
116
|
+
"bundled_paths",
|
|
117
|
+
]
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import PurePosixPath
|
|
4
|
+
from typing import TYPE_CHECKING, Final
|
|
5
|
+
from urllib.parse import urlsplit
|
|
6
|
+
|
|
7
|
+
from ._components import script_tool
|
|
8
|
+
from ._document import check_refs, contributed_properties, formulation, timestamp, with_header
|
|
9
|
+
from ._names import SBOM_FILE, SBOM_REPOSITORY, sbom_property
|
|
10
|
+
from ._tools import tool_components
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from ._content import Contributions
|
|
14
|
+
from ._identity import ProjectIdentity
|
|
15
|
+
from ._inputs import Artifact, BuildFacts, Settings
|
|
16
|
+
from ._types import Body, Component, Document, ExternalReference, Metadata, Property
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def assemble(
|
|
20
|
+
artifact: Artifact, project: ProjectIdentity, settings: Settings, facts: BuildFacts, contributions: Contributions
|
|
21
|
+
) -> Document:
|
|
22
|
+
# what every artifact of the project carries alike: who supplies it, where its source lives and at which revision,
|
|
23
|
+
# the tools that built it and what the project contributes
|
|
24
|
+
root = _provenance(artifact, project, settings, facts, contributions)
|
|
25
|
+
components: Final[list[Component]] = [*artifact.components, *contributions.components]
|
|
26
|
+
environment, tool_dependencies = tool_components(facts)
|
|
27
|
+
tools: Final[list[Component]] = [
|
|
28
|
+
*(
|
|
29
|
+
script_tool(script.path, script.sha256, artifact.version, _file_link(project, settings, facts, script.path))
|
|
30
|
+
for script in artifact.build_scripts
|
|
31
|
+
),
|
|
32
|
+
*environment,
|
|
33
|
+
*contributions.tools,
|
|
34
|
+
]
|
|
35
|
+
uid, name = artifact.workflow
|
|
36
|
+
body: Final[Body] = {
|
|
37
|
+
"metadata": _metadata(tools, artifact, project, settings, facts),
|
|
38
|
+
"components": components,
|
|
39
|
+
"dependencies": [
|
|
40
|
+
{"ref": root["bom-ref"], "dependsOn": [component["bom-ref"] for component in components]},
|
|
41
|
+
*artifact.dependencies,
|
|
42
|
+
*tool_dependencies,
|
|
43
|
+
*contributions.dependencies,
|
|
44
|
+
],
|
|
45
|
+
# what the artifact needs at run time beyond what it ships, an interpreter and the dependencies an installer
|
|
46
|
+
# resolves, is not listed
|
|
47
|
+
"compositions": [{"aggregate": "incomplete", "dependencies": [root["bom-ref"]]}],
|
|
48
|
+
"formulation": [formulation(uid, name, tools, root["bom-ref"], facts)],
|
|
49
|
+
}
|
|
50
|
+
check_refs(body)
|
|
51
|
+
return with_header(body, project.serial_namespace)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _provenance(
|
|
55
|
+
artifact: Artifact, project: ProjectIdentity, settings: Settings, facts: BuildFacts, contributions: Contributions
|
|
56
|
+
) -> Component:
|
|
57
|
+
root = artifact.root
|
|
58
|
+
if settings.supplier is not None:
|
|
59
|
+
root["supplier"] = settings.supplier
|
|
60
|
+
if artifact.copyright is not None:
|
|
61
|
+
root["copyright"] = artifact.copyright
|
|
62
|
+
references: Final[list[ExternalReference]] = [*root.get("externalReferences", [])]
|
|
63
|
+
if project.repository and urlsplit(project.repository).netloc == "github.com":
|
|
64
|
+
references.extend([
|
|
65
|
+
{"type": "security-contact", "url": f"{project.repository}/security/policy"},
|
|
66
|
+
{"type": "advisories", "url": f"{project.repository}/security/advisories"},
|
|
67
|
+
])
|
|
68
|
+
if artifact.license_file is not None and (
|
|
69
|
+
link := _file_link(project, settings, facts, PurePosixPath(artifact.license_file))
|
|
70
|
+
):
|
|
71
|
+
references.append({"type": "license", "url": link})
|
|
72
|
+
properties: Final[list[Property]] = [
|
|
73
|
+
*root.get("properties", []),
|
|
74
|
+
*contributed_properties(contributions.root_properties, project.property_prefix),
|
|
75
|
+
]
|
|
76
|
+
if (revision := facts.revision) is not None:
|
|
77
|
+
if project.repository:
|
|
78
|
+
comment = "source revision, with uncommitted changes" if revision.dirty else "exact source revision"
|
|
79
|
+
references.append({
|
|
80
|
+
"type": "vcs",
|
|
81
|
+
"url": f"{project.repository}/tree/{revision.commit}",
|
|
82
|
+
"comment": comment,
|
|
83
|
+
})
|
|
84
|
+
properties.append({"name": sbom_property(project.property_prefix, "vcs-commit"), "value": revision.commit})
|
|
85
|
+
if revision.dirty:
|
|
86
|
+
properties.append({"name": sbom_property(project.property_prefix, "vcs-dirty"), "value": "true"})
|
|
87
|
+
root["externalReferences"] = references
|
|
88
|
+
root["properties"] = properties
|
|
89
|
+
return root
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _file_link(project: ProjectIdentity, settings: Settings, facts: BuildFacts, path: PurePosixPath) -> str | None:
|
|
93
|
+
if project.repository is None:
|
|
94
|
+
return None
|
|
95
|
+
# the commit of a clean checkout holds the file as it was built; with no commit, or uncommitted changes, the default
|
|
96
|
+
# branch is where the file most likely still lives
|
|
97
|
+
revision = facts.revision
|
|
98
|
+
tree = settings.default_branch if revision is None or revision.dirty else revision.commit
|
|
99
|
+
return f"{project.repository}/blob/{tree}/{path}"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _metadata(
|
|
103
|
+
tools: list[Component],
|
|
104
|
+
artifact: Artifact,
|
|
105
|
+
project: ProjectIdentity,
|
|
106
|
+
settings: Settings,
|
|
107
|
+
facts: BuildFacts,
|
|
108
|
+
) -> Metadata:
|
|
109
|
+
metadata: Final[Metadata] = {
|
|
110
|
+
"timestamp": timestamp(facts),
|
|
111
|
+
"lifecycles": [{"phase": "build"}],
|
|
112
|
+
"tools": {"components": tools},
|
|
113
|
+
"component": (root := artifact.root),
|
|
114
|
+
}
|
|
115
|
+
if settings.supplier is not None:
|
|
116
|
+
metadata["manufacturer"] = metadata["supplier"] = settings.supplier
|
|
117
|
+
if "authors" in root:
|
|
118
|
+
metadata["authors"] = root["authors"]
|
|
119
|
+
if "licenses" in root:
|
|
120
|
+
metadata["licenses"] = root["licenses"]
|
|
121
|
+
properties: Final[list[Property]] = [
|
|
122
|
+
{"name": sbom_property(project.property_prefix, "sbom:scope"), "value": artifact.scope},
|
|
123
|
+
# names the document's file, which marks it as one python-sbom built, so validation checks it in full
|
|
124
|
+
{"name": sbom_property(project.property_prefix, SBOM_FILE), "value": artifact.sbom_file},
|
|
125
|
+
]
|
|
126
|
+
if project.repository is not None:
|
|
127
|
+
properties.append({
|
|
128
|
+
"name": sbom_property(project.property_prefix, SBOM_REPOSITORY),
|
|
129
|
+
"value": project.repository,
|
|
130
|
+
})
|
|
131
|
+
metadata["properties"] = properties
|
|
132
|
+
return metadata
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
__all__ = [
|
|
136
|
+
"assemble",
|
|
137
|
+
]
|