Skip to content

Commit 29c7d32

Browse files
authored
Add the ability to download the VEX output #108 (#174)
Signed-off-by: tdruez <[email protected]>
1 parent a8cb6f8 commit 29c7d32

File tree

17 files changed

+362
-36
lines changed

17 files changed

+362
-36
lines changed

CHANGELOG.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,9 @@ Release notes
110110
- Add a "Improve Packages from PurlDB" action in the Product details view.
111111
https://github.com/aboutcode-org/dejacode/issues/45
112112

113+
- Add the ability to download the CycloneDX VEX-only and SBOM+VEX combined outputs.
114+
https://github.com/aboutcode-org/dejacode/issues/108
115+
113116
### Version 5.1.0
114117

115118
- Upgrade Python version to 3.12 and Django to 5.0.x

component_catalog/models.py

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
# See https://aboutcode.org for more information about AboutCode FOSS projects.
77
#
88

9+
import decimal
910
import logging
1011
import re
1112
from contextlib import suppress
@@ -37,6 +38,7 @@
3738
from cyclonedx.model import component as cyclonedx_component
3839
from cyclonedx.model import contact as cyclonedx_contact
3940
from cyclonedx.model import license as cyclonedx_license
41+
from cyclonedx.model import vulnerability as cdx_vulnerability
4042
from license_expression import ExpressionError
4143
from packageurl import PackageURL
4244
from packageurl.contrib import purl2url
@@ -930,7 +932,7 @@ def as_cyclonedx(self, license_expression_spdx=None):
930932
name=self.name,
931933
type=component_type,
932934
version=self.version,
933-
bom_ref=str(self.uuid),
935+
bom_ref=self.cyclonedx_bom_ref,
934936
supplier=supplier,
935937
licenses=licenses,
936938
copyright=self.copyright,
@@ -1324,6 +1326,10 @@ def details_url(self):
13241326
def get_extra_relational_fields():
13251327
return ["external_references"]
13261328

1329+
@property
1330+
def cyclonedx_bom_ref(self):
1331+
return str(self.uuid)
1332+
13271333
@property
13281334
def permission_protected_fields(self):
13291335
return {"usage_policy": "change_usage_policy_on_component"}
@@ -2361,6 +2367,10 @@ def as_spdx(self, license_concluded=None):
23612367
def get_spdx_packages(self):
23622368
return [self]
23632369

2370+
@property
2371+
def cyclonedx_bom_ref(self):
2372+
return self.package_url or str(self.uuid)
2373+
23642374
def as_cyclonedx(self, license_expression_spdx=None):
23652375
"""Return this Package as an CycloneDX Component entry."""
23662376
expression_spdx = license_expression_spdx or self.concluded_license_expression_spdx
@@ -2383,12 +2393,11 @@ def as_cyclonedx(self, license_expression_spdx=None):
23832393
if (hash_value := getattr(self, field_name))
23842394
]
23852395

