diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 8db09ebe..4917858f 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -35,7 +35,7 @@ jobs:
- name: Build documentation
run: |
- poetry run pdoc --html cyclonedx
+ poetry run pdoc --template-dir doc/templates --html cyclonedx
- name: Deploy documentation
uses: JamesIves/github-pages-deploy-action@4.1.5
with:
diff --git a/cyclonedx/model/__init__.py b/cyclonedx/model/__init__.py
index 4ef3b2fc..2c62ac56 100644
--- a/cyclonedx/model/__init__.py
+++ b/cyclonedx/model/__init__.py
@@ -17,6 +17,7 @@
import hashlib
from enum import Enum
+from typing import List, Union
"""
Uniform set of models to represent objects within a CycloneDX software bill-of-materials.
@@ -69,6 +70,36 @@ class HashType:
_algorithm: HashAlgorithm
_value: str
+ @staticmethod
+ def from_composite_str(composite_hash: str):
+ """
+ Attempts to convert a string which includes both the Hash Algorithm and Hash Value and represent using our
+ internal model classes.
+
+ Args:
+ composite_hash:
+ Composite Hash string of the format `HASH_ALGORITHM`:`HASH_VALUE`.
+ Example: `sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b`.
+
+ Returns:
+ An instance of `HashType` when possible, else `None`.
+ """
+ algorithm: HashAlgorithm = None
+ parts = composite_hash.split(':')
+
+ algorithm_prefix = parts[0].lower()
+ if algorithm_prefix == 'md5':
+ algorithm = HashAlgorithm.MD5
+ elif algorithm_prefix[0:3] == 'sha':
+ algorithm = getattr(HashAlgorithm, 'SHA_{}'.format(algorithm_prefix[3:]))
+ elif algorithm_prefix[0:6] == 'blake2':
+ algorithm = getattr(HashAlgorithm, 'BLAKE2b_{}'.format(algorithm_prefix[6:]))
+
+ return HashType(
+ algorithm=algorithm,
+ hash_value=parts[1].lower()
+ )
+
def __init__(self, algorithm: HashAlgorithm, hash_value: str):
self._algorithm = algorithm
self._value = hash_value
@@ -78,3 +109,95 @@ def get_algorithm(self) -> HashAlgorithm:
def get_hash_value(self) -> str:
return self._value
+
+
+class ExternalReferenceType(Enum):
+ """
+ Enum object that defines the permissible 'types' for an External Reference according to the CycloneDX schema.
+
+ .. note::
+ See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.3/#type_externalReferenceType
+ """
+ ADVISORIES = 'advisories'
+ BOM = 'bom'
+ BUILD_META = 'build-meta'
+ BUILD_SYSTEM = 'build-system'
+ CHAT = 'chat'
+ DISTRIBUTION = 'distribution'
+ DOCUMENTATION = 'documentation'
+ ISSUE_TRACKER = 'issue-tracker'
+ LICENSE = 'license'
+ MAILING_LIST = 'mailing-list'
+ OTHER = 'other'
+ SOCIAL = 'social'
+ SCM = 'vcs'
+ SUPPORT = 'support'
+ VCS = 'vcs'
+ WEBSITE = 'website'
+
+
+class ExternalReference:
+ """
+ This is out internal representation of an ExternalReference complex type that can be used in multiple places within
+ a CycloneDX BOM document.
+
+ .. note::
+ See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.3/#type_externalReference
+ """
+ _reference_type: ExternalReferenceType
+ _url: str
+ _comment: str
+ _hashes: List[HashType] = []
+
+ def __init__(self, reference_type: ExternalReferenceType, url: str, comment: str = None,
+ hashes: List[HashType] = []):
+ self._reference_type = reference_type
+ self._url = url
+ self._comment = comment
+ self._hashes = hashes
+
+ def add_hash(self, our_hash: HashType):
+ """
+ Adds a hash that pins/identifies this External Reference.
+
+ Args:
+ our_hash:
+ `HashType` instance
+ """
+ self._hashes.append(our_hash)
+
+ def get_comment(self) -> Union[str, None]:
+ """
+ Get the comment for this External Reference.
+
+ Returns:
+ Any comment as a `str` else `None`.
+ """
+ return self._comment
+
+ def get_hashes(self) -> List[HashType]:
+ """
+ List of cryptographic hashes that identify this External Reference.
+
+ Returns:
+ `List` of `HashType` objects where there are any hashes, else an empty `List`.
+ """
+ return self._hashes
+
+ def get_reference_type(self) -> ExternalReferenceType:
+ """
+ Get the type of this External Reference.
+
+ Returns:
+ `ExternalReferenceType` that represents the type of this External Reference.
+ """
+ return self._reference_type
+
+ def get_url(self) -> str:
+ """
+ Get the URL/URI for this External Reference.
+
+ Returns:
+ URI as a `str`.
+ """
+ return self._url
diff --git a/cyclonedx/model/component.py b/cyclonedx/model/component.py
index cb7f49bc..7b706cdc 100644
--- a/cyclonedx/model/component.py
+++ b/cyclonedx/model/component.py
@@ -22,7 +22,7 @@
from packageurl import PackageURL
from typing import List
-from . import HashAlgorithm, HashType, sha1sum
+from . import ExternalReference, HashAlgorithm, HashType, sha1sum
from .vulnerability import Vulnerability
@@ -62,6 +62,7 @@ class Component:
_hashes: List[HashType] = []
_vulnerabilites: List[Vulnerability] = []
+ _external_references: List[ExternalReference] = []
@staticmethod
def for_file(absolute_file_path: str, path_for_bom: str = None):
@@ -92,15 +93,28 @@ def for_file(absolute_file_path: str, path_for_bom: str = None):
package_url_type='generic'
)
- def __init__(self, name: str, version: str, qualifiers: str = None, hashes: List[HashType] = [],
+ def __init__(self, name: str, version: str, qualifiers: str = None, hashes: List[HashType] = None,
component_type: ComponentType = ComponentType.LIBRARY, package_url_type: str = 'pypi'):
self._name = name
self._version = version
self._type = component_type
self._qualifiers = qualifiers
- self._hashes = hashes
- self._vulnerabilites = []
+ self._hashes.clear()
+ if hashes:
+ self._hashes = hashes
+ self._vulnerabilites.clear()
self._package_url_type = package_url_type
+ self._external_references.clear()
+
+ def add_external_reference(self, reference: ExternalReference):
+ """
+ Add an `ExternalReference` to this `Component`.
+
+ Args:
+ reference:
+ `ExternalReference` instance to add.
+ """
+ self._external_references.append(reference)
def add_hash(self, hash: HashType):
"""
@@ -143,6 +157,15 @@ def get_description(self) -> str:
"""
return self._description
+ def get_external_references(self) -> List[ExternalReference]:
+ """
+ List of external references for this Component.
+
+ Returns:
+ `List` of `ExternalReference` objects where there are any, else an empty `List`.
+ """
+ return self._external_references
+
def get_hashes(self) -> List[HashType]:
"""
List of cryptographic hashes that identify this Component.
@@ -182,6 +205,9 @@ def get_purl(self) -> str:
base_purl = '{}?{}'.format(base_purl, self._qualifiers)
return base_purl
+ def get_pypi_url(self) -> str:
+ return f'https://pypi.org/project/{self.get_name()}/{self.get_version()}'
+
def get_type(self) -> ComponentType:
"""
Get the type of this Component.
diff --git a/cyclonedx/output/json.py b/cyclonedx/output/json.py
index d8205299..0f4c7b49 100644
--- a/cyclonedx/output/json.py
+++ b/cyclonedx/output/json.py
@@ -53,7 +53,7 @@ def _get_component_as_dict(self, component: Component) -> dict:
"purl": component.get_purl()
}
- if len(component.get_hashes()) > 0:
+ if component.get_hashes():
hashes = []
for component_hash in component.get_hashes():
hashes.append({
@@ -62,9 +62,31 @@ def _get_component_as_dict(self, component: Component) -> dict:
})
c['hashes'] = hashes
- if self.component_supports_author() and component.get_author() is not None:
+ if self.component_supports_author() and component.get_author():
c['author'] = component.get_author()
+ if self.component_supports_external_references() and component.get_external_references():
+ c['externalReferences'] = []
+ for ext_ref in component.get_external_references():
+ ref = {
+ "type": ext_ref.get_reference_type().value,
+ "url": ext_ref.get_url()
+ }
+
+ if ext_ref.get_comment():
+ ref['comment'] = ext_ref.get_comment()
+
+ if ext_ref.get_hashes():
+ ref_hashes = []
+ for ref_hash in ext_ref.get_hashes():
+ ref_hashes.append({
+ "alg": ref_hash.get_algorithm().value,
+ "content": ref_hash.get_hash_value()
+ })
+ ref['hashes'] = ref_hashes
+
+ c['externalReferences'].append(ref)
+
return c
def _get_metadata_as_dict(self) -> dict:
diff --git a/cyclonedx/output/schema.py b/cyclonedx/output/schema.py
index 247d935b..36ccf28f 100644
--- a/cyclonedx/output/schema.py
+++ b/cyclonedx/output/schema.py
@@ -34,6 +34,9 @@ def component_supports_author(self) -> bool:
def component_supports_bom_ref(self) -> bool:
return True
+ def component_supports_external_references(self) -> bool:
+ return True
+
def get_schema_version(self) -> str:
pass
@@ -79,5 +82,8 @@ def component_supports_author(self) -> bool:
def component_supports_bom_ref(self) -> bool:
return False
+ def component_supports_external_references(self) -> bool:
+ return False
+
def get_schema_version(self) -> str:
return '1.0'
diff --git a/cyclonedx/output/xml.py b/cyclonedx/output/xml.py
index 706e3777..82ba1530 100644
--- a/cyclonedx/output/xml.py
+++ b/cyclonedx/output/xml.py
@@ -17,10 +17,12 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright (c) OWASP Foundation. All Rights Reserved.
+from typing import List
from xml.etree import ElementTree
from . import BaseOutput
from .schema import BaseSchemaVersion, SchemaVersion1Dot0, SchemaVersion1Dot1, SchemaVersion1Dot2, SchemaVersion1Dot3
+from ..model import HashType
from ..model.component import Component
from ..model.vulnerability import Vulnerability, VulnerabilityRating
@@ -89,16 +91,32 @@ def _get_component_as_xml_element(self, component: Component) -> ElementTree.Ele
# version
ElementTree.SubElement(component_element, 'version').text = component.get_version()
+ # hashes
+ if len(component.get_hashes()) > 0:
+ Xml._add_hashes_to_element(hashes=component.get_hashes(), element=component_element)
+ # hashes_e = ElementTree.SubElement(component_element, 'hashes')
+ # for hash in component.get_hashes():
+ # ElementTree.SubElement(
+ # hashes_e, 'hash', {'alg': hash.get_algorithm().value}
+ # ).text = hash.get_hash_value()
+
# purl
ElementTree.SubElement(component_element, 'purl').text = component.get_purl()
- # hashes
- if len(component.get_hashes()) > 0:
- hashes_e = ElementTree.SubElement(component_element, 'hashes')
- for hash in component.get_hashes():
- ElementTree.SubElement(
- hashes_e, 'hash', {'alg': hash.get_algorithm().value}
- ).text = hash.get_hash_value()
+ # externalReferences
+ if self.component_supports_external_references() and len(component.get_external_references()) > 0:
+ external_references_e = ElementTree.SubElement(component_element, 'externalReferences')
+ for ext_ref in component.get_external_references():
+ external_reference_e = ElementTree.SubElement(
+ external_references_e, 'reference', {'type': ext_ref.get_reference_type().value}
+ )
+ ElementTree.SubElement(external_reference_e, 'url').text = ext_ref.get_url()
+
+ if ext_ref.get_comment():
+ ElementTree.SubElement(external_reference_e, 'comment').text = ext_ref.get_comment()
+
+ if len(ext_ref.get_hashes()) > 0:
+ Xml._add_hashes_to_element(hashes=ext_ref.get_hashes(), element=external_reference_e)
return component_element
@@ -194,6 +212,14 @@ def _add_metadata(self, bom: ElementTree.Element) -> ElementTree.Element:
return bom
+ @staticmethod
+ def _add_hashes_to_element(hashes: List[HashType], element: ElementTree.Element):
+ hashes_e = ElementTree.SubElement(element, 'hashes')
+ for h in hashes:
+ ElementTree.SubElement(
+ hashes_e, 'hash', {'alg': h.get_algorithm().value}
+ ).text = h.get_hash_value()
+
class XmlV1Dot0(Xml, SchemaVersion1Dot0):
diff --git a/cyclonedx/parser/__init__.py b/cyclonedx/parser/__init__.py
index f8f7fa79..76fe256a 100644
--- a/cyclonedx/parser/__init__.py
+++ b/cyclonedx/parser/__init__.py
@@ -18,6 +18,50 @@
Set of classes and methods which allow for quick creation of a Bom instance from your environment or Python project.
Use a Parser instead of programmatically creating a Bom as a developer.
+
+Different parsers support population of different information about your dependencies due to how information is
+obtained and limitations of what information is available to each Parser. The table below explains coverage as to what
+information is obtained by each set of Parsers. It does NOT guarantee the information is output in the resulting
+CycloneDX BOM document.
+
+| Data Path | Environment | Pipenv | Poetry | Requirements |
+| ----------- | ----------- | ----------- | ----------- | ----------- |
+| `component.supplier` | N (if in package METADATA) | N/A | | |
+| `component.author` | Y (if in package METADATA) | N/A | | |
+| `component.publisher` | N (if in package METADATA) | N/A | | |
+| `component.group` | - | - | - | - |
+| `component.name` | Y | Y | Y | Y |
+| `component.version` | Y | Y | Y | Y |
+| `component.description` | N | N/A | N | N/A |
+| `component.scope` | N | N/A | N | N/A |
+| `component.hashes` | N/A | Y - see below (1) | Y - see below (1) | N/A |
+| `component.licenses` | Y (if in package METADATA) | N/A | N/A | N/A |
+| `component.copyright` | N (if in package METADATA) | N/A | N/A | N/A |
+| `component.cpe` | _Deprecated_ | _Deprecated_ | _Deprecated_ | _Deprecated_ |
+| `component.purl` | Y | Y | Y | Y |
+| `component.swid` | N/A | N/A | N/A | N/A |
+| `component.modified` | _Deprecated_ | _Deprecated_ | _Deprecated_ | _Deprecated_ |
+| `component.pedigree` | N/A | N/A | N/A | N/A |
+| `component.externalReferences` | N/A | Y - see below (1) | Y - see below (1) | N/A |
+| `component.properties` | N/A | N/A | N/A | N/A |
+| `component.components` | N/A | N/A | N/A | N/A |
+| `component.evidence` | N/A | N/A | N/A | N/A |
+
+**Legend:**
+
+* `Y`: YES with any caveat states.
+* `N`: Not currently supported, but support believed to be possible.
+* `N/A`: Not supported and not deemed possible (i.e. the Parser would never be able to reliably determine this info).
+* `-`: Deemed not applicable to the Python ecosystem.
+
+**Notes:**
+
+1. Python packages are regularly available as both `.whl` and `.tar.gz` packages. This means for that for a given
+ package and version multiple artefacts are possible - which would mean multiple hashes are possible. CycloneDX
+ supports only a single set of hashes identifying a single artefact at `component.hashes`. To cater for this
+ situation in Python, we add the hashes to `component.externalReferences`, as we cannot determine which package was
+ actually obtained and installed to meet a given dependency.
+
"""
from abc import ABC
diff --git a/cyclonedx/parser/environment.py b/cyclonedx/parser/environment.py
index c249382f..6d12a6d8 100644
--- a/cyclonedx/parser/environment.py
+++ b/cyclonedx/parser/environment.py
@@ -17,6 +17,17 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright (c) OWASP Foundation. All Rights Reserved.
+"""
+Parser classes for reading installed packages in your current Python environment.
+
+These parsers look at installed packages only - not what you have defined in any dependency tool - see the other Parsers
+if you want to derive CycloneDX from declared dependencies.
+
+
+The Environment Parsers support population of the following data about Components:
+
+"""
+
import sys
if sys.version_info >= (3, 8, 0):
diff --git a/cyclonedx/parser/pipenv.py b/cyclonedx/parser/pipenv.py
index c3f033d7..c0d685f8 100644
--- a/cyclonedx/parser/pipenv.py
+++ b/cyclonedx/parser/pipenv.py
@@ -19,6 +19,7 @@
import json
from . import BaseParser
+from ..model import ExternalReference, ExternalReferenceType, HashType
from ..model.component import Component
@@ -29,13 +30,21 @@ def __init__(self, pipenv_contents: str):
pipfile_lock_contents = json.loads(pipenv_contents)
for package_name in pipfile_lock_contents['default'].keys():
- print('Processing {}'.format(package_name))
package_data = pipfile_lock_contents['default'][package_name]
c = Component(
name=package_name, version=str(package_data['version']).strip('='),
)
- # @todo: Add hashes
+ if package_data['index'] == 'pypi':
+ # Add download location with hashes stored in Pipfile.lock
+ for pip_hash in package_data['hashes']:
+ ext_ref = ExternalReference(
+ reference_type=ExternalReferenceType.DISTRIBUTION,
+ url=c.get_pypi_url(),
+ comment='Distribution available from pypi.org'
+ )
+ ext_ref.add_hash(HashType.from_composite_str(pip_hash))
+ c.add_external_reference(ext_ref)
self._components.append(c)
diff --git a/cyclonedx/parser/poetry.py b/cyclonedx/parser/poetry.py
index 7eb8833d..88fd246f 100644
--- a/cyclonedx/parser/poetry.py
+++ b/cyclonedx/parser/poetry.py
@@ -20,6 +20,7 @@
import toml
from . import BaseParser
+from ..model import ExternalReference, ExternalReferenceType, HashType
from ..model.component import Component
@@ -30,9 +31,21 @@ def __init__(self, poetry_lock_contents: str):
poetry_lock = toml.loads(poetry_lock_contents)
for package in poetry_lock['package']:
- self._components.append(Component(
- name=package['name'], version=package['version'],
- ))
+ component = Component(
+ name=package['name'], version=package['version']
+ )
+
+ for file_metadata in poetry_lock['metadata']['files'][package['name']]:
+ component.add_external_reference(ExternalReference(
+ reference_type=ExternalReferenceType.DISTRIBUTION,
+ url=component.get_pypi_url(),
+ comment=f'Distribution file: {file_metadata["file"]}',
+ hashes=[
+ HashType.from_composite_str(file_metadata['hash'])
+ ]
+ ))
+
+ self._components.append(component)
class PoetryFileParser(PoetryParser):
diff --git a/doc/templates/config.mako b/doc/templates/config.mako
new file mode 100644
index 00000000..da70d141
--- /dev/null
+++ b/doc/templates/config.mako
@@ -0,0 +1,10 @@
+<%!
+
+git_link_template = 'https://github.com/cyclonedx-python-lib/blob/{commit}/{path}#L{start_line}-L{end_line}'
+
+google_search_query = '''
+ site:cyclonedx.github.io
+ inurl:github.com/cyclonedx/cyclonedx-python-lib
+'''
+
+%>
\ No newline at end of file
diff --git a/doc/templates/css.mako b/doc/templates/css.mako
new file mode 100644
index 00000000..059d2e3b
--- /dev/null
+++ b/doc/templates/css.mako
@@ -0,0 +1,426 @@
+<%!
+ from pdoc.html_helpers import minify_css
+%>
+
+<%def name="mobile()" filter="minify_css">
+ :root {
+ --highlight-color: #fe9;
+ }
+ .flex {
+ display: flex !important;
+ }
+
+ body {
+ line-height: 1.5em;
+ }
+
+ #content {
+ padding: 20px;
+ }
+
+ #sidebar {
+ padding: 30px;
+ overflow: hidden;
+ }
+ #sidebar > *:last-child {
+ margin-bottom: 2cm;
+ }
+
+ % if lunr_search is not None:
+ #lunr-search {
+ width: 100%;
+ font-size: 1em;
+ padding: 6px 9px 5px 9px;
+ border: 1px solid silver;
+ }
+ % endif
+
+ .http-server-breadcrumbs {
+ font-size: 130%;
+ margin: 0 0 15px 0;
+ }
+
+ #footer {
+ font-size: .75em;
+ padding: 5px 30px;
+ border-top: 1px solid #ddd;
+ text-align: right;
+ }
+ #footer p {
+ margin: 0 0 0 1em;
+ display: inline-block;
+ }
+ #footer p:last-child {
+ margin-right: 30px;
+ }
+
+ h1, h2, h3, h4, h5 {
+ font-weight: 300;
+ }
+ h1 {
+ font-size: 2.5em;
+ line-height: 1.1em;
+ }
+ h2 {
+ font-size: 1.75em;
+ margin: 1em 0 .50em 0;
+ }
+ h3 {
+ font-size: 1.4em;
+ margin: 25px 0 10px 0;
+ }
+ h4 {
+ margin: 0;
+ font-size: 105%;
+ }
+ h1:target,
+ h2:target,
+ h3:target,
+ h4:target,
+ h5:target,
+ h6:target {
+ background: var(--highlight-color);
+ padding: .2em 0;
+ }
+
+ a {
+ color: #058;
+ text-decoration: none;
+ transition: color .3s ease-in-out;
+ }
+ a:hover {
+ color: #e82;
+ }
+
+ .title code {
+ font-weight: bold;
+ }
+ h2[id^="header-"] {
+ margin-top: 2em;
+ }
+ .ident {
+ color: #900;
+ }
+
+ pre code {
+ background: #f8f8f8;
+ font-size: .8em;
+ line-height: 1.4em;
+ }
+ code {
+ background: #f2f2f1;
+ padding: 1px 4px;
+ overflow-wrap: break-word;
+ }
+ h1 code { background: transparent }
+
+ pre {
+ background: #f8f8f8;
+ border: 0;
+ border-top: 1px solid #ccc;
+ border-bottom: 1px solid #ccc;
+ margin: 1em 0;
+ padding: 1ex;
+ }
+
+ #http-server-module-list {
+ display: flex;
+ flex-flow: column;
+ }
+ #http-server-module-list div {
+ display: flex;
+ }
+ #http-server-module-list dt {
+ min-width: 10%;
+ }
+ #http-server-module-list p {
+ margin-top: 0;
+ }
+
+ .toc ul,
+ #index {
+ list-style-type: none;
+ margin: 0;
+ padding: 0;
+ }
+ #index code {
+ background: transparent;
+ }
+ #index h3 {
+ border-bottom: 1px solid #ddd;
+ }
+ #index ul {
+ padding: 0;
+ }
+ #index h4 {
+ margin-top: .6em;
+ font-weight: bold;
+ }
+ /* Make TOC lists have 2+ columns when viewport is wide enough.
+ Assuming ~20-character identifiers and ~30% wide sidebar. */
+ @media (min-width: 200ex) { #index .two-column { column-count: 2 } }
+ @media (min-width: 300ex) { #index .two-column { column-count: 3 } }
+
+ dl {
+ margin-bottom: 2em;
+ }
+ dl dl:last-child {
+ margin-bottom: 4em;
+ }
+ dd {
+ margin: 0 0 1em 3em;
+ }
+ #header-classes + dl > dd {
+ margin-bottom: 3em;
+ }
+ dd dd {
+ margin-left: 2em;
+ }
+ dd p {
+ margin: 10px 0;
+ }
+ .name {
+ background: #eee;
+ font-weight: bold;
+ font-size: .85em;
+ padding: 5px 10px;
+ display: inline-block;
+ min-width: 40%;
+ }
+ .name:hover {
+ background: #e0e0e0;
+ }
+ dt:target .name {
+ background: var(--highlight-color);
+ }
+ .name > span:first-child {
+ white-space: nowrap;
+ }
+ .name.class > span:nth-child(2) {
+ margin-left: .4em;
+ }
+ .inherited {
+ color: #999;
+ border-left: 5px solid #eee;
+ padding-left: 1em;
+ }
+ .inheritance em {
+ font-style: normal;
+ font-weight: bold;
+ }
+
+ /* Docstrings titles, e.g. in numpydoc format */
+ .desc h2 {
+ font-weight: 400;
+ font-size: 1.25em;
+ }
+ .desc h3 {
+ font-size: 1em;
+ }
+ .desc dt code {
+ background: inherit; /* Don't grey-back parameters */
+ }
+
+ .source summary,
+ .git-link-div {
+ color: #666;
+ text-align: right;
+ font-weight: 400;
+ font-size: .8em;
+ text-transform: uppercase;
+ }
+ .source summary > * {
+ white-space: nowrap;
+ cursor: pointer;
+ }
+ .git-link {
+ color: inherit;
+ margin-left: 1em;
+ }
+ .source pre {
+ max-height: 500px;
+ overflow: auto;
+ margin: 0;
+ }
+ .source pre code {
+ font-size: 12px;
+ overflow: visible;
+ }
+ .hlist {
+ list-style: none;
+ }
+ .hlist li {
+ display: inline;
+ }
+ .hlist li:after {
+ content: ',\2002';
+ }
+ .hlist li:last-child:after {
+ content: none;
+ }
+ .hlist .hlist {
+ display: inline;
+ padding-left: 1em;
+ }
+
+ img {
+ max-width: 100%;
+ }
+ td {
+ padding: 0 .5em;
+ }
+
+ .admonition {
+ padding: .1em .5em;
+ margin-bottom: 1em;
+ }
+ .admonition-title {
+ font-weight: bold;
+ }
+ .admonition.note,
+ .admonition.info,
+ .admonition.important {
+ background: #aef;
+ }
+ .admonition.todo,
+ .admonition.versionadded,
+ .admonition.tip,
+ .admonition.hint {
+ background: #dfd;
+ }
+ .admonition.warning,
+ .admonition.versionchanged,
+ .admonition.deprecated {
+ background: #fd4;
+ }
+ .admonition.error,
+ .admonition.danger,
+ .admonition.caution {
+ background: lightpink;
+ }
+%def>
+
+<%def name="desktop()" filter="minify_css">
+ @media screen and (min-width: 700px) {
+ #sidebar {
+ width: 30%;
+ height: 100vh;
+ overflow: auto;
+ position: sticky;
+ top: 0;
+ }
+ #content {
+ width: 70%;
+ #max-width: 100ch;
+ padding: 3em 4em;
+ border-left: 1px solid #ddd;
+ }
+ #content table thead tr th {
+ border-bottom: 1px solid black;
+ padding: 0 10px;
+ }
+ #content table tbody tr td {
+ padding: 0 10px;
+ }
+ pre code {
+ font-size: 1em;
+ }
+ .item .name {
+ font-size: 1em;
+ }
+ main {
+ display: flex;
+ flex-direction: row-reverse;
+ justify-content: flex-end;
+ }
+ .toc ul ul,
+ #index ul {
+ padding-left: 1.5em;
+ }
+ .toc > ul > li {
+ margin-top: .5em;
+ }
+ }
+%def>
+
+<%def name="print()" filter="minify_css">
+@media print {
+ #sidebar h1 {
+ page-break-before: always;
+ }
+ .source {
+ display: none;
+ }
+}
+@media print {
+ * {
+ background: transparent !important;
+ color: #000 !important; /* Black prints faster: h5bp.com/s */
+ box-shadow: none !important;
+ text-shadow: none !important;
+ }
+
+ a[href]:after {
+ content: " (" attr(href) ")";
+ font-size: 90%;
+ }
+ /* Internal, documentation links, recognized by having a title,
+ don't need the URL explicity stated. */
+ a[href][title]:after {
+ content: none;
+ }
+
+ abbr[title]:after {
+ content: " (" attr(title) ")";
+ }
+
+ /*
+ * Don't show links for images, or javascript/internal links
+ */
+
+ .ir a:after,
+ a[href^="javascript:"]:after,
+ a[href^="#"]:after {
+ content: "";
+ }
+
+ pre,
+ blockquote {
+ border: 1px solid #999;
+ page-break-inside: avoid;
+ }
+
+ thead {
+ display: table-header-group; /* h5bp.com/t */
+ }
+
+ tr,
+ img {
+ page-break-inside: avoid;
+ }
+
+ img {
+ max-width: 100% !important;
+ }
+
+ @page {
+ margin: 0.5cm;
+ }
+
+ p,
+ h2,
+ h3 {
+ orphans: 3;
+ widows: 3;
+ }
+
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ page-break-after: avoid;
+ }
+}
+%def>
diff --git a/doc/templates/head.mako b/doc/templates/head.mako
new file mode 100644
index 00000000..0fa3cb29
--- /dev/null
+++ b/doc/templates/head.mako
@@ -0,0 +1,26 @@
+<%!
+ from pdoc.html_helpers import minify_css
+%>
+<%def name="homelink()" filter="minify_css">
+ .homelink {
+ display: block;
+ font-size: 2em;
+ font-weight: bold;
+ color: #555;
+ padding-bottom: .5em;
+ border-bottom: 1px solid silver;
+ }
+ .homelink:hover {
+ color: inherit;
+ }
+ .homelink img {
+ max-width:20%;
+ max-height: 5em;
+ margin: auto;
+ margin-bottom: .3em;
+ }
+%def>
+
+
+
+
\ No newline at end of file
diff --git a/doc/templates/logo.mako b/doc/templates/logo.mako
new file mode 100644
index 00000000..35dda058
--- /dev/null
+++ b/doc/templates/logo.mako
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/tests/fixtures/bom_v1.3_toml_with_component_external_references.json b/tests/fixtures/bom_v1.3_toml_with_component_external_references.json
new file mode 100644
index 00000000..e221410f
--- /dev/null
+++ b/tests/fixtures/bom_v1.3_toml_with_component_external_references.json
@@ -0,0 +1,43 @@
+{
+ "bomFormat": "CycloneDX",
+ "specVersion": "1.3",
+ "serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79",
+ "version": 1,
+ "metadata": {
+ "timestamp": "2021-09-01T10:50:42.051979+00:00",
+ "tools": [
+ {
+ "vendor": "CycloneDX",
+ "name": "cyclonedx-python-lib",
+ "version": "VERSION"
+ }
+ ]
+ },
+ "components": [
+ {
+ "type": "library",
+ "name": "toml",
+ "version": "0.10.2",
+ "purl": "pkg:pypi/toml@0.10.2?extension=tar.gz",
+ "hashes": [
+ {
+ "alg": "SHA-256",
+ "content": "806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"
+ }
+ ],
+ "externalReferences": [
+ {
+ "type": "distribution",
+ "url": "https://cyclonedx.org",
+ "comment": "No comment",
+ "hashes": [
+ {
+ "alg": "SHA-256",
+ "content": "806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/tests/fixtures/bom_v1.3_toml_with_component_external_references.xml b/tests/fixtures/bom_v1.3_toml_with_component_external_references.xml
new file mode 100644
index 00000000..a6f8af4e
--- /dev/null
+++ b/tests/fixtures/bom_v1.3_toml_with_component_external_references.xml
@@ -0,0 +1,32 @@
+
+
+
+ 2021-09-01T10:50:42.051979+00:00
+
+
+ CycloneDX
+ cyclonedx-python-lib
+ VERSION
+
+
+
+
+
+ toml
+ 0.10.2
+
+ 806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b
+
+ pkg:pypi/toml@0.10.2?extension=tar.gz
+
+
+ https://cyclonedx.org
+ No comment
+
+ 806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/fixtures/bom_v1.3_toml_with_component_hashes.json b/tests/fixtures/bom_v1.3_toml_with_component_hashes.json
new file mode 100644
index 00000000..47896e31
--- /dev/null
+++ b/tests/fixtures/bom_v1.3_toml_with_component_hashes.json
@@ -0,0 +1,30 @@
+{
+ "bomFormat": "CycloneDX",
+ "specVersion": "1.3",
+ "serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79",
+ "version": 1,
+ "metadata": {
+ "timestamp": "2021-09-01T10:50:42.051979+00:00",
+ "tools": [
+ {
+ "vendor": "CycloneDX",
+ "name": "cyclonedx-python-lib",
+ "version": "VERSION"
+ }
+ ]
+ },
+ "components": [
+ {
+ "type": "library",
+ "name": "toml",
+ "version": "0.10.2",
+ "purl": "pkg:pypi/toml@0.10.2?extension=tar.gz",
+ "hashes": [
+ {
+ "alg": "SHA-256",
+ "content": "806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/tests/fixtures/bom_v1.3_toml_with_component_hashes.xml b/tests/fixtures/bom_v1.3_toml_with_component_hashes.xml
new file mode 100644
index 00000000..5843c1ef
--- /dev/null
+++ b/tests/fixtures/bom_v1.3_toml_with_component_hashes.xml
@@ -0,0 +1,23 @@
+
+
+
+ 2021-09-01T10:50:42.051979+00:00
+
+
+ CycloneDX
+ cyclonedx-python-lib
+ VERSION
+
+
+
+
+
+ toml
+ 0.10.2
+
+ 806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b
+
+ pkg:pypi/toml@0.10.2?extension=tar.gz
+
+
+
\ No newline at end of file
diff --git a/tests/test_model.py b/tests/test_model.py
new file mode 100644
index 00000000..81e75abd
--- /dev/null
+++ b/tests/test_model.py
@@ -0,0 +1,16 @@
+from unittest import TestCase
+
+from cyclonedx.model import HashAlgorithm, HashType
+
+
+class TestModel(TestCase):
+
+ def test_hash_type_from_composite_str_1(self):
+ h = HashType.from_composite_str('sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b')
+ self.assertEqual(h.get_algorithm(), HashAlgorithm.SHA_256)
+ self.assertEqual(h.get_hash_value(), '806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b')
+
+ def test_hash_type_from_composite_str_2(self):
+ h = HashType.from_composite_str('md5:dc26cd71b80d6757139f38156a43c545')
+ self.assertEqual(h.get_algorithm(), HashAlgorithm.MD5)
+ self.assertEqual(h.get_hash_value(), 'dc26cd71b80d6757139f38156a43c545')
diff --git a/tests/test_model_component.py b/tests/test_model_component.py
new file mode 100644
index 00000000..c5599a3d
--- /dev/null
+++ b/tests/test_model_component.py
@@ -0,0 +1,65 @@
+from unittest import TestCase
+
+from cyclonedx.model import ExternalReference, ExternalReferenceType
+from cyclonedx.model.component import Component, ComponentType
+
+
+class TestModelComponent(TestCase):
+
+ def test_empty_basic_component(self):
+ c = Component(
+ name='test-component', version='1.2.3'
+ )
+ self.assertEqual(c.get_name(), 'test-component')
+ self.assertEqual(c.get_version(), '1.2.3')
+ self.assertEqual(c.get_type(), ComponentType.LIBRARY)
+ self.assertEqual(len(c.get_external_references()), 0)
+ self.assertEqual(len(c.get_hashes()), 0)
+ self.assertEqual(len(c.get_vulnerabilities()), 0)
+
+ def test_multiple_basic_components(self):
+ c1 = Component(
+ name='test-component', version='1.2.3'
+ )
+ self.assertEqual(c1.get_name(), 'test-component')
+ self.assertEqual(c1.get_version(), '1.2.3')
+ self.assertEqual(c1.get_type(), ComponentType.LIBRARY)
+ self.assertEqual(len(c1.get_external_references()), 0)
+ self.assertEqual(len(c1.get_hashes()), 0)
+ self.assertEqual(len(c1.get_vulnerabilities()), 0)
+
+ c2 = Component(
+ name='test2-component', version='3.2.1'
+ )
+ self.assertEqual(c2.get_name(), 'test2-component')
+ self.assertEqual(c2.get_version(), '3.2.1')
+ self.assertEqual(c2.get_type(), ComponentType.LIBRARY)
+ self.assertEqual(len(c2.get_external_references()), 0)
+ self.assertEqual(len(c2.get_hashes()), 0)
+ self.assertEqual(len(c2.get_vulnerabilities()), 0)
+
+ def test_external_references(self):
+ c = Component(
+ name='test-component', version='1.2.3'
+ )
+ c.add_external_reference(ExternalReference(
+ reference_type=ExternalReferenceType.OTHER,
+ url='https://cyclonedx.org',
+ comment='No comment'
+ ))
+ self.assertEqual(c.get_name(), 'test-component')
+ self.assertEqual(c.get_version(), '1.2.3')
+ self.assertEqual(c.get_type(), ComponentType.LIBRARY)
+ self.assertEqual(len(c.get_external_references()), 1)
+ self.assertEqual(len(c.get_hashes()), 0)
+ self.assertEqual(len(c.get_vulnerabilities()), 0)
+
+ c2 = Component(
+ name='test2-component', version='3.2.1'
+ )
+ self.assertEqual(c2.get_name(), 'test2-component')
+ self.assertEqual(c2.get_version(), '3.2.1')
+ self.assertEqual(c2.get_type(), ComponentType.LIBRARY)
+ self.assertEqual(len(c2.get_external_references()), 0)
+ self.assertEqual(len(c2.get_hashes()), 0)
+ self.assertEqual(len(c2.get_vulnerabilities()), 0)
diff --git a/tests/test_output_json.py b/tests/test_output_json.py
index 0d7006f9..2dc03ae4 100644
--- a/tests/test_output_json.py
+++ b/tests/test_output_json.py
@@ -19,10 +19,11 @@
from os.path import dirname, join
+from cyclonedx.model import ExternalReference, ExternalReferenceType, HashType
from cyclonedx.model.bom import Bom
from cyclonedx.model.component import Component
from cyclonedx.output import get_instance, OutputFormat, SchemaVersion
-from cyclonedx.output.json import JsonV1Dot3, JsonV1Dot2
+from cyclonedx.output.json import Json, JsonV1Dot3, JsonV1Dot2
from tests.base import BaseJsonTestCase
@@ -45,3 +46,41 @@ def test_simple_bom_v1_2(self):
with open(join(dirname(__file__), 'fixtures/bom_v1.2_setuptools.json')) as expected_json:
self.assertEqualJsonBom(outputter.output_as_string(), expected_json.read())
expected_json.close()
+
+ def test_bom_v1_3_with_component_hashes(self):
+ bom = Bom()
+ c = Component(name='toml', version='0.10.2', qualifiers='extension=tar.gz')
+ c.add_hash(
+ HashType.from_composite_str('sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b')
+ )
+ bom.add_component(c)
+ outputter: Json = get_instance(bom=bom, output_format=OutputFormat.JSON)
+ self.assertIsInstance(outputter, JsonV1Dot3)
+ with open(join(dirname(__file__), 'fixtures/bom_v1.3_toml_with_component_hashes.json')) as expected_json:
+ self.assertEqualJsonBom(a=outputter.output_as_string(), b=expected_json.read())
+ expected_json.close()
+
+ def test_bom_v1_3_with_component_external_references(self):
+ bom = Bom()
+ c = Component(name='toml', version='0.10.2', qualifiers='extension=tar.gz')
+ c.add_hash(
+ HashType.from_composite_str('sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b')
+ )
+ c.add_external_reference(
+ ExternalReference(
+ reference_type=ExternalReferenceType.DISTRIBUTION,
+ url='https://cyclonedx.org',
+ comment='No comment',
+ hashes=[
+ HashType.from_composite_str(
+ 'sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b')
+ ]
+ )
+ )
+ bom.add_component(c)
+ outputter: Json = get_instance(bom=bom, output_format=OutputFormat.JSON)
+ self.assertIsInstance(outputter, JsonV1Dot3)
+ with open(join(dirname(__file__),
+ 'fixtures/bom_v1.3_toml_with_component_external_references.json')) as expected_json:
+ self.assertEqualJsonBom(a=outputter.output_as_string(), b=expected_json.read())
+ expected_json.close()
diff --git a/tests/test_output_xml.py b/tests/test_output_xml.py
index 32d3d9fb..6dbbee4f 100644
--- a/tests/test_output_xml.py
+++ b/tests/test_output_xml.py
@@ -19,6 +19,7 @@
from os.path import dirname, join
+from cyclonedx.model import ExternalReference, ExternalReferenceType, HashType
from cyclonedx.model.bom import Bom
from cyclonedx.model.component import Component
from cyclonedx.model.vulnerability import Vulnerability, VulnerabilityRating, VulnerabilitySeverity, \
@@ -63,6 +64,7 @@ def test_simple_bom_v1_1(self):
def test_simple_bom_v1_0(self):
bom = Bom()
bom.add_component(Component(name='setuptools', version='50.3.2', qualifiers='extension=tar.gz'))
+ self.assertEqual(len(bom.get_components()), 1)
outputter = get_instance(bom=bom, schema_version=SchemaVersion.V1_0)
self.assertIsInstance(outputter, XmlV1Dot0)
with open(join(dirname(__file__), 'fixtures/bom_v1.0_setuptools.xml')) as expected_xml:
@@ -123,3 +125,43 @@ def test_simple_bom_v1_0_with_vulnerabilities(self):
namespace=outputter.get_target_namespace())
expected_xml.close()
+
+ def test_bom_v1_3_with_component_hashes(self):
+ bom = Bom()
+ c = Component(name='toml', version='0.10.2', qualifiers='extension=tar.gz')
+ c.add_hash(
+ HashType.from_composite_str('sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b')
+ )
+ bom.add_component(c)
+ outputter: Xml = get_instance(bom=bom)
+ self.assertIsInstance(outputter, XmlV1Dot3)
+ with open(join(dirname(__file__), 'fixtures/bom_v1.3_toml_with_component_hashes.xml')) as expected_xml:
+ self.assertEqualXmlBom(a=outputter.output_as_string(), b=expected_xml.read(),
+ namespace=outputter.get_target_namespace())
+ expected_xml.close()
+
+ def test_bom_v1_3_with_component_external_references(self):
+ bom = Bom()
+ c = Component(name='toml', version='0.10.2', qualifiers='extension=tar.gz')
+ c.add_hash(
+ HashType.from_composite_str('sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b')
+ )
+ c.add_external_reference(
+ ExternalReference(
+ reference_type=ExternalReferenceType.DISTRIBUTION,
+ url='https://cyclonedx.org',
+ comment='No comment',
+ hashes=[
+ HashType.from_composite_str(
+ 'sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b')
+ ]
+ )
+ )
+ bom.add_component(c)
+ outputter: Xml = get_instance(bom=bom)
+ self.assertIsInstance(outputter, XmlV1Dot3)
+ with open(join(dirname(__file__),
+ 'fixtures/bom_v1.3_toml_with_component_external_references.xml')) as expected_xml:
+ self.assertEqualXmlBom(a=outputter.output_as_string(), b=expected_xml.read(),
+ namespace=outputter.get_target_namespace())
+ expected_xml.close()
diff --git a/tests/test_parser_pipenv.py b/tests/test_parser_pipenv.py
index f2346dee..be139718 100644
--- a/tests/test_parser_pipenv.py
+++ b/tests/test_parser_pipenv.py
@@ -33,3 +33,4 @@ def test_simple(self):
components = parser.get_components()
self.assertEqual('toml', components[0].get_name())
self.assertEqual('0.10.2', components[0].get_version())
+ self.assertEqual(len(components[0].get_external_references()), 2)
diff --git a/tests/test_parser_poetry.py b/tests/test_parser_poetry.py
index 74a7f543..22426c38 100644
--- a/tests/test_parser_poetry.py
+++ b/tests/test_parser_poetry.py
@@ -33,3 +33,4 @@ def test_simple(self):
components = parser.get_components()
self.assertEqual('toml', components[0].get_name())
self.assertEqual('0.10.2', components[0].get_version())
+ self.assertEqual(len(components[0].get_external_references()), 2)