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 the ability to download the VEX output #108 #174

Merged
merged 5 commits into from
Sep 4, 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 @@ -110,6 +110,9 @@ Release notes
- Add a "Improve Packages from PurlDB" action in the Product details view.
https://github.com/aboutcode-org/dejacode/issues/45

- Add the ability to download the CycloneDX VEX-only and SBOM+VEX combined outputs.
https://github.com/aboutcode-org/dejacode/issues/108

### Version 5.1.0

- Upgrade Python version to 3.12 and Django to 5.0.x
Expand Down
76 changes: 72 additions & 4 deletions component_catalog/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
# See https://aboutcode.org for more information about AboutCode FOSS projects.
#

import decimal
import logging
import re
from contextlib import suppress
Expand Down Expand Up @@ -37,6 +38,7 @@
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 cyclonedx.model import vulnerability as cdx_vulnerability
from license_expression import ExpressionError
from packageurl import PackageURL
from packageurl.contrib import purl2url
Expand Down Expand Up @@ -930,7 +932,7 @@ def as_cyclonedx(self, license_expression_spdx=None):
name=self.name,
type=component_type,
version=self.version,
bom_ref=str(self.uuid),
bom_ref=self.cyclonedx_bom_ref,
supplier=supplier,
licenses=licenses,
copyright=self.copyright,
Expand Down Expand Up @@ -1324,6 +1326,10 @@ def details_url(self):
def get_extra_relational_fields():
return ["external_references"]

@property
def cyclonedx_bom_ref(self):
return str(self.uuid)

@property
def permission_protected_fields(self):
return {"usage_policy": "change_usage_policy_on_component"}
Expand Down Expand Up @@ -2361,6 +2367,10 @@ def as_spdx(self, license_concluded=None):
def get_spdx_packages(self):
return [self]

@property
def cyclonedx_bom_ref(self):
return self.package_url or str(self.uuid)

def as_cyclonedx(self, license_expression_spdx=None):
"""Return this Package as an CycloneDX Component entry."""
expression_spdx = license_expression_spdx or self.concluded_license_expression_spdx
Expand All @@ -2383,12 +2393,11 @@ def as_cyclonedx(self, license_expression_spdx=None):
if (hash_value := getattr(self, field_name))
]

package_url = self.get_package_url()
return cyclonedx_component.Component(
name=self.name,
version=self.version,
bom_ref=str(package_url) or str(self.uuid),
purl=package_url,
bom_ref=self.cyclonedx_bom_ref,
purl=self.get_package_url(),
licenses=licenses,
copyright=self.copyright,
description=self.description,
Expand Down Expand Up @@ -2747,3 +2756,62 @@ def get_severity_scores(severities):
consolidated_scores.extend(score_range)

return consolidated_scores

def as_cyclonedx(self, affected_instances):
affects = [
cdx_vulnerability.BomTarget(ref=instance.cyclonedx_bom_ref)
for instance in affected_instances
]

source_url = f"https://public.vulnerablecode.io/vulnerabilities/{self.vulnerability_id}"
source = cdx_vulnerability.VulnerabilitySource(
name="VulnerableCode",
url=source_url,
)

references = []
ratings = []
for reference in self.references:
reference_source = cdx_vulnerability.VulnerabilitySource(
url=reference.get("reference_url"),
)
references.append(
cdx_vulnerability.VulnerabilityReference(
id=reference.get("reference_id"),
source=reference_source,
)
)

for score_entry in reference.get("scores", []):
# CycloneDX only support a float value for the score field,
# where on the VulnerableCode data it can be either a score float value
# or a severity string value.
score_value = score_entry.get("value")
try:
score = decimal.Decimal(score_value)
severity = None
except decimal.DecimalException:
score = None
severity = getattr(
cdx_vulnerability.VulnerabilitySeverity,
score_value.upper(),
None,
)

ratings.append(
cdx_vulnerability.VulnerabilityRating(
source=reference_source,
score=score,
severity=severity,
vector=score_entry.get("scoring_elements"),
)
)

return cdx_vulnerability.Vulnerability(
id=self.vulnerability_id,
source=source,
description=self.summary,
affects=affects,
references=sorted(references),
ratings=ratings,
)
26 changes: 26 additions & 0 deletions component_catalog/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2734,3 +2734,29 @@ def test_vulnerability_model_queryset_count_methods(self):
)
self.assertEqual(2, qs[0].affected_packages_count)
self.assertEqual(1, qs[0].affected_products_count)

