|
1 # -*- coding: utf-8 -*- |
|
2 |
|
3 # Copyright (c) 2022 Detlev Offenbach <detlev@die-offenbachs.de> |
|
4 # |
|
5 |
|
6 """ |
|
7 Module implementing the interface to CycloneDX. |
|
8 """ |
|
9 |
|
10 import os |
|
11 |
|
12 from PyQt6.QtCore import QCoreApplication |
|
13 from PyQt6.QtWidgets import QDialog |
|
14 |
|
15 from EricWidgets.EricApplication import ericApp |
|
16 from EricWidgets import EricMessageBox |
|
17 |
|
18 from packageurl import PackageURL |
|
19 |
|
20 from cyclonedx.model import ( |
|
21 ExternalReference, ExternalReferenceType, LicenseChoice, |
|
22 OrganizationalContact, OrganizationalEntity, Tool, XsUri |
|
23 ) |
|
24 from cyclonedx.model.bom import Bom |
|
25 from cyclonedx.model.component import Component |
|
26 from cyclonedx.model.vulnerability import Vulnerability, VulnerabilitySource |
|
27 from cyclonedx.output import ( |
|
28 OutputFormat, SchemaVersion, get_instance as get_output_instance |
|
29 ) |
|
30 from cyclonedx.parser import BaseParser |
|
31 |
|
32 from cyclonedx_py.parser.pipenv import PipEnvFileParser |
|
33 from cyclonedx_py.parser.poetry import PoetryFileParser |
|
34 from cyclonedx_py.parser.requirements import RequirementsFileParser |
|
35 |
|
36 from PipInterface.PipVulnerabilityChecker import ( |
|
37 Package, VulnerabilityCheckError |
|
38 ) |
|
39 |
|
40 |
|
41 class CycloneDXEnvironmentParser(BaseParser): |
|
42 """ |
|
43 Class implementing a parser to get package data for a named environment. |
|
44 """ |
|
45 def __init__(self, venvName): |
|
46 """ |
|
47 Constructor |
|
48 |
|
49 @param venvName name of the virtual environment |
|
50 @type str |
|
51 """ |
|
52 super().__init__() |
|
53 |
|
54 pip = ericApp().getObject("Pip") |
|
55 packages = pip.getLicenses(venvName) |
|
56 for package in packages: |
|
57 comp = Component( |
|
58 name=package["Name"], |
|
59 version=package["Version"], |
|
60 author=package["Author"], |
|
61 description=package["Description"], |
|
62 purl=PackageURL( |
|
63 type='pypi', |
|
64 name=package["Name"], |
|
65 version=package["Version"] |
|
66 ) |
|
67 ) |
|
68 for lic in package["License"].split(";"): |
|
69 comp.licenses.add( |
|
70 LicenseChoice(license_expression=lic.strip()) |
|
71 ) |
|
72 |
|
73 self._components.append(comp) |
|
74 |
|
75 |
|
76 def createCycloneDXFile(venvName): |
|
77 """ |
|
78 Function to create a CyccloneDX SBOM file. |
|
79 |
|
80 @param venvName name of the virtual environment |
|
81 @type str |
|
82 @exception RuntimeError raised to indicate illegal creation parameters |
|
83 """ |
|
84 from .CycloneDXConfigDialog import CycloneDXConfigDialog |
|
85 dlg = CycloneDXConfigDialog(venvName) |
|
86 if dlg.exec() == QDialog.DialogCode.Accepted: |
|
87 (inputSource, inputFile, fileFormat, schemaVersion, sbomFile, |
|
88 withVulnerabilities, withDependencies, metadataDict) = dlg.getData() |
|
89 |
|
90 # check error conditions first |
|
91 if inputSource not in ("environment", "pipenv", "poetry", |
|
92 "requirements"): |
|
93 raise RuntimeError("Unsupported input source given.") |
|
94 if fileFormat not in ("XML", "JSON"): |
|
95 raise RuntimeError("Unsupported SBOM file format given.") |
|
96 |
|
97 if inputSource == "environment": |
|
98 parser = CycloneDXEnvironmentParser(venvName) |
|
99 else: |
|
100 # all other parsers need an input file |
|
101 if not os.path.isfile(inputFile): |
|
102 EricMessageBox.warning( |
|
103 None, |
|
104 QCoreApplication.translate( |
|
105 "CycloneDX", "CycloneDX - SBOM Creation"), |
|
106 QCoreApplication.translate( |
|
107 "CycloneDX", |
|
108 "<p>The configured input file <b>{0}</b> does not" |
|
109 " exist. Aborting...</p>" |
|
110 ).format(inputFile) |
|
111 ) |
|
112 return |
|
113 |
|
114 if inputSource == "pipenv": |
|
115 parser = PipEnvFileParser(pipenv_lock_filename=inputFile) |
|
116 elif inputSource == "poetry": |
|
117 parser = PoetryFileParser(poetry_lock_filename=inputFile) |
|
118 elif inputSource == "requirements": |
|
119 parser = RequirementsFileParser(requirements_file=inputFile) |
|
120 |
|
121 if withVulnerabilities: |
|
122 addCycloneDXVulnerabilities(parser) |
|
123 |
|
124 if withDependencies: |
|
125 addCycloneDXDependencies(parser, venvName) |
|
126 |
|
127 if fileFormat == "XML": |
|
128 outputFormat = OutputFormat.XML |
|
129 elif fileFormat == "JSON": |
|
130 outputFormat = OutputFormat.JSON |
|
131 |
|
132 if parser.has_warnings(): |
|
133 excludedList = ["<li>{0}</li>".format(warning.get_item()) |
|
134 for warning in parser.get_warnings()] |
|
135 EricMessageBox.warning( |
|
136 None, |
|
137 QCoreApplication.translate( |
|
138 "CycloneDX", "CycloneDX - SBOM Creation"), |
|
139 QCoreApplication.translate( |
|
140 "CycloneDX", |
|
141 "<p>Some of the dependencies do not have pinned version" |
|
142 " numbers.<ul>{0}</ul>The above listed packages will NOT" |
|
143 " be included in the generated CycloneDX SBOM file as" |
|
144 " version is a mandatory field.</p>" |
|
145 ).format("".join(excludedList)) |
|
146 ) |
|
147 |
|
148 bom = Bom.from_parser(parser=parser) |
|
149 _amendMetaData(bom.metadata, metadataDict) |
|
150 output = get_output_instance( |
|
151 bom=bom, |
|
152 output_format=outputFormat, |
|
153 schema_version=SchemaVersion['V{0}'.format( |
|
154 schemaVersion.replace('.', '_') |
|
155 )] |
|
156 ) |
|
157 output.output_to_file(filename=sbomFile, allow_overwrite=True) |
|
158 |
|
159 EricMessageBox.information( |
|
160 None, |
|
161 QCoreApplication.translate( |
|
162 "CycloneDX", "CycloneDX - SBOM Creation"), |
|
163 QCoreApplication.translate( |
|
164 "CycloneDX", |
|
165 "<p>The SBOM data was written to file <b>{0}</b>.</p>" |
|
166 ).format(sbomFile) |
|
167 ) |
|
168 |
|
169 |
|
170 def addCycloneDXVulnerabilities(parser): |
|
171 """ |
|
172 Function to add vulnerability data to the list of created components. |
|
173 |
|
174 @param parser reference to the parser object containing the list of |
|
175 components |
|
176 @type BaseParser |
|
177 """ |
|
178 components = parser.get_components() |
|
179 |
|
180 packages = [ |
|
181 Package(name=component.name, version=component.version) |
|
182 for component in components |
|
183 ] |
|
184 |
|
185 pip = ericApp().getObject("Pip") |
|
186 error, vulnerabilities = pip.getVulnerabilityChecker().check(packages) |
|
187 |
|
188 if error == VulnerabilityCheckError.OK: |
|
189 for package in vulnerabilities: |
|
190 component = findCyccloneDXComponent(components, package) |
|
191 if component: |
|
192 for vuln in vulnerabilities[package]: |
|
193 component.add_vulnerability(Vulnerability( |
|
194 id=vuln.cve, |
|
195 description=vuln.advisory, |
|
196 recommendation="upgrade required", |
|
197 source=VulnerabilitySource(name="pyup.io") |
|
198 )) |
|
199 |
|
200 |
|
201 def addCycloneDXDependencies(parser, venvName): |
|
202 """ |
|
203 Function to add dependency data to the list of created components. |
|
204 |
|
205 @param parser reference to the parser object containing the list of |
|
206 components |
|
207 @type BaseParser |
|
208 @param venvName name of the virtual environment |
|
209 @type str |
|
210 """ |
|
211 components = parser.get_components() |
|
212 |
|
213 pip = ericApp().getObject("Pip") |
|
214 dependencies = pip.getDependencyTree(venvName) |
|
215 for dependency in dependencies: |
|
216 _addCycloneDXDependency(dependency, components) |
|
217 |
|
218 |
|
219 def _addCycloneDXDependency(dependency, components): |
|
220 """ |
|
221 Function to add a dependency to the given list of components. |
|
222 |
|
223 @param dependency dependency to be added |
|
224 @type dict |
|
225 @param components list of components |
|
226 @type list of Component |
|
227 """ |
|
228 component = findCyccloneDXComponent(components, dependency["package_name"]) |
|
229 if component is not None: |
|
230 bomRefs = component.dependencies |
|
231 for dep in dependency["dependencies"]: |
|
232 depComponent = findCyccloneDXComponent( |
|
233 components, dep["package_name"]) |
|
234 if depComponent is not None: |
|
235 bomRefs.add(depComponent.bom_ref) |
|
236 # recursively add sub-dependencies |
|
237 _addCycloneDXDependency(dep, components) |
|
238 component.dependencies = bomRefs |
|
239 |
|
240 |
|
241 def findCyccloneDXComponent(components, name): |
|
242 """ |
|
243 Function to find a component in a given list of components. |
|
244 |
|
245 @param components list of components to scan |
|
246 @type list of Component |
|
247 @param name name of the component to search for |
|
248 @type str |
|
249 @return reference to the found component or None |
|
250 @rtype Component or None |
|
251 """ |
|
252 for component in components: |
|
253 if component.name == name: |
|
254 return component |
|
255 |
|
256 return None |
|
257 |
|
258 |
|
259 def _amendMetaData(bomMetaData, metadataDict): |
|
260 """ |
|
261 Function to amend the SBOM meta data according the given data. |
|
262 |
|
263 The modifications done are: |
|
264 <ul> |
|
265 <li>add eric7 to the tools</li> |
|
266 </ul> |
|
267 |
|
268 @param bomMetaData reference to the SBOM meta data object |
|
269 @type BomMetaData |
|
270 @param metadataDict dictionary containing additional meta data |
|
271 @type dict |
|
272 @return reference to the modified SBOM meta data object |
|
273 @rtype BomMetaData |
|
274 """ |
|
275 # add a Tool entry for eric7 |
|
276 try: |
|
277 from importlib.metadata import version as meta_version |
|
278 __EricToolVersion = str(meta_version('eric-ide')) |
|
279 except Exception: |
|
280 from UI.Info import Version |
|
281 __EricToolVersion = Version |
|
282 |
|
283 EricTool = Tool(vendor='python-projects.org', |
|
284 name='eric-ide', |
|
285 version=__EricToolVersion) |
|
286 EricTool.external_references.update([ |
|
287 ExternalReference( |
|
288 reference_type=ExternalReferenceType.DISTRIBUTION, |
|
289 url=XsUri( |
|
290 "https://pypi.org/project/eric-ide/" |
|
291 ) |
|
292 ), |
|
293 ExternalReference( |
|
294 reference_type=ExternalReferenceType.DOCUMENTATION, |
|
295 url=XsUri( |
|
296 "https://pypi.org/project/eric-ide/" |
|
297 ) |
|
298 ), |
|
299 ExternalReference( |
|
300 reference_type=ExternalReferenceType.ISSUE_TRACKER, |
|
301 url=XsUri( |
|
302 "https://tracker.die-offenbachs.homelinux.org" |
|
303 ) |
|
304 ), |
|
305 ExternalReference( |
|
306 reference_type=ExternalReferenceType.LICENSE, |
|
307 url=XsUri( |
|
308 "https://hg.die-offenbachs.homelinux.org/eric/file/tip/docs/" |
|
309 "LICENSE.GPL3" |
|
310 ) |
|
311 ), |
|
312 ExternalReference( |
|
313 reference_type=ExternalReferenceType.RELEASE_NOTES, |
|
314 url=XsUri( |
|
315 "https://hg.die-offenbachs.homelinux.org/eric/file/tip/docs/" |
|
316 "changelog" |
|
317 ) |
|
318 ), |
|
319 ExternalReference( |
|
320 reference_type=ExternalReferenceType.VCS, |
|
321 url=XsUri( |
|
322 "https://hg.die-offenbachs.homelinux.org/eric" |
|
323 ) |
|
324 ), |
|
325 ExternalReference( |
|
326 reference_type=ExternalReferenceType.WEBSITE, |
|
327 url=XsUri( |
|
328 "https://eric-ide.python-projects.org" |
|
329 ) |
|
330 ) |
|
331 ]) |
|
332 bomMetaData.tools.add(EricTool) |
|
333 |
|
334 # add the meta data info entered by the user (if any) |
|
335 if metadataDict is not None: |
|
336 if metadataDict["AuthorName"]: |
|
337 bomMetaData.authors = [OrganizationalContact( |
|
338 name=metadataDict["AuthorName"], |
|
339 email=metadataDict["AuthorEmail"] |
|
340 )] |
|
341 if metadataDict["Manufacturer"]: |
|
342 bomMetaData.manufacture = OrganizationalEntity( |
|
343 name=metadataDict["Manufacturer"] |
|
344 ) |
|
345 if metadataDict["Supplier"]: |
|
346 bomMetaData.supplier = OrganizationalEntity( |
|
347 name=metadataDict["Supplier"]) |
|
348 if metadataDict["License"]: |
|
349 bomMetaData.licenses = [LicenseChoice( |
|
350 license_expression=metadataDict["License"] |
|
351 )] |
|
352 if metadataDict["Name"]: |
|
353 bomMetaData.component = Component( |
|
354 name=metadataDict["Name"], |
|
355 component_type=metadataDict["Type"], |
|
356 version=metadataDict["Version"], |
|
357 description=metadataDict["Description"], |
|
358 author=metadataDict["AuthorName"], |
|
359 licenses=[LicenseChoice( |
|
360 license_expression=metadataDict["License"] |
|
361 )], |
|
362 ) |
|
363 |
|
364 return bomMetaData |