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,182 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from email.utils import getaddresses
|
|
5
|
+
from itertools import starmap
|
|
6
|
+
from typing import TYPE_CHECKING, Final
|
|
7
|
+
|
|
8
|
+
from packaging.utils import canonicalize_name
|
|
9
|
+
|
|
10
|
+
from ._graph import walk_components
|
|
11
|
+
from ._iri import iri_reference
|
|
12
|
+
from ._licenses import licenses
|
|
13
|
+
from ._names import purl, pypi_url, reference_type, sbom_property, tool_ref
|
|
14
|
+
from ._types import Dependency
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from pathlib import PurePosixPath
|
|
18
|
+
|
|
19
|
+
from ._content import BundledWheelInfo
|
|
20
|
+
from ._inputs import BundledWheel, RecordRow
|
|
21
|
+
from ._metadata import CoreMetadata
|
|
22
|
+
from ._types import Component, Contact, ExternalReference, Identity, Property
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def bundled_wheel_component(wheel: BundledWheel, property_prefix: str, info: BundledWheelInfo) -> Component:
|
|
26
|
+
component = component_from_metadata(wheel.metadata)
|
|
27
|
+
mark_bundled(component, wheel.path, property_prefix, info)
|
|
28
|
+
component["hashes"] = [{"alg": "SHA-256", "content": wheel.sha256}]
|
|
29
|
+
component["externalReferences"] = [
|
|
30
|
+
*component["externalReferences"],
|
|
31
|
+
{"type": "distribution", "url": pypi_url(wheel.metadata.name, wheel.metadata.version)},
|
|
32
|
+
]
|
|
33
|
+
component["evidence"] = {
|
|
34
|
+
"identity": [
|
|
35
|
+
_evidence("purl", "manifest-analysis", wheel.metadata_name),
|
|
36
|
+
_evidence("hash", "hash-comparison", wheel.sha256),
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
vendored: Final[dict[str, Component]] = {}
|
|
40
|
+
for source, metadata in wheel.vendored:
|
|
41
|
+
child = component_from_metadata(metadata)
|
|
42
|
+
child["bom-ref"] = f"{component['bom-ref']}#vendored/{child['bom-ref']}"
|
|
43
|
+
child["properties"] = [
|
|
44
|
+
*child["properties"],
|
|
45
|
+
{"name": sbom_property(property_prefix, "vendored-manifest"), "value": source},
|
|
46
|
+
]
|
|
47
|
+
child["evidence"] = {"identity": [_evidence("purl", "manifest-analysis", source)]}
|
|
48
|
+
# a vendored .dist-info comes after pip's manifest line naming the same package, so its metadata wins; keyed on
|
|
49
|
+
# the PEP 503 name, since the manifest and the .dist-info may spell it differently
|
|
50
|
+
vendored[f"{canonicalize_name(child['name'])}=={child['version']}"] = child
|
|
51
|
+
component["components"] = [
|
|
52
|
+
*(file_component(component["bom-ref"], row) for row in wheel.files),
|
|
53
|
+
*(vendored[key] for key in sorted(vendored)),
|
|
54
|
+
]
|
|
55
|
+
return component
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def component_from_metadata(metadata: CoreMetadata) -> Component:
|
|
59
|
+
name, version = metadata.name, metadata.version
|
|
60
|
+
component: Final[Component] = {"type": "library", "bom-ref": purl(name, version), "name": name, "version": version}
|
|
61
|
+
component["purl"] = component["bom-ref"]
|
|
62
|
+
if summary := metadata.first("summary"):
|
|
63
|
+
component["description"] = summary
|
|
64
|
+
# maintainers are who users reach today, so they win over the original authors when both are named
|
|
65
|
+
for names, addresses in (("maintainer", "maintainer_email"), ("author", "author_email")):
|
|
66
|
+
contacts = [
|
|
67
|
+
*(_contact(name, "") for name in metadata.values(names)),
|
|
68
|
+
*starmap(_contact, getaddresses(metadata.values(addresses))),
|
|
69
|
+
]
|
|
70
|
+
if people := [contact for contact in contacts if contact]:
|
|
71
|
+
component["authors"] = people
|
|
72
|
+
break
|
|
73
|
+
if declared := licenses(metadata):
|
|
74
|
+
component["licenses"] = declared
|
|
75
|
+
component["externalReferences"], unlinked = _project_references(metadata)
|
|
76
|
+
# core metadata headers copied verbatim, so a reader sees what the distribution declared
|
|
77
|
+
headers: Final[list[tuple[str, list[str]]]] = [
|
|
78
|
+
("requires-python", metadata.values("requires_python")),
|
|
79
|
+
("requires-dist", metadata.values("requires_dist")),
|
|
80
|
+
("provides-extra", metadata.values("provides_extra")),
|
|
81
|
+
("classifier", metadata.values("classifiers")),
|
|
82
|
+
("keywords", metadata.keywords()),
|
|
83
|
+
]
|
|
84
|
+
component["properties"] = []
|
|
85
|
+
for header, values in headers:
|
|
86
|
+
component["properties"].extend({"name": f"python:{header}", "value": value} for value in values)
|
|
87
|
+
component["properties"].extend(unlinked)
|
|
88
|
+
return component
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _contact(name: str, email: str) -> Contact:
|
|
92
|
+
if email and not re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", email):
|
|
93
|
+
# an address the schema rejects as an email, such as "jane at example.com", is kept as the name
|
|
94
|
+
return {"name": " ".join(part for part in (name, email) if part)}
|
|
95
|
+
contact: Final[Contact] = {}
|
|
96
|
+
if name:
|
|
97
|
+
contact["name"] = name
|
|
98
|
+
if email:
|
|
99
|
+
contact["email"] = email
|
|
100
|
+
return contact
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _project_references(metadata: CoreMetadata) -> tuple[list[ExternalReference], list[Property]]:
|
|
104
|
+
# a URL CycloneDX would reject is percent-encoded where that keeps what it points at and left out where it cannot
|
|
105
|
+
# be, as an scp-style git address; either way the header's text is kept verbatim as a property
|
|
106
|
+
references: Final[list[ExternalReference]] = []
|
|
107
|
+
unlinked: Final[list[Property]] = []
|
|
108
|
+
# a home page that is not an http(s) URL, such as UNKNOWN, is left out
|
|
109
|
+
if (home_page := metadata.first("home_page")) and re.match(r"https?:", home_page, re.IGNORECASE):
|
|
110
|
+
if (url := iri_reference(home_page)) is not None:
|
|
111
|
+
references.append({"type": "website", "url": url})
|
|
112
|
+
if url != home_page:
|
|
113
|
+
unlinked.append({"name": "python:home-page", "value": home_page})
|
|
114
|
+
for label, link in metadata.project_urls():
|
|
115
|
+
if (url := iri_reference(link)) is not None:
|
|
116
|
+
references.append({"type": reference_type(label), "url": url, "comment": f"Project-URL: {label}"})
|
|
117
|
+
if url != link:
|
|
118
|
+
unlinked.append({"name": "python:project-url", "value": f"{label}, {link}"})
|
|
119
|
+
return references, unlinked
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def mark_bundled(component: Component, path: str, property_prefix: str, info: BundledWheelInfo) -> None:
|
|
123
|
+
# the purl names the package; the path, of a .whl or of the directory a wheel is unpacked into, tells apart copies
|
|
124
|
+
# of one release bundled in two places
|
|
125
|
+
component["bom-ref"] = f"bundled:{path}"
|
|
126
|
+
if info.supplier is not None:
|
|
127
|
+
component["supplier"] = info.supplier
|
|
128
|
+
component["properties"] = [
|
|
129
|
+
{"name": sbom_property(property_prefix, "bundled-wheel"), "value": path},
|
|
130
|
+
*info.properties,
|
|
131
|
+
*component["properties"],
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _evidence(field: str, technique: str, value: str) -> Identity:
|
|
136
|
+
return {"field": field, "confidence": 1, "methods": [{"technique": technique, "confidence": 1, "value": value}]}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def file_component(parent_ref: str, row: RecordRow) -> Component:
|
|
140
|
+
return {
|
|
141
|
+
"type": "file",
|
|
142
|
+
"bom-ref": f"{parent_ref}#{row.path}",
|
|
143
|
+
"name": row.path,
|
|
144
|
+
"hashes": [{"alg": row.algorithm, "content": row.digest}],
|
|
145
|
+
"properties": [{"name": "size", "value": row.size}],
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def vendored_dependencies(components: list[Component]) -> list[Dependency]:
|
|
150
|
+
# each bundled wheel depends on the packages it vendors, wherever it sits in the document
|
|
151
|
+
found: Final[list[Dependency]] = []
|
|
152
|
+
for component in walk_components(components):
|
|
153
|
+
if not component["bom-ref"].startswith("bundled:"):
|
|
154
|
+
continue
|
|
155
|
+
# a file's ref holds its path, which may itself hold "#vendored/"
|
|
156
|
+
libraries = (child["bom-ref"] for child in component.get("components", []) if child["type"] == "library")
|
|
157
|
+
if vendored := [ref for ref in libraries if "#vendored/" in ref]:
|
|
158
|
+
found.append(Dependency(ref=component["bom-ref"], dependsOn=vendored))
|
|
159
|
+
return found
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def script_tool(path: PurePosixPath, sha256: str, version: str, link: str | None) -> Component:
|
|
163
|
+
tool: Final[Component] = {
|
|
164
|
+
"type": "application",
|
|
165
|
+
"bom-ref": tool_ref(str(path)),
|
|
166
|
+
"name": path.name,
|
|
167
|
+
"version": version,
|
|
168
|
+
"hashes": [{"alg": "SHA-256", "content": sha256}],
|
|
169
|
+
}
|
|
170
|
+
if link is not None:
|
|
171
|
+
tool["externalReferences"] = [{"type": "vcs", "url": link}]
|
|
172
|
+
return tool
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
__all__ = [
|
|
176
|
+
"bundled_wheel_component",
|
|
177
|
+
"component_from_metadata",
|
|
178
|
+
"file_component",
|
|
179
|
+
"mark_bundled",
|
|
180
|
+
"script_tool",
|
|
181
|
+
"vendored_dependencies",
|
|
182
|
+
]
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from pathlib import Path, PurePosixPath
|
|
9
|
+
|
|
10
|
+
from ._identity import ProjectIdentity
|
|
11
|
+
from ._inputs import Settings
|
|
12
|
+
from ._types import Component, Dependency, OrganizationalEntity, Property
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ProviderContext:
|
|
16
|
+
"""
|
|
17
|
+
What a content provider may use about the build it contributes to.
|
|
18
|
+
|
|
19
|
+
:param root: the project directory
|
|
20
|
+
:param settings: the project's options
|
|
21
|
+
:param identity: how the project's SBOMs name it, including the prefix of its properties
|
|
22
|
+
:param read: reads a member of the artifact being described, for :meth:`read`
|
|
23
|
+
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self, *, root: Path, settings: Settings, identity: ProjectIdentity, read: Callable[[PurePosixPath], bytes]
|
|
28
|
+
) -> None:
|
|
29
|
+
self.root = root
|
|
30
|
+
self.settings = settings
|
|
31
|
+
self.identity = identity
|
|
32
|
+
self._read = read
|
|
33
|
+
|
|
34
|
+
def read(self, path: PurePosixPath) -> bytes:
|
|
35
|
+
"""
|
|
36
|
+
Read a file of the artifact being described: a member of the built wheel or of the archive.
|
|
37
|
+
|
|
38
|
+
:param path: the file's path inside the artifact, as a bundled wheel's path is given
|
|
39
|
+
:returns: the file's bytes
|
|
40
|
+
:raises KeyError: when the artifact holds no such file
|
|
41
|
+
|
|
42
|
+
"""
|
|
43
|
+
return self._read(path)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ContentProvider:
|
|
47
|
+
"""
|
|
48
|
+
Adds what a project knows beyond its metadata to its SBOM; subclass it and override what the project needs.
|
|
49
|
+
|
|
50
|
+
Name a subclass in the ``content-provider`` option, which the hook, ``python-sbom embed`` and ``python-sbom
|
|
51
|
+
archive`` load, or return an instance from :meth:`python_sbom.hatch.SbomBuildHook.content_provider`. When the class
|
|
52
|
+
lives in the project, the SBOM lists its module among the build tools.
|
|
53
|
+
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
# ruff: disable[unused-method-argument, no-self-use] # the defaults add nothing, whatever they are given
|
|
57
|
+
def bundled_wheel(self, context: ProviderContext, wheel: PurePosixPath) -> BundledWheelInfo:
|
|
58
|
+
"""
|
|
59
|
+
Describe what the project does with a wheel it bundles, as a file or unpacked into a directory.
|
|
60
|
+
|
|
61
|
+
:param context: the project and the artifact being described
|
|
62
|
+
:param wheel: the wheel's path inside the artifact
|
|
63
|
+
:returns: its extra properties, and whether the project's supplier supplies it; neither by default
|
|
64
|
+
|
|
65
|
+
"""
|
|
66
|
+
return BundledWheelInfo()
|
|
67
|
+
|
|
68
|
+
def contributions(self, context: ProviderContext) -> Contributions:
|
|
69
|
+
"""
|
|
70
|
+
Add components, their dependencies, root properties and build tools, before references and the serial derive.
|
|
71
|
+
|
|
72
|
+
:param context: the project and the artifact being described
|
|
73
|
+
:returns: the contributions; none by default
|
|
74
|
+
|
|
75
|
+
"""
|
|
76
|
+
return Contributions()
|
|
77
|
+
|
|
78
|
+
# ruff: enable[unused-method-argument, no-self-use]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
82
|
+
class BundledWheelInfo:
|
|
83
|
+
"""
|
|
84
|
+
What a project adds to the component of one bundled wheel.
|
|
85
|
+
|
|
86
|
+
Both fields default to empty, so a provider names only what it adds.
|
|
87
|
+
|
|
88
|
+
:param properties: properties after ``<prefix>:bundled-wheel``, before those from the wheel's metadata
|
|
89
|
+
:param supplier: the wheel's supplier, or ``None`` to name none; only the project decides a bundled wheel's
|
|
90
|
+
supplier, never the wheel's own metadata
|
|
91
|
+
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
properties: list[Property] = field(default_factory=list)
|
|
95
|
+
supplier: OrganizationalEntity | None = None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
99
|
+
class Contributions:
|
|
100
|
+
"""
|
|
101
|
+
What a project adds to the document beyond its bundled wheels, merged before references and the serial are derived.
|
|
102
|
+
|
|
103
|
+
Each field defaults to empty, so a provider names only what it adds. The build fails when a component or tool lacks
|
|
104
|
+
``type``, ``bom-ref`` or ``name``, a ``bom-ref`` repeats, the ``formula:`` and ``workflow:`` refs of the build's
|
|
105
|
+
formulation included, a dependency names one that does not exist, or a ``bom-ref`` gets a second dependency entry,
|
|
106
|
+
as the root does if a provider adds one, since python-sbom writes the root's.
|
|
107
|
+
|
|
108
|
+
:param components: extra top-level components, such as vendored C libraries; the root depends on each, and a
|
|
109
|
+
component's own parts nest under its ``components``
|
|
110
|
+
:param dependencies: extra dependency entries, such as the dependencies of those components
|
|
111
|
+
:param root_properties: extra properties of the root component; their names must not start with the project's
|
|
112
|
+
prefix, which python-sbom's own properties use
|
|
113
|
+
:param tools: extra build tools
|
|
114
|
+
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
components: list[Component] = field(default_factory=list)
|
|
118
|
+
dependencies: list[Dependency] = field(default_factory=list)
|
|
119
|
+
root_properties: list[Property] = field(default_factory=list)
|
|
120
|
+
tools: list[Component] = field(default_factory=list)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
__all__ = [
|
|
124
|
+
"BundledWheelInfo",
|
|
125
|
+
"ContentProvider",
|
|
126
|
+
"Contributions",
|
|
127
|
+
"ProviderContext",
|
|
128
|
+
]
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import uuid
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from typing import TYPE_CHECKING, Final
|
|
7
|
+
|
|
8
|
+
from python_sbom._errors import SbomError
|
|
9
|
+
|
|
10
|
+
from ._graph import reference_problems, walk_components
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from ._content import ContentProvider, Contributions
|
|
14
|
+
from ._inputs import BuildFacts
|
|
15
|
+
from ._types import Body, Component, Document, Formula, Property
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def check_refs(body: Body) -> None:
|
|
19
|
+
# a provider contributes refs of its own, so the document is checked before it is written rather than by validate
|
|
20
|
+
components: Final[list[Component]] = [
|
|
21
|
+
*walk_components([
|
|
22
|
+
body["metadata"]["component"],
|
|
23
|
+
*body["components"],
|
|
24
|
+
*body["metadata"]["tools"]["components"],
|
|
25
|
+
])
|
|
26
|
+
]
|
|
27
|
+
# formulas and workflows share the bom-ref namespace, so a component cannot take theirs
|
|
28
|
+
formulas: Final[list[str]] = [
|
|
29
|
+
ref
|
|
30
|
+
for formula in body.get("formulation", [])
|
|
31
|
+
for ref in (formula["bom-ref"], *(workflow["bom-ref"] for workflow in formula["workflows"]))
|
|
32
|
+
]
|
|
33
|
+
if problems := reference_problems(
|
|
34
|
+
[*(component["bom-ref"] for component in components), *formulas], body["dependencies"]
|
|
35
|
+
):
|
|
36
|
+
raise SbomError(problems[0])
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def with_header(body: Body, serial_namespace: uuid.UUID) -> Document:
|
|
40
|
+
return {
|
|
41
|
+
"$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json",
|
|
42
|
+
"bomFormat": "CycloneDX",
|
|
43
|
+
"specVersion": "1.6",
|
|
44
|
+
"serialNumber": f"urn:uuid:{uuid.uuid5(serial_namespace, json.dumps(body, sort_keys=True))}",
|
|
45
|
+
"version": 1,
|
|
46
|
+
**body,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def checked_contributions(contributions: Contributions, provider: ContentProvider) -> Contributions:
|
|
51
|
+
# what a provider adds is checked for the fields everything else here reads, so a mistake names the provider rather
|
|
52
|
+
# than surfacing as a KeyError deep in the build
|
|
53
|
+
for component in walk_components([*contributions.components, *contributions.tools]):
|
|
54
|
+
if missing := [key for key in ("type", "bom-ref", "name") if key not in component]:
|
|
55
|
+
name = type(provider).__qualname__
|
|
56
|
+
msg = f"content provider {name} added a component without {', '.join(missing)}: {component!r:.200}"
|
|
57
|
+
raise SbomError(msg)
|
|
58
|
+
return contributions
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def contributed_properties(properties: list[Property], prefix: str) -> list[Property]:
|
|
62
|
+
if claimed := sorted(prop["name"] for prop in properties if prop["name"].startswith(f"{prefix}:")):
|
|
63
|
+
msg = f"contributed root properties {', '.join(claimed)} use the {prefix}: prefix python-sbom writes"
|
|
64
|
+
raise SbomError(msg)
|
|
65
|
+
return properties
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def formulation(uid: str, name: str, tools: list[Component], output_ref: str, facts: BuildFacts) -> Formula:
|
|
69
|
+
formula: Formula = {
|
|
70
|
+
"bom-ref": f"formula:{uid}",
|
|
71
|
+
"workflows": [
|
|
72
|
+
{
|
|
73
|
+
"bom-ref": f"workflow:{uid}",
|
|
74
|
+
"uid": uid,
|
|
75
|
+
"name": name,
|
|
76
|
+
"taskTypes": ["build"],
|
|
77
|
+
"resourceReferences": [{"ref": tool["bom-ref"]} for tool in tools],
|
|
78
|
+
"outputs": [{"type": "artifact", "resource": {"ref": output_ref}}],
|
|
79
|
+
}
|
|
80
|
+
],
|
|
81
|
+
}
|
|
82
|
+
if facts.epoch_from_environment:
|
|
83
|
+
formula["workflows"][0]["inputs"] = [
|
|
84
|
+
{"environmentVars": [{"name": "SOURCE_DATE_EPOCH", "value": str(facts.epoch)}]}
|
|
85
|
+
]
|
|
86
|
+
return formula
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def timestamp(facts: BuildFacts) -> str:
|
|
90
|
+
return datetime.fromtimestamp(facts.epoch, tz=UTC).isoformat()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
__all__ = [
|
|
94
|
+
"check_refs",
|
|
95
|
+
"checked_contributions",
|
|
96
|
+
"contributed_properties",
|
|
97
|
+
"formulation",
|
|
98
|
+
"timestamp",
|
|
99
|
+
"with_header",
|
|
100
|
+
]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import Counter
|
|
4
|
+
from typing import TYPE_CHECKING, Final
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from collections.abc import Iterable, Iterator
|
|
8
|
+
|
|
9
|
+
from ._types import Component, Dependency
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def artifact_contents(root: Component, components: list[Component]) -> list[Component]:
|
|
13
|
+
# what the artifact holds or declares, wherever the document places it: the files and wheels nested under the root,
|
|
14
|
+
# which sit in an archive's top level, and the components; not the root itself, nor the tools that built it, so
|
|
15
|
+
# generation, the license policy and validation all judge the same set
|
|
16
|
+
return [*root.get("components", []), *components]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def shipped_libraries(contents: list[Component]) -> list[Component]:
|
|
20
|
+
# what the artifact carries bytes for: a declared requirement resolves at install time, and a library a bundled
|
|
21
|
+
# wheel vendors ships under that wheel's terms
|
|
22
|
+
return [
|
|
23
|
+
component
|
|
24
|
+
for component in walk_components(contents)
|
|
25
|
+
if component["type"] == "library"
|
|
26
|
+
and ("hashes" in component or any(child["type"] == "file" for child in component.get("components", [])))
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def walk_components(components: Iterable[Component]) -> Iterator[Component]:
|
|
31
|
+
# depth first, each component before the ones it nests, in document order; iterative, so a deeply nested document
|
|
32
|
+
# cannot exhaust the stack
|
|
33
|
+
pending: Final[list[Component]] = list(reversed(list(components)))
|
|
34
|
+
while pending:
|
|
35
|
+
component = pending.pop()
|
|
36
|
+
yield component
|
|
37
|
+
pending.extend(reversed(component.get("components", [])))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def reference_problems(refs: list[str], dependencies: list[Dependency]) -> list[str]:
|
|
41
|
+
# what generation refuses and validation reports alike: a bom-ref given twice, which formulas and workflows share
|
|
42
|
+
# with components; a second dependency entry for one ref, which a reader taking them as a mapping would let a valid
|
|
43
|
+
# entry hide an invalid one with, while the SPDX rendering keeps the edges of both; and an entry naming no component
|
|
44
|
+
if twice := repeated(refs):
|
|
45
|
+
return [f"bom-ref values repeat: {', '.join(twice)}"]
|
|
46
|
+
if twice := repeated(entry["ref"] for entry in dependencies):
|
|
47
|
+
return [f"dependencies list more than one entry for: {', '.join(twice)}"]
|
|
48
|
+
named: Final[set[str]] = {ref for entry in dependencies for ref in (entry["ref"], *entry.get("dependsOn", []))}
|
|
49
|
+
if unknown := sorted(named - set(refs)):
|
|
50
|
+
return [f"dependencies name bom-refs no component has: {', '.join(unknown)}"]
|
|
51
|
+
return []
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def repeated(values: Iterable[str]) -> list[str]:
|
|
55
|
+
return sorted(value for value, count in Counter(values).items() if count > 1)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"artifact_contents",
|
|
60
|
+
"reference_problems",
|
|
61
|
+
"repeated",
|
|
62
|
+
"shipped_libraries",
|
|
63
|
+
"walk_components",
|
|
64
|
+
]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from ._iri import iri_reference
|
|
8
|
+
from ._names import property_prefix, reference_type
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from ._inputs import Settings
|
|
12
|
+
from ._metadata import CoreMetadata
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def project_identity(metadata: CoreMetadata, settings: Settings) -> ProjectIdentity:
|
|
16
|
+
project = property_prefix(metadata.name)
|
|
17
|
+
repository = settings.repository
|
|
18
|
+
# links into the repository extend its URL, so one that cannot be an IRI reference, as an scp-style git address, is
|
|
19
|
+
# passed over for the next
|
|
20
|
+
for label, url in [] if repository else metadata.project_urls():
|
|
21
|
+
if reference_type(label) == "vcs" and (link := iri_reference(url)) is not None:
|
|
22
|
+
repository = link.rstrip("/")
|
|
23
|
+
break
|
|
24
|
+
base = repository or f"https://pypi.org/project/{project}"
|
|
25
|
+
return ProjectIdentity(
|
|
26
|
+
property_prefix=project,
|
|
27
|
+
repository=repository,
|
|
28
|
+
serial_namespace=uuid.uuid5(uuid.NAMESPACE_URL, f"{base}/sboms"),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
33
|
+
class ProjectIdentity:
|
|
34
|
+
"""
|
|
35
|
+
How every SBOM of a project names it.
|
|
36
|
+
|
|
37
|
+
:param property_prefix: the project's normalized name, which starts the names of the properties python-sbom writes,
|
|
38
|
+
such as ``virtualenv`` in ``virtualenv:bundled-wheel``
|
|
39
|
+
:param repository: the repository URL, from the options or the first ``Source``, ``Repository`` or ``GitHub``
|
|
40
|
+
project URL, or ``None``
|
|
41
|
+
:param serial_namespace: the UUID namespace of the serial numbers of the project's SBOMs
|
|
42
|
+
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
property_prefix: str
|
|
46
|
+
repository: str | None
|
|
47
|
+
serial_namespace: uuid.UUID
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"ProjectIdentity",
|
|
52
|
+
"project_identity",
|
|
53
|
+
]
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from pathlib import PurePosixPath
|
|
8
|
+
|
|
9
|
+
from ._metadata import CoreMetadata
|
|
10
|
+
from ._types import Component, Dependency, OrganizationalEntity
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
14
|
+
class RecordRow:
|
|
15
|
+
path: str
|
|
16
|
+
algorithm: str
|
|
17
|
+
digest: str
|
|
18
|
+
size: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
22
|
+
class Interpreter:
|
|
23
|
+
name: str
|
|
24
|
+
version: str
|
|
25
|
+
implementation: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
29
|
+
class InstalledDistribution:
|
|
30
|
+
name: str
|
|
31
|
+
metadata: CoreMetadata
|
|
32
|
+
files: list[RecordRow]
|
|
33
|
+
depends_on: list[str]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
37
|
+
class Revision:
|
|
38
|
+
commit: str
|
|
39
|
+
dirty: bool
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
43
|
+
class BuildFacts:
|
|
44
|
+
epoch: int
|
|
45
|
+
epoch_from_environment: bool
|
|
46
|
+
revision: Revision | None
|
|
47
|
+
interpreter: Interpreter
|
|
48
|
+
distributions: list[InstalledDistribution]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
52
|
+
class Settings:
|
|
53
|
+
"""
|
|
54
|
+
The project's options, from ``[tool.python-sbom]``.
|
|
55
|
+
|
|
56
|
+
:param supplier: the organization that supplies the project, or ``None`` to name none
|
|
57
|
+
:param repository: the repository URL, or ``None`` to take the first ``Source``, ``Repository`` or ``GitHub``
|
|
58
|
+
project URL
|
|
59
|
+
:param default_branch: the branch in links to files in the repository when the build's commit is unknown or has
|
|
60
|
+
uncommitted changes
|
|
61
|
+
:param bundled_wheels: glob patterns, relative to the project directory, of wheels the project ships as data files
|
|
62
|
+
:param content_provider: ``module:attribute`` of the content provider, or ``None``
|
|
63
|
+
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
supplier: OrganizationalEntity | None
|
|
67
|
+
repository: str | None
|
|
68
|
+
default_branch: str
|
|
69
|
+
bundled_wheels: tuple[str, ...]
|
|
70
|
+
content_provider: str | None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
74
|
+
class BundledWheel:
|
|
75
|
+
path: str
|
|
76
|
+
sha256: str
|
|
77
|
+
metadata_name: str
|
|
78
|
+
metadata: CoreMetadata
|
|
79
|
+
files: list[RecordRow]
|
|
80
|
+
vendored: list[tuple[str, CoreMetadata]]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
84
|
+
class BuildScript:
|
|
85
|
+
path: PurePosixPath
|
|
86
|
+
sha256: str
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
90
|
+
class ArchiveTree:
|
|
91
|
+
# what an archive holds, read from the zip without unpacking it: each distribution's metadata and members by the
|
|
92
|
+
# directory that holds its .dist-info, "" for the project's own, the archive's own top-level files, the digest of
|
|
93
|
+
# every member but the bundled wheels, and those wheels
|
|
94
|
+
metadata: dict[str, CoreMetadata]
|
|
95
|
+
owned: dict[str, list[str]]
|
|
96
|
+
loose: list[str]
|
|
97
|
+
files: dict[str, RecordRow]
|
|
98
|
+
bundled: dict[str, BundledWheel]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
102
|
+
class Artifact:
|
|
103
|
+
# what one artifact, a wheel or an archive, puts in its SBOM; the provenance every artifact of the project shares is
|
|
104
|
+
# added around it
|
|
105
|
+
root: Component
|
|
106
|
+
components: list[Component]
|
|
107
|
+
dependencies: list[Dependency]
|
|
108
|
+
build_scripts: list[BuildScript]
|
|
109
|
+
version: str
|
|
110
|
+
sbom_file: str
|
|
111
|
+
scope: str
|
|
112
|
+
workflow: tuple[str, str]
|
|
113
|
+
license_file: str | None
|
|
114
|
+
copyright: str | None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
118
|
+
class WheelTree:
|
|
119
|
+
# what a built wheel holds, read from the wheel without unpacking it: its file name, its core metadata and the
|
|
120
|
+
# wheels it bundles; and what the project adds: the copyright line of its license files and the scripts that built
|
|
121
|
+
# it
|
|
122
|
+
name: str
|
|
123
|
+
metadata: CoreMetadata
|
|
124
|
+
copyright: str | None
|
|
125
|
+
bundled: list[BundledWheel]
|
|
126
|
+
build_scripts: list[BuildScript]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
__all__ = [
|
|
130
|
+
"ArchiveTree",
|
|
131
|
+
"Artifact",
|
|
132
|
+
"BuildFacts",
|
|
133
|
+
"BuildScript",
|
|
134
|
+
"BundledWheel",
|
|
135
|
+
"InstalledDistribution",
|
|
136
|
+
"Interpreter",
|
|
137
|
+
"RecordRow",
|
|
138
|
+
"Revision",
|
|
139
|
+
"Settings",
|
|
140
|
+
"WheelTree",
|
|
141
|
+
]
|