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 @@
|
|
|
1
|
+
"""Zip artifacts: one checked handle per build, verifying the wheels they bundle, and writing an SBOM in."""
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import Counter
|
|
4
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
5
|
+
from typing import TYPE_CHECKING, Final
|
|
6
|
+
|
|
7
|
+
from installer.records import InvalidRecordEntry, parse_record_file
|
|
8
|
+
from installer.sources import WheelFile
|
|
9
|
+
from packaging.requirements import InvalidRequirement, Requirement
|
|
10
|
+
|
|
11
|
+
from python_sbom._errors import SbomError
|
|
12
|
+
from python_sbom._model._inputs import BundledWheel, RecordRow
|
|
13
|
+
from python_sbom._model._metadata import CoreMetadata, parse_metadata
|
|
14
|
+
|
|
15
|
+
from ._record import record_rows
|
|
16
|
+
from ._zip import open_archive, read_member, read_wheel_metadata, sha256_hex
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
import zipfile
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
from typing import IO
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# wheels verified at once, each perhaps in memory whole
|
|
25
|
+
_WORKERS: Final[int] = 4
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def read_bundled_wheels(paths: list[str], open_wheel: Callable[[str], IO[bytes]]) -> list[BundledWheel]:
|
|
29
|
+
# inflating and hashing release the GIL, so the wheels verify side by side, a few at a time, as each may be held in
|
|
30
|
+
# memory whole; results and the first error come in the order given
|
|
31
|
+
def read(path: str) -> BundledWheel:
|
|
32
|
+
with open_wheel(path) as stream:
|
|
33
|
+
return _read_bundled_wheel(stream, path)
|
|
34
|
+
|
|
35
|
+
with ThreadPoolExecutor(max_workers=_WORKERS) as pool:
|
|
36
|
+
return list(pool.map(read, paths))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _read_bundled_wheel(stream: IO[bytes], path: str) -> BundledWheel:
|
|
40
|
+
where: Final[str] = f"bundled wheel {path}"
|
|
41
|
+
sha256: Final[str] = sha256_hex(stream)
|
|
42
|
+
stream.seek(0)
|
|
43
|
+
with open_archive(stream, where) as archive:
|
|
44
|
+
# installer takes the project name from the archive's file name, which an archive read from memory lacks
|
|
45
|
+
archive.filename = path
|
|
46
|
+
members: Final[list[str]] = sorted(info.filename for info in archive.infolist() if not info.is_dir())
|
|
47
|
+
wheel, metadata = read_wheel_metadata(archive, where)
|
|
48
|
+
dist_info: Final[str] = wheel.dist_info_dir
|
|
49
|
+
_check_named(metadata, where, f"{dist_info}/METADATA")
|
|
50
|
+
files: Final[list[RecordRow]] = _verified_record(archive, wheel, where)
|
|
51
|
+
vendored: Final[list[tuple[str, CoreMetadata]]] = [
|
|
52
|
+
*_vendor_manifest(archive, where, members),
|
|
53
|
+
*(
|
|
54
|
+
(name, _vendored_metadata(archive, where, name))
|
|
55
|
+
for name in members
|
|
56
|
+
if "/_vendor/" in name and name.endswith(".dist-info/METADATA")
|
|
57
|
+
),
|
|
58
|
+
]
|
|
59
|
+
return BundledWheel(
|
|
60
|
+
path=path,
|
|
61
|
+
sha256=sha256,
|
|
62
|
+
metadata_name=f"{dist_info}/METADATA",
|
|
63
|
+
metadata=metadata,
|
|
64
|
+
files=files,
|
|
65
|
+
vendored=vendored,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _check_named(metadata: CoreMetadata, where: str, name: str) -> None:
|
|
70
|
+
if not (metadata.name and metadata.version):
|
|
71
|
+
msg = f"{where}: {name} lacks Name or Version"
|
|
72
|
+
raise SbomError(msg)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _verified_record(archive: zipfile.ZipFile, wheel: WheelFile, where: str) -> list[RecordRow]:
|
|
76
|
+
# the SBOM copies these rows, so a RECORD that misstates the wheel would make the SBOM misstate it too
|
|
77
|
+
dist_info: Final[str] = wheel.dist_info_dir
|
|
78
|
+
# installer decompresses RECORD whole, so a capped read comes first and bounds installer's read of the same bytes
|
|
79
|
+
record: Final[str] = read_member(archive, f"{dist_info}/RECORD", where).decode("utf-8")
|
|
80
|
+
try:
|
|
81
|
+
rows: Final[list[tuple[str, str, str]]] = list(parse_record_file(record.splitlines()))
|
|
82
|
+
# read before installer's check, which takes any hashlib name and fails on one needing a length, such as
|
|
83
|
+
# shake_128, with a TypeError
|
|
84
|
+
found: Final[list[RecordRow]] = record_rows(rows, where)
|
|
85
|
+
except InvalidRecordEntry as error:
|
|
86
|
+
msg = f"{where}: RECORD does not parse: {error}"
|
|
87
|
+
raise SbomError(msg) from error
|
|
88
|
+
# installer checks the last row of a path only, skips rows naming no member, and lets a RECORD.jws or RECORD.p7s
|
|
89
|
+
# anywhere under the .dist-info, "../" included, go unlisted, so each of these checks is ours
|
|
90
|
+
if repeated := sorted(path for path, count in Counter(path for path, _, _ in rows).items() if count > 1):
|
|
91
|
+
msg = f"{where}: RECORD lists {', '.join(repeated)} more than once"
|
|
92
|
+
raise SbomError(msg)
|
|
93
|
+
members: Final[set[str]] = {info.filename for info in archive.infolist() if not info.is_dir()}
|
|
94
|
+
recorded: Final[set[str]] = {path for path, _, _ in rows}
|
|
95
|
+
# installer checks the row of RECORD itself, the one row without a digest
|
|
96
|
+
if missing := sorted(
|
|
97
|
+
members - recorded - {f"{dist_info}/{name}" for name in ("RECORD", "RECORD.jws", "RECORD.p7s")}
|
|
98
|
+
):
|
|
99
|
+
msg = f"{where}: RECORD lacks a digest for {', '.join(missing)}"
|
|
100
|
+
raise SbomError(msg)
|
|
101
|
+
if extra := sorted(recorded - members):
|
|
102
|
+
msg = f"{where}: RECORD lists {', '.join(extra)}, which the wheel does not hold"
|
|
103
|
+
raise SbomError(msg)
|
|
104
|
+
try:
|
|
105
|
+
wheel.validate_record()
|
|
106
|
+
except WheelFile.validation_error as error:
|
|
107
|
+
# installer documents the exception class only, not the list of issues it carries
|
|
108
|
+
issues: Final[list[str]] = getattr(error, "issues", [str(error)])
|
|
109
|
+
prefix: Final[str] = f"In {archive.filename}, "
|
|
110
|
+
msg = f"{where}: RECORD disagrees with the wheel: {'; '.join(issue.removeprefix(prefix) for issue in issues)}"
|
|
111
|
+
raise SbomError(msg) from error
|
|
112
|
+
return found
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _vendor_manifest(archive: zipfile.ZipFile, where: str, members: list[str]) -> list[tuple[str, CoreMetadata]]:
|
|
116
|
+
# pip lists what it vendors in _vendor/vendor.txt instead of shipping each vendored .dist-info
|
|
117
|
+
found: Final[list[tuple[str, CoreMetadata]]] = []
|
|
118
|
+
for name in (member for member in members if member.endswith("/_vendor/vendor.txt")):
|
|
119
|
+
for line in read_member(archive, name, where).decode("utf-8").splitlines():
|
|
120
|
+
if not (text := line.partition("#")[0].strip()):
|
|
121
|
+
continue
|
|
122
|
+
try:
|
|
123
|
+
pins = list((requirement := Requirement(text)).specifier)
|
|
124
|
+
except InvalidRequirement as error:
|
|
125
|
+
msg = f"{where}: vendored requirement in {name} does not parse: {text}"
|
|
126
|
+
raise SbomError(msg) from error
|
|
127
|
+
if len(pins) != 1 or pins[0].operator != "==" or "*" in pins[0].version:
|
|
128
|
+
msg = f"{where}: vendored requirement in {name} pins no one exact version: {text}"
|
|
129
|
+
raise SbomError(msg)
|
|
130
|
+
found.append((name, CoreMetadata(raw={"name": requirement.name, "version": pins[0].version}, unparsed={})))
|
|
131
|
+
return found
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _vendored_metadata(archive: zipfile.ZipFile, where: str, name: str) -> CoreMetadata:
|
|
135
|
+
metadata: Final[CoreMetadata] = parse_metadata(read_member(archive, name, where).decode("utf-8"))
|
|
136
|
+
_check_named(metadata, where, name)
|
|
137
|
+
return metadata
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
__all__ = [
|
|
141
|
+
"read_bundled_wheels",
|
|
142
|
+
]
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from io import BytesIO
|
|
6
|
+
from typing import TYPE_CHECKING, Final
|
|
7
|
+
|
|
8
|
+
from ._bundled import read_bundled_wheels
|
|
9
|
+
from ._zip import open_archive, read_member, sha256_hex
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
import zipfile
|
|
13
|
+
from collections.abc import Generator
|
|
14
|
+
from pathlib import Path, PurePosixPath
|
|
15
|
+
|
|
16
|
+
from python_sbom._model._inputs import BundledWheel
|
|
17
|
+
|
|
18
|
+
# a bundled wheel is read whole, to hash it and open it as a zip, so its size is capped as metadata's is
|
|
19
|
+
_WHEEL_LIMIT: Final[int] = 512 * 1024 * 1024
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@contextmanager
|
|
23
|
+
def open_artifact(path: Path) -> Generator[ArtifactReader, None, None]:
|
|
24
|
+
# one open zip serves every read of a build, the provider's included, so the archive is checked and indexed once
|
|
25
|
+
with open_archive(path, str(path)) as archive:
|
|
26
|
+
yield ArtifactReader(path=path, archive=archive, members=frozenset(archive.namelist()))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
30
|
+
class ArtifactReader:
|
|
31
|
+
path: Path
|
|
32
|
+
archive: zipfile.ZipFile
|
|
33
|
+
members: frozenset[str]
|
|
34
|
+
|
|
35
|
+
def digest(self, name: str) -> str:
|
|
36
|
+
with self.archive.open(name) as member:
|
|
37
|
+
return sha256_hex(member)
|
|
38
|
+
|
|
39
|
+
def provider_read(self, path: PurePosixPath) -> bytes:
|
|
40
|
+
# a provider asking for a member the artifact lacks gets the KeyError a mapping raises, which it may expect
|
|
41
|
+
if (name := str(path)) not in self.members:
|
|
42
|
+
raise KeyError(name)
|
|
43
|
+
return self.read(name)
|
|
44
|
+
|
|
45
|
+
def read(self, name: str) -> bytes:
|
|
46
|
+
return read_member(self.archive, name, str(self.path))
|
|
47
|
+
|
|
48
|
+
def bundled_wheels(self, names: list[str]) -> list[BundledWheel]:
|
|
49
|
+
# zipfile serializes the seeks of concurrent member reads on its own lock, so the verifying threads share this
|
|
50
|
+
# handle and inflate side by side
|
|
51
|
+
return read_bundled_wheels(
|
|
52
|
+
names, lambda name: BytesIO(read_member(self.archive, name, str(self.path), _WHEEL_LIMIT))
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
__all__ = [
|
|
57
|
+
"ArtifactReader",
|
|
58
|
+
"open_artifact",
|
|
59
|
+
]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import binascii
|
|
5
|
+
from itertools import starmap
|
|
6
|
+
from typing import TYPE_CHECKING, Final
|
|
7
|
+
|
|
8
|
+
from installer.records import RecordEntry
|
|
9
|
+
|
|
10
|
+
from python_sbom._errors import SbomError
|
|
11
|
+
from python_sbom._model._inputs import RecordRow
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Iterable
|
|
15
|
+
|
|
16
|
+
# the algorithms a wheel RECORD may name, as CycloneDX spells them
|
|
17
|
+
_ALGORITHMS: Final[dict[str, str]] = {
|
|
18
|
+
"md5": "MD5",
|
|
19
|
+
"sha1": "SHA-1",
|
|
20
|
+
"sha256": "SHA-256",
|
|
21
|
+
"sha384": "SHA-384",
|
|
22
|
+
"sha512": "SHA-512",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def record_rows(rows: Iterable[tuple[str, str, str]], where: str) -> list[RecordRow]:
|
|
27
|
+
# the rows with a digest, as the SBOM lists them: RECORD's own row has none, nor has a file an installer left
|
|
28
|
+
# unhashed; installer's InvalidRecordEntry passes to the caller, which knows what a malformed row means there
|
|
29
|
+
found: Final[list[RecordRow]] = []
|
|
30
|
+
for entry in starmap(RecordEntry.from_elements, rows):
|
|
31
|
+
if entry.hash_ is None:
|
|
32
|
+
continue
|
|
33
|
+
algorithm, digest = _decode_digest(str(entry.hash_), f"{where}: RECORD row for {entry.path}")
|
|
34
|
+
found.append(RecordRow(path=entry.path, algorithm=algorithm, digest=digest, size=str(entry.size)))
|
|
35
|
+
return found
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _decode_digest(digest: str, where: str) -> tuple[str, str]:
|
|
39
|
+
# installer checks RECORD digests as urlsafe base64 under any hashlib name, while CycloneDX takes hex under its own
|
|
40
|
+
# names for a few algorithms
|
|
41
|
+
name, _, encoded = digest.partition("=")
|
|
42
|
+
if (algorithm := _ALGORITHMS.get(name)) is None:
|
|
43
|
+
msg = f"{where}: unknown hash algorithm {name!r}"
|
|
44
|
+
raise SbomError(msg)
|
|
45
|
+
try:
|
|
46
|
+
raw = base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4))
|
|
47
|
+
except binascii.Error as error:
|
|
48
|
+
msg = f"{where}: digest {encoded!r} is not urlsafe base64"
|
|
49
|
+
raise SbomError(msg) from error
|
|
50
|
+
return algorithm, raw.hex()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
"record_rows",
|
|
55
|
+
]
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import stat
|
|
7
|
+
import tempfile
|
|
8
|
+
import time
|
|
9
|
+
import zipfile
|
|
10
|
+
from io import BytesIO, StringIO
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Final
|
|
13
|
+
|
|
14
|
+
import zipremove
|
|
15
|
+
from installer.records import Hash, RecordEntry
|
|
16
|
+
from installer.utils import copyfileobj_with_hashing
|
|
17
|
+
|
|
18
|
+
from python_sbom._errors import SbomError
|
|
19
|
+
|
|
20
|
+
from ._zip import open_archive, wheel_source
|
|
21
|
+
|
|
22
|
+
# 1980-01-01, the earliest time a zip entry holds
|
|
23
|
+
_ZIP_EPOCH: Final[int] = 315532800
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def write_sbom(wheel: Path, name: str, content: bytes, epoch: int) -> str:
|
|
27
|
+
with open_archive(wheel, str(wheel)) as archive:
|
|
28
|
+
_, dist_info = wheel_source(archive, str(wheel))
|
|
29
|
+
record: Final[str] = f"{dist_info}/RECORD"
|
|
30
|
+
if missing := sorted({f"{dist_info}/METADATA", record} - set(archive.namelist())):
|
|
31
|
+
msg = f"{wheel} has no {', '.join(missing)}"
|
|
32
|
+
raise SbomError(msg)
|
|
33
|
+
record_info: Final[zipfile.ZipInfo] = archive.getinfo(record)
|
|
34
|
+
record_data: Final[bytes] = archive.read(record_info)
|
|
35
|
+
sbom: Final[str] = f"{dist_info}/sboms/{name}"
|
|
36
|
+
entry: Final[zipfile.ZipInfo] = _entry(sbom, epoch)
|
|
37
|
+
entry.compress_type = zipfile.ZIP_DEFLATED
|
|
38
|
+
rewritten: Final[bytes] = _rewritten_record(record_data, record, sbom, content)
|
|
39
|
+
# a name no "dist/*.whl" glob matches, in the wheel's directory so the final rename stays on one file system
|
|
40
|
+
descriptor, temporary = tempfile.mkstemp(dir=wheel.parent, prefix=".python-sbom-", suffix=".tmp")
|
|
41
|
+
os.close(descriptor)
|
|
42
|
+
try: # ruff: ignore[too-many-statements-in-try-clause] # whichever step fails, the temporary file goes
|
|
43
|
+
# copied through the process: copy_file_range makes some file systems first flush the wheel just written
|
|
44
|
+
with wheel.open("rb") as source, Path(temporary).open("wb") as target:
|
|
45
|
+
shutil.copyfileobj(source, target)
|
|
46
|
+
# the other members keep their bytes as stored, still compressed, so only the SBOM and RECORD get compressed
|
|
47
|
+
# here; both go last, RECORD after the SBOM
|
|
48
|
+
with zipremove.ZipFile(temporary, "a") as archive:
|
|
49
|
+
stale = [info for info in archive.infolist() if info.filename in {record, sbom}]
|
|
50
|
+
archive.repack(list(map(archive.remove, stale))) # ty: ignore[unresolved-attribute] # zipremove adds both
|
|
51
|
+
archive.writestr(entry, content)
|
|
52
|
+
archive.writestr(record_info, rewritten)
|
|
53
|
+
Path(temporary).chmod(stat.S_IMODE(wheel.stat().st_mode))
|
|
54
|
+
Path(temporary).replace(wheel)
|
|
55
|
+
except BaseException:
|
|
56
|
+
Path(temporary).unlink(missing_ok=True)
|
|
57
|
+
raise
|
|
58
|
+
return sbom
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _entry(name: str, epoch: int) -> zipfile.ZipInfo:
|
|
62
|
+
# zip times start at 1980; Unix attributes whichever system writes the entry, so the bytes match across systems
|
|
63
|
+
entry: Final[zipfile.ZipInfo] = zipfile.ZipInfo(name, time.gmtime(max(epoch, _ZIP_EPOCH))[:6])
|
|
64
|
+
entry.create_system = 3
|
|
65
|
+
entry.external_attr = (stat.S_IFREG | 0o644) << 16
|
|
66
|
+
return entry
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _rewritten_record(record: bytes, name: str, sbom: str, content: bytes) -> bytes:
|
|
70
|
+
lines: Final[list[str]] = [line for line in record.decode("utf-8").splitlines(keepends=True) if _path(line) != sbom]
|
|
71
|
+
newline: Final[str] = "\r\n" if lines and lines[0].endswith("\r\n") else "\n"
|
|
72
|
+
# a last row without its line end would take the SBOM's row onto its own line
|
|
73
|
+
if lines and not lines[-1].endswith(("\n", "\r")):
|
|
74
|
+
lines[-1] += newline
|
|
75
|
+
# the row of RECORD itself stays last
|
|
76
|
+
at: Final[int] = next((index for index, line in enumerate(lines) if _path(line) == name), len(lines))
|
|
77
|
+
# installer's helper hashes as it copies; the copy is thrown away
|
|
78
|
+
digest, size = copyfileobj_with_hashing(BytesIO(content), BytesIO(), "sha256")
|
|
79
|
+
row: Final[StringIO] = StringIO()
|
|
80
|
+
csv.writer(row, lineterminator=newline).writerow(RecordEntry(sbom, Hash("sha256", digest), size).to_row())
|
|
81
|
+
lines.insert(at, row.getvalue())
|
|
82
|
+
return "".join(lines).encode()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _path(line: str) -> str:
|
|
86
|
+
# RECORD quotes a path that holds a comma, a quote or a line end, so the raw start of a line misses its row
|
|
87
|
+
return next(csv.reader([line]), [""])[0]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def append_member(archive: Path, name: str, content: bytes, epoch: int) -> None:
|
|
91
|
+
with zipfile.ZipFile(archive, "a") as handle:
|
|
92
|
+
if name in handle.namelist():
|
|
93
|
+
msg = f"{archive.name} already holds {name}"
|
|
94
|
+
raise SbomError(msg)
|
|
95
|
+
# deflated at the highest level whatever the other members use, since a zipapp may store them all and the SBOM
|
|
96
|
+
# then adds megabytes; a fixed level, time and attributes keep the bytes the same for one input on every system
|
|
97
|
+
handle.writestr(_entry(name, epoch), content, compress_type=zipfile.ZIP_DEFLATED, compresslevel=9)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
__all__ = [
|
|
101
|
+
"append_member",
|
|
102
|
+
"write_sbom",
|
|
103
|
+
]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import zipfile
|
|
5
|
+
import zlib
|
|
6
|
+
from collections import Counter
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from typing import IO, TYPE_CHECKING, Final
|
|
9
|
+
|
|
10
|
+
from installer.sources import WheelFile
|
|
11
|
+
|
|
12
|
+
from python_sbom._errors import SbomError
|
|
13
|
+
from python_sbom._model._metadata import CoreMetadata, parse_metadata
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import Generator
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
# metadata files beyond this size are refused, so a hostile wheel cannot exhaust memory
|
|
20
|
+
_METADATA_LIMIT: Final[int] = 16 * 1024 * 1024
|
|
21
|
+
# members inflate a chunk at a time, so a size the archive states falsely is caught before it is all in memory
|
|
22
|
+
CHUNK: Final[int] = 1024 * 1024
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@contextmanager
|
|
26
|
+
def open_archive(file: Path | IO[bytes], where: str) -> Generator[zipfile.ZipFile, None, None]:
|
|
27
|
+
try:
|
|
28
|
+
with zipfile.ZipFile(file) as archive:
|
|
29
|
+
# installers extract the first of two same-named members and zipfile reads the last, and a file system that
|
|
30
|
+
# ignores case merges names that differ only by case, so such names would hide what gets installed;
|
|
31
|
+
# installer's WheelFile accepts names that differ only by case
|
|
32
|
+
if clashes := sorted(
|
|
33
|
+
name for name, count in Counter(name.casefold() for name in archive.namelist()).items() if count > 1
|
|
34
|
+
):
|
|
35
|
+
msg = f"{where} holds more than one member named {', '.join(clashes)}, ignoring case"
|
|
36
|
+
raise SbomError(msg)
|
|
37
|
+
yield archive
|
|
38
|
+
# zipfile raises these as late as a member's last read, inside installer's checks too, which let them through
|
|
39
|
+
except (zipfile.BadZipFile, zlib.error) as error:
|
|
40
|
+
msg = f"{where} is not a valid zip archive: {error}"
|
|
41
|
+
raise SbomError(msg) from error
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def read_wheel_metadata(archive: zipfile.ZipFile, where: str) -> tuple[WheelFile, CoreMetadata]:
|
|
45
|
+
wheel, dist_info = wheel_source(archive, where)
|
|
46
|
+
# read capped, as installer's read_dist_info decompresses the member whole
|
|
47
|
+
return wheel, parse_metadata(read_member(archive, f"{dist_info}/METADATA", where).decode("utf-8"))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def wheel_source(archive: zipfile.ZipFile, where: str) -> tuple[WheelFile, str]:
|
|
51
|
+
# installer refuses a wheel whose file name does not parse, or that holds other than one top-level .dist-info, since
|
|
52
|
+
# a second one is a decoy that could decide whose metadata counts
|
|
53
|
+
try:
|
|
54
|
+
wheel = WheelFile(archive)
|
|
55
|
+
dist_info = wheel.dist_info_dir
|
|
56
|
+
except ValueError as error:
|
|
57
|
+
msg = f"{where} is not a wheel: {error}"
|
|
58
|
+
raise SbomError(msg) from error
|
|
59
|
+
return wheel, dist_info
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def read_member(archive: zipfile.ZipFile, name: str, where: str, limit: int = _METADATA_LIMIT) -> bytes:
|
|
63
|
+
try:
|
|
64
|
+
info = archive.getinfo(name)
|
|
65
|
+
except KeyError as error:
|
|
66
|
+
msg = f"{where} has no {name}"
|
|
67
|
+
raise SbomError(msg) from error
|
|
68
|
+
# counted as it decompresses, since the size the archive states may lie and one read decompresses it all at once
|
|
69
|
+
content: Final[bytearray] = bytearray()
|
|
70
|
+
with archive.open(info) as member:
|
|
71
|
+
while chunk := member.read(CHUNK):
|
|
72
|
+
content.extend(chunk)
|
|
73
|
+
if len(content) > limit:
|
|
74
|
+
msg = f"{where}: {name} is over the {limit} byte limit"
|
|
75
|
+
raise SbomError(msg)
|
|
76
|
+
return bytes(content)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def sha256_hex(stream: IO[bytes]) -> str:
|
|
80
|
+
# hashlib.file_digest wants readinto, which a zip member stream typed as IO[bytes] does not promise
|
|
81
|
+
digest: Final = hashlib.sha256()
|
|
82
|
+
while chunk := stream.read(CHUNK):
|
|
83
|
+
digest.update(chunk)
|
|
84
|
+
return digest.hexdigest()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
__all__ = [
|
|
88
|
+
"CHUNK",
|
|
89
|
+
"open_archive",
|
|
90
|
+
"read_member",
|
|
91
|
+
"read_wheel_metadata",
|
|
92
|
+
"sha256_hex",
|
|
93
|
+
"wheel_source",
|
|
94
|
+
]
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import tempfile
|
|
5
|
+
import weakref
|
|
6
|
+
from functools import cached_property
|
|
7
|
+
from pathlib import Path, PurePosixPath
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Final
|
|
9
|
+
|
|
10
|
+
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
|
11
|
+
from hatchling.builders.utils import get_reproducible_timestamp
|
|
12
|
+
from hatchling.utils.constants import DEFAULT_BUILD_SCRIPT
|
|
13
|
+
|
|
14
|
+
from python_sbom._collect._content import resolve_provider
|
|
15
|
+
from python_sbom._collect._revision import REVISION_FILE, default_revision
|
|
16
|
+
from python_sbom._collect._settings import settings_from_table
|
|
17
|
+
from python_sbom._errors import SbomError
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
|
|
22
|
+
from python_sbom._model._content import ContentProvider
|
|
23
|
+
from python_sbom._model._inputs import Settings
|
|
24
|
+
|
|
25
|
+
# options of every hatchling hook table, which hatchling itself reads
|
|
26
|
+
_HATCHLING_OPTIONS: Final[frozenset[str]] = frozenset({
|
|
27
|
+
"dependencies",
|
|
28
|
+
"enable-by-default",
|
|
29
|
+
"path",
|
|
30
|
+
"require-runtime-dependencies",
|
|
31
|
+
"require-runtime-features",
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SbomBuildHook(BuildHookInterface):
|
|
36
|
+
"""
|
|
37
|
+
Hatchling build hook that writes a CycloneDX SBOM into the wheel's ``.dist-info/sboms/``, as PEP 770 describes.
|
|
38
|
+
|
|
39
|
+
It runs once hatchling has written the wheel, so the SBOM describes the wheel's final metadata and file name
|
|
40
|
+
whatever other hooks changed. Options come from ``[tool.python-sbom]``, the one place ``python-sbom embed`` and
|
|
41
|
+
``python-sbom archive`` read them too. For an sdist it records the source revision, so a wheel built from that sdist
|
|
42
|
+
still names its commit.
|
|
43
|
+
|
|
44
|
+
:cvar PLUGIN_NAME: the hook's name in ``[tool.hatch.build.hooks]``
|
|
45
|
+
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
PLUGIN_NAME = "python-sbom"
|
|
49
|
+
_remove_revision_file: Callable[[], None] | None = None
|
|
50
|
+
|
|
51
|
+
def initialize(
|
|
52
|
+
self,
|
|
53
|
+
version: str,
|
|
54
|
+
build_data: dict[str, Any], # simplint: ignore[SMP602] # hatchling's signature
|
|
55
|
+
) -> None:
|
|
56
|
+
"""
|
|
57
|
+
Read the source revision, and record it in an sdist.
|
|
58
|
+
|
|
59
|
+
:param version: hatchling's build variant
|
|
60
|
+
:param build_data: hatchling's build data, which receives the revision file
|
|
61
|
+
|
|
62
|
+
"""
|
|
63
|
+
if self.target_name not in {"sdist", "wheel"} or version == "editable":
|
|
64
|
+
return
|
|
65
|
+
# once per build, before it writes the artifact into a checkout that may not ignore it; a value kept for the
|
|
66
|
+
# process would name an older commit in the next build after a commit
|
|
67
|
+
self._revision = revision = default_revision(Path(self.root))
|
|
68
|
+
# a dirty checkout has no commit that reproduces the sdist, so a wheel built from it names none
|
|
69
|
+
if self.target_name != "sdist" or revision is None or revision.dirty:
|
|
70
|
+
return
|
|
71
|
+
# outside the project, so the build leaves nothing behind in it
|
|
72
|
+
directory = tempfile.mkdtemp()
|
|
73
|
+
# hatchling reads the file only while it writes the sdist, and skips finalize when the build fails, so the
|
|
74
|
+
# directory then goes with the hook, at the latest when the interpreter exits
|
|
75
|
+
self._remove_revision_file = weakref.finalize(self, shutil.rmtree, directory)
|
|
76
|
+
# bytes, so the sdist holds the same LF line on every OS
|
|
77
|
+
(written := Path(directory) / REVISION_FILE).write_bytes(f"node: {revision.commit}\n".encode())
|
|
78
|
+
build_data["force_include"][str(written)] = REVISION_FILE
|
|
79
|
+
|
|
80
|
+
def finalize(
|
|
81
|
+
self,
|
|
82
|
+
version: str,
|
|
83
|
+
build_data: ( # ruff: ignore[unused-method-argument] # hatchling's signature
|
|
84
|
+
dict[str, Any] # simplint: ignore[SMP602] # see above
|
|
85
|
+
),
|
|
86
|
+
artifact_path: str,
|
|
87
|
+
) -> None:
|
|
88
|
+
"""
|
|
89
|
+
Write the SBOM into the wheel hatchling built, and remove the revision file an sdist build wrote.
|
|
90
|
+
|
|
91
|
+
:param version: hatchling's build variant; an editable wheel gets no SBOM, since it carries no code
|
|
92
|
+
:param build_data: hatchling's build data
|
|
93
|
+
:param artifact_path: the built wheel
|
|
94
|
+
|
|
95
|
+
"""
|
|
96
|
+
if self._remove_revision_file is not None:
|
|
97
|
+
self._remove_revision_file()
|
|
98
|
+
if self.target_name != "wheel" or version == "editable":
|
|
99
|
+
return
|
|
100
|
+
# what a wheel needs loads only when one is built, so an sdist build stays light
|
|
101
|
+
from python_sbom._collect._facts import collect_facts # ruff: ignore[import-outside-top-level] # see above
|
|
102
|
+
from python_sbom._generate._embed import embed_into # ruff: ignore[import-outside-top-level] # see above
|
|
103
|
+
|
|
104
|
+
root = Path(self.root)
|
|
105
|
+
# content_provider() may be a subclass's override, so its result goes through resolve_provider again, which
|
|
106
|
+
# returns a given instance as is, for the provider's source files to be listed among the build tools
|
|
107
|
+
content, provider_scripts = resolve_provider(self.content_provider(), self._settings, root)
|
|
108
|
+
# a custom hook script shapes the document, as a provider defined in the project does, so the SBOM lists both
|
|
109
|
+
# among the build tools
|
|
110
|
+
own = [PurePosixPath(self.config.get("path", DEFAULT_BUILD_SCRIPT))] if self.PLUGIN_NAME == "custom" else []
|
|
111
|
+
scripts = tuple(dict.fromkeys([*own, *provider_scripts]))
|
|
112
|
+
# hatchling's own fixed time for a reproducible build when SOURCE_DATE_EPOCH is unset, so the SBOM agrees with
|
|
113
|
+
# the zip entries hatchling wrote
|
|
114
|
+
facts = collect_facts(
|
|
115
|
+
root,
|
|
116
|
+
lambda _root: self._revision,
|
|
117
|
+
default_epoch=get_reproducible_timestamp() if self.build_config.reproducible else None,
|
|
118
|
+
)
|
|
119
|
+
embed_into(
|
|
120
|
+
Path(artifact_path), root, settings=self._settings, content=content, build_scripts=scripts, facts=facts
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def content_provider(self) -> ContentProvider:
|
|
124
|
+
"""
|
|
125
|
+
Return what the project adds to its SBOM.
|
|
126
|
+
|
|
127
|
+
Override it in a hatchling custom build hook to build the provider with what the hook knows; the
|
|
128
|
+
``content-provider`` option is then ignored.
|
|
129
|
+
|
|
130
|
+
:returns: the ``content-provider`` option's provider, or one that adds nothing
|
|
131
|
+
|
|
132
|
+
"""
|
|
133
|
+
return resolve_provider(None, self._settings, Path(self.root))[0]
|
|
134
|
+
|
|
135
|
+
@cached_property
|
|
136
|
+
def _settings(self) -> Settings:
|
|
137
|
+
# hatchling has parsed pyproject.toml already; the hook's own table holds only hatchling's options, so every
|
|
138
|
+
# entry point reads the same [tool.python-sbom]
|
|
139
|
+
if unknown := sorted(self.config.keys() - _HATCHLING_OPTIONS):
|
|
140
|
+
msg = (
|
|
141
|
+
f"the {self.PLUGIN_NAME} hook's table holds {', '.join(unknown)}; move python-sbom's options to"
|
|
142
|
+
" [tool.python-sbom], where python-sbom embed and python-sbom archive read them too"
|
|
143
|
+
)
|
|
144
|
+
raise SbomError(msg)
|
|
145
|
+
return settings_from_table(self.metadata.config.get("tool", {}).get("python-sbom", {}), "[tool.python-sbom]")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
__all__ = [
|
|
149
|
+
"SbomBuildHook",
|
|
150
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Register the build hook with hatchling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from hatchling.plugin import hookimpl
|
|
6
|
+
|
|
7
|
+
from ._hook import SbomBuildHook
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@hookimpl
|
|
11
|
+
def hatch_register_build_hook() -> type[SbomBuildHook]:
|
|
12
|
+
"""
|
|
13
|
+
Return the build hook hatchling loads for ``[tool.hatch.build.hooks.python-sbom]``.
|
|
14
|
+
|
|
15
|
+
:returns: the build hook class
|
|
16
|
+
|
|
17
|
+
"""
|
|
18
|
+
return SbomBuildHook
|
python_sbom/py.typed
ADDED
|
File without changes
|
python_sbom/version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.1'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 1)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|