Skip to content

Commit

Permalink
feat: support complete model for bom.metadata (#162)
Browse files Browse the repository at this point in the history
* feat: support complete model for `bom.metadata`
fix: JSON comparison in unit tests was broken
chore: corrected some source license headers

Signed-off-by: Paul Horton <[email protected]>
  • Loading branch information
madpah authored Feb 8, 2022
1 parent 142b8bf commit 2938a6c
Show file tree
Hide file tree
Showing 12 changed files with 446 additions and 26 deletions.
111 changes: 100 additions & 11 deletions cyclonedx/model/bom.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from typing import Iterable, Optional, Set
from uuid import uuid4, UUID

from . import ExternalReference, ThisTool, Tool
from . import ExternalReference, OrganizationalContact, OrganizationalEntity, LicenseChoice, Property, ThisTool, Tool
from .component import Component
from .service import Service
from ..parser import BaseParser
Expand All @@ -31,17 +31,40 @@ class BomMetaData:
This is our internal representation of the metadata complex type within the CycloneDX standard.
.. note::
See the CycloneDX Schema for Bom metadata: https://cyclonedx.org/docs/1.3/#type_metadata
See the CycloneDX Schema for Bom metadata: https://cyclonedx.org/docs/1.4/#type_metadata
"""

def __init__(self, *, tools: Optional[Iterable[Tool]] = None) -> None:
def __init__(self, *, tools: Optional[Iterable[Tool]] = None,
authors: Optional[Iterable[OrganizationalContact]] = None, component: Optional[Component] = None,
manufacture: Optional[OrganizationalEntity] = None,
supplier: Optional[OrganizationalEntity] = None,
licenses: Optional[Iterable[LicenseChoice]] = None,
properties: Optional[Iterable[Property]] = None) -> None:
self.timestamp = datetime.now(tz=timezone.utc)
self.tools = set(tools or [])
self.authors = set(authors or [])
self.component = component
self.manufacture = manufacture
self.supplier = supplier
self.licenses = set(licenses or [])
self.properties = set(properties or [])

if not self.tools:
self.tools.add(ThisTool)

self.component: Optional[Component] = None
@property
def timestamp(self) -> datetime:
"""
The date and time (in UTC) when this BomMetaData was created.
Returns:
`datetime` instance in UTC timezone
"""
return self._timestamp

@timestamp.setter
def timestamp(self, timestamp: datetime) -> None:
self._timestamp = timestamp

@property
def tools(self) -> Set[Tool]:
Expand All @@ -58,18 +81,22 @@ def tools(self, tools: Iterable[Tool]) -> None:
self._tools = set(tools)

@property
def timestamp(self) -> datetime:
def authors(self) -> Set[OrganizationalContact]:
"""
The date and time (in UTC) when this BomMetaData was created.
The person(s) who created the BOM.
Authors are common in BOMs created through manual processes.
BOMs created through automated means may not have authors.
Returns:
`datetime` instance in UTC timezone
Set of `OrganizationalContact`
"""
return self._timestamp
return self._authors

@timestamp.setter
def timestamp(self, timestamp: datetime) -> None:
self._timestamp = timestamp
@authors.setter
def authors(self, authors: Iterable[OrganizationalContact]) -> None:
self._authors = set(authors)

@property
def component(self) -> Optional[Component]:
Expand All @@ -95,6 +122,68 @@ def component(self, component: Component) -> None:
"""
self._component = component

@property
def manufacture(self) -> Optional[OrganizationalEntity]:
"""
The organization that manufactured the component that the BOM describes.
Returns:
`OrganizationalEntity` if set else `None`
"""
return self._manufacture

@manufacture.setter
def manufacture(self, manufacture: Optional[OrganizationalEntity]) -> None:
self._manufacture = manufacture

@property
def supplier(self) -> Optional[OrganizationalEntity]:
"""
The organization that supplied the component that the BOM describes.
The supplier may often be the manufacturer, but may also be a distributor or repackager.
Returns:
`OrganizationalEntity` if set else `None`
"""
return self._supplier

@supplier.setter
def supplier(self, supplier: Optional[OrganizationalEntity]) -> None:
self._supplier = supplier

@property
def licenses(self) -> Set[LicenseChoice]:
"""
A optional list of statements about how this BOM is licensed.
Returns:
Set of `LicenseChoice`
"""
return self._licenses

@licenses.setter
def licenses(self, licenses: Iterable[LicenseChoice]) -> None:
self._licenses = set(licenses)

@property
def properties(self) -> Set[Property]:
"""
Provides the ability to document properties in a key/value store. This provides flexibility to include data not
officially supported in the standard without having to use additional namespaces or create extensions.
Property names of interest to the general public are encouraged to be registered in the CycloneDX Property
Taxonomy - https://github.com/CycloneDX/cyclonedx-property-taxonomy. Formal registration is OPTIONAL.
Return:
Set of `Property`
"""
return self._properties

@properties.setter
def properties(self, properties: Iterable[Property]) -> None:
self._properties = set(properties)

def __eq__(self, other: object) -> bool:
if isinstance(other, BomMetaData):
return hash(other) == hash(self)
Expand Down
10 changes: 8 additions & 2 deletions cyclonedx/output/json.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
#
# SPDX-License-Identifier: Apache-2.0
# Copyright (c) OWASP Foundation. All Rights Reserved.

import json
from abc import abstractmethod
from typing import cast, Any, Dict, List, Optional, Union
Expand Down Expand Up @@ -79,13 +78,20 @@ def _specialise_output_for_schema_version(self, bom_json: Dict[Any, Any]) -> str
if not self.bom_supports_metadata():
if 'metadata' in bom_json.keys():
del bom_json['metadata']
elif not self.bom_metadata_supports_tools():

if not self.bom_metadata_supports_tools():
del bom_json['metadata']['tools']
elif not self.bom_metadata_supports_tools_external_references():
for i in range(len(bom_json['metadata']['tools'])):
if 'externalReferences' in bom_json['metadata']['tools'][i].keys():
del bom_json['metadata']['tools'][i]['externalReferences']

if not self.bom_metadata_supports_licenses() and 'licenses' in bom_json['metadata'].keys():
del bom_json['metadata']['licenses']

if not self.bom_metadata_supports_properties() and 'properties' in bom_json['metadata'].keys():
del bom_json['metadata']['properties']

# Iterate Components
if 'components' in bom_json.keys():
for i in range(len(bom_json['components'])):
Expand Down
12 changes: 12 additions & 0 deletions cyclonedx/output/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ def bom_metadata_supports_tools(self) -> bool:
def bom_metadata_supports_tools_external_references(self) -> bool:
return True

def bom_metadata_supports_licenses(self) -> bool:
return True

def bom_metadata_supports_properties(self) -> bool:
return True

def bom_supports_services(self) -> bool:
return True

Expand Down Expand Up @@ -147,6 +153,12 @@ def schema_version_enum(self) -> SchemaVersion:
def bom_metadata_supports_tools_external_references(self) -> bool:
return False

def bom_metadata_supports_licenses(self) -> bool:
return False

def bom_metadata_supports_properties(self) -> bool:
return False

def services_supports_properties(self) -> bool:
return False

Expand Down
24 changes: 24 additions & 0 deletions cyclonedx/output/xml.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,33 @@ def _add_metadata_element(self) -> None:
for tool in bom_metadata.tools:
self._add_tool(parent_element=tools_e, tool=tool)

if bom_metadata.authors:
authors_e = ElementTree.SubElement(metadata_e, 'authors')
for author in bom_metadata.authors:
Xml._add_organizational_contact(
parent_element=authors_e, contact=author, tag_name='author'
)

if bom_metadata.component:
metadata_e.append(self._add_component_element(component=bom_metadata.component))

if bom_metadata.manufacture:
Xml._add_organizational_entity(
parent_element=metadata_e, organization=bom_metadata.manufacture, tag_name='manufacture'
)

if bom_metadata.supplier:
Xml._add_organizational_entity(
parent_element=metadata_e, organization=bom_metadata.supplier, tag_name='supplier'
)

if self.bom_metadata_supports_licenses() and bom_metadata.licenses:
licenses_e = ElementTree.SubElement(metadata_e, 'licenses')
self._add_licenses_to_element(licenses=bom_metadata.licenses, parent_element=licenses_e)

if self.bom_metadata_supports_properties() and bom_metadata.properties:
Xml._add_properties_element(properties=bom_metadata.properties, parent_element=metadata_e)

def _add_component_element(self, component: Component) -> ElementTree.Element:
element_attributes = {'type': component.type.value}
if self.component_supports_bom_ref_attribute() and component.bom_ref:
Expand Down
16 changes: 13 additions & 3 deletions tests/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import sys
import xml.etree.ElementTree
from datetime import datetime, timezone
from typing import Any
from unittest import TestCase
from uuid import uuid4

Expand Down Expand Up @@ -66,10 +67,19 @@ def assertValidAgainstSchema(self, bom_json: str, schema_version: SchemaVersion)
else:
self.assertTrue(True, 'JSON Schema Validation is not possible in Python < 3.7')

@staticmethod
def _sort_json_dict(item: object) -> Any:
if isinstance(item, dict):
return sorted((key, BaseJsonTestCase._sort_json_dict(values)) for key, values in item.items())
if isinstance(item, list):
return sorted(BaseJsonTestCase._sort_json_dict(x) for x in item)
else:
return item

def assertEqualJson(self, a: str, b: str) -> None:
self.assertEqual(
json.dumps(sorted(json.loads(a)), sort_keys=True),
json.dumps(sorted(json.loads(b)), sort_keys=True)
BaseJsonTestCase._sort_json_dict(json.loads(a)),
BaseJsonTestCase._sort_json_dict(json.loads(b))
)

def assertEqualJsonBom(self, a: str, b: str) -> None:
Expand Down Expand Up @@ -123,7 +133,7 @@ def assertValidAgainstSchema(self, bom_xml: str, schema_version: SchemaVersion)
def assertEqualXml(self, a: str, b: str) -> None:
diff_results = main.diff_texts(a, b, diff_options={'F': 0.5})
diff_results = list(filter(lambda o: not isinstance(o, MoveNode), diff_results))
self.assertEqual(len(diff_results), 0)
self.assertEqual(len(diff_results), 0, f'There are XML differences: {diff_results}')

def assertEqualXmlBom(self, a: str, b: str, namespace: str) -> None:
"""
Expand Down
29 changes: 21 additions & 8 deletions tests/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,7 @@ def get_bom_with_component_setuptools_with_vulnerability() -> Bom:
organizations=[
get_org_entity_1()
],
individuals=[
OrganizationalContact(name='A N Other', email='[email protected]', phone='+44 (0)1234 567890'),
]
individuals=[get_org_contact_2()]
),
tools=[
Tool(vendor='CycloneDX', name='cyclonedx-python-lib')
Expand Down Expand Up @@ -148,9 +146,14 @@ def get_bom_with_component_toml_1() -> Bom:

def get_bom_just_complete_metadata() -> Bom:
bom = Bom()
bom.metadata.authors = [get_org_contact_1(), get_org_contact_2()]
bom.metadata.component = Component(
name='cyclonedx-python-lib', version='1.0.0', component_type=ComponentType.LIBRARY
)
bom.metadata.manufacture = get_org_entity_1()
bom.metadata.supplier = get_org_entity_2()
bom.metadata.licenses = [LicenseChoice(license_expression='Commercial')]
bom.metadata.properties = get_properties_1()
return bom


Expand Down Expand Up @@ -326,13 +329,23 @@ def get_issue_2() -> IssueType:
)


def get_org_contact_1() -> OrganizationalContact:
return OrganizationalContact(name='Paul Horton', email='[email protected]')


def get_org_contact_2() -> OrganizationalContact:
return OrganizationalContact(name='A N Other', email='[email protected]', phone='+44 (0)1234 567890')


def get_org_entity_1() -> OrganizationalEntity:
return OrganizationalEntity(
name='CycloneDX', urls=[XsUri('https://cyclonedx.org')], contacts=[
OrganizationalContact(name='Paul Horton', email='[email protected]'),
OrganizationalContact(name='A N Other', email='[email protected]',
phone='+44 (0)1234 567890')
]
name='CycloneDX', urls=[XsUri('https://cyclonedx.org')], contacts=[get_org_contact_1(), get_org_contact_2()]
)


def get_org_entity_2() -> OrganizationalEntity:
return OrganizationalEntity(
name='Cyclone DX', urls=[XsUri('https://cyclonedx.org/')], contacts=[get_org_contact_2()]
)


Expand Down
41 changes: 41 additions & 0 deletions tests/fixtures/json/1.2/bom_with_full_metadata.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,52 @@
"version": "VERSION"
}
],
"authors": [
{
"name": "Paul Horton",
"email": "[email protected]"
},
{
"name": "A N Other",
"email": "[email protected]",
"phone": "+44 (0)1234 567890"
}
],
"component": {
"bom-ref": "0b049d09-64c0-4490-a0f5-c84d9aacf857",
"type": "library",
"name": "cyclonedx-python-lib",
"version": "1.0.0"
},
"manufacture": {
"name": "CycloneDX",
"url": [
"https://cyclonedx.org"
],
"contact": [
{
"name": "Paul Horton",
"email": "[email protected]"
},
{
"name": "A N Other",
"email": "[email protected]",
"phone": "+44 (0)1234 567890"
}
]
},
"supplier": {
"name": "Cyclone DX",
"url": [
"https://cyclonedx.org/"
],
"contact": [
{
"name": "A N Other",
"email": "[email protected]",
"phone": "+44 (0)1234 567890"
}
]
}
},
"components": []
Expand Down
Loading

0 comments on commit 2938a6c

Please sign in to comment.