diff --git a/requirements.txt b/requirements.txt index 503c0347..2eee4954 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,3 +16,4 @@ debian-inspector>=21.5 regex>=2021.7 GitPython~=3.1 prettytable~=2.1 +packageurl-python>=0.9.4 \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index fd2defc3..4c94b82c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -51,6 +51,7 @@ tern.formats = jsonc = tern.formats.json.consumer:JSON yaml = tern.formats.yaml.generator:YAML html = tern.formats.html.generator:HTML + cyclonedxjson = tern.formats.cyclonedx.cyclonedxjson.generator:CycloneDXJSON tern.extensions = cve_bin_tool = tern.extensions.cve_bin_tool.executor:CveBinTool scancode = tern.extensions.scancode.executor:Scancode diff --git a/tern/formats/cyclonedx/__init__.py b/tern/formats/cyclonedx/__init__.py new file mode 100644 index 00000000..a048eda0 --- /dev/null +++ b/tern/formats/cyclonedx/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2019 VMware, Inc. All Rights Reserved. +# SPDX-License-Identifier: BSD-2-Clause diff --git a/tern/formats/cyclonedx/cyclonedx.py b/tern/formats/cyclonedx/cyclonedx.py new file mode 100644 index 00000000..3b6220aa --- /dev/null +++ b/tern/formats/cyclonedx/cyclonedx.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2019-2020 VMware, Inc. All Rights Reserved. +# SPDX-License-Identifier: BSD-2-Clause + +from tern.classes.template import Template + + +class CycloneDX(Template): + ''' This is the CycloneDX Template class + + It provides mappings for the CycloneDX document format ''' + + def file_data(self): + return {} + + def package(self): + return {} + + def image_layer(self): + return {} + + def image(self): + return {} diff --git a/tern/formats/cyclonedx/cyclonedx_common.py b/tern/formats/cyclonedx/cyclonedx_common.py new file mode 100644 index 00000000..5f1f38de --- /dev/null +++ b/tern/formats/cyclonedx/cyclonedx_common.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2021 VMware, Inc. All Rights Reserved. +# SPDX-License-Identifier: BSD-2-Clause + +""" +Common functions that are useful for CycloneDX document creation +""" + +import datetime +import uuid +from tern.utils import general + + +################### +# General Helpers # +################### + + +# document level tool information +metadata_tool = { + 'vendor': 'Tern Tools', + 'name': 'Tern', + 'version': general.get_git_rev_or_version()[1] +} + + +# keys are what Tern uses, values what CycloneDX uses +hash_type_mapping = { + 'md5': 'MD5', + 'sha1': 'SHA-1', + 'sha256': 'SHA-256', +} + + +pkg_format_purl_type_mapping = { + 'deb': 'deb', + 'rpm': 'rpm', + 'apk': 'apk', + 'pip': 'pip', + 'gem': 'gem', + 'npm': 'npm', + 'go.mod': 'go', +} + + +purl_types_with_namespaces = [ + 'deb', + 'rpm', + 'apk', +] + + +# map Tern OS guesses to package URL namespace +os_guess_purl_namespace_mapping = { + 'debian': 'debian', + 'ubuntu': 'ubuntu', + 'alpine linux': 'alpine', + 'centos': 'centos', + 'fedora': 'fedora', + 'opensuse': 'opensuse', + 'rhel': 'rhel', + # Arch Linux + # Photon +} + + +def get_serial_number(): + ''' Return a randomly generated CycloneDX BOM serial number ''' + return 'urn:uuid:' + str(uuid.uuid4()) + + +def get_timestamp(): + ''' Return a timestamp suitable for the BOM timestamp ''' + return datetime.datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ') + + +def get_hash(checksum_type, checksum): + ''' Return a CycloneDX hash object from Tern checksum values ''' + hash_algorithm = hash_type_mapping.get(checksum_type, None) + return None if hash_algorithm is None else {'alg': hash_algorithm, 'content': checksum} + + +def get_property(name, value): + ''' Return a CycloneDX property object ''' + return {'name': name, 'value': value} + + +def get_purl_type(pkg_format): + return pkg_format_purl_type_mapping.get(pkg_format.lower()) + + +def get_purl_namespace(os_guess, pkg_format): + if pkg_format in purl_types_with_namespaces: + for os in os_guess_purl_namespace_mapping: + if os_guess.lower().startswith(os): + return os_guess_purl_namespace_mapping.get(os) + return None + + +def get_os_guess(image_obj): + for layer in image_obj.layers: + if layer.os_guess: + return layer.os_guess + return None + + +def get_license_from_name(name): + return {'license': {'name': name}} diff --git a/tern/formats/cyclonedx/cyclonedxjson/__init__.py b/tern/formats/cyclonedx/cyclonedxjson/__init__.py new file mode 100644 index 00000000..605cea42 --- /dev/null +++ b/tern/formats/cyclonedx/cyclonedxjson/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2021 VMware, Inc. All Rights Reserved. +# SPDX-License-Identifier: BSD-2-Clause diff --git a/tern/formats/cyclonedx/cyclonedxjson/generator.py b/tern/formats/cyclonedx/cyclonedxjson/generator.py new file mode 100644 index 00000000..bef8cb84 --- /dev/null +++ b/tern/formats/cyclonedx/cyclonedxjson/generator.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2021 VMware, Inc. All Rights Reserved. +# SPDX-License-Identifier: BSD-2-Clause + +''' +CycloneDX JSON document generator +''' + +import json +import logging + +from tern.utils import constants +from tern.formats import generator +from tern.formats.cyclonedx.cyclonedx import CycloneDX +from tern.formats.cyclonedx import cyclonedx_common +from tern.formats.cyclonedx.cyclonedxjson import image_helpers as mhelpers +from tern.formats.cyclonedx.cyclonedxjson import package_helpers as phelpers + + +# global logger +logger = logging.getLogger(constants.logger_name) + + +def get_document_dict(image_obj_list, template): # pylint: disable=[unused-argument] + ''' Return document info as a dictionary ''' + docu_dict = { + 'bomFormat': 'CycloneDX', + 'specVersion': '1.3', + 'serialNumber': cyclonedx_common.get_serial_number(), + 'version': 1, + 'metadata': { + 'timestamp': cyclonedx_common.get_timestamp(), + 'tools': [cyclonedx_common.metadata_tool], + }, + 'components': [], + } + + # if representing a single image populate top level BOM metadata component + # else representing multiple images so list them as components + if len(image_obj_list) == 1: + docu_dict['metadata']['component'] = mhelpers.get_image_dict(image_obj_list[0]) + docu_dict['components'] = phelpers.get_packages_list(image_obj_list[0]) + else: + for image_obj in image_obj_list: + image_componet = mhelpers.get_image_dict(image_obj) + image_componet['components'] = phelpers.get_packages_list(image_obj) + docu_dict['components'].append(image_componet) + + return docu_dict + + +class CycloneDXJSON(generator.Generate): + def generate(self, image_obj_list, print_inclusive=False): + ''' Generate a CycloneDX document + The whole document should be stored in a dictionary which can be + converted to JSON and dumped to a file using the write_report function + in report.py. ''' + logger.debug('Generating CycloneDX JSON document...') + + template = CycloneDX() + report = get_document_dict(image_obj_list, template) + + return json.dumps(report, indent=2) diff --git a/tern/formats/cyclonedx/cyclonedxjson/image_helpers.py b/tern/formats/cyclonedx/cyclonedxjson/image_helpers.py new file mode 100644 index 00000000..109faeac --- /dev/null +++ b/tern/formats/cyclonedx/cyclonedxjson/image_helpers.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2021 VMware, Inc. All Rights Reserved. +# SPDX-License-Identifier: BSD-2-Clause + +''' +Helper functions for image level JSON CycloneDX document dictionaries +''' + + +from tern.formats.cyclonedx import cyclonedx_common +from packageurl import PackageURL + + +def get_image_dict(image_obj): + ''' Given an image object return the CycloneDX document dictionary for the + given image. For CycloneDX, the image is a component and hence follows the + JSON spec for components. ''' + image_dict = { + 'type': 'container', + 'scope': 'required', + 'name': image_obj.name, + 'version': image_obj.checksum_type + ':' + image_obj.checksum, + 'hashes': [], + 'properties': [] + } + + purl = PackageURL('docker', None, image_dict['name'], image_dict['version']) + image_dict['purl'] = str(purl) + + if image_obj.repotags: + for repotag in image_obj.repotags: + image_dict['properties'].append(cyclonedx_common.get_property('tern:repotag', repotag)) + + os_guess = cyclonedx_common.get_os_guess(image_obj) + if os_guess: + image_dict['properties'].append(cyclonedx_common.get_property('tern:os_guess', os_guess)) + + cdx_hash = cyclonedx_common.get_hash(image_obj.checksum_type, image_obj.checksum) + if cdx_hash is not None: + image_dict['hashes'].append(cdx_hash) + + return image_dict diff --git a/tern/formats/cyclonedx/cyclonedxjson/package_helpers.py b/tern/formats/cyclonedx/cyclonedxjson/package_helpers.py new file mode 100644 index 00000000..4ff4da23 --- /dev/null +++ b/tern/formats/cyclonedx/cyclonedxjson/package_helpers.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2021 VMware, Inc. All Rights Reserved. +# SPDX-License-Identifier: BSD-2-Clause + +''' +Helper functions for packages in CycloneDX JSON document creation +''' + +from tern.formats.cyclonedx import cyclonedx_common +from packageurl import PackageURL + + +def get_package_dict(os_guess, package): + ''' Given a package format, namespace and package object return a + CycloneDX JSON dictionary representation of the package. ''' + package_dict = { + 'name': package.name, + 'version': package.version, + 'type': 'application', + } + + purl_type = cyclonedx_common.get_purl_type(package.pkg_format) + purl_namespace = cyclonedx_common.get_purl_namespace(os_guess, package.pkg_format) + if purl_type: + purl = PackageURL(purl_type, purl_namespace, package.name, package.version) + package_dict['purl'] = str(purl) + + if package.pkg_license: + package_dict['licenses'] = [cyclonedx_common.get_license_from_name(package.pkg_license)] + + if package.pkg_licenses: + package_dict['evidence'] = {'licenses': []} + for pkg_license in package.pkg_licenses: + package_dict['evidence']['licenses'].append(cyclonedx_common.get_license_from_name(pkg_license)) + + return package_dict + + +def get_packages_list(image_obj): + ''' Given an image object return a list of CycloneDX dictionary + representations for each of the packages in the image ''' + package_dicts = [] + + os_guess = cyclonedx_common.get_os_guess(image_obj) + + for layer_obj in image_obj.layers: + for package in layer_obj.packages: + package_dicts.append(get_package_dict(os_guess, package)) + + return package_dicts