Skip to content

Commit 6914272

Browse files
authored
Merge pull request #5 from CycloneDX/feat/support-schema-extension-vulnerability-1.0
FEATURE: add support for Vulnerability Disclosures
2 parents 866eda7 + d5aabcf commit 6914272

File tree

8 files changed

+410
-4
lines changed

8 files changed

+410
-4
lines changed

README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,13 @@ parser = EnvironmentParser()
6565

6666
### Modelling
6767

68-
You can create a BOM Model from either an Parser instance or manually using the methods avaialbel directly on the `Bom` class.
68+
You can create a BOM Model from either a Parser instance or manually using the methods avaialbel directly on the `Bom` class.
69+
70+
The model also supports definition of vulnerabilities for output using the CycloneDX schema extension for
71+
[Vulnerability Disclosures](https://cyclonedx.org/use-cases/#vulnerability-disclosure) as of version 0.3.0.
72+
73+
**Note:** Known vulnerabilities associated with Components can be sourced from various data sources, but this library
74+
will not source them for you. Perhaps look at [Jake](https://github.com/sonatype-nexus-community/jake) if you're interested in this.
6975

7076
#### Example from a Parser
7177

cyclonedx/model/bom.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,10 @@ def get_urn_uuid(self) -> str:
8484

8585
def has_component(self, component: Component) -> bool:
8686
return component in self._components
87+
88+
def has_vulnerabilities(self) -> bool:
89+
for c in self.get_components():
90+
if c.has_vulnerabilities():
91+
return True
92+
93+
return False

cyclonedx/model/component.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@
1919

2020
from enum import Enum
2121
from packageurl import PackageURL
22+
from typing import List
23+
24+
from .vulnerability import Vulnerability
2225

2326
PURL_TYPE_PREFIX = 'pypi'
2427

@@ -51,12 +54,18 @@ class Component:
5154
_description: str = None
5255
_license: str = None
5356

57+
_vulnerabilites: List[Vulnerability] = []
58+
5459
def __init__(self, name: str, version: str, qualifiers: str = None,
5560
component_type: ComponentType = ComponentType.LIBRARY):
5661
self._name = name
5762
self._version = version
5863
self._type = component_type
5964
self._qualifiers = qualifiers
65+
self._vulnerabilites = []
66+
67+
def add_vulnerability(self, vulnerability: Vulnerability):
68+
self._vulnerabilites.append(vulnerability)
6069

6170
def get_author(self) -> str:
6271
return self._author
@@ -82,6 +91,12 @@ def get_type(self) -> ComponentType:
8291
def get_version(self) -> str:
8392
return self._version
8493

94+
def get_vulnerabilities(self) -> List[Vulnerability]:
95+
return self._vulnerabilites
96+
97+
def has_vulnerabilities(self) -> bool:
98+
return len(self._vulnerabilites) != 0
99+
85100
def set_author(self, author: str):
86101
self._author = author
87102

cyclonedx/model/vulnerability.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
# encoding: utf-8
2+
3+
# This file is part of CycloneDX Python Lib
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
#
17+
# SPDX-License-Identifier: Apache-2.0
18+
# Copyright (c) OWASP Foundation. All Rights Reserved.
19+
20+
from enum import Enum
21+
from typing import List, Union
22+
from urllib.parse import ParseResult, urlparse
23+
24+
"""
25+
This set of classes represents the data that is possible under the CycloneDX extension
26+
schema for Vulnerabilties (version 1.0).
27+
28+
See: https://github.com/CycloneDX/specification/blob/master/schema/ext/vulnerability-1.0.xsd
29+
"""
30+
31+
32+
class VulnerabilitySourceType(Enum):
33+
"""
34+
Represents <xs:simpleType name="scoreSourceType">
35+
"""
36+
CVSS_V2 = 'CVSSv2'
37+
CVSS_V3 = 'CVSSv3'
38+
OWASP = 'OWASP Risk'
39+
OPEN_FAIR = 'Open FAIR'
40+
OTHER = 'Other'
41+
42+
43+
class VulnerabilitySeverity(Enum):
44+
"""
45+
Represents <xs:simpleType name="severityType">
46+
"""
47+
NONE = 'None'
48+
LOW = 'Low'
49+
MEDIUM = 'Medium'
50+
HIGH = 'High'
51+
CRITICAL = 'Critical'
52+
UNKNOWN = 'Unknown'
53+
54+
55+
class VulnerabilityRating:
56+
"""
57+
Represents <xs:complexType name="scoreType">
58+
"""
59+
_score_base: float
60+
_score_impact: float
61+
_score_exploitability: float
62+
_severity: VulnerabilitySeverity
63+
_method: VulnerabilitySourceType
64+
_vector: str
65+
66+
def __init__(self, score_base: float = None, score_impact: float = None, score_exploitability=None,
67+
severity: VulnerabilitySeverity = None, method: VulnerabilitySourceType = None,
68+
vector: str = None):
69+
self._score_base = score_base
70+
self._score_impact = score_impact
71+
self._score_exploitability = score_exploitability
72+
self._severity = severity
73+
self._method = method
74+
self._vector = vector
75+
76+
def get_base_score(self) -> float:
77+
return self._score_base
78+
79+
def get_impact_score(self) -> float:
80+
return self._score_impact
81+
82+
def get_exploitability_score(self) -> float:
83+
return self._score_exploitability
84+
85+
def get_severity(self) -> Union[VulnerabilitySeverity, None]:
86+
return self._severity
87+
88+
def get_method(self) -> Union[VulnerabilitySourceType, None]:
89+
return self._method
90+
91+
def get_vector(self) -> Union[str, None]:
92+
return self._vector
93+
94+
def has_score(self) -> bool:
95+
return (None, None, None) != (self._score_base, self._score_impact, self._score_exploitability)
96+
97+
98+
class Vulnerability:
99+
"""
100+
Represents <xs:complexType name="vulnerability">
101+
"""
102+
_id: str
103+
_source_name: str
104+
_source_url: ParseResult
105+
_ratings: List[VulnerabilityRating] = []
106+
_cwes: List[int] = []
107+
_description: str = None
108+
_recommendations: List[str] = []
109+
_advisories: List[str] = []
110+
111+
def __init__(self, id: str, source_name: str = None, source_url: str = None,
112+
ratings: List[VulnerabilityRating] = [], cwes: List[int] = [], description: str = None,
113+
recommendations: List[str] = [], advisories: List[str] = []):
114+
self._id = id
115+
self._source_name = source_name
116+
self._source_url = urlparse(source_url) if source_url else None
117+
self._ratings = ratings
118+
self._cwes = cwes
119+
self._description = description
120+
self._recommendations = recommendations
121+
self._advisories = advisories
122+
123+
def get_id(self) -> str:
124+
return self._id
125+
126+
def get_source_name(self) -> Union[str, None]:
127+
return self._source_name
128+
129+
def get_source_url(self) -> Union[ParseResult, None]:
130+
return self._source_url
131+
132+
def get_ratings(self) -> List[VulnerabilityRating]:
133+
return self._ratings
134+
135+
def get_cwes(self) -> List[int]:
136+
return self._cwes
137+
138+
def get_description(self) -> Union[str, None]:
139+
return self._description
140+
141+
def get_recommendations(self) -> Union[List[str], None]:
142+
return self._recommendations
143+
144+
def get_advisories(self) -> Union[List[str], None]:
145+
return self._advisories
146+
147+
def has_ratings(self) -> bool:
148+
return len(self.get_ratings()) > 0
149+
150+
def has_cwes(self) -> bool:
151+
return len(self._cwes) > 0
152+
153+
def has_recommendations(self) -> bool:
154+
return len(self._recommendations) > 0
155+
156+
def has_advisories(self) -> bool:
157+
return len(self._advisories) > 0

cyclonedx/output/xml.py

Lines changed: 100 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from . import BaseOutput
2323
from .schema import BaseSchemaVersion, SchemaVersion1Dot0, SchemaVersion1Dot1, SchemaVersion1Dot2, SchemaVersion1Dot3
2424
from ..model.component import Component
25+
from ..model.vulnerability import Vulnerability, VulnerabilityRating
2526

2627

2728
class Xml(BaseOutput, BaseSchemaVersion):
@@ -30,24 +31,49 @@ class Xml(BaseOutput, BaseSchemaVersion):
3031
def get_target_namespace(self) -> str:
3132
return 'http://cyclonedx.org/schema/bom/{}'.format(self.get_schema_version())
3233

34+
@staticmethod
35+
def get_vulnerabilities_namespace() -> str:
36+
return 'http://cyclonedx.org/schema/ext/vulnerability/1.0'
37+
3338
def output_as_string(self) -> str:
3439
bom = self._get_bom_root_element()
3540

3641
if self.bom_supports_metadata():
3742
bom = self._add_metadata(bom=bom)
3843

44+
if self.get_bom().has_vulnerabilities():
45+
ElementTree.register_namespace('v', Xml.get_vulnerabilities_namespace())
46+
3947
components = ElementTree.SubElement(bom, 'components')
48+
# if self.get_bom().has_vulnerabilities():
49+
# vulnerabilities = ElementTree.SubElement(bom, 'v:vulnerabilities')
50+
4051
for component in self.get_bom().get_components():
41-
components.append(self._get_component_as_xml_element(component=component))
52+
component_element = self._get_component_as_xml_element(component=component)
53+
components.append(component_element)
54+
if component.has_vulnerabilities() and self.component_supports_bom_ref():
55+
# Vulnerabilities are only possible when bom-ref is supported by the main CycloneDX schema version
56+
vulnerabilities = ElementTree.SubElement(component_element, 'v:vulnerabilities')
57+
for vulnerability in component.get_vulnerabilities():
58+
vulnerabilities.append(self._get_vulnerability_as_xml_element(bom_ref=component.get_purl(),
59+
vulnerability=vulnerability))
4260

4361
return Xml.XML_VERSION_DECLARATION + ElementTree.tostring(bom, 'unicode')
4462

4563
def _component_supports_bom_ref_attribute(self) -> bool:
4664
return True
4765

4866
def _get_bom_root_element(self) -> ElementTree.Element:
49-
return ElementTree.Element('bom', {'xmlns': self.get_target_namespace(), 'version': '1',
50-
'serialNumber': self.get_bom().get_urn_uuid()})
67+
root_attributes = {
68+
'xmlns': self.get_target_namespace(),
69+
'version': '1',
70+
'serialNumber': self.get_bom().get_urn_uuid()
71+
}
72+
73+
if self.get_bom().has_vulnerabilities():
74+
root_attributes['xmlns:v'] = Xml.get_vulnerabilities_namespace()
75+
76+
return ElementTree.Element('bom', root_attributes)
5177

5278
def _get_component_as_xml_element(self, component: Component) -> ElementTree.Element:
5379
element_attributes = {'type': component.get_type().value}
@@ -91,6 +117,77 @@ def _get_component_as_xml_element(self, component: Component) -> ElementTree.Ele
91117

92118
return component_element
93119

120+
@staticmethod
121+
def _get_vulnerability_as_xml_element(bom_ref: str, vulnerability: Vulnerability) -> ElementTree.Element:
122+
vulnerability_element = ElementTree.Element('v:vulnerability', {
123+
'ref': bom_ref
124+
})
125+
126+
# id
127+
ElementTree.SubElement(vulnerability_element, 'v:id').text = vulnerability.get_id()
128+
129+
# source
130+
if vulnerability.get_source_name():
131+
source_element = ElementTree.SubElement(
132+
vulnerability_element, 'v:source', attrib={'name': vulnerability.get_source_name()}
133+
)
134+
if vulnerability.get_source_url():
135+
ElementTree.SubElement(source_element, 'v:url').text = vulnerability.get_source_url().geturl()
136+
137+
# ratings
138+
if vulnerability.has_ratings():
139+
ratings_element = ElementTree.SubElement(vulnerability_element, 'v:ratings')
140+
rating: VulnerabilityRating
141+
for rating in vulnerability.get_ratings():
142+
rating_element = ElementTree.SubElement(ratings_element, 'v:rating')
143+
144+
# rating.score
145+
if rating.has_score():
146+
score_element = ElementTree.SubElement(rating_element, 'v:score')
147+
if rating.get_base_score():
148+
ElementTree.SubElement(score_element, 'v:base').text = str(rating.get_base_score())
149+
if rating.get_impact_score():
150+
ElementTree.SubElement(score_element, 'v:impact').text = str(rating.get_impact_score())
151+
if rating.get_exploitability_score():
152+
ElementTree.SubElement(score_element,
153+
'v:exploitability').text = str(rating.get_exploitability_score())
154+
155+
# rating.severity
156+
if rating.get_severity():
157+
ElementTree.SubElement(rating_element, 'v:severity').text = rating.get_severity().value
158+
159+
# rating.severity
160+
if rating.get_method():
161+
ElementTree.SubElement(rating_element, 'v:method').text = rating.get_method().value
162+
163+
# rating.vector
164+
if rating.get_vector():
165+
ElementTree.SubElement(rating_element, 'v:vector').text = rating.get_vector()
166+
167+
# cwes
168+
if vulnerability.has_cwes():
169+
cwes_element = ElementTree.SubElement(vulnerability_element, 'v:cwes')
170+
for cwe in vulnerability.get_cwes():
171+
ElementTree.SubElement(cwes_element, 'v:cwe').text = str(cwe)
172+
173+
# description
174+
if vulnerability.get_description():
175+
ElementTree.SubElement(vulnerability_element, 'v:description').text = vulnerability.get_description()
176+
177+
# recommendations
178+
if vulnerability.has_recommendations():
179+
recommendations_element = ElementTree.SubElement(vulnerability_element, 'v:recommendations')
180+
for recommendation in vulnerability.get_recommendations():
181+
ElementTree.SubElement(recommendations_element, 'v:recommendation').text = recommendation
182+
183+
# advisories
184+
if vulnerability.has_advisories():
185+
advisories_element = ElementTree.SubElement(vulnerability_element, 'v:advisories')
186+
for advisory in vulnerability.get_advisories():
187+
ElementTree.SubElement(advisories_element, 'v:advisory').text = advisory
188+
189+
return vulnerability_element
190+
94191
def _add_metadata(self, bom: ElementTree.Element) -> ElementTree.Element:
95192
metadata_e = ElementTree.SubElement(bom, 'metadata')
96193
ElementTree.SubElement(metadata_e, 'timestamp').text = self.get_bom().get_metadata().get_timestamp().isoformat()

0 commit comments

Comments
 (0)