--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/eric7/CycloneDXInterface/CycloneDXUtilities.py Thu Jul 07 11:23:56 2022 +0200 @@ -0,0 +1,364 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2022 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Module implementing the interface to CycloneDX. +""" + +import os + +from PyQt6.QtCore import QCoreApplication +from PyQt6.QtWidgets import QDialog + +from EricWidgets.EricApplication import ericApp +from EricWidgets import EricMessageBox + +from packageurl import PackageURL + +from cyclonedx.model import ( + ExternalReference, ExternalReferenceType, LicenseChoice, + OrganizationalContact, OrganizationalEntity, Tool, XsUri +) +from cyclonedx.model.bom import Bom +from cyclonedx.model.component import Component +from cyclonedx.model.vulnerability import Vulnerability, VulnerabilitySource +from cyclonedx.output import ( + OutputFormat, SchemaVersion, get_instance as get_output_instance +) +from cyclonedx.parser import BaseParser + +from cyclonedx_py.parser.pipenv import PipEnvFileParser +from cyclonedx_py.parser.poetry import PoetryFileParser +from cyclonedx_py.parser.requirements import RequirementsFileParser + +from PipInterface.PipVulnerabilityChecker import ( + Package, VulnerabilityCheckError +) + + +class CycloneDXEnvironmentParser(BaseParser): + """ + Class implementing a parser to get package data for a named environment. + """ + def __init__(self, venvName): + """ + Constructor + + @param venvName name of the virtual environment + @type str + """ + super().__init__() + + pip = ericApp().getObject("Pip") + packages = pip.getLicenses(venvName) + for package in packages: + comp = Component( + name=package["Name"], + version=package["Version"], + author=package["Author"], + description=package["Description"], + purl=PackageURL( + type='pypi', + name=package["Name"], + version=package["Version"] + ) + ) + for lic in package["License"].split(";"): + comp.licenses.add( + LicenseChoice(license_expression=lic.strip()) + ) + + self._components.append(comp) + + +def createCycloneDXFile(venvName): + """ + Function to create a CyccloneDX SBOM file. + + @param venvName name of the virtual environment + @type str + @exception RuntimeError raised to indicate illegal creation parameters + """ + from .CycloneDXConfigDialog import CycloneDXConfigDialog + dlg = CycloneDXConfigDialog(venvName) + if dlg.exec() == QDialog.DialogCode.Accepted: + (inputSource, inputFile, fileFormat, schemaVersion, sbomFile, + withVulnerabilities, withDependencies, metadataDict) = dlg.getData() + + # check error conditions first + if inputSource not in ("environment", "pipenv", "poetry", + "requirements"): + raise RuntimeError("Unsupported input source given.") + if fileFormat not in ("XML", "JSON"): + raise RuntimeError("Unsupported SBOM file format given.") + + if inputSource == "environment": + parser = CycloneDXEnvironmentParser(venvName) + else: + # all other parsers need an input file + if not os.path.isfile(inputFile): + EricMessageBox.warning( + None, + QCoreApplication.translate( + "CycloneDX", "CycloneDX - SBOM Creation"), + QCoreApplication.translate( + "CycloneDX", + "<p>The configured input file <b>{0}</b> does not" + " exist. Aborting...</p>" + ).format(inputFile) + ) + return + + if inputSource == "pipenv": + parser = PipEnvFileParser(pipenv_lock_filename=inputFile) + elif inputSource == "poetry": + parser = PoetryFileParser(poetry_lock_filename=inputFile) + elif inputSource == "requirements": + parser = RequirementsFileParser(requirements_file=inputFile) + + if withVulnerabilities: + addCycloneDXVulnerabilities(parser) + + if withDependencies: + addCycloneDXDependencies(parser, venvName) + + if fileFormat == "XML": + outputFormat = OutputFormat.XML + elif fileFormat == "JSON": + outputFormat = OutputFormat.JSON + + if parser.has_warnings(): + excludedList = ["<li>{0}</li>".format(warning.get_item()) + for warning in parser.get_warnings()] + EricMessageBox.warning( + None, + QCoreApplication.translate( + "CycloneDX", "CycloneDX - SBOM Creation"), + QCoreApplication.translate( + "CycloneDX", + "<p>Some of the dependencies do not have pinned version" + " numbers.<ul>{0}</ul>The above listed packages will NOT" + " be included in the generated CycloneDX SBOM file as" + " version is a mandatory field.</p>" + ).format("".join(excludedList)) + ) + + bom = Bom.from_parser(parser=parser) + _amendMetaData(bom.metadata, metadataDict) + output = get_output_instance( + bom=bom, + output_format=outputFormat, + schema_version=SchemaVersion['V{0}'.format( + schemaVersion.replace('.', '_') + )] + ) + output.output_to_file(filename=sbomFile, allow_overwrite=True) + + EricMessageBox.information( + None, + QCoreApplication.translate( + "CycloneDX", "CycloneDX - SBOM Creation"), + QCoreApplication.translate( + "CycloneDX", + "<p>The SBOM data was written to file <b>{0}</b>.</p>" + ).format(sbomFile) + ) + + +def addCycloneDXVulnerabilities(parser): + """ + Function to add vulnerability data to the list of created components. + + @param parser reference to the parser object containing the list of + components + @type BaseParser + """ + components = parser.get_components() + + packages = [ + Package(name=component.name, version=component.version) + for component in components + ] + + pip = ericApp().getObject("Pip") + error, vulnerabilities = pip.getVulnerabilityChecker().check(packages) + + if error == VulnerabilityCheckError.OK: + for package in vulnerabilities: + component = findCyccloneDXComponent(components, package) + if component: + for vuln in vulnerabilities[package]: + component.add_vulnerability(Vulnerability( + id=vuln.cve, + description=vuln.advisory, + recommendation="upgrade required", + source=VulnerabilitySource(name="pyup.io") + )) + + +def addCycloneDXDependencies(parser, venvName): + """ + Function to add dependency data to the list of created components. + + @param parser reference to the parser object containing the list of + components + @type BaseParser + @param venvName name of the virtual environment + @type str + """ + components = parser.get_components() + + pip = ericApp().getObject("Pip") + dependencies = pip.getDependencyTree(venvName) + for dependency in dependencies: + _addCycloneDXDependency(dependency, components) + + +def _addCycloneDXDependency(dependency, components): + """ + Function to add a dependency to the given list of components. + + @param dependency dependency to be added + @type dict + @param components list of components + @type list of Component + """ + component = findCyccloneDXComponent(components, dependency["package_name"]) + if component is not None: + bomRefs = component.dependencies + for dep in dependency["dependencies"]: + depComponent = findCyccloneDXComponent( + components, dep["package_name"]) + if depComponent is not None: + bomRefs.add(depComponent.bom_ref) + # recursively add sub-dependencies + _addCycloneDXDependency(dep, components) + component.dependencies = bomRefs + + +def findCyccloneDXComponent(components, name): + """ + Function to find a component in a given list of components. + + @param components list of components to scan + @type list of Component + @param name name of the component to search for + @type str + @return reference to the found component or None + @rtype Component or None + """ + for component in components: + if component.name == name: + return component + + return None + + +def _amendMetaData(bomMetaData, metadataDict): + """ + Function to amend the SBOM meta data according the given data. + + The modifications done are: + <ul> + <li>add eric7 to the tools</li> + </ul> + + @param bomMetaData reference to the SBOM meta data object + @type BomMetaData + @param metadataDict dictionary containing additional meta data + @type dict + @return reference to the modified SBOM meta data object + @rtype BomMetaData + """ + # add a Tool entry for eric7 + try: + from importlib.metadata import version as meta_version + __EricToolVersion = str(meta_version('eric-ide')) + except Exception: + from UI.Info import Version + __EricToolVersion = Version + + EricTool = Tool(vendor='python-projects.org', + name='eric-ide', + version=__EricToolVersion) + EricTool.external_references.update([ + ExternalReference( + reference_type=ExternalReferenceType.DISTRIBUTION, + url=XsUri( + "https://pypi.org/project/eric-ide/" + ) + ), + ExternalReference( + reference_type=ExternalReferenceType.DOCUMENTATION, + url=XsUri( + "https://pypi.org/project/eric-ide/" + ) + ), + ExternalReference( + reference_type=ExternalReferenceType.ISSUE_TRACKER, + url=XsUri( + "https://tracker.die-offenbachs.homelinux.org" + ) + ), + ExternalReference( + reference_type=ExternalReferenceType.LICENSE, + url=XsUri( + "https://hg.die-offenbachs.homelinux.org/eric/file/tip/docs/" + "LICENSE.GPL3" + ) + ), + ExternalReference( + reference_type=ExternalReferenceType.RELEASE_NOTES, + url=XsUri( + "https://hg.die-offenbachs.homelinux.org/eric/file/tip/docs/" + "changelog" + ) + ), + ExternalReference( + reference_type=ExternalReferenceType.VCS, + url=XsUri( + "https://hg.die-offenbachs.homelinux.org/eric" + ) + ), + ExternalReference( + reference_type=ExternalReferenceType.WEBSITE, + url=XsUri( + "https://eric-ide.python-projects.org" + ) + ) + ]) + bomMetaData.tools.add(EricTool) + + # add the meta data info entered by the user (if any) + if metadataDict is not None: + if metadataDict["AuthorName"]: + bomMetaData.authors = [OrganizationalContact( + name=metadataDict["AuthorName"], + email=metadataDict["AuthorEmail"] + )] + if metadataDict["Manufacturer"]: + bomMetaData.manufacture = OrganizationalEntity( + name=metadataDict["Manufacturer"] + ) + if metadataDict["Supplier"]: + bomMetaData.supplier = OrganizationalEntity( + name=metadataDict["Supplier"]) + if metadataDict["License"]: + bomMetaData.licenses = [LicenseChoice( + license_expression=metadataDict["License"] + )] + if metadataDict["Name"]: + bomMetaData.component = Component( + name=metadataDict["Name"], + component_type=metadataDict["Type"], + version=metadataDict["Version"], + description=metadataDict["Description"], + author=metadataDict["AuthorName"], + licenses=[LicenseChoice( + license_expression=metadataDict["License"] + )], + ) + + return bomMetaData