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
python_sbom/__init__.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""SBOMs for Python distributions: write PEP 770 CycloneDX SBOMs into wheels."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._errors import SbomError
|
|
6
|
+
from ._model._content import BundledWheelInfo, ContentProvider, Contributions, ProviderContext
|
|
7
|
+
from ._model._identity import ProjectIdentity
|
|
8
|
+
from ._model._inputs import Settings
|
|
9
|
+
from ._model._types import (
|
|
10
|
+
Component,
|
|
11
|
+
Contact,
|
|
12
|
+
Dependency,
|
|
13
|
+
Evidence,
|
|
14
|
+
ExternalReference,
|
|
15
|
+
Hash,
|
|
16
|
+
Identity,
|
|
17
|
+
IdentityMethod,
|
|
18
|
+
License,
|
|
19
|
+
LicenseInfo,
|
|
20
|
+
OrganizationalEntity,
|
|
21
|
+
Property,
|
|
22
|
+
)
|
|
23
|
+
from .version import __version__
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"BundledWheelInfo",
|
|
27
|
+
"Component",
|
|
28
|
+
"Contact",
|
|
29
|
+
"ContentProvider",
|
|
30
|
+
"Contributions",
|
|
31
|
+
"Dependency",
|
|
32
|
+
"Evidence",
|
|
33
|
+
"ExternalReference",
|
|
34
|
+
"Hash",
|
|
35
|
+
"Identity",
|
|
36
|
+
"IdentityMethod",
|
|
37
|
+
"License",
|
|
38
|
+
"LicenseInfo",
|
|
39
|
+
"OrganizationalEntity",
|
|
40
|
+
"ProjectIdentity",
|
|
41
|
+
"Property",
|
|
42
|
+
"ProviderContext",
|
|
43
|
+
"SbomError",
|
|
44
|
+
"Settings",
|
|
45
|
+
"__version__",
|
|
46
|
+
]
|
python_sbom/__main__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The command line: parsing options and running one command per subcommand."""
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path, PurePosixPath
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
from python_sbom._generate._archive import archive_sbom
|
|
8
|
+
|
|
9
|
+
from ._parser import BuildOptions
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class _Options(BuildOptions, Protocol):
|
|
13
|
+
archive: Path
|
|
14
|
+
build_scripts: list[str] | None
|
|
15
|
+
output: Path | None
|
|
16
|
+
embed: bool
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def run(options: _Options) -> bool:
|
|
20
|
+
target, embedded = archive_sbom(
|
|
21
|
+
Path.cwd() if options.root is None else options.root,
|
|
22
|
+
options.archive,
|
|
23
|
+
provider=options.content_provider,
|
|
24
|
+
commit=options.commit,
|
|
25
|
+
build_env=options.build_env,
|
|
26
|
+
build_scripts=tuple(PurePosixPath(script) for script in options.build_scripts or []),
|
|
27
|
+
output=options.output,
|
|
28
|
+
embed=options.embed,
|
|
29
|
+
)
|
|
30
|
+
sys.stdout.write(f"wrote {target}\n")
|
|
31
|
+
if embedded is not None:
|
|
32
|
+
sys.stdout.write(f"wrote {embedded} into {options.archive.name}\n")
|
|
33
|
+
return True
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"run", # simplint: ignore[SMP402] # the parser names it for pkgutil.resolve_name
|
|
38
|
+
]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
from python_sbom._generate._embed import embed_sbom
|
|
8
|
+
|
|
9
|
+
from ._parser import BuildOptions
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class _Options(BuildOptions, Protocol):
|
|
13
|
+
wheels: list[Path]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def run(options: _Options) -> bool:
|
|
17
|
+
root = Path.cwd() if options.root is None else options.root
|
|
18
|
+
for wheel in options.wheels:
|
|
19
|
+
written = embed_sbom(
|
|
20
|
+
wheel, root, provider=options.content_provider, commit=options.commit, build_env=options.build_env
|
|
21
|
+
)
|
|
22
|
+
sys.stdout.write(f"wrote {written} into {wheel.name}\n")
|
|
23
|
+
return True
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"run", # simplint: ignore[SMP402] # the parser names it for pkgutil.resolve_name
|
|
28
|
+
]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pkgutil
|
|
4
|
+
import sys
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from python_sbom._errors import SbomError
|
|
8
|
+
|
|
9
|
+
from ._parser import build_parser
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main(args: Sequence[str] | None = None) -> None:
|
|
16
|
+
options = build_parser().parse_args(args)
|
|
17
|
+
try:
|
|
18
|
+
succeeded = pkgutil.resolve_name(options.handler)(options)
|
|
19
|
+
except SbomError as error:
|
|
20
|
+
sys.stderr.write(f"python-sbom: {error}\n")
|
|
21
|
+
succeeded = False
|
|
22
|
+
if not succeeded:
|
|
23
|
+
raise SystemExit(1)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"main",
|
|
28
|
+
]
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from argparse import ArgumentParser, ArgumentTypeError, _SubParsersAction
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
# the limits module imports nothing, so --help and --version load none of validation
|
|
8
|
+
from python_sbom._validate._limits import MAX_COMPONENTS
|
|
9
|
+
from python_sbom.version import __version__
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_parser() -> ArgumentParser:
|
|
13
|
+
parser = ArgumentParser(prog="python-sbom", description="Write, convert and check the CycloneDX SBOMs of wheels.")
|
|
14
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
15
|
+
commands = parser.add_subparsers(required=True)
|
|
16
|
+
_add_embed(commands)
|
|
17
|
+
_add_archive(commands)
|
|
18
|
+
_add_spdx(commands)
|
|
19
|
+
_add_validate(commands)
|
|
20
|
+
return parser
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _add_embed(commands: _SubParsersAction[ArgumentParser]) -> None:
|
|
24
|
+
parser = commands.add_parser(
|
|
25
|
+
"embed",
|
|
26
|
+
help="write the SBOM into wheels another build backend built",
|
|
27
|
+
description="Write the SBOM the hatchling hook writes into wheels another build backend built, such as"
|
|
28
|
+
" meson-python. Options come from the [tool.python-sbom] table of the project's pyproject.toml.",
|
|
29
|
+
)
|
|
30
|
+
# a name, not the function, so each command imports only what it runs and --help imports none of it
|
|
31
|
+
parser.set_defaults(handler="python_sbom._cli._embed:run")
|
|
32
|
+
parser.add_argument("wheels", type=Path, nargs="+", metavar="WHEEL", help="wheel to rewrite in place")
|
|
33
|
+
_add_build_options(parser)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _add_build_options(parser: ArgumentParser) -> None:
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"--root", type=Path, metavar="DIR", help="project directory of the artifact (default: current directory)"
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"--commit", metavar="SHA", help="source revision to record, for an artifact built from an sdist"
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--build-env",
|
|
45
|
+
action="append",
|
|
46
|
+
metavar="DIR",
|
|
47
|
+
help="site-packages directory of the environment that built the artifact; repeat it per directory"
|
|
48
|
+
" (default: this interpreter's)",
|
|
49
|
+
)
|
|
50
|
+
parser.add_argument(
|
|
51
|
+
"--content-provider",
|
|
52
|
+
metavar="MODULE:ATTRIBUTE",
|
|
53
|
+
help="content provider, overriding the content-provider option",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _add_archive(commands: _SubParsersAction[ArgumentParser]) -> None:
|
|
58
|
+
parser = commands.add_parser(
|
|
59
|
+
"archive",
|
|
60
|
+
help="write the SBOM of a zip archive that is not a wheel, such as a zipapp",
|
|
61
|
+
description="Describe a zip archive that holds the project's .dist-info at its top level, such as a zipapp:"
|
|
62
|
+
" the archive's own files, the project's distribution, and every wheel it bundles, as a file or unpacked,"
|
|
63
|
+
" described as the wheel's SBOM describes them. Options come from [tool.python-sbom].",
|
|
64
|
+
)
|
|
65
|
+
parser.set_defaults(handler="python_sbom._cli._archive:run")
|
|
66
|
+
parser.add_argument("archive", type=Path, metavar="ARCHIVE", help="archive to describe")
|
|
67
|
+
_add_build_options(parser)
|
|
68
|
+
parser.add_argument(
|
|
69
|
+
"--build-script",
|
|
70
|
+
dest="build_scripts",
|
|
71
|
+
action="append",
|
|
72
|
+
metavar="PATH",
|
|
73
|
+
help="script, relative to the project, that built the archive; repeat it per script",
|
|
74
|
+
)
|
|
75
|
+
parser.add_argument(
|
|
76
|
+
"--output", type=Path, metavar="FILE", help="SBOM file to write (default: <archive>.cdx.json next to it)"
|
|
77
|
+
)
|
|
78
|
+
parser.add_argument(
|
|
79
|
+
"--embed", action="store_true", help="also add the SBOM to the archive, at its top level as <archive>.cdx.json"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _add_spdx(commands: _SubParsersAction[ArgumentParser]) -> None:
|
|
84
|
+
parser = commands.add_parser(
|
|
85
|
+
"spdx",
|
|
86
|
+
help="render a CycloneDX SBOM as SPDX 2.3 JSON",
|
|
87
|
+
description="Render a CycloneDX JSON SBOM, such as one python-sbom wrote, as an SPDX 2.3 JSON document. It"
|
|
88
|
+
" checks only the fields the rendering reads, so it needs no extra; python-sbom validate checks the whole"
|
|
89
|
+
" document.",
|
|
90
|
+
)
|
|
91
|
+
parser.set_defaults(handler="python_sbom._cli._spdx:run")
|
|
92
|
+
parser.add_argument("source", type=Path, metavar="SOURCE", help="CycloneDX JSON file to read")
|
|
93
|
+
parser.add_argument("target", type=Path, metavar="TARGET", help="SPDX JSON file to write")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _add_validate(commands: _SubParsersAction[ArgumentParser]) -> None:
|
|
97
|
+
parser = commands.add_parser(
|
|
98
|
+
"validate",
|
|
99
|
+
help="validate the SBOMs in wheels and archives",
|
|
100
|
+
description="Check every CycloneDX SBOM a wheel or archive holds against the CycloneDX 1.6 schema; for one"
|
|
101
|
+
" python-sbom wrote, also its serial number, references, SPDX rendering and, with --allow-license, the licenses"
|
|
102
|
+
" of what it ships. Needs the validate extra: python-sbom[validate].",
|
|
103
|
+
)
|
|
104
|
+
parser.set_defaults(handler="python_sbom._cli._validate:run")
|
|
105
|
+
parser.add_argument(
|
|
106
|
+
"paths", type=Path, nargs="+", metavar="PATH", help="wheel, archive, or directory holding wheels"
|
|
107
|
+
)
|
|
108
|
+
parser.add_argument(
|
|
109
|
+
"--allow-license",
|
|
110
|
+
dest="allowed_licenses",
|
|
111
|
+
action="append",
|
|
112
|
+
metavar="SPDX-ID",
|
|
113
|
+
help="SPDX license id shipped components may use; repeat it per id, leave it out to skip the license policy",
|
|
114
|
+
)
|
|
115
|
+
parser.add_argument(
|
|
116
|
+
"--max-components",
|
|
117
|
+
type=_positive_int,
|
|
118
|
+
default=MAX_COMPONENTS,
|
|
119
|
+
metavar="N",
|
|
120
|
+
help="reject an SBOM listing more than N components, nested ones included, or larger than 1 MiB plus 1 KiB per"
|
|
121
|
+
" component, without checking it further, which bounds the work an untrusted SBOM can ask for (default:"
|
|
122
|
+
f" {MAX_COMPONENTS})",
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _positive_int(text: str) -> int:
|
|
127
|
+
# argparse reports the error through parser.error, with the usage, like any other bad argument
|
|
128
|
+
if text.isdecimal() and (number := int(text)) > 0:
|
|
129
|
+
return number
|
|
130
|
+
msg = f"must be a positive integer, got {text!r}"
|
|
131
|
+
raise ArgumentTypeError(msg)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class BuildOptions(Protocol):
|
|
135
|
+
root: Path | None
|
|
136
|
+
commit: str | None
|
|
137
|
+
build_env: list[str] | None
|
|
138
|
+
content_provider: str | None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
__all__ = [
|
|
142
|
+
"BuildOptions",
|
|
143
|
+
"build_parser",
|
|
144
|
+
]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Protocol
|
|
4
|
+
|
|
5
|
+
from python_sbom._errors import SbomError
|
|
6
|
+
from python_sbom._formats._json import dumps
|
|
7
|
+
from python_sbom._formats._spdx import render_spdx
|
|
8
|
+
from python_sbom._validate._document import is_document, load_json
|
|
9
|
+
from python_sbom._validate._limits import MAX_COMPONENTS, byte_limit
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from python_sbom._model._types import Document
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _Options(Protocol):
|
|
18
|
+
source: Path
|
|
19
|
+
target: Path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run(options: _Options) -> bool:
|
|
23
|
+
document = _read(options.source)
|
|
24
|
+
try:
|
|
25
|
+
rendered = dumps(render_spdx(document))
|
|
26
|
+
# ValueError: a timestamp fromisoformat rejects; AttributeError: a tools value that is neither an array nor an
|
|
27
|
+
# object; RecursionError: nesting json.dumps, a few frames deeper than json.loads, gives up on
|
|
28
|
+
except (AttributeError, KeyError, TypeError, ValueError, RecursionError) as error:
|
|
29
|
+
# the CLI renders without the schema check, which needs the validate extra
|
|
30
|
+
msg = f"{options.source} is not a CycloneDX document python-sbom can render: {error!r}"
|
|
31
|
+
raise SbomError(msg) from error
|
|
32
|
+
# bytes, so the line endings are the same on every operating system
|
|
33
|
+
options.target.write_bytes(rendered)
|
|
34
|
+
return True
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _read(source: Path) -> Document:
|
|
38
|
+
# the size validate accepts at its default component cap, so the parser never holds more than validate lets it
|
|
39
|
+
limit = byte_limit(MAX_COMPONENTS)
|
|
40
|
+
try:
|
|
41
|
+
with source.open("rb") as handle:
|
|
42
|
+
raw = handle.read(limit + 1)
|
|
43
|
+
except OSError as error:
|
|
44
|
+
raise SbomError(str(error)) from error
|
|
45
|
+
if len(raw) > limit:
|
|
46
|
+
msg = f"{source} is over {limit} bytes, the size limit of an SBOM python-sbom reads"
|
|
47
|
+
raise SbomError(msg)
|
|
48
|
+
try:
|
|
49
|
+
loaded = load_json(raw)
|
|
50
|
+
except SbomError as error:
|
|
51
|
+
msg = f"{source} {error}"
|
|
52
|
+
raise SbomError(msg) from error
|
|
53
|
+
if not is_document(loaded):
|
|
54
|
+
msg = f"{source} is not a CycloneDX document python-sbom can render"
|
|
55
|
+
raise SbomError(msg)
|
|
56
|
+
return loaded
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
__all__ = [
|
|
60
|
+
"run", # simplint: ignore[SMP402] # the parser names it for pkgutil.resolve_name
|
|
61
|
+
]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from typing import TYPE_CHECKING, Protocol
|
|
5
|
+
|
|
6
|
+
from python_sbom._validate._artifact import validate
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class _Options(Protocol):
|
|
13
|
+
paths: list[Path]
|
|
14
|
+
allowed_licenses: list[str] | None
|
|
15
|
+
max_components: int
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def run(options: _Options) -> bool:
|
|
19
|
+
allowed = None if options.allowed_licenses is None else frozenset(options.allowed_licenses)
|
|
20
|
+
if not (
|
|
21
|
+
artifacts := sorted(
|
|
22
|
+
artifact for path in options.paths for artifact in (path.glob("*.whl") if path.is_dir() else [path])
|
|
23
|
+
)
|
|
24
|
+
):
|
|
25
|
+
sys.stderr.write(f"no wheel found in {', '.join(map(str, options.paths))}\n")
|
|
26
|
+
return False
|
|
27
|
+
valid = True
|
|
28
|
+
for artifact in artifacts:
|
|
29
|
+
for problem in (problems := validate(artifact, allowed, options.max_components)):
|
|
30
|
+
sys.stderr.write(f"SBOM invalid: {artifact.name}: {problem}\n")
|
|
31
|
+
if problems:
|
|
32
|
+
valid = False
|
|
33
|
+
else:
|
|
34
|
+
sys.stdout.write(f"SBOM in {artifact.name} is valid\n")
|
|
35
|
+
return valid
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"run", # simplint: ignore[SMP402] # the parser names it for pkgutil.resolve_name
|
|
40
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""What the edge reads: settings, build facts, the source revision, the project tree, the artifact and the provider."""
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Final
|
|
4
|
+
|
|
5
|
+
from python_sbom._errors import SbomError
|
|
6
|
+
from python_sbom._model._archive import bundled_members
|
|
7
|
+
from python_sbom._model._inputs import ArchiveTree, RecordRow
|
|
8
|
+
from python_sbom._model._metadata import parse_metadata
|
|
9
|
+
from python_sbom._model._names import archive_sbom_file_name
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Container
|
|
13
|
+
|
|
14
|
+
from python_sbom._model._metadata import CoreMetadata
|
|
15
|
+
from python_sbom._wheel._reader import ArtifactReader
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def read_archive(artifact: ArtifactReader) -> ArchiveTree:
|
|
19
|
+
# every member is read from the zip by its name, never from a file system path, so a name such as /etc/hostname or
|
|
20
|
+
# ../outside names a member, not a file of the host
|
|
21
|
+
where: Final[str] = artifact.path.name
|
|
22
|
+
members: Final[list[str]] = sorted(
|
|
23
|
+
info.filename
|
|
24
|
+
for info in artifact.archive.infolist()
|
|
25
|
+
if not info.is_dir() and info.filename != archive_sbom_file_name(where)
|
|
26
|
+
)
|
|
27
|
+
metadata: Final[dict[str, CoreMetadata]] = {}
|
|
28
|
+
for name in members:
|
|
29
|
+
if not name.endswith(".dist-info/METADATA"):
|
|
30
|
+
continue
|
|
31
|
+
# files belong to a distribution by the directory that holds its .dist-info, which two cannot share
|
|
32
|
+
if (directory := _distribution_prefix(name)) in metadata:
|
|
33
|
+
msg = (
|
|
34
|
+
f"{where} holds more than one .dist-info in {directory or 'its top level'}, so their files cannot"
|
|
35
|
+
" be told apart"
|
|
36
|
+
)
|
|
37
|
+
raise SbomError(msg)
|
|
38
|
+
metadata[directory] = parse_metadata(artifact.read(name).decode("utf-8"))
|
|
39
|
+
if "" not in metadata:
|
|
40
|
+
msg = f"{where} holds no top-level .dist-info/METADATA naming the project"
|
|
41
|
+
raise SbomError(msg)
|
|
42
|
+
files: Final[dict[str, RecordRow]] = {
|
|
43
|
+
name: RecordRow(
|
|
44
|
+
path=name,
|
|
45
|
+
algorithm="SHA-256",
|
|
46
|
+
digest=artifact.digest(name),
|
|
47
|
+
size=str(artifact.archive.getinfo(name).file_size),
|
|
48
|
+
)
|
|
49
|
+
for name in members
|
|
50
|
+
if not name.endswith(".whl")
|
|
51
|
+
}
|
|
52
|
+
owned: Final[dict[str, list[str]]] = {prefix: [] for prefix in metadata}
|
|
53
|
+
loose: Final[list[str]] = []
|
|
54
|
+
for name in members:
|
|
55
|
+
# the project's own distribution holds every subdirectory no other one does
|
|
56
|
+
if owner := _owner(name, metadata):
|
|
57
|
+
owned[owner].append(name)
|
|
58
|
+
elif "/" in name:
|
|
59
|
+
owned[""].append(name)
|
|
60
|
+
else:
|
|
61
|
+
loose.append(name)
|
|
62
|
+
# in the order the components list them, so the first broken wheel is the one reported
|
|
63
|
+
wheels: Final[list[str]] = [member for member in bundled_members(loose, owned) if not member.endswith("/")]
|
|
64
|
+
return ArchiveTree(
|
|
65
|
+
metadata=metadata,
|
|
66
|
+
owned=owned,
|
|
67
|
+
loose=loose,
|
|
68
|
+
files=files,
|
|
69
|
+
bundled=dict(zip(wheels, artifact.bundled_wheels(wheels), strict=True)),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _distribution_prefix(name: str) -> str:
|
|
74
|
+
# each distribution by the directory that holds its .dist-info, the project's own at the top level
|
|
75
|
+
return f"{name.rsplit('/', 2)[0]}/" if name.count("/") > 1 else ""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _owner(name: str, distributions: Container[str]) -> str | None:
|
|
79
|
+
# the deepest distribution directory that holds the member, found by looking up each of its ancestor directories,
|
|
80
|
+
# deepest first, so the work grows with the member's depth rather than the number of distributions
|
|
81
|
+
parents: Final[list[str]] = name.split("/")[:-1]
|
|
82
|
+
return next(
|
|
83
|
+
(
|
|
84
|
+
prefix
|
|
85
|
+
for depth in range(len(parents), 0, -1)
|
|
86
|
+
if (prefix := f"{'/'.join(parents[:depth])}/") in distributions
|
|
87
|
+
),
|
|
88
|
+
None,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
__all__ = [
|
|
93
|
+
"read_archive",
|
|
94
|
+
]
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import pkgutil
|
|
5
|
+
import sys
|
|
6
|
+
from importlib.machinery import PathFinder
|
|
7
|
+
from pathlib import Path, PurePosixPath
|
|
8
|
+
from typing import TYPE_CHECKING, Final
|
|
9
|
+
|
|
10
|
+
from python_sbom._errors import SbomError
|
|
11
|
+
from python_sbom._model._content import ContentProvider
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from python_sbom._model._inputs import Settings
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def resolve_provider(
|
|
18
|
+
given: ContentProvider | str | None, settings: Settings, root: Path
|
|
19
|
+
) -> tuple[ContentProvider, tuple[PurePosixPath, ...]]:
|
|
20
|
+
# an instance wins, as a hook subclass builds one; a module:attribute string overrides the option, as the CLI's flag
|
|
21
|
+
# does; otherwise the content-provider option, or the provider that adds nothing
|
|
22
|
+
if isinstance(given, ContentProvider):
|
|
23
|
+
provider = given
|
|
24
|
+
elif (spec := settings.content_provider if given is None else given) is None:
|
|
25
|
+
provider = ContentProvider()
|
|
26
|
+
else:
|
|
27
|
+
provider = _load(spec, root)
|
|
28
|
+
return provider, _provider_scripts(provider, root)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _load(spec: str, root: Path) -> ContentProvider:
|
|
32
|
+
# resolve_name also takes a bare module, which is no provider
|
|
33
|
+
module_name, _, attribute = spec.partition(":")
|
|
34
|
+
if not (module_name and attribute):
|
|
35
|
+
msg = f"content provider {spec!r} must be `module:attribute`"
|
|
36
|
+
raise SbomError(msg)
|
|
37
|
+
if (imported := _imported_elsewhere(module_name, root)) is not None:
|
|
38
|
+
msg = (
|
|
39
|
+
f"content provider {spec!r} cannot load from {root}: this process already imported {imported}, a module of"
|
|
40
|
+
" the same name, and would run that instead; build each such project in a process of its own"
|
|
41
|
+
)
|
|
42
|
+
raise SbomError(msg)
|
|
43
|
+
sys.path.insert(0, str(root))
|
|
44
|
+
try:
|
|
45
|
+
provider = pkgutil.resolve_name(spec)
|
|
46
|
+
# ValueError: a name resolve_name cannot parse, such as a second colon
|
|
47
|
+
except (ImportError, AttributeError, ValueError) as error:
|
|
48
|
+
msg = f"content provider {spec!r} does not load from {root}: {error}"
|
|
49
|
+
raise SbomError(msg) from error
|
|
50
|
+
finally:
|
|
51
|
+
sys.path.remove(str(root))
|
|
52
|
+
if isinstance(provider, type) and issubclass(provider, ContentProvider):
|
|
53
|
+
provider = provider()
|
|
54
|
+
if not isinstance(provider, ContentProvider):
|
|
55
|
+
msg = f"content provider {spec!r} is not a ContentProvider subclass or instance"
|
|
56
|
+
raise SbomError(msg)
|
|
57
|
+
return provider
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _imported_elsewhere(module_name: str, root: Path) -> str | None:
|
|
61
|
+
# the import cache is per process, so a hatch build of a second project with a provider module of the same name
|
|
62
|
+
# would run the first project's; each package on the way down to the module, and the module itself, that the project
|
|
63
|
+
# provides is compared with what is already imported under its name; namespace packages merge, so only modules and
|
|
64
|
+
# regular packages can clash
|
|
65
|
+
search: list[str] | None = [str(root)]
|
|
66
|
+
parts: Final[list[str]] = module_name.split(".")
|
|
67
|
+
for depth in range(1, len(parts) + 1):
|
|
68
|
+
if search is None or (found := PathFinder.find_spec(".".join(parts[:depth]), search)) is None:
|
|
69
|
+
return None
|
|
70
|
+
imported = getattr(sys.modules.get(found.name), "__file__", None)
|
|
71
|
+
if (
|
|
72
|
+
found.origin is not None
|
|
73
|
+
and imported is not None
|
|
74
|
+
and Path(imported).resolve() != Path(found.origin).resolve()
|
|
75
|
+
):
|
|
76
|
+
return imported
|
|
77
|
+
search = None if found.submodule_search_locations is None else list(found.submodule_search_locations)
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _provider_scripts(provider: ContentProvider, root: Path) -> tuple[PurePosixPath, ...]:
|
|
82
|
+
# each class of the provider defined in the project shapes the document, a base class or mixin as much as the class
|
|
83
|
+
# itself, so the SBOM lists their modules among the build tools; python-sbom's own ContentProvider adds nothing
|
|
84
|
+
found: Final[dict[PurePosixPath, None]] = {}
|
|
85
|
+
for cls in type(provider).__mro__:
|
|
86
|
+
if cls in {ContentProvider, object}:
|
|
87
|
+
continue
|
|
88
|
+
if (source := _source(cls)) is None:
|
|
89
|
+
continue
|
|
90
|
+
resolved, base = Path(source).resolve(), root.resolve()
|
|
91
|
+
if resolved.is_relative_to(base):
|
|
92
|
+
found[PurePosixPath(resolved.relative_to(base).as_posix())] = None
|
|
93
|
+
return tuple(found)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _source(cls: type) -> str | None:
|
|
97
|
+
# a method's code names the file even for a module hatchling loaded outside the import system, such as a custom
|
|
98
|
+
# hook script
|
|
99
|
+
methods = (
|
|
100
|
+
function.__code__.co_filename
|
|
101
|
+
for _, function in inspect.getmembers(cls, inspect.isfunction)
|
|
102
|
+
if function.__qualname__.startswith(f"{cls.__qualname__}.")
|
|
103
|
+
)
|
|
104
|
+
return next(methods, getattr(sys.modules.get(cls.__module__), "__file__", None))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
__all__ = [
|
|
108
|
+
"resolve_provider",
|
|
109
|
+
]
|