Skip to content

Commit

Permalink
Initial CycloneDX JSON output support
Browse files Browse the repository at this point in the history
- supports single or multiple images in output
- includes basic component information (name, version, hashes, license)
- includes license evidence
- groups components by image
- adds packageurl-python as a dependency

Signed-off-by: Patrick Dwyer <patrick.dwyer@owasp.org>
  • Loading branch information
coderpatros committed Jul 26, 2021
1 parent 75bd6ac commit 0a6a132
Show file tree
Hide file tree
Showing 9 changed files with 301 additions and 0 deletions.
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ debian-inspector>=21.5
regex>=2021.7
GitPython~=3.1
prettytable~=2.1
packageurl-python>=0.9.4
1 change: 1 addition & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions tern/formats/cyclonedx/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2019 VMware, Inc. All Rights Reserved.
# SPDX-License-Identifier: BSD-2-Clause
24 changes: 24 additions & 0 deletions tern/formats/cyclonedx/cyclonedx.py
Original file line number Diff line number Diff line change
@@ -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 {}
109 changes: 109 additions & 0 deletions tern/formats/cyclonedx/cyclonedx_common.py
Original file line number Diff line number Diff line change
@@ -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}}
4 changes: 4 additions & 0 deletions tern/formats/cyclonedx/cyclonedxjson/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2021 VMware, Inc. All Rights Reserved.
# SPDX-License-Identifier: BSD-2-Clause
64 changes: 64 additions & 0 deletions tern/formats/cyclonedx/cyclonedxjson/generator.py
Original file line number Diff line number Diff line change
@@ -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)
43 changes: 43 additions & 0 deletions tern/formats/cyclonedx/cyclonedxjson/image_helpers.py
Original file line number Diff line number Diff line change
@@ -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
51 changes: 51 additions & 0 deletions tern/formats/cyclonedx/cyclonedxjson/package_helpers.py
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 0a6a132

Please sign in to comment.