diff --git a/ChangeLog.md b/ChangeLog.md index 004f9b1..47117b6 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -8,6 +8,11 @@ ## NEXT * make `findsources` more resilient against SW360 issues. +* `project createbom` now stores multiple purls in the property "purl_list" instead of + trying to encode them in a strange way in the "purl" field. +* `project createbom` will not add rejected attachments to SBOM +* `project createbom` adds CLI and report information to SBOM +* new command `bom downloadattachments` to download CLI and report attachments ## 2.5.1 (2024-10-16) diff --git a/capycli/bom/download_attachments.py b/capycli/bom/download_attachments.py new file mode 100644 index 0000000..ef5e0f5 --- /dev/null +++ b/capycli/bom/download_attachments.py @@ -0,0 +1,177 @@ +# ------------------------------------------------------------------------------- +# Copyright (c) 2020-2023 Siemens +# All Rights Reserved. +# Author: gernot.hillier@siemens.com, thomas.graf@siemens.com +# +# SPDX-License-Identifier: MIT +# ------------------------------------------------------------------------------- + +import logging +import os +import sys +from typing import Tuple + +import sw360.sw360_api +from cyclonedx.model.bom import Bom + +import capycli.common.json_support +import capycli.common.script_base +from capycli.common.capycli_bom_support import CaPyCliBom, CycloneDxSupport, SbomWriter +from capycli.common.print import print_red, print_text, print_yellow +from capycli.common.script_support import ScriptSupport +from capycli.common.json_support import load_json_file +from capycli.main.result_codes import ResultCode + +LOG = capycli.get_logger(__name__) + + +class BomDownloadAttachments(capycli.common.script_base.ScriptBase): + """ + Download SW360 attachments as specified in the SBOM. + """ + + def download_attachments(self, sbom: Bom, control_components: list, source_folder: str, bompath: str = None, + attachment_types: Tuple[str] = ("COMPONENT_LICENSE_INFO_XML", "CLEARING_REPORT")) -> Bom: + + for component in sbom.components: + item_name = ScriptSupport.get_full_name_from_component(component) + print_text(" " + item_name) + + for ext_ref in component.external_references: + if not ext_ref.comment: + continue + found = False + for at_type in attachment_types: + if ext_ref.comment.startswith(CaPyCliBom.FILE_COMMENTS[at_type]): + found = True + if not found: + continue + + release_id = CycloneDxSupport.get_property_value(component, CycloneDxSupport.CDX_PROP_SW360ID) + if not release_id: + print_red(" No sw360Id for release!") + continue + url = str(ext_ref.url) + filename = os.path.join(source_folder, url) + + details = [e for e in control_components + if e["Sw360Id"] == release_id and ( + e.get("CliFile", "") == url + or e.get("ReportFile", "") == url)] + if len(details) != 1: + print_red(" ERROR: Found", len(details), "entries for attachment", + ext_ref.url, "of", item_name, "in control file!") + continue + attachment_id = details[0]["Sw360AttachmentId"] + + print_text(" Downloading file " + filename) + try: + self.client.download_release_attachment(filename, release_id, attachment_id) + ext_ref.url = filename + try: + if bompath: + CycloneDxSupport.have_relative_ext_ref_path(ext_ref, bompath) + except ValueError: + print_yellow(" SBOM file is not relative to source file " + ext_ref.url) + + except sw360.sw360_api.SW360Error as swex: + print_red(" Error getting", swex.url, swex.response) + return sbom + + def run(self, args): + """Main method + + @params: + args - command line arguments + """ + if args.debug: + global LOG + LOG = capycli.get_logger(__name__) + else: + # suppress (debug) log output from requests and urllib + logging.getLogger("requests").setLevel(logging.WARNING) + logging.getLogger("urllib3").setLevel(logging.WARNING) + logging.getLogger("urllib3.connectionpool").setLevel(logging.WARNING) + + print_text( + "\n" + capycli.APP_NAME + ", " + capycli.get_app_version() + + " - Download SW360 attachments as specified in the SBOM\n") + + if args.help: + print("usage: capycli bom downloadattachments -i bom.json [-source ]") + print("") + print("optional arguments:") + print(" -h, --help show this help message and exit") + print(" -i INPUTFILE, input SBOM to read from, e.g. created by \"project CreateBom\"") + print(" -ct CONTROLFILE, control file to read from as created by \"project CreateBom\"") + print(" -source SOURCE source folder or additional source file") + print(" -o OUTPUTFILE output file to write to") + print(" -v be verbose") + return + + if not args.inputfile: + print_red("No input file specified!") + sys.exit(ResultCode.RESULT_COMMAND_ERROR) + + if not args.controlfile: + print_red("No control file specified!") + sys.exit(ResultCode.RESULT_COMMAND_ERROR) + + if not os.path.isfile(args.inputfile): + print_red("Input file not found!") + sys.exit(ResultCode.RESULT_FILE_NOT_FOUND) + + print_text("Loading SBOM file " + args.inputfile) + try: + bom = CaPyCliBom.read_sbom(args.inputfile) + except Exception as ex: + print_red("Error reading input SBOM file: " + repr(ex)) + sys.exit(ResultCode.RESULT_ERROR_READING_BOM) + + if args.verbose: + print_text(" " + str(len(bom.components)) + "components read from SBOM file") + + print_text("Loading control file " + args.controlfile) + try: + control = load_json_file(args.controlfile) + except Exception as ex: + print_red("JSON error reading control file: " + repr(ex)) + sys.exit(ResultCode.RESULT_ERROR_READING_BOM) + if "Components" not in control: + print_red("missing Components in control file") + sys.exit(ResultCode.RESULT_ERROR_READING_BOM) + + source_folder = "./" + if args.source: + source_folder = args.source + if (not source_folder) or (not os.path.isdir(source_folder)): + print_red("Target source code folder does not exist!") + sys.exit(ResultCode.RESULT_COMMAND_ERROR) + + if args.sw360_token and args.oauth2: + self.analyze_token(args.sw360_token) + + print_text(" Checking access to SW360...") + if not self.login(token=args.sw360_token, url=args.sw360_url, oauth2=args.oauth2): + print_red("ERROR: login failed!") + sys.exit(ResultCode.RESULT_AUTH_ERROR) + + print_text("Downloading source files to folder " + source_folder + " ...") + + self.download_attachments(bom, control["Components"], source_folder, os.path.dirname(args.outputfile)) + + if args.outputfile: + print_text("Updating path information") + self.update_local_path(bom, args.outputfile) + + print_text("Writing updated SBOM to " + args.outputfile) + try: + SbomWriter.write_to_json(bom, args.outputfile, True) + except Exception as ex: + print_red("Error writing updated SBOM file: " + repr(ex)) + sys.exit(ResultCode.RESULT_ERROR_WRITING_BOM) + + if args.verbose: + print_text(" " + str(len(bom.components)) + " components written to SBOM file") + + print("\n") diff --git a/capycli/bom/handle_bom.py b/capycli/bom/handle_bom.py index 6f7fb92..7c5490b 100644 --- a/capycli/bom/handle_bom.py +++ b/capycli/bom/handle_bom.py @@ -16,6 +16,7 @@ import capycli.bom.create_components import capycli.bom.diff_bom import capycli.bom.download_sources +import capycli.bom.download_attachments import capycli.bom.filter_bom import capycli.bom.findsources import capycli.bom.map_bom @@ -36,19 +37,20 @@ def run_bom_command(args: Any) -> None: # display `bom` related help print("bom bill of material (BOM) specific sub-commands") - print(" Show display contents of a BOM") - print(" Convert Convert SBOM formats") - print(" Filter apply filter file to a BOM") - print(" Check check that all releases in the BOM exist on target SW360 instance") - print(" CheckItemStatus show additional information about BOM items on SW360") - print(" Map map a given BOM to data on SW360") - print(" CreateReleases create new releases for existing components on SW360") - print(" CreateComponents create new components and releases on SW360 (use with care!)") - print(" DownloadSources download source files from the URL specified in the SBOM") - print(" Granularity check a bill of material for potential component granularity issues") - print(" Diff compare two bills of material.") - print(" Merge merge two bills of material.") - print(" Findsources determine the source code for SBOM items.") + print(" Show display contents of a BOM") + print(" Convert Convert SBOM formats") + print(" Filter apply filter file to a BOM") + print(" Check check that all releases in the BOM exist on target SW360 instance") + print(" CheckItemStatus show additional information about BOM items on SW360") + print(" Map map a given BOM to data on SW360") + print(" CreateReleases create new releases for existing components on SW360") + print(" CreateComponents create new components and releases on SW360 (use with care!)") + print(" DownloadAttachments download SW360 attachments as specified in the SBOM") + print(" DownloadSources download source files from the URL specified in the SBOM") + print(" Granularity check a bill of material for potential component granularity issues") + print(" Diff compare two bills of material.") + print(" Merge merge two bills of material.") + print(" Findsources determine the source code for SBOM items.") return subcommand = args.command[1].lower() @@ -101,6 +103,12 @@ def run_bom_command(args: Any) -> None: app8.run(args) return + if subcommand == "downloadattachments": + """Download attachments from SW360 as specified in the SBOM.""" + app = capycli.bom.download_attachments.BomDownloadAttachments() + app.run(args) + return + if subcommand == "granularity": """Check the granularity of the releases in the SBOM.""" app9 = capycli.bom.check_granularity.CheckGranularity() diff --git a/capycli/common/capycli_bom_support.py b/capycli/common/capycli_bom_support.py index d56a224..b62bd22 100644 --- a/capycli/common/capycli_bom_support.py +++ b/capycli/common/capycli_bom_support.py @@ -662,6 +662,20 @@ class CaPyCliBom(): SOURCE_FILE_COMMENT = "source archive (local copy)" BINARY_URL_COMMENT = "binary (download location)" BINARY_FILE_COMMENT = "relativePath" + # machine-readable XML description of licensing situation of a component + # see https://github.com/sw360/clipython for more information + CLI_FILE_COMMENT = "component license information (local copy)" + # human-readable description of licensing situation and obligations + CRT_FILE_COMMENT = "clearing report (local copy)" + + FILE_COMMENTS = { + "SOURCE": SOURCE_FILE_COMMENT, + "SOURCE_SELF": SOURCE_FILE_COMMENT, + "BINARY": BINARY_FILE_COMMENT, + "BINARY_SELF": BINARY_FILE_COMMENT, + "COMPONENT_LICENSE_INFO_XML": CLI_FILE_COMMENT, + "CLEARING_REPORT": CRT_FILE_COMMENT + } @classmethod def read_sbom(cls, inputfile: str) -> Bom: diff --git a/capycli/common/script_base.py b/capycli/common/script_base.py index fdb1513..c4cb206 100644 --- a/capycli/common/script_base.py +++ b/capycli/common/script_base.py @@ -136,6 +136,11 @@ def release_web_url(self, release_id: str) -> str: return (self.sw360_url + "group/guest/components/-/component/release/detailRelease/" + release_id) + def attachment_api_url(self, release_id, attachment_id) -> str: + """Returns the REST API URL for an attachment.""" + return (self.sw360_url + "resource/api/releases/" + release_id + + "/attachments/" + attachment_id) + def find_project(self, name: str, version: str, show_results: bool = False) -> str: """Find the project with the matching name and version on SW360""" if not self.client: diff --git a/capycli/main/options.py b/capycli/main/options.py index f433354..67f5cc3 100644 --- a/capycli/main/options.py +++ b/capycli/main/options.py @@ -29,48 +29,50 @@ def __init__(self) -> None: custom_usage = "CaPyCli command subcommand [options]" command_help = """Commands and Sub-Commands getdependencies dependency detection specific commands - Nuget determine dependencies for a .Net/Nuget project - Python determine dependencies for a Python project - Javascript determine dependencies for a JavaScript project - MavenPom determine dependencies for a Java/Maven project using the pom.xml file - MavenList determine dependencies for a Java/Maven project using a Maven command + Nuget determine dependencies for a .Net/Nuget project + Python determine dependencies for a Python project + Javascript determine dependencies for a JavaScript project + MavenPom determine dependencies for a Java/Maven project using the pom.xml file + MavenList determine dependencies for a Java/Maven project using a Maven command bom bill of material (BOM) specific commands - Show display contents of a SBOM - Convert convert SBOM formats - Filter apply filter file to a SBOM - Check check that all releases in the SBOM exist on target SW360 instance - CheckItemStatus show additional information about SBOM items on SW360 - Map map a given SBOM to data on SW360 - CreateReleases create new releases for existing components on SW360 - CreateComponents create new components and releases on SW360 (use with care!) - DownloadSources download source files from the URL specified in the SBOM - Granularity check a bill of material for potential component granularity issues - Diff compare two bills of material. - Merge merge two bills of material. - Findsources determine the source code for SBOM items. + Show display contents of a SBOM + Convert convert SBOM formats + Filter apply filter file to a SBOM + Check check that all releases in the SBOM exist on target SW360 instance + CheckItemStatus show additional information about SBOM items on SW360 + Map map a given SBOM to data on SW360 + CreateReleases create new releases for existing components on SW360 + CreateComponents create new components and releases on SW360 (use with care!) + DownloadSources download source files from the URL specified in the SBOM + DownloadAttachments download SW360 attachments as specified in the SBOM + + Granularity check a bill of material for potential component granularity issues + Diff compare two bills of material. + Merge merge two bills of material. + Findsources determine the source code for SBOM items. mapping - ToHtml create a HTML page showing the mapping result - ToXlsx create an Excel sheet showing the mapping result + ToHtml create a HTML page showing the mapping result + ToXlsx create an Excel sheet showing the mapping result moverview - ToHtml create a HTML page showing the mapping result overview - ToXlsx create an Excel sheet showing the mapping result overview + ToHtml create a HTML page showing the mapping result overview + ToXlsx create an Excel sheet showing the mapping result overview project - Find find a project by name - Prerequisites checks whether all prerequisites for a successful - software clearing are fulfilled - Show show project details - Licenses show licenses of all cleared compponents - Create create or update a project on SW360 - Update update an exiting project, preserving linked releases - GetLicenseInfo get license info of all project components - CreateBom create a SBOM for a project on SW360 - CreateReadme create a Readme_OSS - Vulnerabilities show security vulnerabilities of a project - ECC show export control status of a project + Find find a project by name + Prerequisites checks whether all prerequisites for a successful + software clearing are fulfilled + Show show project details + Licenses show licenses of all cleared compponents + Create create or update a project on SW360 + Update update an exiting project, preserving linked releases + GetLicenseInfo get license info of all project components + CreateBom create a SBOM for a project on SW360 + CreateReadme create a Readme_OSS + Vulnerabilities show security vulnerabilities of a project + ECC show export control status of a project Note that each command has also its own help display, i.e. if you enter `capycli project vulnerabilities -h` you will get a help that only shows the options @@ -217,6 +219,13 @@ def register_options(self) -> None: help="create an mapping overview JSON file", ) + self.parser.add_argument( + "-ct", + "--controlfile", + dest="controlfile", + help="control file for \"bom DownloadAttachments\" and \"project CreateReadme\"", + ) + self.parser.add_argument( "-mr", "--mapresult", diff --git a/capycli/project/create_bom.py b/capycli/project/create_bom.py index 6828fbd..a86ca93 100644 --- a/capycli/project/create_bom.py +++ b/capycli/project/create_bom.py @@ -8,7 +8,8 @@ import logging import sys -from typing import Any, Dict, List +from typing import Any, Dict, List, Tuple +import json from cyclonedx.model import ExternalReferenceType, HashAlgorithm from cyclonedx.model.bom import Bom @@ -21,6 +22,7 @@ from capycli.common.capycli_bom_support import CaPyCliBom, CycloneDxSupport, SbomCreator from capycli.common.print import print_red, print_text, print_yellow from capycli.common.purl_utils import PurlUtils +from capycli.common.script_support import ScriptSupport from capycli.main.result_codes import ResultCode LOG = get_logger(__name__) @@ -45,8 +47,9 @@ def get_clearing_state(self, proj: Dict[str, Any], href: str) -> str: return "" - def create_project_bom(self, project: Dict[str, Any]) -> List[Component]: + def create_project_bom(self, project: Dict[str, Any], create_controlfile: bool) -> Tuple[List, List]: bom: List[Component] = [] + details: List[Dict] = [] if not self.client: print_red(" No client!") sys.exit(ResultCode.RESULT_ERROR_ACCESSING_SW360) @@ -56,6 +59,7 @@ def create_project_bom(self, project: Dict[str, Any]) -> List[Component]: for release in releases: print_text(" ", release["name"], release["version"]) href = release["_links"]["self"]["href"] + sw360_id = self.client.get_id_from_href(href) try: release_details = self.client.get_release_by_url(href) @@ -69,16 +73,15 @@ def create_project_bom(self, project: Dict[str, Any]) -> List[Component]: purl = self.get_external_id("purl", release_details) purls = PurlUtils.parse_purls_from_external_id(purl) - if len(purls) > 1: - print_yellow(" Multiple purls added for", release["name"], release["version"]) - print_yellow(" You must remove all but one in your SBOM!") - purl = " ".join(purls).strip() - - if purl: - rel_item = Component(name=release["name"], version=release["version"], - purl=PackageURL.from_string(purl), bom_ref=purl) - else: + if len(purls) != 1: rel_item = Component(name=release["name"], version=release["version"]) + if len(purls) > 1: + print_yellow(" Multiple purls for", release["name"], release["version"]) + print_yellow(" Stored them in property purl_list in your SBOM!") + CycloneDxSupport.set_property(rel_item, "purl_list", " ".join(purls)) + elif len(purls) == 1: + rel_item = Component(name=release["name"], version=release["version"], + purl=PackageURL.from_string(purls[0]), bom_ref=purls[0]) for key, property in (("clearingState", CycloneDxSupport.CDX_PROP_CLEARING_STATE), ("mainlineState", CycloneDxSupport.CDX_PROP_REL_STATE)): @@ -99,15 +102,39 @@ def create_project_bom(self, project: Dict[str, Any]) -> List[Component]: if "repository" in release_details and "url" in release_details["repository"]: CycloneDxSupport.set_ext_ref(rel_item, ExternalReferenceType.VCS, comment="", value=release_details["repository"]["url"]) - - for at_type, comment in (("SOURCE", CaPyCliBom.SOURCE_FILE_COMMENT), - ("BINARY", CaPyCliBom.BINARY_FILE_COMMENT)): - attachments = self.get_release_attachments(release_details, - (at_type, at_type + "_SELF")) # type: ignore - for attachment in attachments: - CycloneDxSupport.set_ext_ref(rel_item, ExternalReferenceType.DISTRIBUTION, - comment, attachment["filename"], - HashAlgorithm.SHA_1, attachment.get("sha1", "")) + attachments = self.get_release_attachments(release_details) + for attachment in attachments: + at_type = attachment["attachmentType"] + if at_type not in CaPyCliBom.FILE_COMMENTS: + continue + comment = CaPyCliBom.FILE_COMMENTS[at_type] + at_data = self.client.get_attachment_by_url(attachment["_links"]["self"]["href"]) + if at_data.get("checkStatus") == "REJECTED": + print_yellow(" WARNING: ignoring REJECTED attachment", + attachment["filename"]) + continue + if at_type in ("SOURCE", "SOURCE_SELF", "BINARY", "BINARY_SELF"): + ext_ref_type = ExternalReferenceType.DISTRIBUTION + else: + ext_ref_type = ExternalReferenceType.OTHER + if create_controlfile: + at_details = { + "ComponentName": " ".join((release["name"], release["version"])), + "Sw360Id": sw360_id, + "Sw360AttachmentId": self.client.get_id_from_href(attachment["_links"]["self"]["href"])} + for key in ("createdBy", "createdTeam", "createdOn", "createdComment", "checkStatus", + "checkedBy", "checkedTeam", "checkedOn", "checkedComment"): + if key in at_data and at_data[key]: + at_details[key[0].upper() + key[1:]] = at_data[key] + + if at_type == "COMPONENT_LICENSE_INFO_XML": + at_details["CliFile"] = attachment["filename"] + elif at_type == "CLEARING_REPORT": + at_details["ReportFile"] = attachment["filename"] + details.append(at_details) + CycloneDxSupport.set_ext_ref(rel_item, ext_ref_type, + comment, attachment["filename"], + HashAlgorithm.SHA_1, attachment.get("sha1")) except SW360Error as swex: print_red(" ERROR: unable to access project:" + repr(swex)) @@ -117,7 +144,6 @@ def create_project_bom(self, project: Dict[str, Any]) -> List[Component]: if state: CycloneDxSupport.set_property(rel_item, CycloneDxSupport.CDX_PROP_PROJ_STATE, state) - sw360_id = self.client.get_id_from_href(href) CycloneDxSupport.set_property(rel_item, CycloneDxSupport.CDX_PROP_SW360ID, sw360_id) CycloneDxSupport.set_property( @@ -129,9 +155,9 @@ def create_project_bom(self, project: Dict[str, Any]) -> List[Component]: # sub-projects are not handled at the moment - return bom + return bom, details - def create_project_cdx_bom(self, project_id: str) -> Bom: + def create_project_cdx_bom(self, project_id: str, create_controlfile: bool) -> Tuple[Bom, Dict]: if not self.client: print_red(" No client!") sys.exit(ResultCode.RESULT_ERROR_ACCESSING_SW360) @@ -147,14 +173,19 @@ def create_project_cdx_bom(self, project_id: str) -> Bom: print_text(" Project name: " + project["name"] + ", " + project["version"]) - cdx_components = self.create_project_bom(project) + cdx_components, control_components = self.create_project_bom(project, create_controlfile) creator = SbomCreator() sbom = creator.create(cdx_components, addlicense=True, addprofile=True, addtools=True, name=project.get("name", ""), version=project.get("version", ""), description=project.get("description", ""), addprojectdependencies=True) - return sbom + controlfile = { + "ProjectName": ScriptSupport.get_full_name_from_dict(project, "name", "version"), + "Components": control_components + } + + return sbom, controlfile def show_command_help(self) -> None: print("\nusage: CaPyCli project createbom [options]") @@ -167,6 +198,7 @@ def show_command_help(self) -> None: -name name of the project, component or release -version version of the project, component or release -o OUTPUTFILE output file to write to + -ct CONTROLFILE write control file for "bom DownloadAttachments" and "project CreateReadme" """) print() @@ -213,7 +245,11 @@ def run(self, args: Any) -> None: sys.exit(ResultCode.RESULT_COMMAND_ERROR) if pid: - bom = self.create_project_cdx_bom(pid) + bom, controlfile = self.create_project_cdx_bom(pid, args.controlfile) CaPyCliBom.write_sbom(bom, args.outputfile) + + if args.controlfile: + with open(args.controlfile, "w") as outfile: + json.dump(controlfile, outfile, indent=2) else: print_yellow(" No matching project found") diff --git a/tests/fixtures/sbom_for_download-control.json b/tests/fixtures/sbom_for_download-control.json new file mode 100644 index 0000000..dbedf07 --- /dev/null +++ b/tests/fixtures/sbom_for_download-control.json @@ -0,0 +1,24 @@ +{ + "ProjectName": "CaPyCLI, 2.0.0-dev1", + "Components": [ + { + "ComponentName": "certifi 2022.12.7", + "Sw360Id": "ae8c7ed", + "Sw360AttachmentId": "794446", + "CreatedBy": "user1@siemens.com", + "CreatedTeam": "AA", + "CreatedOn": "2020-10-23", + "CheckStatus": "ACCEPTED", + "CheckedBy": "user2@siemens.com", + "CheckedTeam": "BB", + "CheckedOn": "2020-10-30", + "CliFile": "CLIXML_certifi-2022.12.7.xml" + }, + { + "ComponentName": "certifi 2022.12.7", + "Sw360Id": "ae8c7ed", + "Sw360AttachmentId": "63b368", + "ReportFile": "certifi-2022.12.7_clearing_report.docx" + } + ] +} diff --git a/tests/fixtures/sbom_for_download.json b/tests/fixtures/sbom_for_download.json index 029b299..d49d8a4 100644 --- a/tests/fixtures/sbom_for_download.json +++ b/tests/fixtures/sbom_for_download.json @@ -88,12 +88,38 @@ { "url": "https://github.com/certifi/python-certifi", "type": "website" + }, + { + "url": "CLIXML_certifi-2022.12.7.xml", + "comment": "component license information (local copy)", + "type": "other", + "hashes": [ + { + "alg": "SHA-1", + "content": "542e87fa0acb8d9c4659145a3e1bfcd66c979f33" + } + ] + }, + { + "url": "certifi-2022.12.7_clearing_report.docx", + "comment": "clearing report (local copy)", + "type": "other", + "hashes": [ + { + "alg": "SHA-1", + "content": "3cd24769fa3da4af74d0118433619a130da091b0" + } + ] } ], "properties": [ { "name": "siemens:primaryLanguage", "value": "Python" + }, + { + "name": "siemens:sw360Id", + "value": "ae8c7ed" } ] } @@ -108,4 +134,4 @@ "dependsOn": [] } ] -} \ No newline at end of file +} diff --git a/tests/test_bom_downloadattachments.py b/tests/test_bom_downloadattachments.py new file mode 100644 index 0000000..95b0c55 --- /dev/null +++ b/tests/test_bom_downloadattachments.py @@ -0,0 +1,273 @@ +# ------------------------------------------------------------------------------- +# Copyright (c) 2023 Siemens +# All Rights Reserved. +# Author: thomas.graf@siemens.com +# +# SPDX-License-Identifier: MIT +# ------------------------------------------------------------------------------- + +import os +import tempfile + +import responses + +from capycli.common.capycli_bom_support import CaPyCliBom +from capycli.common.json_support import load_json_file +from capycli.bom.download_attachments import BomDownloadAttachments +from capycli.main.result_codes import ResultCode +from tests.test_base import AppArguments, TestBase + + +class TestBomDownloadAttachments(TestBase): + INPUTFILE = "sbom_for_download.json" + CONTROLFILE = "sbom_for_download-control.json" + INPUTERROR = "plaintext.txt" + OUTPUTFILE = "output.json" + + @responses.activate + def setUp(self) -> None: + self.app = BomDownloadAttachments() + self.add_login_response() + self.app.login("sometoken", "https://my.server.com") + + # return super().setUp() + + def test_show_help(self) -> None: + sut = BomDownloadAttachments() + + # create argparse command line argument object + args = AppArguments() + args.command = [] + args.command.append("bom") + args.command.append("downloadattachments") + args.help = True + + out = self.capture_stdout(sut.run, args) + self.assertTrue("usage: capycli bom downloadattachments" in out) + + def test_no_inputfile_specified(self) -> None: + try: + sut = BomDownloadAttachments() + + # create argparse command line argument object + args = AppArguments() + args.command = [] + args.command.append("bom") + args.command.append("downloadattachments") + + sut.run(args) + self.assertTrue(False, "Failed to report missing argument") + except SystemExit as ex: + self.assertEqual(ResultCode.RESULT_COMMAND_ERROR, ex.code) + + def test_file_not_found(self) -> None: + try: + sut = BomDownloadAttachments() + + # create argparse command line argument object + args = AppArguments() + args.command = [] + args.command.append("bom") + args.command.append("downloadattachments") + args.inputfile = "DOESNOTEXIST" + args.controlfile = os.path.join(os.path.dirname(__file__), + "fixtures", TestBomDownloadAttachments.CONTROLFILE) + + sut.run(args) + self.assertTrue(False, "Failed to report missing file") + except SystemExit as ex: + self.assertEqual(ResultCode.RESULT_FILE_NOT_FOUND, ex.code) + + def test_error_loading_file(self) -> None: + try: + sut = BomDownloadAttachments() + + # create argparse command line argument object + args = AppArguments() + args.command = [] + args.command.append("bom") + args.command.append("downloadattachments") + args.inputfile = os.path.join(os.path.dirname(__file__), "fixtures", TestBomDownloadAttachments.INPUTERROR) + args.controlfile = os.path.join(os.path.dirname(__file__), + "fixtures", TestBomDownloadAttachments.CONTROLFILE) + + sut.run(args) + self.assertTrue(False, "Failed to report invalid file") + except SystemExit as ex: + self.assertEqual(ResultCode.RESULT_ERROR_READING_BOM, ex.code) + + @responses.activate + def test_source_folder_does_not_exist(self) -> None: + try: + sut = BomDownloadAttachments() + + # create argparse command line argument object + args = AppArguments() + args.command = [] + args.command.append("bom") + args.command.append("downloadattachments") + + args.inputfile = os.path.join(os.path.dirname(__file__), "fixtures", TestBomDownloadAttachments.INPUTFILE) + args.controlfile = os.path.join(os.path.dirname(__file__), + "fixtures", TestBomDownloadAttachments.CONTROLFILE) + args.source = "XXX" + + sut.run(args) + self.assertTrue(False, "Failed to report missing folder") + except SystemExit as ex: + self.assertEqual(ResultCode.RESULT_COMMAND_ERROR, ex.code) + + @responses.activate + def test_simple_bom(self) -> None: + bom = os.path.join(os.path.dirname(__file__), "fixtures", TestBomDownloadAttachments.INPUTFILE) + controlfile = os.path.join(os.path.dirname(__file__), "fixtures", TestBomDownloadAttachments.CONTROLFILE) + + bom = CaPyCliBom.read_sbom(bom) + controlfile = load_json_file(controlfile) + + # get attachment - CLI + cli_file = self.get_cli_file_mit() + responses.add( + method=responses.GET, + url=self.MYURL + "resource/api/releases/ae8c7ed/attachments/794446", + body=cli_file, + status=200, + content_type="application/text", + adding_headers={"Authorization": "Token " + self.MYTOKEN}, + ) + # get attachment - report + responses.add( + method=responses.GET, + url=self.MYURL + "resource/api/releases/ae8c7ed/attachments/63b368", + body="some_report_content", + status=200, + content_type="application/text", + adding_headers={"Authorization": "Token " + self.MYTOKEN}, + ) + + with tempfile.TemporaryDirectory() as tmpdirname: + try: + bom = self.app.download_attachments(bom, controlfile["Components"], tmpdirname) + resultfile = os.path.join(tmpdirname, "CLIXML_certifi-2022.12.7.xml") + self.assertEqual(str(bom.components[0].external_references[5].url), resultfile) + self.assertTrue(os.path.isfile(resultfile), "CLI file missing") + + resultfile = os.path.join(tmpdirname, "certifi-2022.12.7_clearing_report.docx") + self.assertEqual(str(bom.components[0].external_references[6].url), resultfile) + self.assertTrue(os.path.isfile(resultfile), "report file missing") + return + except Exception as e: # noqa + # catch all exception to let Python cleanup the temp folder + print(e) + + self.assertTrue(False, "Error: we must never arrive here") + + @responses.activate + def test_simple_bom_relpath(self) -> None: + bom = os.path.join(os.path.dirname(__file__), "fixtures", TestBomDownloadAttachments.INPUTFILE) + bom = CaPyCliBom.read_sbom(bom) + + controlfile = os.path.join(os.path.dirname(__file__), "fixtures", TestBomDownloadAttachments.CONTROLFILE) + controlfile = load_json_file(controlfile) + + # get attachment - CLI + cli_file = self.get_cli_file_mit() + responses.add( + method=responses.GET, + url=self.MYURL + "resource/api/releases/ae8c7ed/attachments/794446", + body=cli_file, + status=200, + content_type="application/text", + adding_headers={"Authorization": "Token " + self.MYTOKEN}, + ) + + with tempfile.TemporaryDirectory() as tmpdirname: + try: + bom = self.app.download_attachments(bom, controlfile["Components"], + tmpdirname, tmpdirname, ("COMPONENT_LICENSE_INFO_XML",)) + resultfile = os.path.join(tmpdirname, "CLIXML_certifi-2022.12.7.xml") + self.assertEqual(str(bom.components[0].external_references[5].url), + "file://CLIXML_certifi-2022.12.7.xml") + self.assertTrue(os.path.isfile(resultfile), "CLI file missing") + + return + except Exception as e: # noqa + # catch all exception to let Python cleanup the temp folder + print(e) + + self.assertTrue(False, "Error: we must never arrive here") + + @responses.activate + def test_simple_bom_download_errors(self) -> None: + # create argparse command line argument object + bom = os.path.join(os.path.dirname(__file__), "fixtures", TestBomDownloadAttachments.INPUTFILE) + bom = CaPyCliBom.read_sbom(bom) + + controlfile = os.path.join(os.path.dirname(__file__), "fixtures", TestBomDownloadAttachments.CONTROLFILE) + controlfile = load_json_file(controlfile) + + # get attachment - CLI, error + responses.add( + method=responses.GET, + url=self.MYURL + "resource/api/releases/ae8c7ed/attachments/794446", + status=500, + content_type="application/text", + adding_headers={"Authorization": "Token " + self.MYTOKEN}, + ) + # get attachment - CLI, error + responses.add( + method=responses.GET, + url=self.MYURL + "resource/api/releases/ae8c7ed/attachments/63b368", + status=403, + content_type="application/text", + adding_headers={"Authorization": "Token " + self.MYTOKEN}, + ) + + with tempfile.TemporaryDirectory() as tmpdirname: + try: + bom = self.app.download_attachments(bom, controlfile["Components"], tmpdirname) + resultfile = os.path.join(tmpdirname, "CLIXML_certifi-2022.12.7.xml") + self.assertFalse(os.path.isfile(resultfile), "CLI created despite HTTP 500") + + resultfile = os.path.join(tmpdirname, "certifi-2022.12.7_clearing_report.docx") + self.assertFalse(os.path.isfile(resultfile), "report created despite HTTP 404") + return + except Exception as e: # noqa + # catch all exception to let Python cleanup the temp folder + print(e) + + self.assertTrue(False, "Error: we must never arrive here") + + @responses.activate + def test_simple_bom_no_release_id(self) -> None: + bom = os.path.join(os.path.dirname(__file__), "fixtures", TestBomDownloadAttachments.INPUTFILE) + bom = CaPyCliBom.read_sbom(bom) + bom.components[0].properties = [] + with tempfile.TemporaryDirectory() as tmpdirname: + try: + err = self.capture_stdout(self.app.download_attachments, bom, [], tmpdirname) + self.assertIn("No sw360Id for release", err) + + return + except Exception as e: # noqa + # catch all exception to let Python cleanup the temp folder + print(e) + + self.assertTrue(False, "Error: we must never arrive here") + + @responses.activate + def test_simple_bom_no_ctrl_file_entry(self) -> None: + bom = os.path.join(os.path.dirname(__file__), "fixtures", TestBomDownloadAttachments.INPUTFILE) + bom = CaPyCliBom.read_sbom(bom) + + with tempfile.TemporaryDirectory() as tmpdirname: + try: + err = self.capture_stdout(self.app.download_attachments, bom, [], tmpdirname) + assert "Found 0 entries for attachment CLIXML_certifi-2022.12.7.xml" in err + + return + except Exception as e: # noqa + # catch all exception to let Python cleanup the temp folder + print(e) + + self.assertTrue(False, "Error: we must never arrive here") diff --git a/tests/test_create_bom.py b/tests/test_create_bom.py index 8ce47b9..e082086 100644 --- a/tests/test_create_bom.py +++ b/tests/test_create_bom.py @@ -13,7 +13,7 @@ import responses from cyclonedx.model import ExternalReferenceType -from capycli.common.capycli_bom_support import CaPyCliBom +from capycli.common.capycli_bom_support import CaPyCliBom, CycloneDxSupport from capycli.main.result_codes import ResultCode from capycli.project.create_bom import CreateBom from tests.test_base import AppArguments, TestBasePytest @@ -103,6 +103,7 @@ def test_project_not_found(self) -> None: args.verbose = True args.id = "34ef5c5452014c52aa9ce4bc180624d8" args.outputfile = self.OUTPUTFILE + args.controlfile = None self.add_login_response() @@ -149,28 +150,22 @@ def test_create_bom_multiple_purls(self, capsys: Any) -> None: content_type="application/json", adding_headers={"Authorization": "Token " + self.MYTOKEN}, ) - - cdx_components = sut.create_project_bom(self.get_project_for_test()) + self.add_project_attachment_responses() + cdx_components, _ = sut.create_project_bom(self.get_project_for_test(), + create_controlfile=False) captured = capsys.readouterr() - assert "Multiple purls added" in captured.out - assert cdx_components[0].purl is not None - if cdx_components[0].purl: - assert cdx_components[0].purl.to_string() == "pkg:deb/debian/cli-support%401.3-1%20pkg:pypi/cli-support@1.3" - - @responses.activate - def test_project_by_id(self) -> None: - sut = CreateBom() - - self.add_login_response() - sut.login(token=TestBasePytest.MYTOKEN, url=TestBasePytest.MYURL) + assert "Stored them in property purl_list" in captured.out + assert cdx_components[0].purl is None + purl_raw = CycloneDxSupport.get_property(cdx_components[0], "purl_list").value + assert purl_raw == "pkg:deb/debian/cli-support@1.3-1 pkg:pypi/cli-support@1.3" + def add_project_releases_responses(self): # the project - project = self.get_project_for_test() responses.add( responses.GET, url=self.MYURL + "resource/api/projects/p001", - json=project, + json=self.get_project_for_test(), status=200, content_type="application/json", adding_headers={"Authorization": "Token " + self.MYTOKEN}, @@ -197,10 +192,21 @@ def test_project_by_id(self) -> None: "attachmentType": "SOURCE_SELF", "_links": { "self": { - "href": "https://my.server.com/resource/api/attachments/r002a002" + "href": "https://my.server.com/resource/api/attachments/r002a003" } } }) + release["_embedded"]["sw360:attachments"].append({ + "filename": "clipython-1.3.0.docx", + "sha1": "f0d8f2ddd017bdeaecbaec72ff76a6c0a045ec66", + "attachmentType": "CLEARING_REPORT", + "_links": { + "self": { + "href": "https://my.server.com/resource/api/attachments/r002a004" + } + } + }) + responses.add( responses.GET, url=self.MYURL + "resource/api/releases/r002", @@ -209,26 +215,136 @@ def test_project_by_id(self) -> None: content_type="application/json", adding_headers={"Authorization": "Token " + self.MYTOKEN}, ) + return release - cdx_bom = sut.create_project_cdx_bom("p001") - cx_comp = cdx_bom.components[0] - assert cx_comp.purl.to_string() == release["externalIds"]["package-url"] + def add_project_attachment_responses(self): + responses.add( + method=responses.GET, + url=self.MYURL + "resource/api/attachments/r001a002", + body=""" + { + "filename": "wheel-0.38.4.zip", + "attachmentType": "SOURCE" + }""", + status=200, + content_type="application/json", + adding_headers={"Authorization": "Token " + self.MYTOKEN}, + ) + responses.add( + method=responses.GET, + url=self.MYURL + "resource/api/attachments/r001a001", + body=""" + { + "filename": "CLIXML_wheel-0.38.4.xml", + "sha1": "ccd9f1ed2f59c46ff3f0139c05bfd76f83fd9851", + "attachmentType": "COMPONENT_LICENSE_INFO_XML" + }""", + status=200, + content_type="application/json", + adding_headers={"Authorization": "Token " + self.MYTOKEN}, + ) - ext_refs_src_url = [e for e in cx_comp.external_references if e.comment == CaPyCliBom.SOURCE_URL_COMMENT] - assert len(ext_refs_src_url) == 1 - assert str(ext_refs_src_url[0].url) == release["sourceCodeDownloadurl"] - assert ext_refs_src_url[0].type == ExternalReferenceType.DISTRIBUTION + responses.add( + method=responses.GET, + url=self.MYURL + "resource/api/attachments/r002a001", + body=""" + { + "filename": "clipython-1.3.0.zip", + "attachmentType": "SOURCE" + }""", + status=200, + content_type="application/json", + adding_headers={"Authorization": "Token " + self.MYTOKEN}, + ) + responses.add( + method=responses.GET, + url=self.MYURL + "resource/api/attachments/r002a002", + body=""" + { + "filename": "CLIXML_clipython-1.3.0.xml", + "sha1": "dd4c38387c6811dba67d837af7742d84e61e20de", + "attachmentType": "COMPONENT_LICENSE_INFO_XML", + "checkedBy": "user2@siemens.com", + "checkStatus": "ACCEPTED", + "createdBy": "user1@siemens.com" + }""", + status=200, + content_type="application/json", + adding_headers={"Authorization": "Token " + self.MYTOKEN}, + ) + responses.add( + method=responses.GET, + url=self.MYURL + "resource/api/attachments/r002a003", + body=""" + { + "filename": "clipython-repacked-for-fun.zip", + "attachmentType": "SOURCE_SELF" + }""", + status=200, + content_type="application/json", + adding_headers={"Authorization": "Token " + self.MYTOKEN}, + ) + responses.add( + method=responses.GET, + url=self.MYURL + "resource/api/attachments/r002a004", + body=""" + { + "filename": "clipython-1.3.0.docx", + "attachmentType": "CLEARING_REPORT" + }""", + status=200, + content_type="application/json", + adding_headers={"Authorization": "Token " + self.MYTOKEN}, + ) - ext_refs_src_file = [e for e in cx_comp.external_references if e.comment == CaPyCliBom.SOURCE_FILE_COMMENT] - assert len(ext_refs_src_file) == 2 - assert str(ext_refs_src_file[0].url) == release["_embedded"]["sw360:attachments"][0]["filename"] - assert ext_refs_src_file[0].type == ExternalReferenceType.DISTRIBUTION - assert ext_refs_src_file[0].hashes[0].alg == "SHA-1" - assert ext_refs_src_file[0].hashes[0].content == release["_embedded"]["sw360:attachments"][0]["sha1"] + @responses.activate + def test_project_by_id(self) -> None: + sut = CreateBom() - ext_refs_vcs = [e for e in cx_comp.external_references if e.type == ExternalReferenceType.VCS] - assert len(ext_refs_vcs) == 1 - assert str(ext_refs_vcs[0].url) == release["repository"]["url"] + self.add_login_response() + sut.login(token=TestBasePytest.MYTOKEN, url=TestBasePytest.MYURL) + + release = self.add_project_releases_responses() + self.add_project_attachment_responses() + project = self.get_project_for_test() + + cdx_bom, _ = sut.create_project_cdx_bom("p001", create_controlfile=False) + cx_comp = cdx_bom.components[0] + assert cx_comp.purl.to_string() == release["externalIds"]["package-url"] + + ext_refs = [e for e in cx_comp.external_references if e.comment == CaPyCliBom.SOURCE_URL_COMMENT] + assert len(ext_refs) == 1 + assert str(ext_refs[0].url) == release["sourceCodeDownloadurl"] + assert ext_refs[0].type == ExternalReferenceType.DISTRIBUTION + + ext_refs = [e for e in cx_comp.external_references if e.comment == CaPyCliBom.SOURCE_FILE_COMMENT] + assert len(ext_refs) == 2 + assert str(ext_refs[0].url) == release["_embedded"]["sw360:attachments"][0]["filename"] + assert ext_refs[0].type == ExternalReferenceType.DISTRIBUTION + assert ext_refs[0].hashes[0].alg == "SHA-1" + assert ext_refs[0].hashes[0].content == release["_embedded"]["sw360:attachments"][0]["sha1"] + + ext_refs = [e for e in cx_comp.external_references + if e.comment and e.comment.startswith(CaPyCliBom.CLI_FILE_COMMENT)] + assert len(ext_refs) == 1 + assert str(ext_refs[0].url) == release["_embedded"]["sw360:attachments"][1]["filename"] + assert ext_refs[0].type == ExternalReferenceType.OTHER + assert ext_refs[0].comment == CaPyCliBom.CLI_FILE_COMMENT + assert ext_refs[0].hashes[0].alg == "SHA-1" + assert ext_refs[0].hashes[0].content == release["_embedded"]["sw360:attachments"][1]["sha1"] + + ext_refs = [e for e in cx_comp.external_references + if e.comment and e.comment.startswith(CaPyCliBom.CRT_FILE_COMMENT)] + assert len(ext_refs) == 1 + assert str(ext_refs[0].url) == release["_embedded"]["sw360:attachments"][3]["filename"] + assert ext_refs[0].comment == CaPyCliBom.CRT_FILE_COMMENT + assert ext_refs[0].type == ExternalReferenceType.OTHER + assert ext_refs[0].hashes[0].alg == "SHA-1" + assert ext_refs[0].hashes[0].content == release["_embedded"]["sw360:attachments"][3]["sha1"] + + ext_refs = [e for e in cx_comp.external_references if e.type == ExternalReferenceType.VCS] + assert len(ext_refs) == 1 + assert str(ext_refs[0].url) == release["repository"]["url"] assert cdx_bom.metadata.component is not None if cdx_bom.metadata.component: @@ -236,6 +352,42 @@ def test_project_by_id(self) -> None: assert cdx_bom.metadata.component.version == project["version"] assert cdx_bom.metadata.component.description == project["description"] + @responses.activate + def test_project_by_id_controlfile(self): + sut = CreateBom() + self.add_login_response() + sut.login(token=TestBasePytest.MYTOKEN, url=TestBasePytest.MYURL) + + self.add_project_releases_responses() + self.add_project_attachment_responses() + + responses.add( + method=responses.GET, + url=self.MYURL + "resource/api/attachments/r002a004", + body=""" + { + "filename": "clipython-1.3.0.docx", + "sha1": "f0d8f2ddd017bdeaecbaec72ff76a6c0a045ec66", + "attachmentType": "CLEARING_REPORT" + + }""", + status=200, + content_type="application/json", + adding_headers={"Authorization": "Token " + self.MYTOKEN}, + ) + + _, controlfile = sut.create_project_cdx_bom("p001", create_controlfile=True) + assert controlfile['ProjectName'] == 'CaPyCLI, 1.9.0' + assert controlfile['Components'][0]['ComponentName'] == 'cli-support 1.3' + assert controlfile['Components'][0]['Sw360Id'] == 'r002' + assert controlfile['Components'][0]['Sw360AttachmentId'] == 'r002a002' + assert controlfile['Components'][0]['CliFile'] == 'CLIXML_clipython-1.3.0.xml' + assert controlfile['Components'][0]['CheckedBy'] == 'user2@siemens.com' + assert controlfile['Components'][0]['CheckStatus'] == 'ACCEPTED' + assert controlfile['Components'][0]['CreatedBy'] == 'user1@siemens.com' + + assert controlfile['Components'][1]['ReportFile'] == 'clipython-1.3.0.docx' + @responses.activate def test_project_show_by_name(self) -> None: sut = CreateBom() @@ -251,6 +403,7 @@ def test_project_show_by_name(self) -> None: args.name = "CaPyCLI" args.version = "1.9.0" args.outputfile = self.OUTPUTFILE + args.controlfile = None self.add_login_response() @@ -317,6 +470,7 @@ def test_project_show_by_name(self) -> None: content_type="application/json", adding_headers={"Authorization": "Token " + self.MYTOKEN}, ) + self.add_project_attachment_responses() self.delete_file(self.OUTPUTFILE) out = self.capture_stdout(sut.run, args) @@ -334,6 +488,78 @@ def test_project_show_by_name(self) -> None: self.delete_file(self.OUTPUTFILE) + @responses.activate + def test_create_project_bom_release_error(self): + sut = CreateBom() + + self.add_login_response() + sut.login(token=TestBasePytest.MYTOKEN, url=TestBasePytest.MYURL) + + responses.add( + responses.GET, + url=self.MYURL + "resource/api/releases/r001", + status=404, + content_type="application/json", + adding_headers={"Authorization": "Token " + self.MYTOKEN}, + ) + responses.add( + responses.GET, + url=self.MYURL + "resource/api/releases/r002", + json=self.get_release_cli_for_test(), + status=200, + content_type="application/json", + adding_headers={"Authorization": "Token " + self.MYTOKEN}, + ) + self.add_project_attachment_responses() + with pytest.raises(SystemExit): + bom, _ = sut.create_project_bom(self.get_project_for_test(), create_controlfile=False) + + @responses.activate + def test_create_project_bom_controlfile_attachment_error(self): + sut = CreateBom() + + self.add_login_response() + sut.login(token=TestBasePytest.MYTOKEN, url=TestBasePytest.MYURL) + + responses.add( + responses.GET, + url=self.MYURL + "resource/api/releases/r001", + json=self.get_release_wheel_for_test(), + status=200, + content_type="application/json", + adding_headers={"Authorization": "Token " + self.MYTOKEN}, + ) + responses.add( + responses.GET, + url=self.MYURL + "resource/api/releases/r002", + json=self.get_release_cli_for_test(), + status=200, + content_type="application/json", + adding_headers={"Authorization": "Token " + self.MYTOKEN}, + ) + responses.add( + method=responses.GET, + url=self.MYURL + "resource/api/attachments/r002a001", + body=""" + { + "filename": "clipython-1.3.0.zip", + "attachmentType": "COMPONENT_LICENSE_INFO_XML" + }""", + status=200, + content_type="application/json", + adding_headers={"Authorization": "Token " + self.MYTOKEN}, + ) + responses.add( + method=responses.GET, + url=self.MYURL + "resource/api/attachments/r002a002", + status=404, + content_type="application/json", + adding_headers={"Authorization": "Token " + self.MYTOKEN}, + ) + + with pytest.raises(SystemExit): + bom, _ = sut.create_project_bom(self.get_project_for_test(), create_controlfile=True) + if __name__ == "__main__": APP = TestCreateBom() diff --git a/tests/test_merge_bom.py b/tests/test_merge_bom.py index 75f7e79..bee9185 100644 --- a/tests/test_merge_bom.py +++ b/tests/test_merge_bom.py @@ -197,8 +197,8 @@ def test_merge_bom1(self) -> None: self.assertEqual("certifi", bom.components[0].name) self.assertEqual("2022.12.7", bom.components[0].version) self.assertEqual("pkg:pypi/certifi@2022.12.7", bom.components[0].purl.to_string()) - self.assertEqual(6, len(bom.components[0].external_references)) - self.assertEqual(1, len(bom.components[0].properties)) + self.assertEqual(8, len(bom.components[0].external_references)) + self.assertEqual(2, len(bom.components[0].properties)) self.assertEqual(2, len(bom.components)) self.assertEqual("certifi", bom.components[1].name)