2386-
package_url = self.get_package_url()
23872396
return cyclonedx_component.Component(
23882397
name=self.name,
23892398
version=self.version,
2390-
bom_ref=str(package_url) or str(self.uuid),
2391-
purl=package_url,
2399+
bom_ref=self.cyclonedx_bom_ref,
2400+
purl=self.get_package_url(),
23922401
licenses=licenses,
23932402
copyright=self.copyright,
23942403
description=self.description,
@@ -2747,3 +2756,62 @@ def get_severity_scores(severities):
27472756
consolidated_scores.extend(score_range)
27482757

27492758
return consolidated_scores
2759+
2760+
def as_cyclonedx(self, affected_instances):
2761+
affects = [
2762+
cdx_vulnerability.BomTarget(ref=instance.cyclonedx_bom_ref)
2763+
for instance in affected_instances
2764+
]
2765+
2766+
source_url = f"https://public.vulnerablecode.io/vulnerabilities/{self.vulnerability_id}"
2767+
source = cdx_vulnerability.VulnerabilitySource(
2768+
name="VulnerableCode",
2769+
url=source_url,
2770+
)
2771+
2772+
references = []
2773+
ratings = []
2774+
for reference in self.references:
2775+
reference_source = cdx_vulnerability.VulnerabilitySource(
2776+
url=reference.get("reference_url"),
2777+
)
2778+
references.append(
2779+
cdx_vulnerability.VulnerabilityReference(
2780+
id=reference.get("reference_id"),
2781+
source=reference_source,
2782+
)
2783+
)
2784+
2785+
for score_entry in reference.get("scores", []):
2786+
# CycloneDX only support a float value for the score field,
2787+
# where on the VulnerableCode data it can be either a score float value
2788+
# or a severity string value.
2789+
score_value = score_entry.get("value")
2790+
try:
2791+
score = decimal.Decimal(score_value)
2792+
severity = None
2793+
except decimal.DecimalException:
2794+
score = None
2795+
severity = getattr(
2796+
cdx_vulnerability.VulnerabilitySeverity,
2797+
score_value.upper(),
2798+
None,
2799+
)
2800+
2801+
ratings.append(
2802+
cdx_vulnerability.VulnerabilityRating(
2803+
source=reference_source,
2804+
score=score,
2805+
severity=severity,
2806+
vector=score_entry.get("scoring_elements"),
2807+
)
2808+
)
2809+
2810+
return cdx_vulnerability.Vulnerability(
2811+
id=self.vulnerability_id,
2812+
source=source,
2813+
description=self.summary,
2814+
affects=affects,
2815+
references=sorted(references),
2816+
ratings=ratings,
2817+
)

component_catalog/tests/test_models.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2734,3 +2734,29 @@ def test_vulnerability_model_queryset_count_methods(self):
27342734
)
27352735
self.assertEqual(2, qs[0].affected_packages_count)
27362736
self.assertEqual(1, qs[0].affected_products_count)
2737+
2738+
def test_vulnerability_model_as_cyclonedx(self):
2739+
response_file = self.data / "vulnerabilities" / "idna_3.6_response.json"
2740+
json_data = json.loads(response_file.read_text())
2741+
affected_by_vulnerabilities = json_data["results"][0]["affected_by_vulnerabilities"]
2742+
vulnerability1 = Vulnerability.create_from_data(
2743+
dataspace=self.dataspace,
2744+
data=affected_by_vulnerabilities[0],
2745+
)
2746+
package1 = make_package(
2747+
self.dataspace,
2748+
package_url="pkg:type/[email protected]",
2749+
uuid="dd0afd00-89bd-46d6-b1f0-57b553c44d32",
2750+
)
2751+
2752+
vulnerability1_as_cdx = vulnerability1.as_cyclonedx(affected_instances=[package1])
2753+
as_dict = json.loads(vulnerability1_as_cdx.as_json())
2754+
as_dict.pop("ratings", None) # The sorting is inconsistent
2755+
results = json.dumps(as_dict, indent=2)
2756+
2757+
expected_location = self.data / "vulnerabilities" / "idna_3.6_as_cyclonedx.json"
2758+
# Uncomment to regen the expected results
2759+
# if True:
2760+
# expected_location.write_text(results)
2761+
2762+
self.assertJSONEqual(results, expected_location.read_text())
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
{
2+
"affects": [
3+
{
4+
"ref": "pkg:type/[email protected]"
5+
}
6+
],
7+
"description": "Internationalized Domain Names in Applications (IDNA) vulnerable to denial of service from specially crafted inputs to idna.encode",
8+
"id": "VCID-j3au-usaz-aaag",
9+
"references": [
10+
{
11+
"id": "",
12+
"source": {
13+
"url": "https://access.redhat.com/hydra/rest/securitydata/cve/CVE-2024-3651.json"
14+
}
15+
},
16+
{
17+
"id": "",
18+
"source": {
19+
"url": "https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2024-3651"
20+
}
21+
},
22+
{
23+
"id": "",
24+
"source": {
25+
"url": "https://ftp.suse.com/pub/projects/security/yaml/suse-cvss-scores.yaml"
26+
}
27+
},
28+
{
29+
"id": "",
30+
"source": {
31+
"url": "https://github.com/kjd/idna"
32+
}
33+
},
34+
{
35+
"id": "",
36+
"source": {
37+
"url": "https://github.com/kjd/idna/commit/1d365e17e10d72d0b7876316fc7b9ca0eebdd38d"
38+
}
39+
},
40+
{
41+
"id": "",
42+
"source": {
43+
"url": "https://github.com/pypa/advisory-database/tree/main/vulns/idna/PYSEC-2024-60.yaml"
44+
}
45+
},
46+
{
47+
"id": "",
48+
"source": {
49+
"url": "https://huntr.com/bounties/93d78d07-d791-4b39-a845-cbfabc44aadb"
50+
}
51+
},
52+
{
53+
"id": "1069127",
54+
"source": {
55+
"url": "https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=1069127"
56+
}
57+
},
58+
{
59+
"id": "2274779",
60+
"source": {
61+
"url": "https://bugzilla.redhat.com/show_bug.cgi?id=2274779"
62+
}
63+
},
64+
{
65+
"id": "CVE-2024-3651",
66+
"source": {
67+
"url": "https://nvd.nist.gov/vuln/detail/CVE-2024-3651"
68+
}
69+
},
70+
{
71+
"id": "GHSA-jjg7-2v4v-x38h",
72+
"source": {
73+
"url": "https://github.com/advisories/GHSA-jjg7-2v4v-x38h"
74+
}
75+
},
76+
{
77+
"id": "GHSA-jjg7-2v4v-x38h",
78+
"source": {
79+
"url": "https://github.com/kjd/idna/security/advisories/GHSA-jjg7-2v4v-x38h"
80+
}
81+
},
82+
{
83+
"id": "RHSA-2024:3466",
84+
"source": {
85+
"url": "https://access.redhat.com/errata/RHSA-2024:3466"
86+
}
87+
},
88+
{
89+
"id": "RHSA-2024:3543",
90+
"source": {
91+
"url": "https://access.redhat.com/errata/RHSA-2024:3543"
92+
}
93+
},
94+
{
95+
"id": "RHSA-2024:3552",
96+
"source": {
97+
"url": "https://access.redhat.com/errata/RHSA-2024:3552"
98+
}
99+
},
100+
{
101+
"id": "RHSA-2024:3781",
102+
"source": {
103+
"url": "https://access.redhat.com/errata/RHSA-2024:3781"
104+
}
105+
},
106+
{
107+
"id": "RHSA-2024:3846",
108+
"source": {
109+
"url": "https://access.redhat.com/errata/RHSA-2024:3846"
110+
}
111+
},
112+
{
113+
"id": "RHSA-2024:4260",
114+
"source": {
115+
"url": "https://access.redhat.com/errata/RHSA-2024:4260"
116+
}
117+
},
118+
{
119+
"id": "USN-6780-1",
120+
"source": {
121+
"url": "https://usn.ubuntu.com/6780-1/"
122+
}
123+
},
124+
{
125+
"id": "cpe:2.3:a:kjd:internationalized_domain_names_in_applications:3.6:*:*:*:*:*:*:*",
126+
"source": {
127+
"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:*:*:*:*:*:*:*"
128+
}
129+
}
130+
],
131+
"source": {
132+
"name": "VulnerableCode",
133+
"url": "https://public.vulnerablecode.io/vulnerabilities/VCID-j3au-usaz-aaag"
134+
}
135+
}

