Skip to content

Commit

Permalink
Feat: Compare component property/license/hash lists when using --allo…
Browse files Browse the repository at this point in the history
…w-new-data. (#23)

Signed-off-by: Caroline Russell <caroline@appthreat.dev>
  • Loading branch information
cerrussell authored Jun 10, 2024
1 parent 08277f3 commit fae33b0
Show file tree
Hide file tree
Showing 4 changed files with 124 additions and 45 deletions.
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
87 changes: 45 additions & 42 deletions custom_json_diff/custom_diff_classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)

Expand All @@ -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)
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand All @@ -307,25 +310,25 @@ 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
if bom_1.bom_ref and bom_1.bom_ref != bom_2.bom_ref:
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


Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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" },
Expand Down
75 changes: 75 additions & 0 deletions test/test_bom_diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

0 comments on commit fae33b0

Please sign in to comment.