def test_vulnerability_model_as_cyclonedx(self):
response_file = self.data / "vulnerabilities" / "idna_3.6_response.json"
json_data = json.loads(response_file.read_text())
affected_by_vulnerabilities = json_data["results"][0]["affected_by_vulnerabilities"]
vulnerability1 = Vulnerability.create_from_data(
dataspace=self.dataspace,
data=affected_by_vulnerabilities[0],
)
package1 = make_package(
self.dataspace,
package_url="pkg:type/[email protected]",
uuid="dd0afd00-89bd-46d6-b1f0-57b553c44d32",
)

vulnerability1_as_cdx = vulnerability1.as_cyclonedx(affected_instances=[package1])
as_dict = json.loads(vulnerability1_as_cdx.as_json())
as_dict.pop("ratings", None) # The sorting is inconsistent
results = json.dumps(as_dict, indent=2)

expected_location = self.data / "vulnerabilities" / "idna_3.6_as_cyclonedx.json"
# Uncomment to regen the expected results
# if True:
# expected_location.write_text(results)

self.assertJSONEqual(results, expected_location.read_text())
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
{
"affects": [
{
"ref": "pkg:type/[email protected]"
}
],
"description": "Internationalized Domain Names in Applications (IDNA) vulnerable to denial of service from specially crafted inputs to idna.encode",
"id": "VCID-j3au-usaz-aaag",
"references": [
{
"id": "",
"source": {
"url": "https://access.redhat.com/hydra/rest/securitydata/cve/CVE-2024-3651.json"
}
},
{
"id": "",
"source": {
"url": "https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2024-3651"
}
},
{
"id": "",
"source": {
"url": "https://ftp.suse.com/pub/projects/security/yaml/suse-cvss-scores.yaml"
}
},
{
"id": "",
"source": {
"url": "https://github.com/kjd/idna"
}
},
{
"id": "",
"source": {
"url": "https://github.com/kjd/idna/commit/1d365e17e10d72d0b7876316fc7b9ca0eebdd38d"
}
},
{
"id": "",
"source": {
"url": "https://github.com/pypa/advisory-database/tree/main/vulns/idna/PYSEC-2024-60.yaml"
}
},
{
"id": "",
"source": {
"url": "https://huntr.com/bounties/93d78d07-d791-4b39-a845-cbfabc44aadb"
}
},
{
"id": "1069127",
"source": {
"url": "https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=1069127"
}
},
{
"id": "2274779",
"source": {
"url": "https://bugzilla.redhat.com/show_bug.cgi?id=2274779"
}
},
{
"id": "CVE-2024-3651",
"source": {
"url": "https://nvd.nist.gov/vuln/detail/CVE-2024-3651"
}
},
{
"id": "GHSA-jjg7-2v4v-x38h",
"source": {
"url": "https://github.com/advisories/GHSA-jjg7-2v4v-x38h"
}
},
{
"id": "GHSA-jjg7-2v4v-x38h",
"source": {
"url": "https://github.com/kjd/idna/security/advisories/GHSA-jjg7-2v4v-x38h"
}
},
{
"id": "RHSA-2024:3466",
"source": {
"url": "https://access.redhat.com/errata/RHSA-2024:3466"
}
},
{
"id": "RHSA-2024:3543",
"source": {
"url": "https://access.redhat.com/errata/RHSA-2024:3543"
}
},
{
"id": "RHSA-2024:3552",
"source": {
"url": "https://access.redhat.com/errata/RHSA-2024:3552"
}
},
{
"id": "RHSA-2024:3781",
"source": {
"url": "https://access.redhat.com/errata/RHSA-2024:3781"
}
},
{
"id": "RHSA-2024:3846",
"source": {
"url": "https://access.redhat.com/errata/RHSA-2024:3846"
}
},
{
"id": "RHSA-2024:4260",
"source": {
"url": "https://access.redhat.com/errata/RHSA-2024:4260"
}
},
{
"id": "USN-6780-1",
"source": {
"url": "https://usn.ubuntu.com/6780-1/"
}
},
{
"id": "cpe:2.3:a:kjd:internationalized_domain_names_in_applications:3.6:*:*:*:*:*:*:*",
"source": {
"url": "https://nvd.nist.gov/vuln/search/results?adv_search=true&isCpeNameSearch=true&query=cpe:2.3:a:kjd:internationalized_domain_names_in_applications:3.6:*:*:*:*:*:*:*"
}
}
],
"source": {
"name": "VulnerableCode",
"url": "https://public.vulnerablecode.io/vulnerabilities/VCID-j3au-usaz-aaag"
}
}
26 changes: 19 additions & 7 deletions dje/outputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,11 @@ def get_spdx_filename(spdx_document):
return safe_filename(filename)