dje/outputs.py

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,11 @@ def get_spdx_filename(spdx_document):
9595
return safe_filename(filename)
9696

9797

98-
def get_cyclonedx_bom(instance, user):
99-
"""https://cyclonedx.org/use-cases/#dependency-graph"""
98+
def get_cyclonedx_bom(instance, user, include_components=True, include_vex=False):
99+
"""
100+
https://cyclonedx.org/use-cases/#dependency-graph
101+
https://cyclonedx.org/use-cases/#vulnerability-exploitability
102+
"""
100103
root_component = instance.as_cyclonedx()
101104

102105
bom = cyclonedx_bom.Bom()
@@ -122,9 +125,18 @@ def get_cyclonedx_bom(instance, user):
122125
component.as_cyclonedx() for component in instance.get_cyclonedx_components()
123126
]
124127

125-
for component in cyclonedx_components:
126-
bom.components.add(component)
127-
bom.register_dependency(root_component, [component])
128+
if include_components:
129+
for component in cyclonedx_components:
130+
bom.components.add(component)
131+
bom.register_dependency(root_component, [component])
132+
133+
if include_vex:
134+
vulnerability_qs = instance.get_vulnerability_qs(prefetch_related_packages=True)
135+
vulnerabilities = [
136+
vulnerability.as_cyclonedx(affected_instances=vulnerability.affected_packages.all())
137+
for vulnerability in vulnerability_qs
138+
]
139+
bom.vulnerabilities = vulnerabilities
128140

