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 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 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; + } +} + 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; + } + + + + + \ 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 @@ +
+ + cyclonedx-python-lib + +
\ 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)