Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Upgrade the cyclonedx_python_lib for spec 1.6 support #79

Merged
merged 16 commits into from
Apr 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ Release notes
- Replace Celery by RQ for async job queue and worker.
https://github.com/nexB/dejacode/issues/6

- Add support for CycloneDX spec version "1.6".
In the UI and API, older spe version such as "1.4" and "1.5" are also available as
download.
https://github.com/nexB/dejacode/pull/79

- Lookup in PurlDB by purl in Add Package form.
When a Package URL is available in the context of the "Add Package" form,
for example when using a link from the Vulnerabilities tab,
Expand Down
22 changes: 13 additions & 9 deletions component_catalog/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,16 @@
from dejacode_toolkit.download import collect_package_data
from dejacode_toolkit.scancodeio import ScanCodeIO
from dje import tasks
from dje.api import AboutCodeFilesActionMixin
from dje.api import CreateRetrieveUpdateListViewSet
from dje.api import CycloneDXSOMActionMixin
from dje.api import DataspacedAPIFilterSet
from dje.api import DataspacedHyperlinkedRelatedField
from dje.api import DataspacedSerializer
from dje.api import DataspacedSlugRelatedField
from dje.api import ExternalReferenceSerializer
from dje.api import NameVersionHyperlinkedRelatedField
from dje.api import SPDXDocumentActionMixin
from dje.filters import LastModifiedDateFilter
from dje.filters import MultipleCharFilter
from dje.filters import MultipleUUIDFilter
Expand Down Expand Up @@ -447,7 +450,9 @@ class Meta:
)


class ComponentViewSet(CreateRetrieveUpdateListViewSet):
class ComponentViewSet(
SPDXDocumentActionMixin, CycloneDXSOMActionMixin, CreateRetrieveUpdateListViewSet
):
queryset = Component.objects.all()
serializer_class = ComponentSerializer
filterset_class = ComponentFilterSet
Expand Down Expand Up @@ -820,7 +825,13 @@ def collect_create_scan(download_url, user):
return package


class PackageViewSet(SendAboutFilesMixin, CreateRetrieveUpdateListViewSet):
class PackageViewSet(
SendAboutFilesMixin,
AboutCodeFilesActionMixin,
SPDXDocumentActionMixin,
CycloneDXSOMActionMixin,
CreateRetrieveUpdateListViewSet,
):
queryset = Package.objects.all()
serializer_class = PackageSerializer
filterset_class = PackageAPIFilterSet
Expand Down Expand Up @@ -868,13 +879,6 @@ def about(self, request, uuid):
package = self.get_object()
return Response({"about_data": package.as_about_yaml()})

@action(detail=True)
def about_files(self, request, uuid):
package = self.get_object()
about_files = package.get_about_files()
filename = self.get_filename(package)
return self.get_zipped_response(about_files, filename)