129141
return bom
130142

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

167179

168-
def get_cyclonedx_filename(instance):
180+
def get_cyclonedx_filename(instance, extension="cdx"):
169181
base_filename = f"dejacode_{instance.dataspace.name}_{instance._meta.model_name}"
170-
filename = f"{base_filename}_{instance}.cdx.json"
182+
filename = f"{base_filename}_{instance}.{extension}.json"
171183
return safe_filename(filename)

dje/tests/test_outputs.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,15 @@
1010

1111
from cyclonedx.model import bom as cyclonedx_bom
1212

13+
from component_catalog.tests import make_package
14+
from component_catalog.tests import make_vulnerability
1315
from dejacode import __version__ as dejacode_version
1416
from dje import outputs
1517
from dje.models import Dataspace
1618
from dje.tests import create_superuser
1719
from dje.tests import create_user
1820
from product_portfolio.models import Product
21+
from product_portfolio.tests import make_product_package
1922

2023

2124
class OutputsTestCase(TestCase):
@@ -73,6 +76,24 @@ def test_outputs_get_cyclonedx_bom(self):
7376
bom = outputs.get_cyclonedx_bom(instance=self.product1, user=self.super_user)
7477
self.assertIsInstance(bom, cyclonedx_bom.Bom)
7578

79+
def test_outputs_get_cyclonedx_bom_include_vex(self):
80+
package_in_product = make_package(self.dataspace, package_url="pkg:type/name")
81+
make_product_package(self.product1, package_in_product)
82+
package_not_in_product = make_package(self.dataspace)
83+
vulnerability1 = make_vulnerability(
84+
self.dataspace, affecting=[package_in_product, package_not_in_product]
85+
)
86+
make_vulnerability(self.dataspace, affecting=[package_not_in_product])
87+
88+
bom = outputs.get_cyclonedx_bom(
89+
instance=self.product1,
90+
user=self.super_user,
91+
include_vex=True,
92+
)
93+
self.assertIsInstance(bom, cyclonedx_bom.Bom)
94+
self.assertEqual(1, len(bom.vulnerabilities))
95+
self.assertEqual(vulnerability1.vulnerability_id, bom.vulnerabilities[0].id)
96+
7697
def test_outputs_get_cyclonedx_bom_json(self):
7798
bom = outputs.get_cyclonedx_bom(instance=self.product1, user=self.super_user)
7899
bom_json = outputs.get_cyclonedx_bom_json(bom)

0 commit comments

Comments
 (0)