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

Add REST API endpoint to download ABOUT files and SPDX document #60 #82

Merged
merged 4 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
3 changes: 3 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ Release notes
"Pull ScanCode.io project data" feature as Product action in the REST API.
https://github.com/nexB/dejacode/issues/59

- Add REST API endpoints to download SBOMs as CycloneDX and SPDX.
https://github.com/nexB/dejacode/issues/60

- Refactor the "Import manifest" feature as "Load SBOMs".
https://github.com/nexB/dejacode/issues/61

Expand Down
4 changes: 4 additions & 0 deletions dejacode/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,14 @@

import os
import sys
import warnings

VERSION = "5.1.0-dev"
__version__ = VERSION

# Turn off the warnings for the following modules.
warnings.filterwarnings("ignore", module="cyclonedx")


def command_line():
"""Command line entry point."""
Expand Down
137 changes: 137 additions & 0 deletions dje/outputs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
#
# Copyright (c) nexB Inc. and others. All rights reserved.
# DejaCode is a trademark of nexB Inc.
# SPDX-License-Identifier: AGPL-3.0-only
# See https://github.com/nexB/dejacode for support or download.
# See https://aboutcode.org for more information about AboutCode FOSS projects.
#

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 dejacode import __version__ as dejacode_version
from dejacode_toolkit import spdx


def safe_filename(filename):
"""Convert the provided `filename` to a safe filename."""
return re.sub("[^A-Za-z0-9.-]+", "_", filename).lower()


def get_attachment_response(file_content, filename, content_type):
if not file_content or not filename:
raise Http404

response = FileResponse(
file_content,
filename=filename,
content_type=content_type,
)
response["Content-Disposition"] = f'attachment; filename="{filename}"'

return response


def get_spdx_extracted_licenses(spdx_packages):
"""
Return all the licenses to be included in the SPDX extracted_licenses.
Those include the `LicenseRef-` licenses, ie: licenses not available in the
SPDX list.

In the case of Product relationships, ProductComponent and ProductPackage,
the set of licenses of the related object, Component or Package, is used
as the licenses of the relationship is always a subset of the ones of the
related object.
This ensures that we have all the license required for a valid SPDX document.
"""
from product_portfolio.models import ProductRelationshipMixin

all_licenses = set()
for entry in spdx_packages:
if isinstance(entry, ProductRelationshipMixin):
all_licenses.update(entry.related_component_or_package.licenses.all())
else:
all_licenses.update(entry.licenses.all())

return [
license.as_spdx() for license in all_licenses if license.spdx_id.startswith("LicenseRef")
]


def get_spdx_document(instance, user):
spdx_packages = instance.get_spdx_packages()

creation_info = spdx.CreationInfo(
person_name=f"{user.first_name} {user.last_name}",
person_email=user.email,
organization_name=user.dataspace.name,
tool=f"DejaCode-{dejacode_version}",
)

document = spdx.Document(
name=f"dejacode_{instance.dataspace.name}_{instance._meta.model_name}_{instance}",
namespace=f"https://dejacode.com/spdxdocs/{instance.uuid}",
creation_info=creation_info,
packages=[package.as_spdx() for package in spdx_packages],
extracted_licenses=get_spdx_extracted_licenses(spdx_packages),
)

return document


def get_spdx_filename(spdx_document):
document_name = spdx_document.as_dict()["name"]
filename = f"{document_name}.spdx.json"
return safe_filename(filename)


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])

bom.metadata = cyclonedx_bom.BomMetaData(
component=cdx_component,
tools=[
cyclonedx_bom.Tool(
vendor="nexB",
name="DejaCode",
version=dejacode_version,
)
],
authors=[
cyclonedx_bom.OrganizationalContact(
name=f"{user.first_name} {user.last_name}",
)
],
)

return bom


def get_cyclonedx_bom_json(cyclonedx_bom):
outputter = cyclonedx_output.get_instance(
bom=cyclonedx_bom,
output_format=cyclonedx_output.OutputFormat.JSON,
)
return outputter.output_as_string()


def get_cyclonedx_filename(instance):
base_filename = f"dejacode_{instance.dataspace.name}_{instance._meta.model_name}"
filename = f"{base_filename}_{instance}.cdx.json"
return safe_filename(filename)
85 changes: 85 additions & 0 deletions dje/tests/test_outputs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
#
# Copyright (c) nexB Inc. and others. All rights reserved.
# DejaCode is a trademark of nexB Inc.
# SPDX-License-Identifier: AGPL-3.0-only
# See https://github.com/nexB/dejacode for support or download.
# See https://aboutcode.org for more information about AboutCode FOSS projects.
#

from django.test import TestCase

from cyclonedx.model import bom as cyclonedx_bom

from dejacode import __version__ as dejacode_version
from dje import outputs
from dje.models import Dataspace
from dje.tests import create_superuser
from dje.tests import create_user
from product_portfolio.models import Product


class OutputsTestCase(TestCase):
def setUp(self):
self.dataspace = Dataspace.objects.create(name="nexB")
self.super_user = create_superuser("nexb_user", self.dataspace)
self.basic_user = create_user("basic_user", self.dataspace)

self.product1 = Product.objects.create(
name="Product1 With Space", version="1.0", dataspace=self.dataspace
)

def test_outputs_safe_filename(self):
self.assertEqual("low_-_up_", outputs.safe_filename("low -_UP*&//"))

def test_outputs_get_attachment_response(self):
response = outputs.get_attachment_response(
file_content="AAA", filename="file.txt", content_type="application/json"
)
expected = 'attachment; filename="file.txt"'
self.assertEqual(expected, response["Content-Disposition"])
self.assertEqual("application/json", response["Content-Type"])

def test_outputs_get_spdx_document(self):
document = outputs.get_spdx_document(self.product1, self.super_user)
document.creation_info.created = "2000-01-01T01:02:03Z"
expected = {
"spdxVersion": "SPDX-2.3",
"dataLicense": "CC0-1.0",
"SPDXID": "SPDXRef-DOCUMENT",
"name": "dejacode_nexb_product_product1_with_space_1.0",
"documentNamespace": f"https://dejacode.com/spdxdocs/{self.product1.uuid}",
"creationInfo": {
"created": "2000-01-01T01:02:03Z",
"creators": [
"Person: ([email protected])",
"Organization: nexB ()",
f"Tool: DejaCode-{dejacode_version}",
],
"licenseListVersion": "3.18",
},
"packages": [],
"documentDescribes": [],
}
self.assertEqual(expected, document.as_dict())

def test_outputs_get_spdx_filename(self):
document = outputs.get_spdx_document(self.product1, self.super_user)
self.assertEqual(
"dejacode_nexb_product_product1_with_space_1.0.spdx.json",
outputs.get_spdx_filename(document),
)

def test_outputs_get_cyclonedx_bom(self):
bom = outputs.get_cyclonedx_bom(instance=self.product1, user=self.super_user)
self.assertIsInstance(bom, cyclonedx_bom.Bom)

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":'))

def test_outputs_get_cyclonedx_filename(self):
self.assertEqual(
"dejacode_nexb_product_product1_with_space_1.0.cdx.json",
outputs.get_cyclonedx_filename(instance=self.product1),
)
Loading