def get_cyclonedx_bom(instance, user):
"""https://cyclonedx.org/use-cases/#dependency-graph"""
def get_cyclonedx_bom(instance, user, include_components=True, include_vex=False):
"""
https://cyclonedx.org/use-cases/#dependency-graph
https://cyclonedx.org/use-cases/#vulnerability-exploitability
"""
root_component = instance.as_cyclonedx()

bom = cyclonedx_bom.Bom()
Expand All @@ -122,9 +125,18 @@ def get_cyclonedx_bom(instance, user):
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])
if include_components:
for component in cyclonedx_components:
bom.components.add(component)
bom.register_dependency(root_component, [component])

if include_vex:
vulnerability_qs = instance.get_vulnerability_qs(prefetch_related_packages=True)
vulnerabilities = [
vulnerability.as_cyclonedx(affected_instances=vulnerability.affected_packages.all())
for vulnerability in vulnerability_qs
]
bom.vulnerabilities = vulnerabilities

return bom

Expand Down Expand Up @@ -165,7 +177,7 @@ def sort_bom_with_schema_ordering(bom_as_dict, schema_version):
return json.dumps(ordered_dict, indent=2)


def get_cyclonedx_filename(instance):
def get_cyclonedx_filename(instance, extension="cdx"):
base_filename = f"dejacode_{instance.dataspace.name}_{instance._meta.model_name}"
filename = f"{base_filename}_{instance}.cdx.json"
filename = f"{base_filename}_{instance}.{extension}.json"
return safe_filename(filename)
21 changes: 21 additions & 0 deletions dje/tests/test_outputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@

from cyclonedx.model import bom as cyclonedx_bom

from component_catalog.tests import make_package
from component_catalog.tests import make_vulnerability
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
from product_portfolio.tests import make_product_package


class OutputsTestCase(TestCase):
Expand Down Expand Up @@ -73,6 +76,24 @@ 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_include_vex(self):
package_in_product = make_package(self.dataspace, package_url="pkg:type/name")
make_product_package(self.product1, package_in_product)
package_not_in_product = make_package(self.dataspace)
vulnerability1 = make_vulnerability(
self.dataspace, affecting=[package_in_product, package_not_in_product]
)
make_vulnerability(self.dataspace, affecting=[package_not_in_product])

bom = outputs.get_cyclonedx_bom(
instance=self.product1,
user=self.super_user,
include_vex=True,
)
self.assertIsInstance(bom, cyclonedx_bom.Bom)
self.assertEqual(1, len(bom.vulnerabilities))
self.assertEqual(vulnerability1.vulnerability_id, bom.vulnerabilities[0].id)

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)
Expand Down
Loading