buildstream-sbom 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.
- buildstream_sbom/frontend.py +190 -0
- buildstream_sbom/licensing.py +151 -0
- buildstream_sbom/spdx.py +337 -0
- buildstream_sbom/utils.py +30 -0
- buildstream_sbom-1.1.dist-info/METADATA +111 -0
- buildstream_sbom-1.1.dist-info/RECORD +9 -0
- buildstream_sbom-1.1.dist-info/WHEEL +5 -0
- buildstream_sbom-1.1.dist-info/entry_points.txt +2 -0
- buildstream_sbom-1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025 Codethink Limited
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from .spdx import generate_spdx
|
|
17
|
+
from .utils import get_hosttool, bst_common_args
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def configure():
|
|
21
|
+
parser = argparse.ArgumentParser(
|
|
22
|
+
description="Generate an SPDX formatted SBoM manifest from a BuildStream project"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"--spdx-name",
|
|
27
|
+
metavar="NAME",
|
|
28
|
+
help="SPDX document name",
|
|
29
|
+
required=True,
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"--spdx-namespace",
|
|
33
|
+
metavar="NAMESPACE",
|
|
34
|
+
help="SPDX document namespace",
|
|
35
|
+
required=True,
|
|
36
|
+
)
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"--spdx-comment",
|
|
39
|
+
metavar="COMMENT",
|
|
40
|
+
help="SPDX document comment",
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"--spdx-creator",
|
|
44
|
+
metavar="CREATOR",
|
|
45
|
+
action="append",
|
|
46
|
+
dest="spdx_creators",
|
|
47
|
+
help="SPDX creator, in the format mandated by SPDX. "
|
|
48
|
+
"Can be passed multiple times for multiple creators.",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
parser.add_argument(
|
|
52
|
+
"-C",
|
|
53
|
+
"--directory",
|
|
54
|
+
metavar="DIRECTORY",
|
|
55
|
+
help="BuildStream project directory",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
parser.add_argument(
|
|
59
|
+
"-o",
|
|
60
|
+
"--option",
|
|
61
|
+
metavar="OPTION VALUE",
|
|
62
|
+
nargs=2,
|
|
63
|
+
action="append",
|
|
64
|
+
dest="options",
|
|
65
|
+
default=[],
|
|
66
|
+
help="BuildStream project option",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
parser.add_argument(
|
|
70
|
+
"--output",
|
|
71
|
+
metavar="FILE",
|
|
72
|
+
type=argparse.FileType("w"),
|
|
73
|
+
help="Output filename",
|
|
74
|
+
default="-",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
parser.add_argument("--deps", choices=["all", "run"], default="run")
|
|
78
|
+
|
|
79
|
+
parser.add_argument(
|
|
80
|
+
"--with-licenses",
|
|
81
|
+
action=argparse.BooleanOptionalAction,
|
|
82
|
+
default=False,
|
|
83
|
+
help="Attempt to extract licenses from build artifacts and add them to the SBOM",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# A lot of artifacts can be checked out so it is a good idea to let users choose where these
|
|
87
|
+
# go in case of storage space limitations
|
|
88
|
+
parser.add_argument(
|
|
89
|
+
"--artifact-checkout-directory",
|
|
90
|
+
"-A",
|
|
91
|
+
default=Path(os.getcwd()),
|
|
92
|
+
help="Directory to use when checking out artifacts for processing; "
|
|
93
|
+
"the artifacts will be removed once they have been processed "
|
|
94
|
+
"(default: %(default)s)",
|
|
95
|
+
type=Path,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
parser.add_argument(
|
|
99
|
+
"elements",
|
|
100
|
+
metavar="ELEMENT",
|
|
101
|
+
nargs="+",
|
|
102
|
+
help="Elements to generate SBoM for",
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
return parser.parse_args()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def extract_element_infos(config):
|
|
109
|
+
element_infos = []
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
result = run_bst_show(config)
|
|
113
|
+
except subprocess.CalledProcessError:
|
|
114
|
+
exit(1)
|
|
115
|
+
|
|
116
|
+
documents = yaml.load_all(result.stdout, yaml.BaseLoader)
|
|
117
|
+
try:
|
|
118
|
+
while True:
|
|
119
|
+
name = next(documents)
|
|
120
|
+
kind = next(documents)
|
|
121
|
+
source_infos = next(documents)
|
|
122
|
+
build_deps = next(documents)
|
|
123
|
+
runtime_deps = next(documents)
|
|
124
|
+
config = next(documents)
|
|
125
|
+
public = next(documents)
|
|
126
|
+
vars = next(documents)
|
|
127
|
+
|
|
128
|
+
element_infos.append(
|
|
129
|
+
{
|
|
130
|
+
"name": name,
|
|
131
|
+
"kind": None if kind == "%{kind}" else kind,
|
|
132
|
+
"source-infos": source_infos,
|
|
133
|
+
"build-deps": build_deps,
|
|
134
|
+
"runtime-deps": runtime_deps,
|
|
135
|
+
"config": config,
|
|
136
|
+
"public": public,
|
|
137
|
+
"vars": vars,
|
|
138
|
+
}
|
|
139
|
+
)
|
|
140
|
+
except StopIteration:
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
return element_infos
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def check_creators(config):
|
|
147
|
+
spdx_creators = ["Person", "Organization", "Tool"]
|
|
148
|
+
valid_creator = True
|
|
149
|
+
for c in config.spdx_creators:
|
|
150
|
+
valid_creator &= any(re.split(":", c)[0] in sc for sc in spdx_creators)
|
|
151
|
+
if not valid_creator:
|
|
152
|
+
sys.exit(
|
|
153
|
+
"ERROR: SPDX creator should follow the SPDX format.\n"
|
|
154
|
+
"See: https://spdx.github.io/spdx-spec/v2.3/document-creation-information/#68-creator-field"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def run_bst_show(config):
|
|
159
|
+
# Python documentation recommends using a full path for the executable
|
|
160
|
+
# https://docs.python.org/3/library/subprocess.html#subprocess.Popen
|
|
161
|
+
bst_cmdline = [get_hosttool("bst")]
|
|
162
|
+
|
|
163
|
+
bst_cmdline += bst_common_args(config)
|
|
164
|
+
|
|
165
|
+
bst_cmdline += [
|
|
166
|
+
"show",
|
|
167
|
+
"--format",
|
|
168
|
+
"---\n%{name}\n---\n'%{kind}'\n---\n%{source-info}\n---\n%{build-deps}\n---\n%{runtime-deps}\n---\n%{config}\n---\n%{public}---\n%{vars}",
|
|
169
|
+
]
|
|
170
|
+
bst_cmdline += config.elements
|
|
171
|
+
|
|
172
|
+
return subprocess.run(bst_cmdline, stdout=subprocess.PIPE, text=True, check=True)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def main():
|
|
176
|
+
config = configure()
|
|
177
|
+
|
|
178
|
+
if config.spdx_creators:
|
|
179
|
+
check_creators(config)
|
|
180
|
+
|
|
181
|
+
element_infos = extract_element_infos(config)
|
|
182
|
+
|
|
183
|
+
spdx = generate_spdx(element_infos, config)
|
|
184
|
+
|
|
185
|
+
with config.output:
|
|
186
|
+
json.dump(spdx, config.output, indent=2)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
if __name__ == "__main__":
|
|
190
|
+
main()
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Codethink Limited
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import json
|
|
8
|
+
import tempfile
|
|
9
|
+
|
|
10
|
+
from argparse import Namespace
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .utils import get_hosttool, bst_common_args
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_license_artifacts(
|
|
17
|
+
element: str,
|
|
18
|
+
artifact_checkout_path: Path,
|
|
19
|
+
licenses_path: Path,
|
|
20
|
+
config: Namespace,
|
|
21
|
+
) -> Path | None:
|
|
22
|
+
"""Use Buildstream artifact checkout to get the installed licenses of an element and outputs the licenses, specified by
|
|
23
|
+
`license_artifact_path`, to the chosen output directory.
|
|
24
|
+
|
|
25
|
+
If the artifact checkout failed then the it will cause the program to exit.
|
|
26
|
+
|
|
27
|
+
Returns a Path to the licenses or None if none were found in the artifacts
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
# bst artifact checkout
|
|
31
|
+
bst_cmdline = [get_hosttool("bst")]
|
|
32
|
+
|
|
33
|
+
bst_cmdline += bst_common_args(config)
|
|
34
|
+
|
|
35
|
+
bst_cmdline += [
|
|
36
|
+
"artifact",
|
|
37
|
+
"checkout",
|
|
38
|
+
element,
|
|
39
|
+
"--directory", # Note: This is the artifact checkout directory arg and not the same as the bst project directory
|
|
40
|
+
artifact_checkout_path,
|
|
41
|
+
"--deps",
|
|
42
|
+
"none",
|
|
43
|
+
"--no-integrate",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
r = subprocess.run(bst_cmdline)
|
|
47
|
+
|
|
48
|
+
if r.returncode != 0:
|
|
49
|
+
sys.exit("ERROR: Failed to checkout artifacts")
|
|
50
|
+
|
|
51
|
+
if licenses_path.exists():
|
|
52
|
+
return licenses_path
|
|
53
|
+
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def extract_licenses(
|
|
58
|
+
input_path, element_name, artifact_checkout_path
|
|
59
|
+
) -> list[str] | None:
|
|
60
|
+
"""Attempt to extract licenses from `input_path` using Askalono.
|
|
61
|
+
|
|
62
|
+
Unrecognised licenses automatically trigger warning outputs.
|
|
63
|
+
|
|
64
|
+
If the Akalono command fails for any reason it will cause the program to exit.
|
|
65
|
+
|
|
66
|
+
`element_name` and `artifact_checkout_path` are only used for providing error
|
|
67
|
+
context reporting and have no impact on the license extraction.
|
|
68
|
+
"""
|
|
69
|
+
askalono_bin = get_hosttool("askalono")
|
|
70
|
+
|
|
71
|
+
r = subprocess.run(
|
|
72
|
+
[askalono_bin, "--format", "json", "crawl", str(input_path)],
|
|
73
|
+
stdout=subprocess.PIPE,
|
|
74
|
+
stderr=subprocess.PIPE,
|
|
75
|
+
text=True,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
if r.returncode != 0:
|
|
79
|
+
sys.exit(f"ERROR: Askalono command failed.\n{r.stderr}")
|
|
80
|
+
|
|
81
|
+
license_json = r.stdout
|
|
82
|
+
|
|
83
|
+
# Askalono ouptuts multiple licenses as separate objects but not as an array,
|
|
84
|
+
# need to combine them in to an array
|
|
85
|
+
license_json_split = license_json.splitlines()
|
|
86
|
+
|
|
87
|
+
# use set to avoid duplicates
|
|
88
|
+
licenses = set()
|
|
89
|
+
failures = {}
|
|
90
|
+
for license_object_json in license_json_split:
|
|
91
|
+
license_object = json.loads(license_object_json)
|
|
92
|
+
license_result = license_object.get("result", None)
|
|
93
|
+
if license_result is None:
|
|
94
|
+
offending_file = license_object.get("path", "Missing File Name")
|
|
95
|
+
if offending_file is not None:
|
|
96
|
+
offending_file = offending_file.removeprefix(
|
|
97
|
+
str(artifact_checkout_path)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
files_with_error = failures.setdefault(
|
|
101
|
+
license_object.get("error", "Missing Error"), []
|
|
102
|
+
)
|
|
103
|
+
files_with_error.append(offending_file)
|
|
104
|
+
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
licenses.add(license_result["license"]["name"])
|
|
108
|
+
|
|
109
|
+
for error, files in failures.items():
|
|
110
|
+
# python doesn't allow for '\' in f-string parts so need to use format
|
|
111
|
+
print(
|
|
112
|
+
"{0}: {1} found in\n\t{2}\n".format(
|
|
113
|
+
element_name, error, "\n\t".join(files)
|
|
114
|
+
),
|
|
115
|
+
file=sys.stderr,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
if len(licenses) == 0:
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
return list(licenses)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def retrieve_licenses(
|
|
125
|
+
element_name: str, artifact_license_path: Path, config: Namespace
|
|
126
|
+
) -> list[str] | None:
|
|
127
|
+
"""Retrieve licenses for an element by checking out its artifacts to `artifact_checkout_root` and scanning
|
|
128
|
+
`licenses_artifact_path` for any identifiable licenses.
|
|
129
|
+
|
|
130
|
+
Returns None if none could be found otherwise returns licenses as in a list
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
# path to use for artifact checkout
|
|
134
|
+
with tempfile.TemporaryDirectory(
|
|
135
|
+
dir=config.artifact_checkout_directory
|
|
136
|
+
) as artifact_checkout_path:
|
|
137
|
+
# path to the licenses inside the artifact
|
|
138
|
+
licenses_path = Path(artifact_checkout_path, artifact_license_path)
|
|
139
|
+
|
|
140
|
+
license_data_path = get_license_artifacts(
|
|
141
|
+
element_name, artifact_checkout_path, licenses_path, config
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if license_data_path is None:
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
license_data = extract_licenses(
|
|
148
|
+
license_data_path, element_name, artifact_checkout_path
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
return license_data
|
buildstream_sbom/spdx.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025 Codethink Limited
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
from buildstream_sbom.utils import bst_common_args
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import re
|
|
9
|
+
import subprocess
|
|
10
|
+
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .licensing import retrieve_licenses
|
|
15
|
+
|
|
16
|
+
from .utils import get_hosttool
|
|
17
|
+
|
|
18
|
+
# buildstream-sbom supported spdx attributes
|
|
19
|
+
# this could be generic for any key in provenance but being
|
|
20
|
+
# explicit about these attributes guarantees valid SPDX
|
|
21
|
+
SOURCE_PROVENANCE_ATTRIBUTES = {
|
|
22
|
+
"concluded-license": "licenseConcluded",
|
|
23
|
+
"copyright-text": "copyrightText",
|
|
24
|
+
"declared-license": "licenseDeclared",
|
|
25
|
+
"description": "summary",
|
|
26
|
+
"homepage": "homepage",
|
|
27
|
+
"name": "name",
|
|
28
|
+
"originator": "originator",
|
|
29
|
+
"supplier": "supplier",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def generate_spdx(element_infos, config):
|
|
34
|
+
element_infos = {
|
|
35
|
+
element_info["name"]: element_info for element_info in element_infos
|
|
36
|
+
}
|
|
37
|
+
packages = []
|
|
38
|
+
relationships = []
|
|
39
|
+
|
|
40
|
+
elements_done = set()
|
|
41
|
+
elements_to_do = set()
|
|
42
|
+
|
|
43
|
+
for element in config.elements:
|
|
44
|
+
relationships.append(
|
|
45
|
+
{
|
|
46
|
+
"spdxElementId": "SPDXRef-DOCUMENT",
|
|
47
|
+
"relationshipType": "DESCRIBES",
|
|
48
|
+
"relatedSpdxElement": element_spdxid(element),
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
elements_to_do.add(element)
|
|
52
|
+
|
|
53
|
+
while elements_to_do:
|
|
54
|
+
name = elements_to_do.pop()
|
|
55
|
+
if name in elements_done:
|
|
56
|
+
continue
|
|
57
|
+
elements_done.add(name)
|
|
58
|
+
|
|
59
|
+
elem_info = element_infos[name]
|
|
60
|
+
source_infos = elem_info["source-infos"]
|
|
61
|
+
|
|
62
|
+
element_package, source_packages = generate_spdx_for_element(
|
|
63
|
+
name,
|
|
64
|
+
source_infos,
|
|
65
|
+
elem_info,
|
|
66
|
+
config,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
packages.append(element_package)
|
|
70
|
+
packages.extend(source_packages)
|
|
71
|
+
|
|
72
|
+
for source_package in source_packages:
|
|
73
|
+
relationships.append(
|
|
74
|
+
{
|
|
75
|
+
"spdxElementId": element_spdxid(name),
|
|
76
|
+
"relationshipType": "GENERATED_FROM",
|
|
77
|
+
"relatedSpdxElement": source_package["SPDXID"],
|
|
78
|
+
}
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Order here doesn't matter, as we're sorting the relationships at the end
|
|
82
|
+
all_deps = set(elem_info["runtime-deps"])
|
|
83
|
+
|
|
84
|
+
extra_deps = get_extra_dependencies(elem_info, config.deps == "all", config)
|
|
85
|
+
all_deps.update(extra_deps)
|
|
86
|
+
|
|
87
|
+
for dep in all_deps:
|
|
88
|
+
relationships.append(
|
|
89
|
+
{
|
|
90
|
+
"spdxElementId": element_package["SPDXID"],
|
|
91
|
+
"relationshipType": "DEPENDS_ON",
|
|
92
|
+
"relatedSpdxElement": element_spdxid(dep),
|
|
93
|
+
}
|
|
94
|
+
)
|
|
95
|
+
elements_to_do.add(dep)
|
|
96
|
+
|
|
97
|
+
def relationship_sort_key(relationship):
|
|
98
|
+
return (
|
|
99
|
+
relationship["spdxElementId"],
|
|
100
|
+
relationship["relatedSpdxElement"],
|
|
101
|
+
relationship["relationshipType"],
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
spdx = {
|
|
105
|
+
"spdxVersion": "SPDX-2.3",
|
|
106
|
+
"dataLicense": "CC0-1.0",
|
|
107
|
+
"SPDXID": "SPDXRef-DOCUMENT",
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
creation_info = spdx["creationInfo"] = {}
|
|
111
|
+
|
|
112
|
+
spdx["name"] = config.spdx_name
|
|
113
|
+
|
|
114
|
+
spdx["documentNamespace"] = config.spdx_namespace
|
|
115
|
+
|
|
116
|
+
if config.spdx_comment:
|
|
117
|
+
spdx["comment"] = config.spdx_comment
|
|
118
|
+
|
|
119
|
+
if config.spdx_creators:
|
|
120
|
+
creation_info["creators"] = config.spdx_creators
|
|
121
|
+
|
|
122
|
+
created_datetime = datetime.now(timezone.utc)
|
|
123
|
+
# This is a specific variant of isoformat, that python's datetime
|
|
124
|
+
# module doesn't support
|
|
125
|
+
# https://spdx.github.io/spdx-spec/v2.3/document-creation-information/#691-description
|
|
126
|
+
creation_info["created"] = created_datetime.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
127
|
+
|
|
128
|
+
spdx["packages"] = sorted(packages, key=lambda pkg: pkg["SPDXID"])
|
|
129
|
+
spdx["relationships"] = sorted(relationships, key=relationship_sort_key)
|
|
130
|
+
|
|
131
|
+
return spdx
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def resolve_element_name(name, config):
|
|
135
|
+
# Python documentation recommends using a full path for the executable
|
|
136
|
+
# https://docs.python.org/3/library/subprocess.html#subprocess.Popen
|
|
137
|
+
bst_cmdline = [get_hosttool("bst")]
|
|
138
|
+
|
|
139
|
+
bst_cmdline += bst_common_args(config)
|
|
140
|
+
|
|
141
|
+
bst_cmdline += ["show", "--deps", "none", "--format", "%{name}", name]
|
|
142
|
+
|
|
143
|
+
return subprocess.run(
|
|
144
|
+
bst_cmdline, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def get_extra_dependencies(elem_info, all_deps, config):
|
|
149
|
+
"""
|
|
150
|
+
Return extra dependencies (beyond the element runtime dependencies) that need to be
|
|
151
|
+
included in the SPDX. If `all_deps` is true, then all build dependencies are included.
|
|
152
|
+
Otherwise, it uses heuristics are based on the element kind, or hints in the element's
|
|
153
|
+
public data.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
if all_deps:
|
|
157
|
+
return elem_info["build-deps"]
|
|
158
|
+
|
|
159
|
+
# See if we have explicit configuration in the public data
|
|
160
|
+
public_data = elem_info["public"].get("sbom", {})
|
|
161
|
+
if public_data:
|
|
162
|
+
extra_deps = []
|
|
163
|
+
junction, _, _ = elem_info["name"].rpartition(":")
|
|
164
|
+
for dep_name in public_data.get("depends-on", []):
|
|
165
|
+
# need to account for the junction where the element resides
|
|
166
|
+
if junction:
|
|
167
|
+
result = resolve_element_name(f"{junction}:{dep_name}", config)
|
|
168
|
+
if result.returncode == 0:
|
|
169
|
+
resolved_dep_name = result.stdout.strip()
|
|
170
|
+
else:
|
|
171
|
+
sys.exit(
|
|
172
|
+
f"Could not find dependency {dep_name}, referenced "
|
|
173
|
+
f"from public data of element {elem_info['name']}:\n"
|
|
174
|
+
f"{result.stderr.strip()}"
|
|
175
|
+
)
|
|
176
|
+
else:
|
|
177
|
+
resolved_dep_name = dep_name
|
|
178
|
+
|
|
179
|
+
if resolved_dep_name not in elem_info["build-deps"]:
|
|
180
|
+
deps_str = "\n".join(elem_info["build-deps"])
|
|
181
|
+
sys.exit(
|
|
182
|
+
f"Could not find dependency {dep_name}, referenced "
|
|
183
|
+
f"from public data of element {elem_info['name']} "
|
|
184
|
+
f"(resolved to {resolved_dep_name}). "
|
|
185
|
+
f"Dependency must be one of:\n{deps_str}"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
extra_deps.append(resolved_dep_name)
|
|
189
|
+
return extra_deps
|
|
190
|
+
|
|
191
|
+
if include_build_deps(elem_info["kind"], elem_info["config"].keys()):
|
|
192
|
+
return elem_info["build-deps"]
|
|
193
|
+
|
|
194
|
+
return []
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def include_build_deps(kind, config_keys):
|
|
198
|
+
"""
|
|
199
|
+
Returns whether build dependencies should be always included even when generating
|
|
200
|
+
an SBoM with runtime dependencies. This currently means the compose and filter
|
|
201
|
+
elements.
|
|
202
|
+
"""
|
|
203
|
+
if kind is None:
|
|
204
|
+
# Try to guess the element kind depending on its configuration
|
|
205
|
+
if config_keys == set(
|
|
206
|
+
["include", "exclude", "include-orphans", "pass-integration"]
|
|
207
|
+
):
|
|
208
|
+
kind = "filter"
|
|
209
|
+
if config_keys == set(["include", "exclude", "include-orphans", "integrate"]):
|
|
210
|
+
kind = "compose"
|
|
211
|
+
|
|
212
|
+
return kind in ("filter", "compose")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def element_licenses(name: str, elem_info, config):
|
|
216
|
+
license_dir: str = elem_info["vars"].get("licensedir", None)
|
|
217
|
+
|
|
218
|
+
if license_dir is not None:
|
|
219
|
+
license_dir = Path(license_dir.lstrip(os.sep))
|
|
220
|
+
|
|
221
|
+
licenses = retrieve_licenses(name, license_dir, config)
|
|
222
|
+
if licenses is None:
|
|
223
|
+
print(f"{name}: Skipping: No licenses found", file=sys.stderr)
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
return " AND ".join(licenses)
|
|
227
|
+
else:
|
|
228
|
+
print(
|
|
229
|
+
f"Could not find license directory for {name}, skipping licenses.",
|
|
230
|
+
file=sys.stderr,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def generate_spdx_for_element(
|
|
237
|
+
name,
|
|
238
|
+
source_infos,
|
|
239
|
+
elem_info,
|
|
240
|
+
config,
|
|
241
|
+
):
|
|
242
|
+
"""
|
|
243
|
+
Generate SPDX package information from the passed-in module, which is an
|
|
244
|
+
entry from a new style manifest (using the source provenance API)
|
|
245
|
+
"""
|
|
246
|
+
package_name = element_package_name(name)
|
|
247
|
+
spdx_name = element_spdxid(name)
|
|
248
|
+
|
|
249
|
+
element_package = {
|
|
250
|
+
"name": package_name,
|
|
251
|
+
"SPDXID": spdx_name,
|
|
252
|
+
"filesAnalyzed": False,
|
|
253
|
+
"downloadLocation": "NOASSERTION",
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
element_ext_refs = []
|
|
257
|
+
bst_element_name = {
|
|
258
|
+
"referenceCategory": "OTHER",
|
|
259
|
+
"referenceType": "bst-element",
|
|
260
|
+
"referenceLocator": f"{name}",
|
|
261
|
+
}
|
|
262
|
+
element_ext_refs.append(bst_element_name)
|
|
263
|
+
|
|
264
|
+
element_package["externalRefs"] = element_ext_refs
|
|
265
|
+
|
|
266
|
+
if config.with_licenses:
|
|
267
|
+
licenses = element_licenses(name, elem_info, config)
|
|
268
|
+
if licenses is not None:
|
|
269
|
+
element_package["licenseDeclared"] = licenses
|
|
270
|
+
|
|
271
|
+
source_packages = []
|
|
272
|
+
|
|
273
|
+
for idx, source in enumerate(source_infos):
|
|
274
|
+
package = {
|
|
275
|
+
"SPDXID": f"{spdx_name}-{idx}",
|
|
276
|
+
"filesAnalyzed": False,
|
|
277
|
+
}
|
|
278
|
+
source_ext_refs = []
|
|
279
|
+
|
|
280
|
+
if "provenance" in source:
|
|
281
|
+
provenance = source["provenance"]
|
|
282
|
+
for attribute, spdx_field in SOURCE_PROVENANCE_ATTRIBUTES.items():
|
|
283
|
+
if attribute in provenance:
|
|
284
|
+
package[spdx_field] = provenance[attribute]
|
|
285
|
+
|
|
286
|
+
package.setdefault("name", source_package_name(source))
|
|
287
|
+
|
|
288
|
+
package["downloadLocation"] = source["url"]
|
|
289
|
+
if "version-guess" in source:
|
|
290
|
+
package["versionInfo"] = source["version-guess"]
|
|
291
|
+
else:
|
|
292
|
+
package["versionInfo"] = source["version"]
|
|
293
|
+
|
|
294
|
+
package["sourceInfo"] = source["kind"]
|
|
295
|
+
|
|
296
|
+
source_ext_refs.append(bst_element_name)
|
|
297
|
+
package["externalRefs"] = source_ext_refs
|
|
298
|
+
|
|
299
|
+
source_packages.append(package)
|
|
300
|
+
|
|
301
|
+
return element_package, source_packages
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def element_spdxid(name):
|
|
305
|
+
# SPDXID can only contain letters, numbers, `.`, and `-`
|
|
306
|
+
not_allowed = "[^a-zA-Z0-9.-]"
|
|
307
|
+
spdx_name = re.sub(not_allowed, "-", name)
|
|
308
|
+
return f"SPDXRef-{spdx_name}"
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def element_package_name(name):
|
|
312
|
+
element_name = name.split(":")[-1]
|
|
313
|
+
return os.path.splitext(os.path.basename(element_name))[0]
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def source_package_name(source_info):
|
|
317
|
+
basename = os.path.basename(source_info["url"])
|
|
318
|
+
|
|
319
|
+
if "extra-data" in source_info and "crate-name" in source_info["extra-data"]:
|
|
320
|
+
return source_info["extra-data"]["crate-name"]
|
|
321
|
+
|
|
322
|
+
if source_info["medium"] == "git":
|
|
323
|
+
if basename.endswith(".git"):
|
|
324
|
+
return basename[: -len(".git")]
|
|
325
|
+
return basename
|
|
326
|
+
elif source_info["medium"] in ("remote-file", "archive"):
|
|
327
|
+
stem = re.split(r"\.(tar|tgz|tbz|zip|crate)", basename)[0]
|
|
328
|
+
package = re.split("[-_][0-9]", stem)[0]
|
|
329
|
+
return package
|
|
330
|
+
elif source_info["medium"] == "local":
|
|
331
|
+
return basename
|
|
332
|
+
else:
|
|
333
|
+
print(
|
|
334
|
+
f"Can't extract a package name from source info {source_info}",
|
|
335
|
+
file=sys.stderr,
|
|
336
|
+
)
|
|
337
|
+
return "UNKNOWN"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Codethink Limited
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
from argparse import Namespace
|
|
6
|
+
import shutil
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_hosttool(name: str):
|
|
11
|
+
"""Returns the path to the executable of the specified command.
|
|
12
|
+
|
|
13
|
+
Errors if the command could not be found.
|
|
14
|
+
"""
|
|
15
|
+
command = shutil.which(name)
|
|
16
|
+
if command is None:
|
|
17
|
+
sys.exit(f"`{name}` is not installed or could not be found.")
|
|
18
|
+
|
|
19
|
+
return command
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def bst_common_args(config: Namespace) -> list[str]:
|
|
23
|
+
args = []
|
|
24
|
+
if config.directory:
|
|
25
|
+
args += ["--directory", config.directory]
|
|
26
|
+
|
|
27
|
+
for option, value in config.options:
|
|
28
|
+
args += ["--option", option, value]
|
|
29
|
+
|
|
30
|
+
return args
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: buildstream-sbom
|
|
3
|
+
Version: 1.1
|
|
4
|
+
Summary: A tool to generate SPDX SBoMs from buildstream projects
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: pyyaml
|
|
8
|
+
|
|
9
|
+
<!--
|
|
10
|
+
SPDX-FileCopyrightText: 2025 Codethink Limited
|
|
11
|
+
|
|
12
|
+
SPDX-License-Identifier: Apache-2.0
|
|
13
|
+
-->
|
|
14
|
+
|
|
15
|
+
# BuildStream SBOM generator
|
|
16
|
+
|
|
17
|
+
This tool can be used to produce an SBoM (Software Bill of Materials)
|
|
18
|
+
describing a BuildStream element and its dependencies. It currently
|
|
19
|
+
generates [SPDX](https://spdx.dev/) version 2.3.
|
|
20
|
+
|
|
21
|
+
The manifest contains useful information, such as the package name, version,
|
|
22
|
+
source locations and dependencies.
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
## Source provenance data
|
|
26
|
+
|
|
27
|
+
This tool relies on the [Source Provenance API](https://docs.buildstream.build/2.5/buildstream.source.html#generating-sourceinfo-for-provenance-information)
|
|
28
|
+
introduced in BuildStream 2.5. This API is implemented by buildstream-plugins
|
|
29
|
+
version 2.5.0 and buildstream-plugins-community 2.1.0. Please make sure your
|
|
30
|
+
project is using those (or more recent) versions. If your project uses custom
|
|
31
|
+
source plugins, please make sure that they also implement this API.
|
|
32
|
+
|
|
33
|
+
Version guessing is handled by individual source plugins, please check the
|
|
34
|
+
individual plugin documentation for details. Most plugins that implement it
|
|
35
|
+
do something similar to what [DownloadableFileSource](https://docs.buildstream.build/2.5/buildstream.downloadablefilesource.html#core-downloadable-source-builtins)
|
|
36
|
+
does, so it is a good starting point for understanding how this works.
|
|
37
|
+
|
|
38
|
+
The list of currently supported source provenance attributes that can be
|
|
39
|
+
specified and used for a Buildstream project is as follows:
|
|
40
|
+
|
|
41
|
+
| Attribute name | Corresponding SPDX attribute |
|
|
42
|
+
| -------------- | ---------------------------- |
|
|
43
|
+
| concluded-license | [licenseConcluded](https://spdx.github.io/spdx-spec/v2.3/package-information/#713-concluded-license-field) |
|
|
44
|
+
| copyright-text | [copyrightText](https://spdx.github.io/spdx-spec/v2.3/package-information/#717-copyright-text-field) |
|
|
45
|
+
| declared-license | [licenseDeclared](https://spdx.github.io/spdx-spec/v2.3/package-information/#715-declared-license-field) |
|
|
46
|
+
| description | [summary](https://spdx.github.io/spdx-spec/v2.3/package-information/#718-package-summary-description-field) |
|
|
47
|
+
| homepage | [homepage](https://spdx.github.io/spdx-spec/v2.3/package-information/#711-package-home-page-field) |
|
|
48
|
+
| name | [name](https://spdx.github.io/spdx-spec/v2.3/package-information/#71-package-name-field) |
|
|
49
|
+
| originator | [originator](https://spdx.github.io/spdx-spec/v2.3/package-information/#76-package-originator-field) |
|
|
50
|
+
| supplier | [supplier](https://spdx.github.io/spdx-spec/v2.3/package-information/#75-package-supplier-field) |
|
|
51
|
+
|
|
52
|
+
These can be used in projects by use of the `source-provenance-attributes`
|
|
53
|
+
field in the project.conf, this is described in the provenance section of
|
|
54
|
+
the [BuildStream documentation](https://docs.buildstream.build/master/buildstream.source.html#built-in-functionality).
|
|
55
|
+
|
|
56
|
+
### source-provenance-attributes snippet
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
# project.conf
|
|
60
|
+
|
|
61
|
+
source-provenance-attributes:
|
|
62
|
+
concluded-license: The license as determined by the evidence provided by the source project
|
|
63
|
+
copyright-text: Copyright text defined by the source project
|
|
64
|
+
declared-license: The license of the source project as decided by the authors
|
|
65
|
+
description: Description of the source project
|
|
66
|
+
homepage: The URL of the source project's homepage
|
|
67
|
+
name: The name of the source project
|
|
68
|
+
originator: The name of the person or organisation that created the source package originally
|
|
69
|
+
supplier: The name of the person or organisation that provided the source package
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Should any BuildStream plugins implement tracking for source provenance attributes, similar to
|
|
73
|
+
[source tracking](https://docs.buildstream.build/master/using_commands.html#bst-source-track),
|
|
74
|
+
it is recommended for all projects and plugins to use the attribute names exactly as seen above.
|
|
75
|
+
This ensures the source provenance attributes are always generated identically between different
|
|
76
|
+
plugins and makes sure they align with projects' definitions in the same way.
|
|
77
|
+
|
|
78
|
+
## Usage
|
|
79
|
+
|
|
80
|
+
To install, clone this repository and install it using `pip` (or preferably a
|
|
81
|
+
tool like [`uv tool`](https://docs.astral.sh/uv/#tools) or
|
|
82
|
+
[`pipx`](https://pipx.pypa.io/stable/) which install it in a virtual
|
|
83
|
+
environment).
|
|
84
|
+
|
|
85
|
+
To use, run `buildstream-sbom` in a buildstream project passing in the name of
|
|
86
|
+
elements like you would pass to `bst`. There are two additional required
|
|
87
|
+
arguments `--spdx-name` and `--spdx-namespace`, to set the SPDX document name
|
|
88
|
+
and document namespace respectively. See the [SPDX specification](https://spdx.github.io/spdx-spec/v2.3/document-creation-information/)
|
|
89
|
+
for details.
|
|
90
|
+
|
|
91
|
+
`buildstream-sbom` also accepts some buildstream options, notably `-o/--option`
|
|
92
|
+
to set buildstream options, `-C/--directory` to set the directory containing
|
|
93
|
+
the buildstream project, and `--deps` to choose whether to include only runtime
|
|
94
|
+
dependencies or all dependencies.
|
|
95
|
+
|
|
96
|
+
On the topic of runtime dependencies, two BuildStream core plugins are treated
|
|
97
|
+
specially: `filter` and `compose`. All build dependencies of elements using
|
|
98
|
+
these plugins are considered runtime dependencies. You can set the `depends-on`
|
|
99
|
+
key in the `sbom` public domain data to a list of build dependencies to have
|
|
100
|
+
`buildstream-sbom` treat these build dependencies as runtime dependencies. This
|
|
101
|
+
is useful for a `script` or `manual` element that copies artifacts from a build
|
|
102
|
+
dependency into its own artifact.
|
|
103
|
+
|
|
104
|
+
You can also include licenses that have been extracted from the element's
|
|
105
|
+
artifacts using `--include-licenses`. This uses license information installed
|
|
106
|
+
with tooling such as [Freedesktop-SDK's install-extra script](https://gitlab.com/freedesktop-sdk/freedesktop-sdk/-/blob/master/include/install-extra.yml?ref_type=heads). This option can
|
|
107
|
+
also be used in conjunction with `--artifact-checkout-directory` (`-A`), to
|
|
108
|
+
control where element artifacts are checked out to during processing; useful
|
|
109
|
+
for if you are checking out larger artifacts and want to specify a different
|
|
110
|
+
part of a filesystem with more available space (note that artifacts are removed
|
|
111
|
+
once processed anyway to minimise required storage space).
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
buildstream_sbom/frontend.py,sha256=6R_aPdfTg-2FxCSp-ZecDDz3CFtIfdGR-WVQ_ovqVo4,5014
|
|
2
|
+
buildstream_sbom/licensing.py,sha256=ql_BKyJGNqujnjtM64tjuBxXNhdouhNLD_bwio1xpaQ,4533
|
|
3
|
+
buildstream_sbom/spdx.py,sha256=Ir-_GIhtSR6aMVP7rSw6BmBCx89h9BxoHJ4mKBvXHAs,10645
|
|
4
|
+
buildstream_sbom/utils.py,sha256=aTILJ6VWqisHAFRYohyrzjr-pU4NJuYCjuwd-xqtM-k,694
|
|
5
|
+
buildstream_sbom-1.1.dist-info/METADATA,sha256=k-gsPxqJyWPuDA0QM_r2AdC0PGfyHaM1X8CXeOOZSjQ,6178
|
|
6
|
+
buildstream_sbom-1.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
7
|
+
buildstream_sbom-1.1.dist-info/entry_points.txt,sha256=EkX2hGZJaWEHr38PebgrbxzFfVZodXWb9JAKcabtv1M,68
|
|
8
|
+
buildstream_sbom-1.1.dist-info/top_level.txt,sha256=ohCnaVZk9kD6XX4uugH099Lld1PvWwi0mXTmw_v0LU0,17
|
|
9
|
+
buildstream_sbom-1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
buildstream_sbom
|