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,281 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import NotRequired, TypedDict
|
|
4
|
+
|
|
5
|
+
# the document model is our own: cyclonedx-python-lib models neither formulation nor compositions (its model/bom.py
|
|
6
|
+
# holds only commented-out stubs, tracked upstream in CycloneDX/cyclonedx-python-lib#578 and #581), and its SortedSet
|
|
7
|
+
# collections re-order lists whose order the document keeps on purpose
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class OrganizationalEntity(TypedDict):
|
|
11
|
+
"""
|
|
12
|
+
An organization that supplies or makes a component.
|
|
13
|
+
|
|
14
|
+
:ivar name: the organization's name
|
|
15
|
+
:ivar url: the organization's web sites
|
|
16
|
+
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
name: str
|
|
20
|
+
url: list[str]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Contact(TypedDict):
|
|
24
|
+
"""
|
|
25
|
+
A person who wrote or maintains a component.
|
|
26
|
+
|
|
27
|
+
:ivar name: the person's name
|
|
28
|
+
:ivar email: the person's email address
|
|
29
|
+
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
name: NotRequired[str]
|
|
33
|
+
email: NotRequired[str]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Hash(TypedDict):
|
|
37
|
+
"""
|
|
38
|
+
A digest of a component's bytes.
|
|
39
|
+
|
|
40
|
+
:ivar alg: the algorithm as CycloneDX names it, such as ``SHA-256``
|
|
41
|
+
:ivar content: the digest as lowercase hex
|
|
42
|
+
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
alg: str
|
|
46
|
+
content: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Property(TypedDict):
|
|
50
|
+
"""
|
|
51
|
+
A name and value pair attached to a component or the document.
|
|
52
|
+
|
|
53
|
+
:ivar name: the property's name; names this package writes start with the project's prefix or ``python:``
|
|
54
|
+
:ivar value: the property's value
|
|
55
|
+
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
name: str
|
|
59
|
+
value: str
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ExternalReference(TypedDict):
|
|
63
|
+
"""
|
|
64
|
+
A link from a component to something about it.
|
|
65
|
+
|
|
66
|
+
:ivar type: the CycloneDX reference type, such as ``vcs`` or ``website``
|
|
67
|
+
:ivar url: the link
|
|
68
|
+
:ivar comment: what the link is, such as the Project-URL label it came from
|
|
69
|
+
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
type: str
|
|
73
|
+
url: str
|
|
74
|
+
comment: NotRequired[str]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class LicenseInfo(TypedDict):
|
|
78
|
+
"""
|
|
79
|
+
One license, by SPDX id or by name.
|
|
80
|
+
|
|
81
|
+
:ivar id: the SPDX license id
|
|
82
|
+
:ivar name: the license's name, when it has no SPDX id
|
|
83
|
+
:ivar acknowledgement: ``declared`` when the metadata names it, ``concluded`` when python-sbom resolved it
|
|
84
|
+
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
id: NotRequired[str]
|
|
88
|
+
name: NotRequired[str]
|
|
89
|
+
acknowledgement: str
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class License(TypedDict):
|
|
93
|
+
"""
|
|
94
|
+
One license entry of a component: a license or an SPDX expression.
|
|
95
|
+
|
|
96
|
+
:ivar license: the license, by SPDX id or by name
|
|
97
|
+
:ivar expression: an SPDX license expression
|
|
98
|
+
:ivar acknowledgement: ``declared`` or ``concluded``, for an expression
|
|
99
|
+
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
license: NotRequired[LicenseInfo]
|
|
103
|
+
expression: NotRequired[str]
|
|
104
|
+
acknowledgement: NotRequired[str]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class IdentityMethod(TypedDict):
|
|
108
|
+
"""
|
|
109
|
+
How a component's identity was established.
|
|
110
|
+
|
|
111
|
+
:ivar technique: the CycloneDX technique, such as ``manifest-analysis`` or ``hash-comparison``
|
|
112
|
+
:ivar confidence: from 0 to 1
|
|
113
|
+
:ivar value: what the technique read, such as the path of a ``METADATA`` file or a digest
|
|
114
|
+
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
technique: str
|
|
118
|
+
confidence: int
|
|
119
|
+
value: str
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class Identity(TypedDict):
|
|
123
|
+
"""
|
|
124
|
+
The evidence for one field of a component's identity.
|
|
125
|
+
|
|
126
|
+
:ivar field: the field it supports, such as ``purl`` or ``hash``
|
|
127
|
+
:ivar confidence: from 0 to 1
|
|
128
|
+
:ivar methods: how it was established
|
|
129
|
+
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
field: str
|
|
133
|
+
confidence: int
|
|
134
|
+
methods: list[IdentityMethod]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class Evidence(TypedDict):
|
|
138
|
+
"""
|
|
139
|
+
Why a component is what it says it is.
|
|
140
|
+
|
|
141
|
+
:ivar identity: the evidence per field
|
|
142
|
+
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
identity: list[Identity]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
#: A CycloneDX 1.6 component: a distribution, an application, the interpreter or a file, as a dictionary with the keys
|
|
149
|
+
#: the CycloneDX component object defines, of which python-sbom uses ``type``, ``bom-ref``, ``name``, ``version``,
|
|
150
|
+
#: ``purl``, ``supplier``, ``authors``, ``description``, ``licenses``, ``copyright``, ``hashes``,
|
|
151
|
+
#: ``externalReferences``, ``properties``, ``evidence`` and the nested ``components``.
|
|
152
|
+
Component = TypedDict(
|
|
153
|
+
"Component",
|
|
154
|
+
{
|
|
155
|
+
"type": str,
|
|
156
|
+
"bom-ref": str,
|
|
157
|
+
"name": str,
|
|
158
|
+
"version": NotRequired[str],
|
|
159
|
+
"purl": NotRequired[str],
|
|
160
|
+
"supplier": NotRequired[OrganizationalEntity],
|
|
161
|
+
"authors": NotRequired[list[Contact]],
|
|
162
|
+
"description": NotRequired[str],
|
|
163
|
+
"licenses": NotRequired[list[License]],
|
|
164
|
+
"copyright": NotRequired[str],
|
|
165
|
+
"hashes": NotRequired[list[Hash]],
|
|
166
|
+
"externalReferences": NotRequired[list[ExternalReference]],
|
|
167
|
+
"properties": NotRequired[list[Property]],
|
|
168
|
+
"evidence": NotRequired[Evidence],
|
|
169
|
+
"components": NotRequired[list["Component"]],
|
|
170
|
+
},
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class Dependency(TypedDict):
|
|
175
|
+
"""
|
|
176
|
+
The components one component depends on.
|
|
177
|
+
|
|
178
|
+
:ivar ref: the ``bom-ref`` of the dependent component
|
|
179
|
+
:ivar dependsOn: the ``bom-ref`` values of its dependencies
|
|
180
|
+
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
ref: str
|
|
184
|
+
dependsOn: NotRequired[list[str]]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class _Reference(TypedDict):
|
|
188
|
+
ref: str
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class _Output(TypedDict):
|
|
192
|
+
type: str
|
|
193
|
+
resource: _Reference
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class _Input(TypedDict):
|
|
197
|
+
environmentVars: list[Property]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
_Workflow = TypedDict(
|
|
201
|
+
"_Workflow",
|
|
202
|
+
{
|
|
203
|
+
"bom-ref": str,
|
|
204
|
+
"uid": str,
|
|
205
|
+
"name": str,
|
|
206
|
+
"taskTypes": list[str],
|
|
207
|
+
"resourceReferences": list[_Reference],
|
|
208
|
+
"outputs": list[_Output],
|
|
209
|
+
"inputs": NotRequired[list[_Input]],
|
|
210
|
+
},
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
#: How an artifact was built: one or more workflows, each with its tools, inputs and outputs.
|
|
215
|
+
Formula = TypedDict("Formula", {"bom-ref": str, "workflows": list[_Workflow]})
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class Lifecycle(TypedDict):
|
|
219
|
+
phase: str
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class Tools(TypedDict):
|
|
223
|
+
components: list[Component]
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class Metadata(TypedDict):
|
|
227
|
+
timestamp: str
|
|
228
|
+
lifecycles: list[Lifecycle]
|
|
229
|
+
tools: Tools
|
|
230
|
+
manufacturer: NotRequired[OrganizationalEntity]
|
|
231
|
+
authors: NotRequired[list[Contact]]
|
|
232
|
+
supplier: NotRequired[OrganizationalEntity]
|
|
233
|
+
component: Component
|
|
234
|
+
licenses: NotRequired[list[License]]
|
|
235
|
+
properties: NotRequired[list[Property]]
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class Composition(TypedDict):
|
|
239
|
+
aggregate: str
|
|
240
|
+
dependencies: list[str]
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class Body(TypedDict):
|
|
244
|
+
metadata: Metadata
|
|
245
|
+
components: list[Component]
|
|
246
|
+
dependencies: list[Dependency]
|
|
247
|
+
compositions: NotRequired[list[Composition]]
|
|
248
|
+
formulation: NotRequired[list[Formula]]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
_Header = TypedDict(
|
|
252
|
+
"_Header",
|
|
253
|
+
{"$schema": str, "bomFormat": str, "specVersion": str, "serialNumber": str, "version": int},
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class Document(Body, _Header):
|
|
258
|
+
pass
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
__all__ = [
|
|
262
|
+
"Body",
|
|
263
|
+
"Component",
|
|
264
|
+
"Composition",
|
|
265
|
+
"Contact",
|
|
266
|
+
"Dependency",
|
|
267
|
+
"Document",
|
|
268
|
+
"Evidence",
|
|
269
|
+
"ExternalReference",
|
|
270
|
+
"Formula",
|
|
271
|
+
"Hash",
|
|
272
|
+
"Identity",
|
|
273
|
+
"IdentityMethod",
|
|
274
|
+
"License",
|
|
275
|
+
"LicenseInfo",
|
|
276
|
+
"Lifecycle",
|
|
277
|
+
"Metadata",
|
|
278
|
+
"OrganizationalEntity",
|
|
279
|
+
"Property",
|
|
280
|
+
"Tools",
|
|
281
|
+
]
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import PurePosixPath
|
|
4
|
+
from typing import TYPE_CHECKING, Final
|
|
5
|
+
|
|
6
|
+
from packaging.requirements import Requirement
|
|
7
|
+
|
|
8
|
+
from ._components import bundled_wheel_component, component_from_metadata, vendored_dependencies
|
|
9
|
+
from ._inputs import Artifact
|
|
10
|
+
from ._markers import applies_without_extras
|
|
11
|
+
from ._names import purl, pypi_url, sbom_file_name, sbom_property
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
|
|
16
|
+
from ._content import BundledWheelInfo
|
|
17
|
+
from ._identity import ProjectIdentity
|
|
18
|
+
from ._inputs import Settings, WheelTree
|
|
19
|
+
from ._types import Component
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def wheel_artifact(
|
|
23
|
+
tree: WheelTree, *, project: ProjectIdentity, settings: Settings, bundled_info: Mapping[str, BundledWheelInfo]
|
|
24
|
+
) -> Artifact:
|
|
25
|
+
metadata = tree.metadata
|
|
26
|
+
bundled = [
|
|
27
|
+
bundled_wheel_component(wheel, project.property_prefix, bundled_info[wheel.path]) for wheel in tree.bundled
|
|
28
|
+
]
|
|
29
|
+
root = component_from_metadata(metadata)
|
|
30
|
+
root["type"] = "application"
|
|
31
|
+
name, version = metadata.name, metadata.version
|
|
32
|
+
root["externalReferences"] = [
|
|
33
|
+
*root["externalReferences"],
|
|
34
|
+
{"type": "distribution", "url": pypi_url(name, version)},
|
|
35
|
+
{"type": "attestation", "url": f"https://pypi.org/integrity/{name}/{version}/{tree.name}/provenance"},
|
|
36
|
+
]
|
|
37
|
+
return Artifact(
|
|
38
|
+
root=root,
|
|
39
|
+
components=[
|
|
40
|
+
*bundled,
|
|
41
|
+
# hatchling repeats a Requires-Dist when a hook re-adds a dependency, and each bom-ref must be unique
|
|
42
|
+
*(
|
|
43
|
+
_declared_dependency(text, requirement, project.property_prefix)
|
|
44
|
+
for text in dict.fromkeys(metadata.values("requires_dist"))
|
|
45
|
+
if applies_without_extras(requirement := Requirement(text))
|
|
46
|
+
),
|
|
47
|
+
],
|
|
48
|
+
dependencies=vendored_dependencies(bundled),
|
|
49
|
+
build_scripts=tree.build_scripts,
|
|
50
|
+
version=version,
|
|
51
|
+
sbom_file=sbom_file_name(name),
|
|
52
|
+
scope=_scope(settings),
|
|
53
|
+
workflow=("wheel-build", "build the wheel and this SBOM"),
|
|
54
|
+
license_file=next(iter(metadata.values("license_files")), None),
|
|
55
|
+
copyright=tree.copyright,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _declared_dependency(text: str, requirement: Requirement, property_prefix: str) -> Component:
|
|
60
|
+
component: Final[Component] = {
|
|
61
|
+
"type": "library",
|
|
62
|
+
# the same distribution can be declared more than once with different markers, so the purl is not unique
|
|
63
|
+
"bom-ref": f"requires-dist:{text}",
|
|
64
|
+
"name": requirement.name,
|
|
65
|
+
"purl": purl(requirement.name),
|
|
66
|
+
"externalReferences": [{"type": "distribution", "url": pypi_url(requirement.name)}],
|
|
67
|
+
"properties": [{"name": sbom_property(property_prefix, "requires-dist"), "value": text}],
|
|
68
|
+
}
|
|
69
|
+
if requirement.marker is not None:
|
|
70
|
+
component["properties"].append({
|
|
71
|
+
"name": sbom_property(property_prefix, "environment-marker"),
|
|
72
|
+
"value": str(requirement.marker),
|
|
73
|
+
})
|
|
74
|
+
return component
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _scope(settings: Settings) -> str:
|
|
78
|
+
declared = (
|
|
79
|
+
"the direct runtime dependencies declared in the wheel's core metadata, which carry no version because the"
|
|
80
|
+
" installer resolves them"
|
|
81
|
+
)
|
|
82
|
+
if directories := " and ".join(sorted({str(PurePosixPath(pattern).parent) for pattern in settings.bundled_wheels})):
|
|
83
|
+
return f"Components are the wheels bundled as data files under {directories} and {declared}."
|
|
84
|
+
return f"Components are {declared}."
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
__all__ = [
|
|
88
|
+
"wheel_artifact",
|
|
89
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Checks of a document against the schema, its own references, its SPDX rendering and a license policy."""
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import zipfile
|
|
4
|
+
from typing import IO, TYPE_CHECKING, Final
|
|
5
|
+
|
|
6
|
+
from python_sbom._wheel._zip import CHUNK, open_archive
|
|
7
|
+
|
|
8
|
+
from ._document import validate_document
|
|
9
|
+
from ._limits import MAX_COMPONENTS, byte_limit
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def validate(path: Path, allowed_licenses: frozenset[str] | None, max_components: int = MAX_COMPONENTS) -> list[str]:
|
|
16
|
+
limit: Final[int] = byte_limit(max_components)
|
|
17
|
+
if not zipfile.is_zipfile(path):
|
|
18
|
+
with path.open("rb") as file:
|
|
19
|
+
return validate_document(_read(file, limit), allowed_licenses, max_components)
|
|
20
|
+
problems: Final[list[str]] = []
|
|
21
|
+
with open_archive(path, path.name) as archive:
|
|
22
|
+
if not (names := sorted(name for name in archive.namelist() if _is_sbom(name))):
|
|
23
|
+
return [f"no SBOM found in {path.name}"]
|
|
24
|
+
# one at a time, so memory holds a single SBOM however many the archive packs
|
|
25
|
+
for name in names:
|
|
26
|
+
with archive.open(name) as member:
|
|
27
|
+
raw = _read(member, limit)
|
|
28
|
+
problems.extend(
|
|
29
|
+
f"{name}: {problem}" for problem in validate_document(raw, allowed_licenses, max_components)
|
|
30
|
+
)
|
|
31
|
+
return problems
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _read(source: IO[bytes], limit: int) -> bytes:
|
|
35
|
+
# stops within a chunk past the limit: enough for validate_document to refuse the SBOM without reading all of it
|
|
36
|
+
content: Final[bytearray] = bytearray()
|
|
37
|
+
while len(content) <= limit and (chunk := source.read(CHUNK)):
|
|
38
|
+
content.extend(chunk)
|
|
39
|
+
return bytes(content)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _is_sbom(name: str) -> bool:
|
|
43
|
+
# every top-level .dist-info counts, so a decoy one sorting first cannot hide the SBOM of another
|
|
44
|
+
match name.split("/"):
|
|
45
|
+
case [file]:
|
|
46
|
+
return file.endswith(".cdx.json")
|
|
47
|
+
case [dist_info, "sboms", file]:
|
|
48
|
+
return dist_info.endswith(".dist-info") and file.endswith(".cdx.json")
|
|
49
|
+
case _:
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
"validate",
|
|
55
|
+
]
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
import re
|
|
6
|
+
from importlib.util import find_spec
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Final
|
|
8
|
+
|
|
9
|
+
from python_sbom._errors import MissingExtraError, SbomError
|
|
10
|
+
from python_sbom._formats._spdx import render_spdx
|
|
11
|
+
from python_sbom._model._graph import artifact_contents, reference_problems, repeated, walk_components
|
|
12
|
+
from python_sbom._model._names import SBOM_FILE
|
|
13
|
+
|
|
14
|
+
from ._limits import MAX_COMPONENTS, MAX_TEXT, Json, byte_limit
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import Iterable
|
|
18
|
+
from typing import TypeIs # new in 3.13; annotations here are never evaluated
|
|
19
|
+
|
|
20
|
+
from python_sbom._model._types import Component, Document
|
|
21
|
+
|
|
22
|
+
# the schema leaves these optional, yet every document python-sbom writes carries them and the checks below read them
|
|
23
|
+
_REQUIRED: Final[tuple[tuple[str, ...], ...]] = (
|
|
24
|
+
("metadata", "timestamp"),
|
|
25
|
+
("metadata", "tools", "components"),
|
|
26
|
+
("metadata", "component", "bom-ref"),
|
|
27
|
+
("metadata", "component", "name"),
|
|
28
|
+
("components",),
|
|
29
|
+
("dependencies",),
|
|
30
|
+
)
|
|
31
|
+
_SERIAL: Final[re.Pattern[str]] = 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}")
|
|
32
|
+
# the modules of the validate extra
|
|
33
|
+
_EXTRA: Final[tuple[str, ...]] = ("jsonschema_rs", "license_expression", "spdx_tools")
|
|
34
|
+
# the schema validator recurses once per level and crashes the process past a few thousand levels on a small thread
|
|
35
|
+
# stack; the SBOMs python-sbom and other tools write nest about a dozen levels deep
|
|
36
|
+
_MAX_DEPTH: Final[int] = 128
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def validate_document(
|
|
40
|
+
raw: bytes, allowed_licenses: frozenset[str] | None, max_components: int = MAX_COMPONENTS
|
|
41
|
+
) -> list[str]:
|
|
42
|
+
if len(raw) > (limit := byte_limit(max_components)):
|
|
43
|
+
problem = (
|
|
44
|
+
f"is over {limit} bytes, the size limit for {max_components} components;"
|
|
45
|
+
" pass a higher --max-components to check it"
|
|
46
|
+
)
|
|
47
|
+
return [problem]
|
|
48
|
+
# one document's problem never stops the others from being checked, so whatever the checks raise is reported
|
|
49
|
+
try:
|
|
50
|
+
return _problems(raw, allowed_licenses, max_components)
|
|
51
|
+
except MissingExtraError:
|
|
52
|
+
raise
|
|
53
|
+
except SbomError as error:
|
|
54
|
+
return [str(error)]
|
|
55
|
+
except Exception as error: # ruff: ignore[blind-except] # a hostile document may trip any check, see above
|
|
56
|
+
return [f"could not be checked, {type(error).__name__}: {str(error)[:MAX_TEXT]}"]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _problems(raw: bytes, allowed_licenses: frozenset[str] | None, max_components: int) -> list[str]:
|
|
60
|
+
# every document must conform to the CycloneDX 1.6 schema; one python-sbom wrote must also carry the urn:uuid serial
|
|
61
|
+
# actions/attest needs, resolvable and unique bom-refs and a valid SPDX rendering, and meet the license policy. A
|
|
62
|
+
# missing extra fails every document alike, while importing it, half a second, waits until the cheap census passes
|
|
63
|
+
missing_extra: Final[str] = "validation needs the validate extra: pip install 'python-sbom[validate]'"
|
|
64
|
+
if any(find_spec(name) is None for name in _EXTRA):
|
|
65
|
+
raise MissingExtraError(missing_extra)
|
|
66
|
+
loaded: Final = load_json(raw)
|
|
67
|
+
if census := _census_problems(loaded, max_components):
|
|
68
|
+
return census
|
|
69
|
+
# find_spec sees only the top-level modules, not a dependency of theirs that is missing or broken
|
|
70
|
+
try:
|
|
71
|
+
from ._schemas import schema_error, spdx_problems # ruff: ignore[import-outside-top-level] # the optional extra, see above
|
|
72
|
+
except ImportError as error:
|
|
73
|
+
raise MissingExtraError(missing_extra) from error
|
|
74
|
+
|
|
75
|
+
if error := schema_error(loaded):
|
|
76
|
+
return [f"does not conform to the CycloneDX 1.6 schema: {error}"]
|
|
77
|
+
# python-sbom names its file in a metadata property, so documents other tools wrote get the schema check alone; the
|
|
78
|
+
# schema already guarantees the shape of what this reads
|
|
79
|
+
if not any(
|
|
80
|
+
prop.get("name", "").endswith(f":{SBOM_FILE}") for prop in loaded.get("metadata", {}).get("properties", [])
|
|
81
|
+
):
|
|
82
|
+
return []
|
|
83
|
+
missing: Final[list[str]] = [".".join(path) for path in _REQUIRED if not _has(loaded, path)]
|
|
84
|
+
# is_document holds once nothing is missing; asking it narrows the loaded JSON to a document
|
|
85
|
+
if missing or not is_document(loaded):
|
|
86
|
+
return [f"lacks {', '.join(missing)}, which every document python-sbom writes carries"]
|
|
87
|
+
# optional in the CycloneDX spec itself, but actions/attest's format sniffer requires it to recognize the document
|
|
88
|
+
# as CycloneDX at all, and silently rejects anything missing it as an unknown format
|
|
89
|
+
if not _SERIAL.fullmatch(serial := loaded.get("serialNumber", "")):
|
|
90
|
+
return [f"serialNumber must be a urn:uuid: RFC 4122 UUID, got {serial!r}"]
|
|
91
|
+
# the SPDX rendering of a document whose references are broken only repeats those problems
|
|
92
|
+
problems: Final[list[str]] = _reference_problems(loaded) or spdx_problems(render_spdx(loaded))
|
|
93
|
+
if allowed_licenses is not None:
|
|
94
|
+
# the license parser loads only for a policy to judge against, as most checks have none
|
|
95
|
+
try:
|
|
96
|
+
from ._licenses import license_problems # ruff: ignore[import-outside-top-level] # see above
|
|
97
|
+
except ImportError as error:
|
|
98
|
+
raise MissingExtraError(missing_extra) from error
|
|
99
|
+
problems.extend(
|
|
100
|
+
license_problems(artifact_contents(loaded["metadata"]["component"], loaded["components"]), allowed_licenses)
|
|
101
|
+
)
|
|
102
|
+
return problems
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def load_json(raw: bytes) -> Any: # ruff: ignore[any-type] # json.loads' own type: any shape until checked
|
|
106
|
+
# why bytes are no JSON value, worded once for validate and for spdx, which renders a document without validating it
|
|
107
|
+
try:
|
|
108
|
+
return json.loads(raw.decode("utf-8"))
|
|
109
|
+
except UnicodeDecodeError as error:
|
|
110
|
+
msg = f"is not UTF-8: {error.reason} at byte {error.start}"
|
|
111
|
+
raise SbomError(msg) from error
|
|
112
|
+
except json.JSONDecodeError as error:
|
|
113
|
+
msg = f"is not JSON: {error}"
|
|
114
|
+
raise SbomError(msg) from error
|
|
115
|
+
except RecursionError as error:
|
|
116
|
+
# the JSON parser recurses once per level and gives up near the recursion limit, long before the depth check
|
|
117
|
+
# sees the document
|
|
118
|
+
msg = "nests too deep for the JSON parser to read"
|
|
119
|
+
raise SbomError(msg) from error
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _census_problems(document: Json, max_components: int) -> list[str]:
|
|
123
|
+
# counts the components and bom-refs and measures the depth in one linear pass before the schema vouches for any
|
|
124
|
+
# shape; iterative, so deep nesting cannot exhaust the stack. Nested components count too: the schema checks every
|
|
125
|
+
# components array, and one array may hold them all, so only the total bounds its work
|
|
126
|
+
components = 0
|
|
127
|
+
refs: Final[list[str]] = []
|
|
128
|
+
# only lists and objects go on the stack, every other value is judged where its container is read, so a scalar
|
|
129
|
+
# nests too deep when its container already sits at the limit; the document starts out as the one item of a list
|
|
130
|
+
pending: Final[list[tuple[list[Json] | dict[str, Json], int]]] = [([document], 0)]
|
|
131
|
+
while pending:
|
|
132
|
+
node, depth = pending.pop()
|
|
133
|
+
if isinstance(node, dict):
|
|
134
|
+
if isinstance(ref := node.get("bom-ref"), str):
|
|
135
|
+
refs.append(ref)
|
|
136
|
+
components += len(listed) if isinstance(listed := node.get("components"), list) else 0
|
|
137
|
+
if components > max_components:
|
|
138
|
+
return [
|
|
139
|
+
(
|
|
140
|
+
f"lists more than {max_components} components, nested ones included;"
|
|
141
|
+
" pass a higher --max-components to check it"
|
|
142
|
+
)
|
|
143
|
+
]
|
|
144
|
+
children = node.values() if isinstance(node, dict) else node
|
|
145
|
+
if children and depth >= _MAX_DEPTH:
|
|
146
|
+
return [f"nests more than {_MAX_DEPTH} levels deep, too deep to check"]
|
|
147
|
+
if problem := _push_containers(children, depth + 1, pending):
|
|
148
|
+
return problem
|
|
149
|
+
# every bom-ref in the document, wherever it sits, as CycloneDX requires them unique and documents other tools wrote
|
|
150
|
+
# get no reference check beyond this
|
|
151
|
+
if twice := repeated(refs):
|
|
152
|
+
return [f"bom-ref values repeat: {', '.join(twice)}"]
|
|
153
|
+
return []
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _push_containers(
|
|
157
|
+
children: Iterable[Json], depth: int, pending: list[tuple[list[Json] | dict[str, Json], int]]
|
|
158
|
+
) -> list[str]:
|
|
159
|
+
for child in children:
|
|
160
|
+
if isinstance(child, (list, dict)):
|
|
161
|
+
pending.append((child, depth))
|
|
162
|
+
elif isinstance(child, float) and not math.isfinite(child):
|
|
163
|
+
# json.loads takes NaN and Infinity, which RFC 8259 leaves out of JSON and the schema reads as null
|
|
164
|
+
return [f"holds {child}, which is not a JSON number"]
|
|
165
|
+
return []
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _has(value: Json, path: tuple[str, ...]) -> bool:
|
|
169
|
+
for key in path:
|
|
170
|
+
if not isinstance(value, dict) or key not in value:
|
|
171
|
+
return False
|
|
172
|
+
value = value[key]
|
|
173
|
+
return True
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def is_document(value: object) -> TypeIs[Document]:
|
|
177
|
+
# what the SPDX rendering reads without a fallback; the schema leaves even these optional
|
|
178
|
+
return isinstance(value, dict) and all(_has(value, ("metadata", "component", key)) for key in ("bom-ref", "name"))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _reference_problems(document: Document) -> list[str]:
|
|
182
|
+
root: Final[Component] = document["metadata"]["component"]
|
|
183
|
+
tools: Final[list[Component]] = document["metadata"]["tools"]["components"]
|
|
184
|
+
components: Final[list[Component]] = list(walk_components([root, *document["components"], *tools]))
|
|
185
|
+
if nameless := sorted(component["name"] for component in components if "bom-ref" not in component):
|
|
186
|
+
return [f"components lack a bom-ref: {nameless}"]
|
|
187
|
+
formulas: Final[list[str]] = [
|
|
188
|
+
ref
|
|
189
|
+
for formula in document.get("formulation", [])
|
|
190
|
+
for ref in (formula["bom-ref"], *(workflow["bom-ref"] for workflow in formula.get("workflows", [])))
|
|
191
|
+
]
|
|
192
|
+
if problems := reference_problems(
|
|
193
|
+
[*(component["bom-ref"] for component in components), *formulas], document["dependencies"]
|
|
194
|
+
):
|
|
195
|
+
return problems
|
|
196
|
+
problems: Final[list[str]] = []
|
|
197
|
+
dependencies: Final[dict[str, set[str]]] = {
|
|
198
|
+
entry["ref"]: set(entry.get("dependsOn", [])) for entry in document["dependencies"]
|
|
199
|
+
}
|
|
200
|
+
direct: Final[set[str]] = {component["bom-ref"] for component in document["components"]}
|
|
201
|
+
if dependencies.get(root["bom-ref"]) != direct:
|
|
202
|
+
problems.append(
|
|
203
|
+
f"root dependsOn {sorted(dependencies.get(root['bom-ref'], []))} != components {sorted(direct)}"
|
|
204
|
+
)
|
|
205
|
+
tool_refs: Final[set[str]] = {tool["bom-ref"] for tool in tools}
|
|
206
|
+
for formula in document.get("formulation", []):
|
|
207
|
+
for workflow in formula.get("workflows", []):
|
|
208
|
+
# a workflow may also name an external resource, which no tool can be
|
|
209
|
+
referenced = {
|
|
210
|
+
reference["ref"] for reference in workflow.get("resourceReferences", []) if "ref" in reference
|
|
211
|
+
}
|
|
212
|
+
if unknown := referenced - tool_refs:
|
|
213
|
+
problems.append(f"workflow {workflow['uid']} references unknown tools: {sorted(unknown)}")
|
|
214
|
+
return problems
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
__all__ = [
|
|
218
|
+
"is_document",
|
|
219
|
+
"load_json",
|
|
220
|
+
"validate_document",
|
|
221
|
+
]
|