From fae33b0cf0b5b363ad3b9621d597efc52fb9bf90 Mon Sep 17 00:00:00 2001 From: Caroline Russell Date: Mon, 10 Jun 2024 12:39:02 -0400 Subject: [PATCH] Feat: Compare component property/license/hash lists when using --allow-new-data. (#23) Signed-off-by: Caroline Russell --- README.md | 5 +- custom_json_diff/custom_diff_classes.py | 87 +++++++++++++------------ pyproject.toml | 2 +- test/test_bom_diff.py | 75 +++++++++++++++++++++ 4 files changed, 124 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 9dd859b..b1a0927 100644 --- a/README.md +++ b/README.md @@ -99,8 +99,9 @@ is attributable to an updated version. Dependency refs and dependents are compar string removed rather than checking for a newer version. The --allow-new-data option allows for empty fields in the original BOM not to be reported as a -difference when the data is populated in the second specified BOM. This option only applies to the -fields included by default. +difference when the data is populated in the second specified BOM. It also addresses when a field +such as properties is expanded, checking that all original elements are still present but allowing +additional elements in the newer BOM. The --components-only option only analyzes components, not services, dependencies, or other data. diff --git a/custom_json_diff/custom_diff_classes.py b/custom_json_diff/custom_diff_classes.py index 9aaa46f..820362b 100644 --- a/custom_json_diff/custom_diff_classes.py +++ b/custom_json_diff/custom_diff_classes.py @@ -14,24 +14,24 @@ class BomComponent: def __init__(self, comp: Dict, options: "Options"): - self.version = set_version(comp.get("version", ""), options.allow_new_versions) - self.search_key = "" if options.allow_new_data else create_comp_key(comp, options.comp_keys) - self.original_data = comp - self.component_type = comp.get("type", "") - self.options = options - self.name = comp.get("name", "") - self.group = comp.get("group", "") - self.publisher = comp.get("publisher", "") self.author = comp.get("author", "") self.bom_ref = comp.get("bom-ref", "") - self.purl = comp.get("purl", "") - self.properties = comp.get("properties", {}) + self.component_type = comp.get("type", "") + self.description = comp.get("description", "") self.evidence = comp.get("evidence", {}) - self.licenses = comp.get("licenses", []) + self.external_references = comp.get("externalReferences", []) + self.group = comp.get("group", "") self.hashes = comp.get("hashes", []) + self.licenses = comp.get("licenses", []) + self.name = comp.get("name", "") + self.options = options + self.original_data = comp + self.properties = comp.get("properties", []) + self.publisher = comp.get("publisher", "") + self.purl = comp.get("purl", "") self.scope = comp.get("scope", []) - self.description = comp.get("description", "") - self.external_references = comp.get("externalReferences", []) + self.search_key = "" if options.allow_new_data else create_comp_key(comp, options.comp_keys) + self.version = set_version(comp.get("version", ""), options.allow_new_versions) def __eq__(self, other): if self.options.allow_new_versions and self.options.allow_new_data: @@ -49,13 +49,12 @@ def __ne__(self, other): def _advanced_eq(self, other): if self.original_data == other.original_data: return True - if self.options.allow_new_data: - if self.options.bom_num == 2: - return check_for_empty_eq(other, self) + if self.options.bom_num == 1: return check_for_empty_eq(self, other) - return False + return check_for_empty_eq(other, self) def _check_list_eq(self, other): + # Since these elements have been sorted, we can compare them directly return (self.properties == other.properties and self.evidence == other.evidence and self.hashes == other.hashes and self.licenses == other.licenses) @@ -65,22 +64,6 @@ def _check_new_versions(self, other): return self.version >= other.version -class BomService: - def __init__(self, svc: Dict, options: "Options"): - self.search_key = "" if options.allow_new_data else create_comp_key(svc, options.svc_keys) - self.original_data = svc - self.name = svc.get("name", "") - self.endpoints = svc.get("endpoints", []) - self.authenticated = svc.get("authenticated", "") - self.x_trust_boundary = svc.get("x-trust-boundary", "") - - def __eq__(self, other): - return self.search_key == other.search_key and self.endpoints == other.endpoints - - def __ne__(self, other): - return not self == other - - class BomDependency: def __init__(self, dep: Dict, options: "Options"): self.ref, self.deps = import_bom_dependency(dep, options.allow_new_versions) @@ -189,6 +172,22 @@ def to_summary(self) -> Dict: return summary +class BomService: + def __init__(self, svc: Dict, options: "Options"): + self.search_key = "" if options.allow_new_data else create_comp_key(svc, options.svc_keys) + self.original_data = svc + self.name = svc.get("name", "") + self.endpoints = svc.get("endpoints", []) + self.authenticated = svc.get("authenticated", "") + self.x_trust_boundary = svc.get("x-trust-boundary", "") + + def __eq__(self, other): + return self.search_key == other.search_key and self.endpoints == other.endpoints + + def __ne__(self, other): + return not self == other + + class FlatDicts: def __init__(self, elements: Dict | List): @@ -296,6 +295,10 @@ def __post_init__(self): self.svc_keys = list(set(self.svc_keys)) +def advanced_eq_lists(bom_1: List, bom_2: List) -> bool: + return False if len(bom_1) > len(bom_2) else all(i in bom_2 for i in bom_1) + + def check_for_empty_eq(bom_1: BomComponent, bom_2: BomComponent) -> bool: if bom_1.name and bom_1.name != bom_2.name: return False @@ -307,16 +310,8 @@ def check_for_empty_eq(bom_1: BomComponent, bom_2: BomComponent) -> bool: return False if bom_1.component_type and bom_1.component_type != bom_2.component_type: return False - if bom_1.properties and bom_1.properties != bom_2.properties: - return False - if bom_1.evidence and bom_1.evidence != bom_2.evidence: - return False - if bom_1.licenses and bom_1.licenses != bom_2.licenses: - return False if bom_1.scope and bom_1.scope != bom_2.scope: return False - if bom_1.external_references and bom_1.external_references != bom_2.external_references: - return False if not bom_1.options.allow_new_versions: if bom_1.version and bom_1.version != bom_2.version: return False @@ -324,8 +319,16 @@ def check_for_empty_eq(bom_1: BomComponent, bom_2: BomComponent) -> bool: return False if bom_1.purl and bom_1.purl != bom_2.purl: return False - if bom_1.hashes and bom_1.hashes != bom_2.hashes: + if not advanced_eq_lists(bom_1.hashes, bom_2.hashes): return False + if not advanced_eq_lists(bom_1.properties, bom_2.properties): + return False + if not advanced_eq_lists(bom_1.licenses, bom_2.licenses): + return False + if not advanced_eq_lists(bom_1.external_references, bom_2.external_references): + return False + if bom_1.evidence and bom_1.evidence != bom_2.evidence: + return False return not bom_1.description or bom_1.description == bom_2.description diff --git a/pyproject.toml b/pyproject.toml index 4aa4a5c..271d570 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "custom-json-diff" -version = "1.3.0" +version = "1.4.0" description = "Custom JSON and CycloneDx BOM diffing and comparison tool." authors = [ { name = "Caroline Russell", email = "caroline@appthreat.dev" }, diff --git a/test/test_bom_diff.py b/test/test_bom_diff.py index 454af4c..d170518 100644 --- a/test/test_bom_diff.py +++ b/test/test_bom_diff.py @@ -236,6 +236,74 @@ def bom_dicts_8(): return bom_dicts +@pytest.fixture +def bom_component_9(): + return BomComponent( + { + "bom-ref": "pkg:maven/io.netty/netty-resolver-dns@4.1.110.Final-SNAPSHOT?type=jar", + "group": "io.netty", + "name": "netty-resolver-dns", + "properties": [ + { + "name": "SrcFile", + "value": "/home/runner/work/src_repos/java/netty/pom.xml" + }, + { + "name": "SrcFile", + "value": "/home/runner/work/src_repos/java/netty/resolver-dns-native-macos/pom.xml" + }, + { + "name": "SrcFile", + "value": "/home/runner/work/src_repos/java/netty/resolver-dns-classes-macos/pom.xml" + }, + ], + "publisher": "The Netty Project", + "purl": "pkg:maven/io.netty/netty-resolver-dns@4.1.110.Final-SNAPSHOT?type=jar", + "scope": "required", + "type": "framework", + "version": "4.1.110.Final-SNAPSHOT" + }, Options(file_1="bom_1.json", file_2="bom_2.json", bom_diff=True, allow_new_data=True, bom_num=1) + ) + + +@pytest.fixture +def bom_component_10(): + return BomComponent( + { + "bom-ref": "pkg:maven/io.netty/netty-resolver-dns@4.1.110.Final-SNAPSHOT?type=jar", + "group": "io.netty", + "name": "netty-resolver-dns", + "properties": [ + { + "name": "SrcFile", + "value": "/home/runner/work/src_repos/java/netty/pom.xml" + }, + { + "name": "SrcFile", + "value": "/home/runner/work/src_repos/java/netty/resolver-dns-native-macos/pom.xml" + }, + { + "name": "SrcFile", + "value": "/home/runner/work/src_repos/java/netty/resolver-dns-classes-macos/pom.xml" + }, + { + "name": "SrcFile", + "value": "/home/runner/work/src_repos/java/netty/handler-ssl-ocsp/pom.xml" + }, + { + "name": "SrcFile", + "value": "/home/runner/work/src_repos/java/netty/all/pom.xml" + } + ], + "publisher": "The Netty Project", + "purl": "pkg:maven/io.netty/netty-resolver-dns@4.1.110.Final-SNAPSHOT?type=jar", + "scope": "required", + "type": "framework", + "version": "4.1.110.Final-SNAPSHOT" + }, Options(file_1="bom_1.json", file_2="bom_2.json", bom_diff=True, allow_new_data=True, bom_num=2) + ) + + @pytest.fixture def results(): with open("test/test_data.json", "r", encoding="utf-8") as f: @@ -264,3 +332,10 @@ def test_bom_diff_options(results, bom_dicts_1, bom_dicts_2, bom_dicts_3, bom_di result_summary = perform_bom_diff(bom_dicts_5, bom_dicts_6) assert result_summary == results["result_3"] + +def test_bom_components_lists(bom_component_9, bom_component_10): + # tests allow_new_data with component lists of dicts + assert bom_component_9 == bom_component_10 + bom_component_9.options.bom_num = 2 + bom_component_10.options.bom_num = 1 + assert bom_component_9 != bom_component_10