diff --git a/cve_bin_tool/cli.py b/cve_bin_tool/cli.py index 24b8560190..00414569be 100644 --- a/cve_bin_tool/cli.py +++ b/cve_bin_tool/cli.py @@ -75,6 +75,7 @@ from cve_bin_tool.util import ProductInfo from cve_bin_tool.version import VERSION from cve_bin_tool.version_scanner import VersionScanner +from cve_bin_tool.vex_manager.parse import VEXParse sys.excepthook = excepthook # Always install excepthook for entrypoint module. @@ -180,12 +181,6 @@ def main(argv=None): default="", help="provide input filename", ) - input_group.add_argument( - "--triage-input-file", - action="store", - default="", - help="provide input filename for triage data", - ) input_group.add_argument( "-C", "--config", action="store", default="", help="provide config file" ) @@ -358,7 +353,7 @@ def main(argv=None): output_group.add_argument( "--vex-type", action="store", - default="cyclonedx", + default="", choices=["cyclonedx", "csaf", "openvex"], help="specify type of vulnerability exchange (vex) to generate (default: cyclonedx)", ) @@ -380,6 +375,19 @@ def main(argv=None): default="", help="Vendor/Supplier of Product", ) + output_group.add_argument( + "-rr", + "--revision-reason", + action="store", + default="", + help="a reason for the update to the vex document should be specified in double quotes", + ) + output_group.add_argument( + "--filter-triage", + action="store_true", + default=False, + help="Filter cves based on triage data from Vex file", + ) parser.add_argument( "-e", "--exclude", @@ -917,6 +925,7 @@ def main(argv=None): and not args["package_list"] and not args["merge"] and not args["sbom_file"] + and not args["vex_file"] ): parser.print_usage() with ErrorHandler(logger=LOGGER, mode=ErrorMode.NoTrace): @@ -1009,7 +1018,7 @@ def main(argv=None): triage_data: TriageData total_files: int = 0 parsed_data: dict[ProductInfo, TriageData] = {} - + vex_product_info: dict[str, str] = {} # Package List parsing if args["package_list"]: sbom_root = args["package_list"] @@ -1021,18 +1030,6 @@ def main(argv=None): LOGGER.debug(f"{product_info}, {triage_data}") cve_scanner.get_cves(product_info, triage_data) - if args["triage_input_file"]: - input_engine = InputEngine( - args["triage_input_file"], - logger=LOGGER, - error_mode=error_mode, - filetype="vex", - ) - parsed_data = input_engine.parse_input() - for product_info, triage_data in parsed_data.items(): - LOGGER.debug(f"{product_info}, {triage_data}") - cve_scanner.get_cves(product_info, triage_data) - if args["input_file"]: input_engine = InputEngine( args["input_file"], logger=LOGGER, error_mode=error_mode @@ -1092,6 +1089,41 @@ def main(argv=None): LOGGER.debug(f"{product_info}, {triage_data}") cve_scanner.get_cves(product_info, triage_data) + if args["vex_file"]: + # for now use cyclonedx as auto detection is not implemented in latest pypi package of lib4vex + vexdata = VEXParse( + filename=args["vex_file"], + vextype="cyclonedx", + logger=LOGGER, + ) + parsed_vex_data = vexdata.parse_vex() + vex_product_info = vexdata.vex_product_info + if not parsed_data: + # assume the vex file being scanned is a standalone file + args["filter_triage"] = False + parsed_data = parsed_vex_data + for product_info, triage_data in parsed_data.items(): + LOGGER.debug(f"{product_info}, {triage_data}") + cve_scanner.get_cves(product_info, triage_data) + else: + LOGGER.info( + f"VEX file {args['vex_file']} is not a standalone file and will be used as a triage file" + ) + # need to do validation on the sbom part + # need to implement is_linked() function which will check the linkage. + if args["sbom_file"]: + LOGGER.warning( + f"SBOM file: {args['sbom_file']} is not linked to VEX file: {args['vex_file']}." + ) + for product_info, triage_data in parsed_vex_data.items(): + LOGGER.debug(f"{product_info}, {triage_data}") + if product_info in parsed_data: + cve_scanner.get_cves(product_info, triage_data) + else: + LOGGER.info( + f"Product: {product_info.product} with Version: {product_info.version} not found in Parsed Data, is valid vex file being used?" + ) + LOGGER.info("Overall CVE summary: ") LOGGER.info( f"There are {cve_scanner.products_with_cve} products with known CVEs detected" @@ -1105,19 +1137,29 @@ def main(argv=None): ) ) LOGGER.info(f"Known CVEs in {affected_string}:") - vex_product_info: dict[str, str] = {} - if args["vex_output"]: + if args["vex_type"] or args["vex_output"]: + # If vex_type is provided, then use it, else use cyclonedx as default vex_output should be provide in this case + # If vex_output is provided, then use it, else use product, release and vendor to generate the vex file. + if args["vex_output"] and not args["vex_type"]: + # default vex_type is cyclonedx + args["vex_type"] = "cyclonedx" if args["product"] and args["release"] and args["vendor"]: vex_product_info = { "product": args["product"], "release": args["release"], "vendor": args["vendor"], + "revision_reason": args["revision_reason"], } + elif args["vex_file"]: + vex_product_info["revision_reason"] = args["revision_reason"] else: LOGGER.error( "Please provide --product, --release and --vendor for VEX generation" ) return ERROR_CODES[InsufficientArgs] + + if args["vex_file"] and args["filter_triage"]: + cve_scanner.filter_triage_data() # Creates an Object for OutputEngine output = OutputEngine( all_cve_data=cve_scanner.all_cve_data, diff --git a/cve_bin_tool/cve_scanner.py b/cve_bin_tool/cve_scanner.py index e731656fcc..6eab229d02 100644 --- a/cve_bin_tool/cve_scanner.py +++ b/cve_bin_tool/cve_scanner.py @@ -82,12 +82,42 @@ def get_cves(self, product_info: ProductInfo, triage_data: TriageData): return if product_info in self.all_cve_data: - # If product_info already in all_cve_data no need to fetch cves from database again - # We just need to update paths. + # If product_info already in all_cve_data, no need to fetch CVEs from the database again. + # We just need to update paths and triage data. self.logger.debug( - f"{product_info} already processed. Update path {triage_data['paths']}" + f"{product_info} already processed. Update paths {triage_data['paths']}" ) - # self.products_with_cve += 1 + + # Update the triage data + cve_data = self.all_cve_data[product_info]["cves"] + new_cve_data = [] + + for cve in cve_data: + cve_number = cve.cve_number + if cve_number in triage_data: + for key in [ + "remarks", + "comments", + "response", + "justification", + "severity", + ]: + data = triage_data[cve_number].get(key) + if data: + if ( + key == "severity" + and self.check_exploits + and cve_number in self.exploits_list + ): + data += "-EXPLOIT" + + self.logger.debug(f"Setting field {key} to: {data}") + cve = cve._replace(**{key: data}) + new_cve_data.append(cve) + + self.all_cve_data[product_info]["cves"] = new_cve_data + + # Update paths self.all_cve_data[product_info]["paths"] |= set(triage_data["paths"]) return @@ -350,6 +380,43 @@ def get_cves(self, product_info: ProductInfo, triage_data: TriageData): if product_info not in self.all_product_data: self.all_product_data[product_info] = len(cves) + def filter_triage_data(self): + """ + Filter out triage data that is not relevant to the CVEs found, + specifically those marked as NotAffected or FalsePositives. + """ + to_delete: List[ProductInfo] = [] + + for product_info, cve_data in self.all_cve_data.items(): + original_cves = cve_data["cves"] + filtered_cves = [] + filtered_out_cves = [] + + for cve in original_cves: + if cve.remarks not in {Remarks.NotAffected, Remarks.FalsePositive}: + filtered_cves.append(cve) + else: + filtered_out_cves.append(cve) + + for cve in filtered_out_cves: + self.logger.info( + f"Filtered CVE: {cve.cve_number} for Product: {product_info.product}" + ) + + if filtered_cves: + cve_data["cves"] = filtered_cves + self.logger.debug( + f"Filtered triage data for {product_info.product}: {[cve.cve_number for cve in filtered_cves]}" + ) + else: + to_delete.append(product_info) + + for product_info in to_delete: + del self.all_cve_data[product_info] + self.logger.debug( + f"Removed product info for {product_info.product} due to no relevant CVEs" + ) + def affected(self): """Returns list of vendor.product and version tuples identified from scan""" diff --git a/cve_bin_tool/input_engine.py b/cve_bin_tool/input_engine.py index f6ab2fcac6..3e23f0fd87 100644 --- a/cve_bin_tool/input_engine.py +++ b/cve_bin_tool/input_engine.py @@ -10,7 +10,6 @@ import csv import json -import re from collections import defaultdict from logging import Logger from pathlib import Path @@ -35,7 +34,7 @@ class InputEngine: """ Class: InputEngine - This class is responsible for parsing various input file formats (CSV, VEX, JSON) in the CVE Bin Tool. + This class is responsible for parsing various input file formats (CSV, JSON) in the CVE Bin Tool. Attributes: - parsed_data (DefaultDict[ProductInfo, TriageData]): Dictionary containing parsed input data. @@ -45,7 +44,7 @@ class InputEngine: Initializes the InputEngine with the specified filename, logger, error mode, and filetype. - parse_input(self) -> DefaultDict[ProductInfo, TriageData]: - Parses the input file based on its type (CSV, VEX, JSON) and returns the parsed data. + Parses the input file based on its type (CSV, JSON) and returns the parsed data. - input_csv(self) -> None: Parses input data from a CSV file. @@ -53,12 +52,6 @@ class InputEngine: - input_json(self) -> None: Parses input data from a JSON file. - - input_vex(self) -> None: - Parses input data from a CycloneDX VEX file. - - - validate_product(self, product: str) -> bool: - Validates if a product name conforms to the CPE 2.3 standard. - - parse_data(self, fields: Set[str], data: Iterable) -> None: Parses common data structure for CSV and JSON input formats. @@ -106,8 +99,6 @@ def parse_input(self) -> DefaultDict[ProductInfo, TriageData]: raise FileNotFoundError(self.filename) if self.filename.endswith(".csv"): self.input_csv() - elif self.filename.endswith(".vex") or self.filetype == "vex": - self.input_vex() elif self.filename.endswith(".json"): self.input_json() return self.parsed_data @@ -144,155 +135,6 @@ def input_json(self) -> None: self.parse_data(set(json_data[0].keys()), json_data) - def validate_product(self, product: str) -> bool: - """ - Validates if a product name conforms to the CPE 2.3 standard. - - Args: - - product (str): Product name. - - Returns: - - bool: True if the product name is valid, False otherwise. - - """ - """ - Ensure product name conforms to CPE 2.3 standard. - See https://csrc.nist.gov/schema/cpe/2.3/cpe-naming_2.3.xsd for naming specification - """ - cpe_regex = r"\A([A-Za-z0-9\._\-~ %])+\Z" - return re.search(cpe_regex, product) is not None - - def input_vex(self) -> None: - """ - Parses input data from a VEX file. - """ - with open(self.filename) as json_file: - json_data = json.load(json_file) - - # Only handle CycloneDX VEX file format - if json_data["bomFormat"] == "CycloneDX": - self.input_vex_cyclone_dx(json_data) - - def input_vex_cyclone_dx(self, json_data): - """ - Parses input data from a CycloneDX VEX file. - """ - - def strip_remark(detail) -> str: - detail = re.sub("^" + Remarks.NewFound.name + "(: )?", "", detail) - detail = re.sub("^" + Remarks.Unexplored.name + "(: )?", "", detail) - detail = re.sub("^" + Remarks.Confirmed.name + "(: )?", "", detail) - detail = re.sub("^" + Remarks.Mitigated.name + "(: )?", "", detail) - detail = re.sub("^" + Remarks.FalsePositive.name + "(: )?", "", detail) - detail = re.sub("^" + Remarks.NotAffected.name + "(: )?", "", detail) - return detail - - # Map CycloneDX v1.4 anaylsis state to the Remarks enumeration. - remarks_lookup = { - "resolved": Remarks.Mitigated, - "resolved_with_pedigree": Remarks.Mitigated, - "exploitable": Remarks.Confirmed, - "in_triage": Remarks.Unexplored, - "false_positive": Remarks.FalsePositive, - "not_affected": Remarks.NotAffected, - } - - # Not all data from the BOM needs to be read because it will be updated from the - # CVE DB. The analysis fields may have been updated in the VEX and should be - # read. - for vulnerability in json_data["vulnerabilities"]: - id = vulnerability["id"] - analysis_state = vulnerability["analysis"]["state"].lower() - remarks = Remarks.Unexplored - if analysis_state in remarks_lookup: - remarks = remarks_lookup[analysis_state] - justification = vulnerability["analysis"].get("justification", None) - response = vulnerability["analysis"].get("response", None) - comments = strip_remark(vulnerability["analysis"]["detail"]) - severity = None - if "ratings" in vulnerability: - for rating in vulnerability["ratings"]: - severity = rating["severity"].upper() - for affect in vulnerability["affects"]: - product_info = self.decode_bom_ref(affect["ref"]) - - if product_info is not None: - self.parsed_data[product_info][id.strip() or "default"] = { - "remarks": remarks, - "comments": comments.strip(), - "response": response, - } - if justification: - self.parsed_data[product_info][id.strip() or "default"][ - "justification" - ] = justification.strip() - if severity: - self.parsed_data[product_info][id.strip() or "default"][ - "severity" - ] = severity.strip() - self.parsed_data[product_info]["paths"] = {} - - def decode_bom_ref(self, ref) -> ProductInfo: - """ - Decodes the BOM reference for each component. - - Args: - - ref (str): BOM reference string - - Returns: - - bool: ProductInfo object containing the vendor, product, and version. - - """ - # urn:cbt:{bom_version}/{vendor}#{product}-{version} - urn_cbt_ref = re.compile( - r"urn:cbt:(?P.*?)\/(?P.*?)#(?P.*?)-(?P.*)" - ) - - # This URN was added to support CPE's that have dashes in their version field. - # urn:cbt:{bom_version}/{vendor}#{product}:{version} - urn_cbt_ext_ref = re.compile( - r"urn:cbt:(?P.*?)\/(?P.*?)#(?P.*?):(?P.*)" - ) - - # urn:cdx:serialNumber/version#bom-ref (https://cyclonedx.org/capabilities/bomlink/) - urn_cdx = re.compile( - r"urn:cdx:(?P.*?)\/(?P.*?)#(?P.*)" - ) - location = "location/to/product" - if urn_cbt_ext_ref.match(ref): - urn_dict = urn_cbt_ext_ref.match(ref).groupdict() - vendor = urn_dict["vendor"] - product = urn_dict["product"] - version = urn_dict["version"] - elif urn_cbt_ref.match(ref): - urn_dict = urn_cbt_ref.match(ref).groupdict() - vendor = urn_dict["vendor"] - product = urn_dict["product"] - version = urn_dict["version"] - elif urn_cdx.match(ref): - urn_dict = urn_cdx.match(ref).groupdict() - cdx_bom_ref = urn_dict["bom_ref"] - # Try to decode the CDX BOM reference. This can be any unique identifier but may contain - # product:version - # or it could be a Package URL. - try: - product, version = cdx_bom_ref.rsplit("-", 1) - except ValueError: - product, version = None, None - vendor = "UNKNOWN" - else: - product = None - version = None - vendor = None - - product_info = None - if product is not None and self.validate_product(product): - product_info = ProductInfo( - vendor.strip(), product.strip(), version.strip(), location - ) - - return product_info - def parse_data(self, fields: Set[str], data: Iterable) -> None: """ Parses common data structure for CSV and JSON input formats. diff --git a/cve_bin_tool/output_engine/__init__.py b/cve_bin_tool/output_engine/__init__.py index 404da41f0f..4fecb554fa 100644 --- a/cve_bin_tool/output_engine/__init__.py +++ b/cve_bin_tool/output_engine/__init__.py @@ -681,7 +681,7 @@ def __init__( sbom_format: str = "tag", sbom_root: str = "CVE_SBOM", vex_filename: str = "", - vex_type: str = "cyclonedx", + vex_type: str = "", vex_product_info: dict[str, str] = {}, offline: bool = False, ): @@ -792,7 +792,7 @@ def output_cves(self, outfile, output_type="console"): ) self.logger.info(f"Output stored at {self.append}") - if self.vex_filename != "": + if self.vex_filename != "" or self.vex_type != "": vexgen = VEXGenerate( self.vex_product_info["product"], self.vex_product_info["release"], @@ -800,6 +800,7 @@ def output_cves(self, outfile, output_type="console"): self.vex_filename, self.vex_type, self.all_cve_data, + self.vex_product_info["revision_reason"], logger=self.logger, ) vexgen.generate_vex() diff --git a/cve_bin_tool/util.py b/cve_bin_tool/util.py index 0a9d2d2797..7b590f10a9 100644 --- a/cve_bin_tool/util.py +++ b/cve_bin_tool/util.py @@ -565,7 +565,7 @@ def decode_cpe23(cpe23) -> list: return [vendor or None, product or None, version or None] -def decode_cpe22(self, cpe22) -> list: +def decode_cpe22(cpe22) -> list: """ Decode a CPE 2.2 formatted string to extract vendor, product, and version information. diff --git a/cve_bin_tool/vex_manager/generate.py b/cve_bin_tool/vex_manager/generate.py index 1b4cc819cc..c3441cd497 100644 --- a/cve_bin_tool/vex_manager/generate.py +++ b/cve_bin_tool/vex_manager/generate.py @@ -1,7 +1,5 @@ # Copyright (C) 2024 Intel Corporation # SPDX-License-Identifier: GPL-3.0-or-later -import os -from datetime import datetime from logging import Logger from pathlib import Path from typing import Dict, List, Optional @@ -49,6 +47,7 @@ def __init__( filename: str, vextype: str, all_cve_data: Dict[ProductInfo, CVEData], + revision_reason: str = "", sbom: Optional[str] = None, logger: Optional[Logger] = None, validate: bool = True, @@ -56,6 +55,7 @@ def __init__( self.product = product self.release = release self.vendor = vendor + self.revision_reason = revision_reason self.sbom = sbom self.filename = filename self.vextype = vextype @@ -70,48 +70,61 @@ def generate_vex(self) -> None: Returns: None """ - vexgen = VEXGenerator(vex_type=self.vextype) + author = "Unknown Author" + if self.vendor: + author = self.vendor + vexgen = VEXGenerator(vex_type=self.vextype, author=author) kwargs = {"name": self.product, "release": self.release} if self.sbom: kwargs["sbom"] = self.sbom vexgen.set_product(**kwargs) - if Path(self.filename).is_file(): - self.logger.warning( - f"Failed to write '{self.filename}'. File already exists" + if not self.filename: + self.logger.info( + "No filename defined, Generating a new filename with Default Naming Convention" ) - self.logger.info("Generating a new filename with Default Naming Convention") - self.filename = self.generate_vex_filename() + self.filename = self.__generate_vex_filename() + if Path(self.filename).is_file(): + self.logger.info(f"Updating the vex file: {self.filename}") + vexgen.generate( project_name=self.product, - vex_data=self.get_vulnerabilities(), - metadata=self.get_metadata(), + vex_data=self.__get_vulnerabilities(), + metadata=self.__get_metadata(), filename=self.filename, ) - def generate_vex_filename(self) -> str: + def __generate_vex_filename(self) -> str: """ Generates a VEX filename based on the current date and time. Returns: str: The generated VEX filename. """ - now = datetime.now().strftime("%Y-%m-%d.%H-%M-%S") - filename = os.path.abspath( - os.path.join( - os.getcwd(), f"{self.product}_{self.release}_{self.vextype}.{now}.json" - ) + filename = ( + Path.cwd() + / f"{self.product}_{self.release}_{self.vendor}_{self.vextype}.json" ) - return filename + return str(filename) + + def __get_metadata(self) -> Dict: + metadata = {} + if self.vextype == "cyclonedx": + if self.product: + metadata["id"] = f"{self.product.upper()}-VEX" + elif self.vextype == "csaf": + if self.product and self.release and self.vendor: + metadata["id"] = f"{self.product.upper()}-{self.release}-VEX" + metadata["supplier"] = self.vendor + elif self.vextype == "openvex": + if self.vendor: + metadata["author"] = self.vendor + metadata["supplier"] = self.vendor + if self.revision_reason: + metadata["revision_reason"] = self.revision_reason - def get_metadata(self) -> Dict: - metadata = { - "id": f"{self.product.upper()}-{self.release}-VEX", - "supplier": self.vendor, - } - # other metadata can be added here return metadata - def get_vulnerabilities(self) -> List[Vulnerability]: + def __get_vulnerabilities(self) -> List[Vulnerability]: """ Retrieves a list of vulnerabilities. diff --git a/cve_bin_tool/vex_manager/parse.py b/cve_bin_tool/vex_manager/parse.py index 0d82a000b6..6c4136a2f1 100644 --- a/cve_bin_tool/vex_manager/parse.py +++ b/cve_bin_tool/vex_manager/parse.py @@ -66,18 +66,40 @@ def parse_vex(self) -> DefaultDict[ProductInfo, TriageData]: vexparse = VEXParser(vex_type=self.vextype) vexparse.parse(self.filename) self.logger.debug(f"VEX Vulnerabilities: {vexparse.get_vulnerabilities()}") - self.process_vulnerabilities(vexparse.get_vulnerabilities()) - self.process_metadata(vexparse.get_metadata()) - self.process_product(vexparse.get_product()) + self.__process_vulnerabilities(vexparse.get_vulnerabilities()) + self.__process_metadata(vexparse.get_metadata()) + self.__process_product(vexparse.get_product()) + self.__extract_product_info() return self.parsed_data - def process_metadata(self, metadata) -> None: + def __extract_product_info(self): + """Extracts the product information from the parsed vex file""" + product_info = {} + if self.vextype == "cyclonedx": + # release and vendor is not available in cyclonedx + product_info["product"] = self.parsed_metadata.get("name") + product_info["release"] = "" + product_info["vendor"] = "" + elif self.vextype == "csaf": + csaf_product = self.parsed_product.get("CSAFPID_0001", {}) + if csaf_product: + product_info["product"] = csaf_product.get("product") + product_info["release"] = csaf_product.get("version") + product_info["vendor"] = csaf_product.get("vendor") + elif self.vextype == "openvex": + # product and release is not available in openvex + product_info["product"] = "" + product_info["release"] = "" + product_info["vendor"] = self.parsed_metadata.get("author") + self.vex_product_info = product_info + + def __process_metadata(self, metadata) -> None: self.parsed_metadata = metadata - def process_product(self, product) -> None: + def __process_product(self, product) -> None: self.parsed_product = product - def process_vulnerabilities(self, vulnerabilities) -> None: + def __process_vulnerabilities(self, vulnerabilities) -> None: """ "processes the vulnerabilities and extracts the necessary fields from the vulnerability.""" # for now cyclonedx is supported with minor tweaks other will be supported later for vuln in vulnerabilities: diff --git a/test/sbom/test_triage_cyclonedx_sbom.json b/test/sbom/test_triage_cyclonedx_sbom.json new file mode 100644 index 0000000000..15f7c7d1bd --- /dev/null +++ b/test/sbom/test_triage_cyclonedx_sbom.json @@ -0,0 +1,87 @@ +{ + "$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "serialNumber": "urn:uuid:614e1a9d-616f-4f18-88ee-069127a2c271", + "version": 1, + "metadata": { + "timestamp": "2024-08-07T20:26:22Z", + "tools": { + "components": [ + { + "name": "cve-bin-tool", + "version": "3.3.1dev0", + "type": "application" + } + ] + }, + "component": { + "type": "application", + "bom-ref": "CDXRef-DOCUMENT", + "name": "SBOM_CVEBINTOOL-pubspec-lock" + } + }, + "components": [ + { + "type": "application", + "bom-ref": "1-CVEBINTOOL-pubspec-lock", + "name": "CVEBINTOOL-pubspec-lock", + "externalReferences": [ + { + "url": "pubspec.lock", + "type": "distribution", + "comment": "Download location for component" + } + ] + }, + { + "type": "library", + "bom-ref": "2-archive", + "name": "archive", + "version": "3.3.7", + "supplier": { + "name": "archive project" + }, + "cpe": "cpe:/a:archive_project:archive:3.3.7", + "evidence": { + "occurrences": [ + { + "location": "pubspec.lock" + } + ] + } + }, + { + "type": "library", + "bom-ref": "3-dio", + "name": "dio", + "version": "4.0.0", + "supplier": { + "name": "flutterchina" + }, + "cpe": "cpe:/a:flutterchina:dio:4.0.0", + "evidence": { + "occurrences": [ + { + "location": "pubspec.lock" + } + ] + } + } + ], + "dependencies": [ + { + "ref": "CDXRef-DOCUMENT", + "dependsOn": [ + "1-CVEBINTOOL-pubspec-lock" + ] + }, + { + "ref": "1-CVEBINTOOL-pubspec-lock", + "dependsOn": [ + "2-archive", + "3-dio" + ] + } + ] +} diff --git a/test/test_input_engine.py b/test/test_input_engine.py index 87a4eda9eb..0d36911331 100644 --- a/test/test_input_engine.py +++ b/test/test_input_engine.py @@ -3,7 +3,6 @@ import re from ast import literal_eval -from collections import defaultdict from pathlib import Path import pytest @@ -59,148 +58,6 @@ class TestInputEngine: "paths": {""}, }, } - VEX_TRIAGE_DATA = { - ProductInfo("d.r.commander", "libjpeg-turbo", "2.0.1", "location/to/product"): { - "CVE-2018-19664": { - "comments": "High priority need to resolve fast", - "remarks": Remarks.Confirmed, - "justification": "protected_by_compiler", - "response": ["will_not_fix"], - "severity": "CRITICAL", - }, - "paths": {}, - }, - ProductInfo("gnu", "glibc", "2.33", "location/to/product"): { - "CVE-2021-1234": { - "comments": "", - "remarks": Remarks.Unexplored, - "response": ["workaround_available", "update"], - "severity": "HIGH", - }, - "paths": {}, - }, - } - # cyclonedx currently doesn't have vendors - VEX_TRIAGE_DATA_CYCLONEDX = { - ProductInfo("UNKNOWN", "libjpeg-turbo", "2.0.1", "location/to/product"): { - "CVE-2018-19664": { - "comments": "High priority need to resolve fast", - "remarks": Remarks.Confirmed, - "response": [], - "severity": "CRITICAL", - }, - "paths": {}, - }, - ProductInfo("UNKNOWN", "glibc", "2.33", "location/to/product"): { - "CVE-2021-1234": { - "comments": "", - "remarks": Remarks.Unexplored, - "response": [], - "severity": "HIGH", - }, - "paths": {}, - }, - } - VEX_TRIAGE_DATA_CYCLONEDX_CASE13 = { - ProductInfo( - vendor="UNKNOWN", - product="acme-product", - version="1", - location="location/to/product", - ): { - "CVE-2020-25649": { - "comments": "Automated " - "dataflow " - "analysis " - "and " - "manual " - "code " - "review " - "indicates " - "that " - "the " - "vulnerable " - "code " - "is " - "not " - "reachable, " - "either " - "directly " - "or " - "indirectly.", - "justification": "code_not_reachable", - "remarks": Remarks.NotAffected, - "response": ["will_not_fix", "update"], - "severity": "NONE", - }, - "paths": {}, - }, - ProductInfo( - vendor="UNKNOWN", - product="acme-product", - version="2", - location="location/to/product", - ): { - "CVE-2020-25649": { - "comments": "Automated " - "dataflow " - "analysis " - "and " - "manual " - "code " - "review " - "indicates " - "that " - "the " - "vulnerable " - "code " - "is " - "not " - "reachable, " - "either " - "directly " - "or " - "indirectly.", - "justification": "code_not_reachable", - "remarks": Remarks.NotAffected, - "response": ["will_not_fix", "update"], - "severity": "NONE", - }, - "paths": {}, - }, - ProductInfo( - vendor="UNKNOWN", - product="acme-product", - version="3", - location="location/to/product", - ): { - "CVE-2020-25649": { - "comments": "Automated " - "dataflow " - "analysis " - "and " - "manual " - "code " - "review " - "indicates " - "that " - "the " - "vulnerable " - "code " - "is " - "not " - "reachable, " - "either " - "directly " - "or " - "indirectly.", - "remarks": Remarks.Confirmed, - "response": None, - }, - "paths": {}, - }, - } - MISSING_FIELD_REGEX = re.compile( r"({[' ,](([a-z])+[' ,]{1,4})+}) are required fields" ) @@ -268,65 +125,3 @@ def test_valid_file(self, filepath, parsed_data): print("Parsed Data Actual:", parsed_data_actual) print("Expected Data:", parsed_data) assert parsed_data_actual[product_info] == expected_data - - @pytest.mark.parametrize( - "filepath, parsed_data", - ( - (str(VEX_PATH / "test_triage.vex"), VEX_TRIAGE_DATA), - ( - str(VEX_PATH / "test_triage_cyclonedx_case13.vex"), - VEX_TRIAGE_DATA_CYCLONEDX_CASE13, - ), - (str(VEX_PATH / "test_triage_cyclonedx.vex"), VEX_TRIAGE_DATA_CYCLONEDX), - (str(VEX_PATH / "bad.vex"), defaultdict(dict)), - ), - ) - def test_vex_file(self, filepath, parsed_data): - input_engine = InputEngine(filepath, error_mode=ErrorMode.FullTrace) - assert dict(input_engine.parse_input()) == parsed_data - - @pytest.mark.parametrize( - "product, product_result", - ( - ("gcc", True), - ("not_a_bad%product", True), - ("12!", False), - ("!Superproduct", False), - ), - ) - def test_valid_product_name(self, product, product_result): - input_engine = InputEngine("temp.txt", error_mode=ErrorMode.FullTrace) - assert input_engine.validate_product(product) == product_result - - @pytest.mark.parametrize( - "version", - ( - "sky%2fx6069_trx_l601_sky%2fx6069_trx_l601_sky%3a6.0%2fmra58k%2f1482897127%3auser%2frelease-keys", - "v4.02.15%282335dn_mfp%29_11-22-2010", - "_", - "-", - "y", - "2024-01-23", - ), - ) - def test_cpe_versions(self, version): - # Based on the National Vulnerability Database (NVD) - # official-cpe-dictionary_v2.3.xml (2024-02-28T04:51:31.141Z) the - # following are possible characters is a version string: [a-z0-9.%-_] - input_engine = InputEngine("temp.txt", error_mode=ErrorMode.FullTrace) - vex = { - "vulnerabilities": [ - { - "id": "CVE-2018-15007", - "analysis": { - "state": "not_affected", - "response": [], - "justification": "", - "detail": "1", - }, - "affects": [{"ref": f"urn:cbt:1/vendor#product:{version}"}], - } - ] - } - input_engine.input_vex_cyclone_dx(vex) - assert list(input_engine.parsed_data.keys())[0].version == version diff --git a/test/test_requirements.py b/test/test_requirements.py index dc11fd3ad3..e3d287051f 100644 --- a/test/test_requirements.py +++ b/test/test_requirements.py @@ -132,7 +132,7 @@ def test_requirements(): "cve_bin_tool.cli", "--input-file", SCAN_CSV, - "--triage-input-file", + "--vex-file", TRIAGE_JSON, "--format", "json", diff --git a/test/test_vex.py b/test/test_vex.py index aec1910fa6..27fbbc68b9 100644 --- a/test/test_vex.py +++ b/test/test_vex.py @@ -1,6 +1,8 @@ # Copyright (C) 2021 Intel Corporation # SPDX-License-Identifier: GPL-3.0-or-later import json +import subprocess +import tempfile import unittest from pathlib import Path @@ -10,8 +12,11 @@ from cve_bin_tool.vex_manager.generate import VEXGenerate from cve_bin_tool.vex_manager.parse import VEXParse +TEMP_DIR = Path(tempfile.mkdtemp(prefix="test_triage-")) TEST_DIR = Path(__file__).parent.resolve() VEX_PATH = TEST_DIR / "vex" +SBOM_PATH = TEST_DIR / "sbom" +OUTPUT_JSON = str(TEMP_DIR / "test_triage_output.json") class TestVexGeneration(unittest.TestCase): @@ -258,5 +263,71 @@ def test_parse_openvex(self, vex_format, vex_filename, expected_parsed_data): assert parsed_data == expected_parsed_data +class TestTriage: + """Test triage functionality""" + + TEST_SBOM = str(SBOM_PATH / "test_triage_cyclonedx_sbom.json") + TEST_VEX = str(VEX_PATH / "test_triage_cyclonedx_vex.json") + + def test_triage(self): + """Test triage functionality""" + subprocess.run( + [ + "python", + "-m", + "cve_bin_tool.cli", + "--sbom", + "cyclonedx", + "--sbom-file", + self.TEST_SBOM, + "--vex-file", + self.TEST_VEX, + "--format", + "json", + "--output-file", + OUTPUT_JSON, + ] + ) + + with open(OUTPUT_JSON) as f: + output_json = json.load(f) + assert len(output_json) >= 1 + for output in output_json: + if output.get("cve_number", "") == "CVE-2023-39137": + assert output["remarks"] == "NotAffected" + else: + assert output["remarks"] == "NewFound" + Path(OUTPUT_JSON).unlink() + + def test_filter_triage(self): + """Test filter triage functionality""" + subprocess.run( + [ + "python", + "-m", + "cve_bin_tool.cli", + "--filter-triage", + "--sbom", + "cyclonedx", + "--sbom-file", + self.TEST_SBOM, + "--vex-file", + self.TEST_VEX, + "--format", + "json", + "--output-file", + OUTPUT_JSON, + ] + ) + + with open(OUTPUT_JSON) as f: + output_json = json.load(f) + assert len(output_json) >= 1 + print("Output JSON:", output_json) + for output in output_json: + assert output.get("cve_number", "") != "CVE-2023-39137" + Path(OUTPUT_JSON).unlink() + + if __name__ == "__main__": unittest.main() diff --git a/test/vex/test_triage_cyclonedx_vex.json b/test/vex/test_triage_cyclonedx_vex.json new file mode 100644 index 0000000000..f7de006003 --- /dev/null +++ b/test/vex/test_triage_cyclonedx_vex.json @@ -0,0 +1,92 @@ +{ + "$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "serialNumber": "urn:uuid:b54e9152-1a62-464d-be3f-c0149f07bf51", + "version": 1, + "metadata": { + "timestamp": "2024-08-07T20:41:52Z", + "tools": { + "components": [ + { + "name": "lib4vex", + "version": "0.1.0", + "type": "application" + } + ] + }, + "properties": [ + { + "name": "Revision_1", + "value": "Initial version" + } + ], + "component": { + "type": "application", + "bom-ref": "CDXRef-DOCUMENT", + "name": "myapp" + } + }, + "vulnerabilities": [ + { + "bom-ref": "archive@3.3.7", + "id": "CVE-2023-39137", + "source": { + "name": "NVD", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2023-39137" + }, + "description": "An issue in Archive v3.3.7 allows attackers to spoof zip filenames which can lead to inconsistent filename parsing.", + "published": "2024-08-07T20:41:52Z", + "updated": "2024-08-07T20:41:52Z", + "analysis": { + "state": "not_affected", + "detail": "" + }, + "affects": [ + { + "ref": "urn:cbt:1/archive_project#archive:3.3.7" + } + ] + }, + { + "bom-ref": "archive@3.3.7", + "id": "CVE-2023-39139", + "source": { + "name": "NVD", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2023-39139" + }, + "description": "An issue in Archive v3.3.7 allows attackers to execute a path traversal via extracting a crafted zip file.", + "published": "2024-08-07T20:41:52Z", + "updated": "2024-08-07T20:41:52Z", + "analysis": { + "state": "in_triage", + "detail": "" + }, + "affects": [ + { + "ref": "urn:cbt:1/archive_project#archive:3.3.7" + } + ] + }, + { + "bom-ref": "dio@4.0.0", + "id": "CVE-2021-31402", + "source": { + "name": "NVD", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2021-31402" + }, + "description": "The dio package 4.0.0 for Dart allows CRLF injection if the attacker controls the HTTP method string, a different vulnerability than CVE-2020-35669.", + "published": "2024-08-07T20:41:52Z", + "updated": "2024-08-07T20:41:52Z", + "analysis": { + "state": "in_triage", + "detail": "" + }, + "affects": [ + { + "ref": "urn:cbt:1/flutterchina#dio:4.0.0" + } + ] + } + ] +}