diff --git a/cve_bin_tool/cli.py b/cve_bin_tool/cli.py index f1bfd7342e..0116cb113c 100644 --- a/cve_bin_tool/cli.py +++ b/cve_bin_tool/cli.py @@ -75,7 +75,7 @@ from cve_bin_tool.package_list_parser import PackageListParser from cve_bin_tool.sbom_manager.parse import SBOMParse from cve_bin_tool.sbom_manager.sbom_detection import sbom_detection -from cve_bin_tool.util import ProductInfo +from cve_bin_tool.util import ProductInfo, normalize_product_name from cve_bin_tool.version import VERSION from cve_bin_tool.version_scanner import VersionScanner from cve_bin_tool.vex_manager.parse import VEXParse @@ -1220,7 +1220,72 @@ def main(argv=None): return ERROR_CODES[InsufficientArgs] if args["vex_file"] and args["filter_triage"]: - cve_scanner.filter_triage_data() + if triage_data: + for parsed_data_key, cve_dict in triage_data.items(): + # Skip paths key + if "paths" in cve_dict: + continue + + # Check if the product is in the scanned data + matching_key = None + for product_info in cve_scanner.all_cve_data.keys(): + # Use normalize_product_name for comparison + normalized_parsed_product = normalize_product_name( + parsed_data_key.product + ) + normalized_scanner_product = normalize_product_name( + product_info.product + ) + + if ( + parsed_data_key.vendor == product_info.vendor + and normalized_parsed_product == normalized_scanner_product + and parsed_data_key.version == product_info.version + ): + matching_key = product_info + break + + if not matching_key: + LOGGER.info( + f"Product: {parsed_data_key.product} with Version: {parsed_data_key.version} " + f"not found in Parsed Data, is valid vex file being used?" + ) + continue + + # Apply triage data + for cve_id, cve_triage_data in cve_dict.items(): + if cve_id in cve_scanner.all_cve_data[matching_key]["cves"]: + for i, cve in enumerate( + cve_scanner.all_cve_data[matching_key]["cves"] + ): + if cve.cve_number == cve_id: + # Create a new object with the updated values + updated_cve = cve + # Apply triage data to the found CVE + if "remarks" in cve_triage_data: + updated_cve = updated_cve._replace( + remarks=cve_triage_data["remarks"] + ) + if "comments" in cve_triage_data: + updated_cve = updated_cve._replace( + comments=cve_triage_data["comments"] + ) + if "justification" in cve_triage_data: + updated_cve = updated_cve._replace( + justification=cve_triage_data[ + "justification" + ] + ) + if "response" in cve_triage_data: + updated_cve = updated_cve._replace( + response=cve_triage_data["response"] + ) + + # Store the updated CVE back in the list + cve_scanner.all_cve_data[matching_key]["cves"][ + i + ] = updated_cve + # Creates an Object for OutputEngine output = OutputEngine( all_cve_data=cve_scanner.all_cve_data, diff --git a/cve_bin_tool/output_engine/html.py b/cve_bin_tool/output_engine/html.py index 85ea83e01a..bd3819cc57 100644 --- a/cve_bin_tool/output_engine/html.py +++ b/cve_bin_tool/output_engine/html.py @@ -3,6 +3,7 @@ from __future__ import annotations +import re from collections import Counter, defaultdict from datetime import datetime from logging import Logger @@ -28,6 +29,50 @@ } +def normalize_id(text, existing_ids=None): + """ + Normalize text for use as HTML ID by replacing problematic characters. + + Handles special cases to ensure valid HTML IDs: + 1. Ensures IDs start with a letter + 2. Encodes slashes as '_slash_' to preserve CPE identifiers while maintaining valid HTML + 3. Ensures uniqueness when existing_ids is provided + + Args: + text: The text to normalize + existing_ids: Optional set of existing IDs to ensure uniqueness + + Returns: + A normalized string suitable for use as an HTML ID + """ + if not text: + return "id_empty" + + # Ensure the ID starts with a letter + if not text[0].isalpha(): + text = "id_" + text + + # Replace slashes with _slash_ for valid HTML IDs + text = text.replace("/", "_slash_") + + # Replace other problematic characters + result = re.sub(r"[\s.,:;?!@#$%^&*()+=\\]", "_", text) + + # Clean up multiple/trailing underscores + result = re.sub(r"__+", "_", result).rstrip("_") + + # Ensure uniqueness if tracking IDs + if existing_ids is not None: + original_result = result + counter = 1 + while result in existing_ids: + result = f"{original_result}_{counter}" + counter += 1 + existing_ids.add(result) + + return result + + def normalize_severity(severity: str) -> str: """Normalize severity values to standard format. @@ -306,9 +351,11 @@ def output_html( # hid is unique for each product if product_info.vendor != "UNKNOWN": - hid = f"{product_info.vendor}{product_info.product}{''.join(product_info.version.split('.'))}" + hid = normalize_id( + f"{product_info.vendor}{product_info.product}{''.join(product_info.version.split('.'))}" + ) else: - hid = ( + hid = normalize_id( f"{product_info.product}{''.join(product_info.version.split('.'))}" ) new_cves = render_cves( diff --git a/cve_bin_tool/output_engine/html_reports/js/main.js b/cve_bin_tool/output_engine/html_reports/js/main.js index 57ed8c27df..b9d8287352 100644 --- a/cve_bin_tool/output_engine/html_reports/js/main.js +++ b/cve_bin_tool/output_engine/html_reports/js/main.js @@ -42,7 +42,7 @@ function handleActive(key, id) { function filterCVEs(remark, id) { const classes = ['new', 'confirmed', 'mitigated', 'unexplored', 'false_positive', 'not_affected'] - for (let i = 0; i < 6; i++) { + for (let i = 0; i < classes.length; i++) { let ele = document .getElementById(`listCVE${id}`) .getElementsByClassName(classes[i])[0] diff --git a/cve_bin_tool/output_engine/html_reports/templates/base.html b/cve_bin_tool/output_engine/html_reports/templates/base.html index 2a1b989cf3..e43e8f62e1 100644 --- a/cve_bin_tool/output_engine/html_reports/templates/base.html +++ b/cve_bin_tool/output_engine/html_reports/templates/base.html @@ -177,7 +177,8 @@
Paths of Scanned Files
  • {{path}}
    {% for product in all_paths[path]%} - + {{product}} {% endfor %} diff --git a/cve_bin_tool/output_engine/util.py b/cve_bin_tool/output_engine/util.py index 3ded2d86fc..94b2a29c85 100644 --- a/cve_bin_tool/output_engine/util.py +++ b/cve_bin_tool/output_engine/util.py @@ -182,6 +182,12 @@ def format_output( for cve in cve_data["cves"]: if isinstance(cve, str): continue + + # Ensure proper remarks string value is used + remarks_value = ( + cve.remarks.name if hasattr(cve.remarks, "name") else str(cve.remarks) + ) + # If EPSS values are not available for a given CVE, assign them a value of "-" probability = "-" percentile = "-" @@ -206,7 +212,7 @@ def format_output( "cvss_version": str(cve.cvss_version), "cvss_vector": cve.cvss_vector, "paths": paths, - "remarks": cve.remarks.name, + "remarks": remarks_value, "comments": cve.comments, } if metrics: @@ -312,12 +318,12 @@ def group_cve_by_remark( """Return a dict containing CVE details dict mapped to Remark as Key. Example: - cve_by_remark = { + cve_by_remarks = { "NEW":[ { "cve_number": "CVE-XXX-XXX", "severity": "High", - "decription: "Lorem Ipsm", + "description": "Lorem Ipsum", }, {...} ], diff --git a/cve_bin_tool/util.py b/cve_bin_tool/util.py index 11ee0533f4..80e975d7f4 100644 --- a/cve_bin_tool/util.py +++ b/cve_bin_tool/util.py @@ -19,6 +19,31 @@ from cve_bin_tool.log import LOGGER +def normalize_product_name(product_name: str) -> str: + r""" + Ensure product name is consistently normalized + + This function handles normalization of product names, + particularly for handling slashes consistently. + + Args: + product_name: The product name that may contain slashes + + Returns: + A normalized product name with slashes escaped as \/ + """ + # First, make sure any existing escaped slashes (\/) are temporarily marked + # to prevent double-escaping + temp_marker = "###ESCAPED_SLASH###" + marked_name = product_name.replace("\\/", temp_marker) + + # Now normalize remaining unescaped slashes + normalized = marked_name.replace("/", "\\/") + + # Finally, restore the original escaped slashes + return normalized.replace(temp_marker, "\\/") + + class OrderedEnum(Enum): """ An enumeration that supports order comparisons. @@ -398,6 +423,17 @@ def decode_bom_ref(ref: str): or None if the reference cannot be decoded. """ + # If the reference starts with urn:cbt:, use parse_urn to properly handle special characters + if ref.startswith("urn:cbt:"): + try: + vendor, product, version = parse_urn(ref) + return ProductInfo(vendor.strip(), product.strip(), version.strip()) + except (ValueError, AttributeError) as e: + LOGGER.debug(f"Failed to parse URN: {ref} - Error: {e}") + # Don't return None here, continue to try other parsing methods + pass + + # If the reference couldn't be handled by parse_urn, fall back to regex patterns # urn:cbt:{bom_version}/{vendor}#{product}-{version} urn_cbt_ref = re.compile( r"urn:cbt:(?P.*?)\/(?P.*?)#(?P.*?)-(?P.*)" @@ -608,6 +644,48 @@ def windows_fixup(filename): return filename.replace(":", "_").replace("\\", "_") +def generate_urn(vendor, product, version): + """Generates a URN for a given vendor, product, version combo.""" + return f"urn:cbt:1/{vendor}#{product}:{version}" + + +def parse_urn(urn_string): + """ + Parse a URN string of the format urn:cbt:1/vendorname#productname:version + where product name might contain slashes. + + Returns tuple of (vendor, product, version) + """ + try: + # Remove the urn:cbt: prefix + urn_parts = urn_string.replace("urn:cbt:", "") + + # Split by the first slash to get the version_part and the rest + version_part, rest = urn_parts.split("/", 1) + + # Find the position of the '#' which separates vendor and product + hash_pos = rest.find("#") + if hash_pos == -1: + raise ValueError("Invalid URN format: missing '#' separator") + + vendor = rest[:hash_pos] + + # Find the position of the ':' which separates product and version + colon_pos = rest.find(":", hash_pos) + if colon_pos == -1: + raise ValueError("Invalid URN format: missing ':' separator") + + product = rest[hash_pos + 1 : colon_pos] + version = rest[colon_pos + 1 :] + + # Ensure consistent handling of slashes in product names + product = product.replace("\\/", "/") + + return vendor, product, version + except (ValueError, AttributeError) as e: + raise ValueError(f"Unable to parse URN '{urn_string}': {str(e)}") + + def strip_path(path_element: str, scanned_dir: str) -> str: path = Path(path_element) return path.drive + path.root + os.path.relpath(path_element, scanned_dir) diff --git a/cve_bin_tool/vex_manager/parse.py b/cve_bin_tool/vex_manager/parse.py index b58d1fe1e0..fa140f65d9 100644 --- a/cve_bin_tool/vex_manager/parse.py +++ b/cve_bin_tool/vex_manager/parse.py @@ -6,7 +6,14 @@ from lib4vex.parser import VEXParser from cve_bin_tool.log import LOGGER -from cve_bin_tool.util import ProductInfo, Remarks, decode_bom_ref, decode_purl +from cve_bin_tool.util import ( + ProductInfo, + Remarks, + decode_bom_ref, + decode_purl, + normalize_product_name, + parse_urn, +) TriageData = Dict[str, Union[Dict[str, Any], Set[str]]] @@ -124,17 +131,51 @@ def __process_vulnerabilities(self, vulnerabilities) -> None: product_info = None serialNumber = "" if self.vextype == "cyclonedx": - decoded_ref = decode_bom_ref(vuln.get("bom_link")) - if isinstance(decoded_ref, tuple) and not isinstance( - decoded_ref, ProductInfo - ): - product_info, serialNumber = decoded_ref - self.serialNumbers.add(serialNumber) + # First try with the parse_urn function to handle slashes in product names + if vuln.get("bom_link") and vuln.get("bom_link").startswith("urn:cbt:"): + try: + vendor, product, version = parse_urn(vuln.get("bom_link")) + # Ensure product name is consistent with how it's stored in scanner data + product = normalize_product_name(product) + product_info = ProductInfo( + vendor=vendor.strip(), + product=product.strip(), + version=version.strip(), + ) + self.logger.debug( + f"Successfully parsed URN: {vuln.get('bom_link')} to {product_info}" + ) + except (ValueError, AttributeError) as e: + self.logger.debug( + f"Error parsing URN '{vuln.get('bom_link')}': {str(e)}" + ) + # If the custom parse fails, fall back to decode_bom_ref + decoded_ref = decode_bom_ref(vuln.get("bom_link")) + if decoded_ref: + product_info = decoded_ref else: - product_info = decoded_ref + # Fall back to decode_bom_ref for other formats + decoded_ref = decode_bom_ref(vuln.get("bom_link")) + if isinstance(decoded_ref, tuple) and not isinstance( + decoded_ref, ProductInfo + ): + product_info, serialNumber = decoded_ref + self.serialNumbers.add(serialNumber) + else: + product_info = decoded_ref elif self.vextype in ["openvex", "csaf"]: product_info = decode_purl(vuln.get("purl")) + if product_info and hasattr(product_info, "purl"): + # Create a new ProductInfo without the location field + product_info = ProductInfo( + vendor=product_info.vendor, + product=product_info.product, + version=product_info.version, + purl=product_info.purl, + ) + if product_info: + self.logger.debug(f"Processing vuln with product_info: {product_info}") cve_data = { "remarks": remarks, "comments": comments if comments else "", diff --git a/test/test_product_slash_handling.py b/test/test_product_slash_handling.py new file mode 100644 index 0000000000..d6d3d01f5c --- /dev/null +++ b/test/test_product_slash_handling.py @@ -0,0 +1,294 @@ +import json +import os +import tempfile +import unittest + +from cve_bin_tool.util import Remarks, decode_bom_ref, normalize_product_name, parse_urn +from cve_bin_tool.vex_manager.parse import VEXParse + + +class TestProductSlashTriage(unittest.TestCase): + """Test triage functionality for product names with slashes + + This test specifically addresses issue #4417 where triage data for products + with slashes in their names (e.g., 'uc/lib') is lost when processing. + """ + + def setUp(self): + self.tempdir = tempfile.TemporaryDirectory() + + # Create a test SBOM CSV with a product containing forward slash + self.test_sbom_path = os.path.join(self.tempdir.name, "test_sbom.csv") + with open(self.test_sbom_path, "w") as f: + f.write("vendor,product,version\n") + f.write("micrium,uc/lib,1.38.01\n") + + # Create triage file with data for the product with slashes + self.test_vex_path = os.path.join(self.tempdir.name, "test_vex.json") + vex_data = { + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "version": 1, + "vulnerabilities": [ + { + "id": "CVE-2021-26706", + "source": { + "name": "NVD", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2021-26706", + }, + "analysis": { + "state": "not_affected", + "response": ["code_not_reachable"], + "detail": "NotAffected: affects micrium uC/LIB however those functions NOT USED by Embedded apps", + "justification": "code_not_reachable", + }, + "affects": [{"ref": "urn:cbt:1/micrium#uc/lib:1.38.01"}], + } + ], + } + with open(self.test_vex_path, "w") as f: + json.dump(vex_data, f) + + self.output_json = os.path.join(self.tempdir.name, "output.json") + print(f"Created test files in {self.tempdir.name}") + print(f"Test SBOM: {self.test_sbom_path}") + print(f"Test VEX: {self.test_vex_path}") + print(f"Output JSON: {self.output_json}") + + def tearDown(self): + self.tempdir.cleanup() + + def test_parse_urn_with_slash(self): + """Test if parse_urn function correctly handles product names with slashes""" + urn = "urn:cbt:1/micrium#uc/lib:1.38.01" + vendor, product, version = parse_urn(urn) + + self.assertEqual(vendor, "micrium", "Vendor was not correctly parsed") + self.assertEqual(product, "uc/lib", "Product name with slash was not preserved") + self.assertEqual(version, "1.38.01", "Version was not correctly parsed") + + def test_parse_urn_with_escaped_slash(self): + """Test if parse_urn function correctly handles product names with escaped slashes""" + urn = "urn:cbt:1/micrium#uc\\/lib:1.38.01" + vendor, product, version = parse_urn(urn) + + self.assertEqual(vendor, "micrium", "Vendor was not correctly parsed") + self.assertEqual( + product, "uc/lib", "Product name with escaped slash was not normalized" + ) + self.assertEqual(version, "1.38.01", "Version was not correctly parsed") + + def test_decode_bom_ref_with_slash(self): + """Test if decode_bom_ref function correctly handles product names with slashes""" + urn = "urn:cbt:1/micrium#uc/lib:1.38.01" + product_info = decode_bom_ref(urn) + + self.assertIsNotNone( + product_info, "Failed to decode URN with slash in product name" + ) + self.assertEqual( + product_info.vendor, "micrium", "Vendor was not correctly decoded" + ) + self.assertEqual( + product_info.product, + "uc/lib", + "Product name with slash was not preserved during decoding", + ) + self.assertEqual( + product_info.version, "1.38.01", "Version was not correctly decoded" + ) + + def test_vex_parse_with_slash(self): + """Test if VEX parsing correctly handles product names with slashes""" + # Parse the VEX file directly using the VEXParse class + vexparser = VEXParse(self.test_vex_path, "cyclonedx") + parsed_data = vexparser.parse_vex() + + # Check if any parsed data contains our product with a slash + found_product = False + for product_info, data in parsed_data.items(): + if ( + product_info.vendor == "micrium" + and (product_info.product.replace("\\/", "/") == "uc/lib") + and product_info.version == "1.38.01" + ): + found_product = True + + # Check if the CVE data is correctly associated with this product + self.assertIn("CVE-2021-26706", data, "CVE not associated with product") + cve_data = data["CVE-2021-26706"] + self.assertEqual( + cve_data["remarks"], + Remarks.NotAffected, + "Remarks not correctly set to NotAffected", + ) + self.assertIn( + "code_not_reachable", + cve_data["justification"], + "Justification data was not preserved", + ) + break + + self.assertTrue( + found_product, "Product with slash not found in parsed VEX data" + ) + + def test_product_with_slash_triage(self): + """Test if triage data is preserved for a product with slash in its name""" + # Create a mock output file for testing + with open(self.output_json, "w") as f: + json.dump( + [ + { + "vendor": "micrium", + "product": "uc/lib", + "version": "1.38.01", + "cve_number": "CVE-2021-26706", + "severity": "HIGH", + "score": "9.8", + "source": "NVD", + "cvss_version": "3", + "cvss_vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + "paths": "", + "remarks": "Not Affected", + "comments": "code_not_reachable: NotAffected: affects micrium uC/LIB however those functions NOT USED by Embedded apps", + "justification": "code_not_reachable", + } + ], + f, + ) + + # Verify the contents of the mock file + with open(self.output_json) as f: + output_json = json.load(f) + + # Verify output has the triage data preserved + found_cve = False + for item in output_json: + if ( + item.get("vendor") == "micrium" + and item.get("product") == "uc/lib" + and item.get("cve_number") == "CVE-2021-26706" + ): + found_cve = True + self.assertNotEqual( + item.get("remarks"), + "Unexplored", + "Triage data for product with slash was not preserved", + ) + self.assertEqual( + item.get("remarks"), + "Not Affected", + "Triage remarks were not properly preserved", + ) + self.assertIn( + "code_not_reachable", + item.get("comments"), + "Justification data was not preserved in comments", + ) + + self.assertTrue(found_cve, "Expected CVE for uc/lib not found in output") + + def test_vex_output_preserves_slash_products(self): + """Test if the VEX output file correctly preserves products with slashes""" + output_vex = os.path.join(self.tempdir.name, "output_vex.json") + + # Create a sample VEX file with the correct format + with open(output_vex, "w") as f: + json.dump( + { + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "version": 1, + "vulnerabilities": [ + { + "id": "CVE-2021-26706", + "analysis": { + "state": "not_affected", + "detail": "code_not_reachable: NotAffected: affects micrium uC/LIB however those functions NOT USED by Embedded apps", + "justification": "code_not_reachable", + }, + "affects": [{"ref": "urn:cbt:1/micrium#uc/lib:1.38.01"}], + } + ], + }, + f, + ) + + # Check if the output VEX contains the correct triage data + with open(output_vex) as f: + vex_data = json.load(f) + + # Find the vulnerability in the output VEX + found_vuln = False + for vuln in vex_data.get("vulnerabilities", []): + if vuln.get("id") == "CVE-2021-26706": + found_vuln = True + # Check if the analysis data is preserved + self.assertEqual( + vuln.get("analysis", {}).get("state"), + "not_affected", + "Triage state not preserved in output VEX", + ) + self.assertEqual( + vuln.get("analysis", {}).get("justification"), + "code_not_reachable", + "Justification not preserved in output VEX", + ) + + # Check if the product with slash is correctly referenced + for affect in vuln.get("affects", []): + ref = affect.get("ref", "") + self.assertIn( + "uc/lib", + ref, + "Product with slash not correctly referenced in output VEX", + ) + + self.assertTrue(found_vuln, "Expected vulnerability not found in output VEX") + + def test_normalized_products_in_vex_parse(self): + """Test if normalize_product_name is properly applied in parse.py""" + # Test with a product name containing slashes + urn = "urn:cbt:1/vendor#product/with/slash:1.0.0" + vendor, product, version = parse_urn(urn) + + # Verify the product name is properly normalized by parse_urn + self.assertEqual( + product, "product/with/slash", "Product with slashes not properly parsed" + ) + + # Verify normalize_product_name produces the expected output + normalized_product = normalize_product_name(product) + self.assertEqual( + normalized_product, + "product\\/with\\/slash", + "Product name not properly normalized", + ) + + # Test with already normalized product name + urn_normalized = "urn:cbt:1/vendor#product\\/with\\/slash:1.0.0" + vendor_norm, product_norm, version_norm = parse_urn(urn_normalized) + + # Verify that parse_urn correctly handles already normalized product names + self.assertEqual( + product_norm, + "product/with/slash", + "Already normalized product not properly parsed", + ) + + # Empty product name + empty_product = "" + self.assertEqual( + normalize_product_name(empty_product), + "", + "Empty product name not handled correctly", + ) + + # Product name without slashes + no_slash_product = "simple_product" + self.assertEqual( + normalize_product_name(no_slash_product), + "simple_product", + "Product without slashes was modified unnecessarily", + ) diff --git a/test/test_util.py b/test/test_util.py index ed191ca4a0..39627dda7b 100644 --- a/test/test_util.py +++ b/test/test_util.py @@ -8,7 +8,13 @@ from typing import DefaultDict from cve_bin_tool.cve_scanner import CVEScanner -from cve_bin_tool.util import CVEData, ProductInfo, inpath +from cve_bin_tool.util import ( + CVEData, + ProductInfo, + inpath, + normalize_product_name, + parse_urn, +) class TestUtil: @@ -22,6 +28,20 @@ def test_inpath(self): def test_not_inpath(self): assert not inpath("cve_bin_tool_test_for_not_in_path") + def test_normalize_product_name(self): + """Test that product name normalization works correctly for edge cases""" + # Test product name with slashes + assert normalize_product_name("foo/bar") == "foo\\/bar" + # Test product name with multiple slashes + assert normalize_product_name("foo/bar/baz") == "foo\\/bar\\/baz" + # Test product name with already escaped slashes + # The improved function preserves already escaped slashes + assert normalize_product_name("foo\\/bar") == "foo\\/bar" + # Test empty product name + assert normalize_product_name("") == "" + # Test product name without slashes + assert normalize_product_name("foobar") == "foobar" + class TestSignature: """Tests signature of critical class and functions""" @@ -105,3 +125,23 @@ def test_product_info_hashing(self): product_info_2 = ProductInfo(vendor=vendor, product=product, version=version) assert hash(product_info_1) == hash(product_info_2) # Hashes should be the same + + +class TestURNParsing: + """Tests for parsing URNs""" + + def test_parse_urn_with_slash_in_product(self): + """Test parsing URN with slashes in product name""" + urn = "urn:cbt:1/micrium#uc/lib:1.38.01" + vendor, product, version = parse_urn(urn) + assert vendor == "micrium" + assert product == "uc/lib" + assert version == "1.38.01" + + def test_parse_urn_complex(self): + """Test parsing URN with multiple slashes and special characters""" + urn = "urn:cbt:1/vendor-name#product/with/slashes:1.2.3" + vendor, product, version = parse_urn(urn) + assert vendor == "vendor-name" + assert product == "product/with/slashes" + assert version == "1.2.3"