download_url_description = (
"A single, or list of, Download URL(s).<br><br>"
'<b>cURL style</b>: <code>-d "download_url=url1&download_url=url2"</code><br><br>'
Expand Down
30 changes: 16 additions & 14 deletions component_catalog/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@
from attributecode.model import About
from cyclonedx import model as cyclonedx_model
from cyclonedx.model import component as cyclonedx_component
from cyclonedx.model import contact as cyclonedx_contact
from cyclonedx.model import license as cyclonedx_license
from packageurl import PackageURL
from packageurl.contrib import purl2url
from packageurl.contrib import url2purl
Expand Down Expand Up @@ -758,17 +760,17 @@ def as_cyclonedx(self, license_expression_spdx=None):
"""Return this Component/Product as an CycloneDX Component entry."""
supplier = None
if self.owner:
supplier = cyclonedx_model.OrganizationalEntity(
supplier = cyclonedx_contact.OrganizationalEntity(
name=self.owner.name,
urls=[self.owner.homepage_url],
)

expression_spdx = license_expression_spdx or self.get_license_expression_spdx_id()
licenses = []
if expression_spdx:
licenses = [
cyclonedx_model.LicenseChoice(license_expression=expression_spdx),
]
# Using the LicenseExpression directly as the make_with_expression method
# does not support the "LicenseRef-" keys.
licenses = [cyclonedx_license.LicenseExpression(value=expression_spdx)]

if self.__class__.__name__ == "Product":
component_type = cyclonedx_component.ComponentType.APPLICATION
Expand All @@ -777,12 +779,12 @@ def as_cyclonedx(self, license_expression_spdx=None):

return cyclonedx_component.Component(
name=self.name,
component_type=component_type,
type=component_type,
version=self.version,
bom_ref=str(self.uuid),
supplier=supplier,
licenses=licenses,
copyright_=self.copyright,
copyright=self.copyright,
description=self.description,
cpe=getattr(self, "cpe", None),
properties=get_cyclonedx_properties(self),
Expand Down Expand Up @@ -2222,9 +2224,9 @@ def as_cyclonedx(self, license_expression_spdx=None):

licenses = []
if expression_spdx:
licenses = [
cyclonedx_model.LicenseChoice(license_expression=expression_spdx),
]
# Using the LicenseExpression directly as the make_with_expression method
# does not support the "LicenseRef-" keys.
licenses = [cyclonedx_license.LicenseExpression(value=expression_spdx)]

hash_fields = {
"md5": cyclonedx_model.HashAlgorithm.MD5,
Expand All @@ -2233,19 +2235,19 @@ def as_cyclonedx(self, license_expression_spdx=None):
"sha512": cyclonedx_model.HashAlgorithm.SHA_512,
}
hashes = [
cyclonedx_model.HashType(algorithm=algorithm, hash_value=hash_value)
cyclonedx_model.HashType(alg=algorithm, content=hash_value)
for field_name, algorithm in hash_fields.items()
if (hash_value := getattr(self, field_name))
]

purl = self.package_url
package_url = self.get_package_url()
return cyclonedx_component.Component(
name=self.name,
version=self.version,
bom_ref=purl or str(self.uuid),
purl=purl,
bom_ref=str(package_url) or str(self.uuid),
purl=package_url,
licenses=licenses,
copyright_=self.copyright,
copyright=self.copyright,
description=self.description,
cpe=self.cpe,
author=self.author,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,12 @@
<a href="{{ object.get_export_spdx_url }}" class="dropdown-item" target="_blank">
<i class="fas fa-download"></i> SPDX document
</a>
<a href="{{ object.get_export_cyclonedx_url }}" class="dropdown-item" target="_blank">
<div class="dropdown-item">
<i class="fas fa-download"></i> CycloneDX SBOM
</a>
<a class="badge text-bg-primary" href="{{ object.get_export_cyclonedx_url }}?spec_version=1.6">1.6</a>
<a class="badge text-bg-secondary" href="{{ object.get_export_cyclonedx_url }}?spec_version=1.5">1.5</a>
<a class="badge text-bg-secondary" href="{{ object.get_export_cyclonedx_url }}?spec_version=1.4">1.4</a>
</div>
</div>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,12 @@
<a href="{{ object.get_export_spdx_url }}" class="dropdown-item" target="_blank">
<i class="fas fa-download"></i> SPDX document
</a>
<a href="{{ object.get_export_cyclonedx_url }}" class="dropdown-item" target="_blank">
<div class="dropdown-item">
<i class="fas fa-download"></i> CycloneDX SBOM
</a>
<a class="badge text-bg-primary" href="{{ object.get_export_cyclonedx_url }}?spec_version=1.6">1.6</a>
<a class="badge text-bg-secondary" href="{{ object.get_export_cyclonedx_url }}?spec_version=1.5">1.5</a>
<a class="badge text-bg-secondary" href="{{ object.get_export_cyclonedx_url }}?spec_version=1.4">1.4</a>
</div>
</div>
</div>
</div>
Expand Down
4 changes: 2 additions & 2 deletions component_catalog/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1419,8 +1419,8 @@ def test_api_package_viewset_about_action(self):
}
self.assertEqual(expected, response.json())

def test_api_package_viewset_about_files_action(self):
about_url = reverse("api_v2:package-about-files", args=[self.package1.uuid])
def test_api_package_viewset_aboutcode_files_action(self):
about_url = reverse("api_v2:package-aboutcode-files", args=[self.package1.uuid])

response = self.client.get(about_url)
self.assertEqual(403, response.status_code)
Expand Down
17 changes: 13 additions & 4 deletions component_catalog/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2126,8 +2126,11 @@ def test_component_model_as_cyclonedx(self):
self.component1.homepage_url = "https://homepage.url"
self.component1.notice_text = "Notice"
cyclonedx_data = self.component1.as_cyclonedx()
expected_repr = "<Component group=None, name=a, version=1.0, type=ComponentType.LIBRARY>"
self.assertEqual(expected_repr, repr(cyclonedx_data))
self.assertEqual("library", cyclonedx_data.type)
self.assertEqual(self.component1.name, cyclonedx_data.name)
self.assertEqual(self.component1.version, cyclonedx_data.version)
self.assertEqual(str(self.component1.uuid), str(cyclonedx_data.bom_ref))

expected = {
"aboutcode:homepage_url": "https://homepage.url",
"aboutcode:notice_text": "Notice",
Expand All @@ -2150,8 +2153,14 @@ def test_package_model_as_cyclonedx(self):
dataspace=self.dataspace,
)
cyclonedx_data = package.as_cyclonedx()
expected = "<Component group=None, name=curl, version=7.50.3-1, type=ComponentType.LIBRARY>"
self.assertEqual(expected, repr(cyclonedx_data))

self.assertEqual("library", cyclonedx_data.type)
self.assertEqual(package.name, cyclonedx_data.name)
self.assertEqual(package.version, cyclonedx_data.version)
self.assertEqual("pkg:deb/debian/curl@7.50.3-1", str(cyclonedx_data.bom_ref))
package_url = package.get_package_url()
self.assertEqual(package_url, cyclonedx_data.purl)

expected = {
"aboutcode:download_url": "https://download.url",
"aboutcode:filename": "package.zip",
Expand Down
44 changes: 44 additions & 0 deletions dje/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
from rest_framework.relations import ManyRelatedField
from rest_framework.response import Response

from dje import outputs
from dje.api_custom import TabPermission
from dje.copier import copy_object
from dje.fields import ExtendedNullBooleanSelect
Expand Down Expand Up @@ -582,3 +583,46 @@ def get_queryset(self):
.select_related("content_type")
.prefetch_related("content_object")
)


class AboutCodeFilesActionMixin:
@action(detail=True, name="Download AboutCode files")
def aboutcode_files(self, request, uuid):
instance = self.get_object()
about_files = instance.get_about_files()
filename = self.get_filename(instance)
return self.get_zipped_response(about_files, filename)


class SPDXDocumentActionMixin:
@action(detail=True, name="Download SPDX document")
def spdx_document(self, request, uuid):
spdx_document = outputs.get_spdx_document(self.get_object(), self.request.user)

spdx_document_json = spdx_document.as_json()

return outputs.get_attachment_response(
file_content=spdx_document_json,
filename=outputs.get_spdx_filename(spdx_document),
content_type="application/json",
)


class CycloneDXSOMActionMixin:
@action(detail=True, name="Download CycloneDX SBOM")
def cyclonedx_sbom(self, request, uuid):
instance = self.get_object()
spec_version = request.query_params.get("spec_version")

cyclonedx_bom = outputs.get_cyclonedx_bom(instance, self.request.user)
try:
cyclonedx_bom_json = outputs.get_cyclonedx_bom_json(cyclonedx_bom, spec_version)
except ValueError:
error = f"Spec version {spec_version} not supported"
return Response(error, status=status.HTTP_400_BAD_REQUEST)

return outputs.get_attachment_response(
file_content=cyclonedx_bom_json,
filename=outputs.get_cyclonedx_filename(instance),
content_type="application/json",
)
64 changes: 49 additions & 15 deletions dje/outputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,22 @@
# See https://aboutcode.org for more information about AboutCode FOSS projects.
#

import json
import re

from django.http import FileResponse
from django.http import Http404

from cyclonedx import output as cyclonedx_output
from cyclonedx.model import bom as cyclonedx_bom
from cyclonedx.schema import SchemaVersion
from cyclonedx.validation.json import JsonStrictValidator

from dejacode import __version__ as dejacode_version
from dejacode_toolkit import spdx

CYCLONEDX_DEFAULT_SPEC_VERSION = "1.6"


def safe_filename(filename):
"""Convert the provided `filename` to a safe filename."""
Expand Down Expand Up @@ -92,20 +97,11 @@ def get_spdx_filename(spdx_document):

def get_cyclonedx_bom(instance, user):
"""https://cyclonedx.org/use-cases/#dependency-graph"""
cyclonedx_components = []

if hasattr(instance, "get_cyclonedx_components"):
cyclonedx_components = [
component.as_cyclonedx() for component in instance.get_cyclonedx_components()
]

bom = cyclonedx_bom.Bom(components=cyclonedx_components)

cdx_component = instance.as_cyclonedx()
cdx_component.dependencies.update([component.bom_ref for component in cyclonedx_components])
root_component = instance.as_cyclonedx()

bom = cyclonedx_bom.Bom()
bom.metadata = cyclonedx_bom.BomMetaData(
component=cdx_component,
component=root_component,
tools=[
cyclonedx_bom.Tool(
vendor="nexB",
Expand All @@ -120,15 +116,53 @@ def get_cyclonedx_bom(instance, user):
],
)

cyclonedx_components = []
if hasattr(instance, "get_cyclonedx_components"):
cyclonedx_components = [
component.as_cyclonedx() for component in instance.get_cyclonedx_components()
]

for component in cyclonedx_components:
bom.components.add(component)
bom.register_dependency(root_component, [component])

return bom


def get_cyclonedx_bom_json(cyclonedx_bom):
outputter = cyclonedx_output.get_instance(
def get_cyclonedx_bom_json(cyclonedx_bom, spec_version=None):
"""Generate JSON output for the provided instance in CycloneDX BOM format."""
if not spec_version:
spec_version = CYCLONEDX_DEFAULT_SPEC_VERSION
schema_version = SchemaVersion.from_version(spec_version)

json_outputter = cyclonedx_output.make_outputter(
bom=cyclonedx_bom,
output_format=cyclonedx_output.OutputFormat.JSON,
schema_version=schema_version,
)
return outputter.output_as_string()

# Using the internal API in place of the output_as_string() method to avoid
# a round of deserialization/serialization while fixing the field ordering.
json_outputter.generate()
bom_as_dict = json_outputter._bom_json

# The default order out of the outputter is not great, the following sorts the
# bom using the order from the schema.
sorted_json = sort_bom_with_schema_ordering(bom_as_dict, schema_version)

return sorted_json


def sort_bom_with_schema_ordering(bom_as_dict, schema_version):
"""Sort the ``bom_as_dict`` using the ordering from the ``schema_version``."""
schema_file = JsonStrictValidator(schema_version)._schema_file
with open(schema_file) as sf:
schema_dict = json.loads(sf.read())

order_from_schema = list(schema_dict.get("properties", {}).keys())
ordered_dict = {key: bom_as_dict.get(key) for key in order_from_schema if key in bom_as_dict}

return json.dumps(ordered_dict, indent=2)


def get_cyclonedx_filename(instance):
Expand Down
2 changes: 1 addition & 1 deletion dje/tests/test_outputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ def test_outputs_get_cyclonedx_bom(self):
def test_outputs_get_cyclonedx_bom_json(self):
bom = outputs.get_cyclonedx_bom(instance=self.product1, user=self.super_user)
bom_json = outputs.get_cyclonedx_bom_json(bom)
self.assertTrue(bom_json.startswith('{"$schema":'))
self.assertIn('"bomFormat": "CycloneDX"', bom_json)

def test_outputs_get_cyclonedx_filename(self):
self.assertEqual(
Expand Down
Loading