From 849c7a4696816aa07125f06e8a446e0fc4d6ac72 Mon Sep 17 00:00:00 2001 From: Arun Date: Sat, 3 May 2025 13:41:16 +0530 Subject: [PATCH 01/24] feat: add support for component's evidences according to spec Signed-off-by: Arun --- cyclonedx/model/component.py | 745 +++++++++++++++++- tests/_data/models.py | 85 ++ ...et_bom_with_component_evidence-1.0.xml.bin | 11 + ...et_bom_with_component_evidence-1.1.xml.bin | 15 + ...t_bom_with_component_evidence-1.2.json.bin | 56 ++ ...et_bom_with_component_evidence-1.2.xml.bin | 39 + ...t_bom_with_component_evidence-1.3.json.bin | 73 ++ ...et_bom_with_component_evidence-1.3.xml.bin | 50 ++ ...t_bom_with_component_evidence-1.4.json.bin | 72 ++ ...et_bom_with_component_evidence-1.4.xml.bin | 49 ++ ...t_bom_with_component_evidence-1.5.json.bin | 86 ++ ...et_bom_with_component_evidence-1.5.xml.bin | 55 ++ ...t_bom_with_component_evidence-1.6.json.bin | 128 +++ ...et_bom_with_component_evidence-1.6.xml.bin | 95 +++ tests/test_model_component.py | 202 +++++ 15 files changed, 1729 insertions(+), 32 deletions(-) create mode 100644 tests/_data/snapshots/get_bom_with_component_evidence-1.0.xml.bin create mode 100644 tests/_data/snapshots/get_bom_with_component_evidence-1.1.xml.bin create mode 100644 tests/_data/snapshots/get_bom_with_component_evidence-1.2.json.bin create mode 100644 tests/_data/snapshots/get_bom_with_component_evidence-1.2.xml.bin create mode 100644 tests/_data/snapshots/get_bom_with_component_evidence-1.3.json.bin create mode 100644 tests/_data/snapshots/get_bom_with_component_evidence-1.3.xml.bin create mode 100644 tests/_data/snapshots/get_bom_with_component_evidence-1.4.json.bin create mode 100644 tests/_data/snapshots/get_bom_with_component_evidence-1.4.xml.bin create mode 100644 tests/_data/snapshots/get_bom_with_component_evidence-1.5.json.bin create mode 100644 tests/_data/snapshots/get_bom_with_component_evidence-1.5.xml.bin create mode 100644 tests/_data/snapshots/get_bom_with_component_evidence-1.6.json.bin create mode 100644 tests/_data/snapshots/get_bom_with_component_evidence-1.6.xml.bin diff --git a/cyclonedx/model/component.py b/cyclonedx/model/component.py index 6e26947e..da1ab6e9 100644 --- a/cyclonedx/model/component.py +++ b/cyclonedx/model/component.py @@ -17,10 +17,12 @@ import re from collections.abc import Iterable +from decimal import Decimal from enum import Enum from os.path import exists from typing import Any, Optional, Union from warnings import warn +from xml.etree.ElementTree import Element # nosec B405 # See https://github.com/package-url/packageurl-python/issues/65 import py_serializable as serializable @@ -191,6 +193,666 @@ def __repr__(self) -> str: return f'' +@serializable.serializable_enum +class IdentityFieldType(str, Enum): + """ + Enum object that defines the permissible field types for Identity. + + .. note:: + See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.6/json/#components_items_evidence_identity + """ + + GROUP = 'group' + NAME = 'name' + VERSION = 'version' + PURL = 'purl' + CPE = 'cpe' + OMNIBOR_ID = 'omniborId' + SWHID = 'swhid' + SWID = 'swid' + HASH = 'hash' + + +@serializable.serializable_enum +class AnalysisTechnique(str, Enum): + """ + Enum object that defines the permissible analysis techniques. + """ + + SOURCE_CODE_ANALYSIS = 'source-code-analysis' + BINARY_ANALYSIS = 'binary-analysis' + MANIFEST_ANALYSIS = 'manifest-analysis' + AST_FINGERPRINT = 'ast-fingerprint' + HASH_COMPARISON = 'hash-comparison' + INSTRUMENTATION = 'instrumentation' + DYNAMIC_ANALYSIS = 'dynamic-analysis' + FILENAME = 'filename' + ATTESTATION = 'attestation' + OTHER = 'other' + + +@serializable.serializable_class +class Method: + """ + Represents a method used to extract and/or analyze evidence. + """ + + def __init__( + self, *, + technique: Union[AnalysisTechnique, str], + confidence: Decimal, + value: Optional[str] = None, + ) -> None: + self.technique = technique + self.confidence = confidence + self.value = value + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'technique') + @serializable.json_name('technique') + @serializable.xml_sequence(1) + def technique(self) -> str: + return self._technique.value + + @technique.setter + def technique(self, technique: Union[AnalysisTechnique, str]) -> None: + if isinstance(technique, str): + try: + technique = AnalysisTechnique(technique) + except ValueError: + raise ValueError( + f'Technique must be one of: {", ".join(t.value for t in AnalysisTechnique)}' + ) + self._technique = technique + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'confidence') + @serializable.json_name('confidence') + @serializable.xml_sequence(2) + def confidence(self) -> Decimal: + return self._confidence + + @confidence.setter + def confidence(self, confidence: Decimal) -> None: + if not 0 <= confidence <= 1: + raise ValueError('Confidence must be between 0 and 1') + self._confidence = confidence + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'value') + @serializable.json_name('value') + @serializable.xml_sequence(3) + def value(self) -> Optional[str]: + return self._value + + @value.setter + def value(self, value: Optional[str]) -> None: + self._value = value + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple( + ( + self.technique, + self.confidence, + self.value, + ) + ) + + def __eq__(self, other: object) -> bool: + if isinstance(other, Method): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, Method): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f'' + + +class _ToolsSerializationHelper(serializable.helpers.BaseHelper): + """ THIS CLASS IS NON-PUBLIC API """ + + @classmethod + def json_normalize(cls, o: Any, *, + view: Optional[type[serializable.ViewType]], + **__: Any) -> Any: + if isinstance(o, SortedSet): + return [str(t) for t in o] # Convert BomRef to string + return o + + @classmethod + def xml_normalize(cls, o: Any, *, + element_name: str, + view: Optional[type[serializable.ViewType]], + xmlns: Optional[str], + **__: Any) -> Optional[Element]: + if len(o) == 0: + return None + + # Create tools element with namespace if provided + tools_elem = Element(f'{{{xmlns}}}tools' if xmlns else 'tools') + for tool in o: + tool_elem = Element(f'{{{xmlns}}}tool' if xmlns else 'tool') + tool_elem.set(f'{{{xmlns}}}ref' if xmlns else 'ref', str(tool)) + tools_elem.append(tool_elem) + return tools_elem + + @classmethod + def json_denormalize(cls, o: Any, **kwargs: Any) -> SortedSet[BomRef]: + if isinstance(o, (list, set, tuple)): + return SortedSet(BomRef(str(t)) for t in o) + return SortedSet() + + @classmethod + def xml_denormalize(cls, o: Element, + default_ns: Optional[str], + **__: Any) -> SortedSet[BomRef]: + repo = [] + tool_tag = f'{{{default_ns}}}tool' if default_ns else 'tool' + ref_attr = f'{{{default_ns}}}ref' if default_ns else 'ref' + for tool_elem in o.findall(f'.//{tool_tag}'): + ref = tool_elem.get(ref_attr) or tool_elem.get('ref') + if ref: + repo.append(BomRef(str(ref))) + else: + raise CycloneDxDeserializationException(f'unexpected: {tool_elem!r}') + return SortedSet(repo) + + +@serializable.serializable_class +class Identity: + """ + Our internal representation of the `identityType` complex type. + + .. note:: + See the CycloneDX Schema definition: hhttps://cyclonedx.org/docs/1.6/json/#components_items_evidence_identity + """ + + def __init__( + self, *, + field: Union[IdentityFieldType, str], # Accept either enum or string + confidence: Optional[Decimal] = None, + concluded_value: Optional[str] = None, + methods: Optional[Iterable[Method]] = None, # Updated type + tools: Optional[Iterable[Union[str, BomRef]]] = None, + ) -> None: + self.field = field + self.confidence = confidence + self.concluded_value = concluded_value + self.methods = methods or [] # type: ignore[assignment] + self.tools = tools or [] # type: ignore[assignment] + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'field') + @serializable.xml_sequence(1) + def field(self) -> str: + return self._field.value + + @field.setter + def field(self, field: Union[IdentityFieldType, str]) -> None: + if isinstance(field, str): + try: + field = IdentityFieldType(field) + except ValueError: + raise ValueError( + f'Field must be one of: {", ".join(f.value for f in IdentityFieldType)}' + ) + self._field = field + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'confidence') + @serializable.xml_sequence(2) + def confidence(self) -> Optional[Decimal]: + """ + Returns the confidence value if set, otherwise None. + """ + return self._confidence + + @confidence.setter + def confidence(self, confidence: Optional[Decimal]) -> None: + """ + Sets the confidence value. Ensures it is between 0 and 1 if provided. + """ + if confidence is not None and not 0 <= confidence <= 1: + raise ValueError('Confidence must be between 0 and 1') + self._confidence = confidence + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'concludedValue') + @serializable.xml_sequence(3) + def concluded_value(self) -> Optional[str]: + return self._concluded_value + + @concluded_value.setter + def concluded_value(self, concluded_value: Optional[str]) -> None: + self._concluded_value = concluded_value + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'method') + @serializable.xml_sequence(4) + def methods(self) -> 'SortedSet[Method]': # Updated return type + return self._methods + + @methods.setter + def methods(self, methods: Iterable[Method]) -> None: # Updated parameter type + self._methods = SortedSet(methods) + + @property + @serializable.type_mapping(_ToolsSerializationHelper) + @serializable.xml_sequence(5) + def tools(self) -> 'SortedSet[BomRef]': + """ + References to the tools used to perform analysis and collect evidence. + Can be either a string reference (refLinkType) or a BOM reference (bomLinkType). + All references are stored and serialized as strings. + + Returns: + Set of tool references as BomRef + """ + return self._tools + + @tools.setter + def tools(self, tools: Iterable[Union[str, BomRef]]) -> None: + """Convert all inputs to BomRef for consistent storage""" + validated = [] + for t in tools: + ref_str = str(t) + if not (XsUri(ref_str).is_bom_link() or len(ref_str) >= 1): + raise ValueError( + f'Invalid tool reference: {ref_str}. Must be a valid BOM reference or BOM-Link.' + ) + validated.append(BomRef(ref_str)) + self._tools = SortedSet(validated) + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple( + ( + self.field, + self.confidence, + self.concluded_value, + _ComparableTuple(self.methods), + _ComparableTuple(self.tools), + ) + ) + + def __eq__(self, other: object) -> bool: + if isinstance(other, Identity): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, Identity): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f'' + + +@serializable.serializable_class +class Occurrence: + """ + Our internal representation of the `occurrenceType` complex type. + + .. note:: + See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.6/json/#components_items_evidence_occurrences + """ + + def __init__( + self, *, + bom_ref: Optional[Union[str, BomRef]] = None, + location: str, + line: Optional[int] = None, + offset: Optional[int] = None, + symbol: Optional[str] = None, + additional_context: Optional[str] = None, + ) -> None: + self.bom_ref = bom_ref # type: ignore[assignment] + self.location = location + self.line = line + self.offset = offset + self.symbol = symbol + self.additional_context = additional_context + + @property + @serializable.json_name('bom-ref') + @serializable.type_mapping(BomRef) + @serializable.xml_attribute() + @serializable.xml_name('bom-ref') + def bom_ref(self) -> Optional[BomRef]: + """ + Reference to a component defined in the BOM. + """ + return self._bom_ref + + @bom_ref.setter + def bom_ref(self, bom_ref: Optional[Union[str, BomRef]]) -> None: + if bom_ref is None: + self._bom_ref = None + return + bom_ref_str = str(bom_ref) + if len(bom_ref_str) < 1: + raise ValueError('bom_ref must be at least 1 character long') + if XsUri(bom_ref_str).is_bom_link(): + raise ValueError("bom_ref SHOULD NOT start with 'urn:cdx:' to avoid conflicts with BOM-Links") + self._bom_ref = BomRef(bom_ref_str) + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'location') + @serializable.xml_sequence(1) + def location(self) -> str: + """ + Location can be a file path, URL, or a unique identifier from a component discovery tool + """ + return self._location + + @location.setter + def location(self, location: str) -> None: + if location is None: + raise TypeError('location is required and cannot be None') + self._location = location + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'line') + @serializable.xml_sequence(2) + def line(self) -> Optional[int]: + """ + The line number in the file where the dependency or reference was detected. + """ + return self._line + + @line.setter + def line(self, line: Optional[int]) -> None: + self._line = line + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'offset') + @serializable.xml_sequence(3) + def offset(self) -> Optional[int]: + """ + The offset location within the file where the dependency or reference was detected. + """ + return self._offset + + @offset.setter + def offset(self, offset: Optional[int]) -> None: + self._offset = offset + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'symbol') + @serializable.xml_sequence(4) + def symbol(self) -> Optional[str]: + """ + Programming language symbol or import name. + """ + return self._symbol + + @symbol.setter + def symbol(self, symbol: Optional[str]) -> None: + self._symbol = symbol + + @property + @serializable.json_name('additionalContext') + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'additionalContext') + @serializable.xml_sequence(5) + def additional_context(self) -> Optional[str]: + """ + Additional context about the occurrence of the component. + """ + return self._additional_context + + @additional_context.setter + def additional_context(self, additional_context: Optional[str]) -> None: + self._additional_context = additional_context + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple( + ( + self.bom_ref, + self.location, + self.line, + self.offset, + self.symbol, + self.additional_context, + ) + ) + + def __eq__(self, other: object) -> bool: + if isinstance(other, Occurrence): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, Occurrence): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f'' + + +@serializable.serializable_class +class StackFrame: + """ + Represents an individual frame in a call stack. + + .. note:: + See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.6/json/#components_items_evidence_callstack + """ + + def __init__( + self, *, + package: Optional[str] = None, + module: str, # module is required + function: Optional[str] = None, + parameters: Optional[Iterable[str]] = None, + line: Optional[int] = None, + column: Optional[int] = None, + full_filename: Optional[str] = None, + ) -> None: + self.package = package + self.module = module + self.function = function + self.parameters = parameters or [] # type: ignore[assignment] + self.line = line + self.column = column + self.full_filename = full_filename + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'package') + @serializable.xml_sequence(1) + def package(self) -> Optional[str]: + """ + The package name. + """ + return self._package + + @package.setter + def package(self, package: Optional[str]) -> None: + """ + Sets the package name. + """ + self._package = package + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'module') + @serializable.xml_sequence(2) + def module(self) -> str: + """ + The module name + """ + return self._module + + @module.setter + def module(self, module: str) -> None: + if module is None: + raise TypeError('module is required and cannot be None') + self._module = module + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'function') + @serializable.xml_sequence(3) + def function(self) -> Optional[str]: + """ + The function name. + """ + return self._function + + @function.setter + def function(self, function: Optional[str]) -> None: + """ + Sets the function name. + """ + self._function = function + + @property + @serializable.json_name('parameters') + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'parameter') + @serializable.xml_sequence(4) + def parameters(self) -> 'SortedSet[str]': + """ + Function parameters + """ + return self._parameters + + @parameters.setter + def parameters(self, parameters: Iterable[str]) -> None: + self._parameters = SortedSet(parameters) + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'line') + @serializable.xml_sequence(5) + def line(self) -> Optional[int]: + """ + The line number + """ + return self._line + + @line.setter + def line(self, line: Optional[int]) -> None: + self._line = line + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'column') + @serializable.xml_sequence(6) + def column(self) -> Optional[int]: + """ + The column number + """ + return self._column + + @column.setter + def column(self, column: Optional[int]) -> None: + self._column = column + + @property + @serializable.json_name('fullFilename') + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'fullFilename') + @serializable.xml_sequence(7) + def full_filename(self) -> Optional[str]: + """ + The full file path + """ + return self._full_filename + + @full_filename.setter + def full_filename(self, full_filename: Optional[str]) -> None: + self._full_filename = full_filename + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple( + ( + self.package, + self.module, + self.function, + _ComparableTuple(self.parameters), + self.line, + self.column, + self.full_filename, + ) + ) + + def __eq__(self, other: object) -> bool: + if isinstance(other, StackFrame): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, StackFrame): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f'' + + +@serializable.serializable_class +class CallStack: + """ + Our internal representation of the `callStackType` complex type. + Contains an array of stack frames describing a call stack from when a component was identified. + + .. note:: + See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.6/json/#components_items_evidence_callstack + """ + + def __init__( + self, *, + frames: Optional[Iterable[StackFrame]] = None, + ) -> None: + self.frames = frames or [] # type:ignore[assignment] + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'frame') + def frames(self) -> 'SortedSet[StackFrame]': + """ + Array of stack frames + """ + return self._frames + + @frames.setter + def frames(self, frames: Iterable[StackFrame]) -> None: + self._frames = SortedSet(frames) + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple( + ( + _ComparableTuple(self.frames), + ) + ) + + def __eq__(self, other: object) -> bool: + if isinstance(other, CallStack): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, CallStack): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f'' + + @serializable.serializable_class class ComponentEvidence: """ @@ -204,44 +866,59 @@ class ComponentEvidence: def __init__( self, *, + identity: Optional[Iterable[Identity]] = None, + occurrences: Optional[Iterable[Occurrence]] = None, + callstack: Optional[CallStack] = None, licenses: Optional[Iterable[License]] = None, copyright: Optional[Iterable[Copyright]] = None, ) -> None: + self.identity = identity or [] # type:ignore[assignment] + self.occurrences = occurrences or [] # type:ignore[assignment] + self.callstack = callstack self.licenses = licenses or [] # type:ignore[assignment] self.copyright = copyright or [] # type:ignore[assignment] - # @property - # ... - # @serializable.view(SchemaVersion1Dot5) - # @serializable.xml_sequence(1) - # def identity(self) -> ...: - # ... # TODO since CDX1.5 - # - # @identity.setter - # def identity(self, ...) -> None: - # ... # TODO since CDX1.5 + @property + @serializable.view(SchemaVersion1Dot6) + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'identity') + @serializable.xml_sequence(1) + def identity(self) -> 'SortedSet[Identity]': + """ + Provides a way to identify components via various methods. + Returns SortedSet of identities. + """ + return self._identity - # @property - # ... + @identity.setter + def identity(self, identity: Iterable[Identity]) -> None: + self._identity = SortedSet(identity) + + @property # @serializable.view(SchemaVersion1Dot5) - # @serializable.xml_sequence(2) - # def occurrences(self) -> ...: - # ... # TODO since CDX1.5 - # - # @occurrences.setter - # def occurrences(self, ...) -> None: - # ... # TODO since CDX1.5 + @serializable.view(SchemaVersion1Dot6) + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'occurrence') + @serializable.xml_sequence(2) + def occurrences(self) -> 'SortedSet[Occurrence]': + """A list of locations where evidence was obtained from.""" + return self._occurrences - # @property - # ... + @occurrences.setter + def occurrences(self, occurrences: Iterable[Occurrence]) -> None: + self._occurrences = SortedSet(occurrences) + + @property # @serializable.view(SchemaVersion1Dot5) - # @serializable.xml_sequence(3) - # def callstack(self) -> ...: - # ... # TODO since CDX1.5 - # - # @callstack.setter - # def callstack(self, ...) -> None: - # ... # TODO since CDX1.5 + @serializable.view(SchemaVersion1Dot6) + @serializable.xml_sequence(3) + def callstack(self) -> Optional[CallStack]: + """ + A representation of a call stack from when the component was identified. + """ + return self._callstack + + @callstack.setter + def callstack(self, callstack: Optional[CallStack]) -> None: + self._callstack = callstack @property @serializable.type_mapping(_LicenseRepositorySerializationHelper) @@ -276,10 +953,14 @@ def copyright(self, copyright: Iterable[Copyright]) -> None: self._copyright = SortedSet(copyright) def __comparable_tuple(self) -> _ComparableTuple: - return _ComparableTuple(( - _ComparableTuple(self.licenses), - _ComparableTuple(self.copyright), - )) + return _ComparableTuple( + ( + _ComparableTuple(self.licenses), + _ComparableTuple(self.copyright), + self.callstack, + _ComparableTuple(self.identity), + _ComparableTuple(self.occurrences), + )) def __eq__(self, other: object) -> bool: if isinstance(other, ComponentEvidence): diff --git a/tests/_data/models.py b/tests/_data/models.py index 24ec42eb..2ab7b0cd 100644 --- a/tests/_data/models.py +++ b/tests/_data/models.py @@ -17,6 +17,7 @@ import base64 import sys +from collections.abc import Iterable from datetime import datetime, timezone from decimal import Decimal from inspect import getmembers, isfunction @@ -44,16 +45,23 @@ from cyclonedx.model.bom import Bom, BomMetaData from cyclonedx.model.bom_ref import BomRef from cyclonedx.model.component import ( + AnalysisTechnique, + CallStack, Commit, Component, ComponentEvidence, ComponentScope, ComponentType, Diff, + Identity, + IdentityFieldType, + Method, + Occurrence, OmniborId, Patch, PatchClassification, Pedigree, + StackFrame, Swhid, Swid, ) @@ -455,6 +463,35 @@ def get_bom_with_component_setuptools_complete() -> Bom: return _make_bom(components=[get_component_setuptools_complete()]) +def get_bom_with_component_evidence() -> Bom: + bom = _make_bom() + tool_component = Component( + name='product-cbom-generator', + type=ComponentType.APPLICATION, + bom_ref='cbom:generator' + ) + bom.metadata.tools.components.add(tool_component) + bom.metadata.component = Component( + name='root-component', + type=ComponentType.APPLICATION, + licenses=[DisjunctiveLicense(id='MIT')], + bom_ref='myApp', + ) + component = Component( + name='setuptools', version='50.3.2', + bom_ref='pkg:pypi/setuptools@50.3.2?extension=tar.gz', + purl=PackageURL( + type='pypi', name='setuptools', version='50.3.2', qualifiers='extension=tar.gz' + ), + licenses=[DisjunctiveLicense(id='MIT')], + author='Test Author' + ) + component.evidence = get_component_evidence_basic(tools=[tool_component]) + bom.components.add(component) + bom.register_dependency(bom.metadata.component, depends_on=[component]) + return bom + + def get_bom_with_component_setuptools_with_vulnerability() -> Bom: bom = _make_bom() component = get_component_setuptools_simple() @@ -737,6 +774,54 @@ def get_component_setuptools_complete(include_pedigree: bool = True) -> Componen return component +def get_component_evidence_basic(tools: Iterable[Tool]) -> ComponentEvidence: + """ + Returns a basic ComponentEvidence object for testing. + """ + return ComponentEvidence( + identity=[ + Identity( + field=IdentityFieldType.NAME, + confidence=Decimal('0.9'), + concluded_value='example-component', + methods=[ + Method( + technique=AnalysisTechnique.SOURCE_CODE_ANALYSIS, + confidence=Decimal('0.8'), value='analysis-tool' + ) + ], + tools=[tool.bom_ref for tool in tools] + ) + ], + occurrences=[ + Occurrence( + location='path/to/file', + line=42, + offset=16, + symbol='exampleSymbol', + additional_context='Found in source code', + ) + ], + callstack=CallStack( + frames=[ + StackFrame( + package='example.package', + module='example.module', + function='example_function', + parameters=['param1', 'param2'], + line=10, + column=5, + full_filename='path/to/file', + ) + ] + ), + licenses=[DisjunctiveLicense(id='MIT')], + copyright=[ + Copyright(text='Commercial'), Copyright(text='Commercial 2') + ] + ) + + def get_component_setuptools_simple( bom_ref: Optional[str] = 'pkg:pypi/setuptools@50.3.2?extension=tar.gz' ) -> Component: diff --git a/tests/_data/snapshots/get_bom_with_component_evidence-1.0.xml.bin b/tests/_data/snapshots/get_bom_with_component_evidence-1.0.xml.bin new file mode 100644 index 00000000..961bb479 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_component_evidence-1.0.xml.bin @@ -0,0 +1,11 @@ + + + + + setuptools + 50.3.2 + pkg:pypi/setuptools@50.3.2?extension=tar.gz + false + + + diff --git a/tests/_data/snapshots/get_bom_with_component_evidence-1.1.xml.bin b/tests/_data/snapshots/get_bom_with_component_evidence-1.1.xml.bin new file mode 100644 index 00000000..ba1ca960 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_component_evidence-1.1.xml.bin @@ -0,0 +1,15 @@ + + + + + setuptools + 50.3.2 + + + MIT + + + pkg:pypi/setuptools@50.3.2?extension=tar.gz + + + diff --git a/tests/_data/snapshots/get_bom_with_component_evidence-1.2.json.bin b/tests/_data/snapshots/get_bom_with_component_evidence-1.2.json.bin new file mode 100644 index 00000000..ad71ac13 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_component_evidence-1.2.json.bin @@ -0,0 +1,56 @@ +{ + "components": [ + { + "author": "Test Author", + "bom-ref": "pkg:pypi/setuptools@50.3.2?extension=tar.gz", + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ], + "name": "setuptools", + "purl": "pkg:pypi/setuptools@50.3.2?extension=tar.gz", + "type": "library", + "version": "50.3.2" + } + ], + "dependencies": [ + { + "dependsOn": [ + "pkg:pypi/setuptools@50.3.2?extension=tar.gz" + ], + "ref": "myApp" + }, + { + "ref": "pkg:pypi/setuptools@50.3.2?extension=tar.gz" + } + ], + "metadata": { + "component": { + "bom-ref": "myApp", + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ], + "name": "root-component", + "type": "application", + "version": "" + }, + "timestamp": "2023-01-07T13:44:32.312678+00:00", + "tools": [ + { + "name": "product-cbom-generator" + } + ] + }, + "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac", + "version": 1, + "$schema": "http://cyclonedx.org/schema/bom-1.2b.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.2" +} \ No newline at end of file diff --git a/tests/_data/snapshots/get_bom_with_component_evidence-1.2.xml.bin b/tests/_data/snapshots/get_bom_with_component_evidence-1.2.xml.bin new file mode 100644 index 00000000..627c7c20 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_component_evidence-1.2.xml.bin @@ -0,0 +1,39 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + + product-cbom-generator + + + + root-component + + + + MIT + + + + + + + Test Author + setuptools + 50.3.2 + + + MIT + + + pkg:pypi/setuptools@50.3.2?extension=tar.gz + + + + + + + + + diff --git a/tests/_data/snapshots/get_bom_with_component_evidence-1.3.json.bin b/tests/_data/snapshots/get_bom_with_component_evidence-1.3.json.bin new file mode 100644 index 00000000..d6c236a0 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_component_evidence-1.3.json.bin @@ -0,0 +1,73 @@ +{ + "components": [ + { + "author": "Test Author", + "bom-ref": "pkg:pypi/setuptools@50.3.2?extension=tar.gz", + "evidence": { + "copyright": [ + { + "text": "Commercial" + }, + { + "text": "Commercial 2" + } + ], + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ] + }, + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ], + "name": "setuptools", + "purl": "pkg:pypi/setuptools@50.3.2?extension=tar.gz", + "type": "library", + "version": "50.3.2" + } + ], + "dependencies": [ + { + "dependsOn": [ + "pkg:pypi/setuptools@50.3.2?extension=tar.gz" + ], + "ref": "myApp" + }, + { + "ref": "pkg:pypi/setuptools@50.3.2?extension=tar.gz" + } + ], + "metadata": { + "component": { + "bom-ref": "myApp", + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ], + "name": "root-component", + "type": "application", + "version": "" + }, + "timestamp": "2023-01-07T13:44:32.312678+00:00", + "tools": [ + { + "name": "product-cbom-generator" + } + ] + }, + "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac", + "version": 1, + "$schema": "http://cyclonedx.org/schema/bom-1.3a.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.3" +} \ No newline at end of file diff --git a/tests/_data/snapshots/get_bom_with_component_evidence-1.3.xml.bin b/tests/_data/snapshots/get_bom_with_component_evidence-1.3.xml.bin new file mode 100644 index 00000000..0cc9d8a2 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_component_evidence-1.3.xml.bin @@ -0,0 +1,50 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + + product-cbom-generator + + + + root-component + + + + MIT + + + + + + + Test Author + setuptools + 50.3.2 + + + MIT + + + pkg:pypi/setuptools@50.3.2?extension=tar.gz + + + + MIT + + + + Commercial + Commercial 2 + + + + + + + + + + + diff --git a/tests/_data/snapshots/get_bom_with_component_evidence-1.4.json.bin b/tests/_data/snapshots/get_bom_with_component_evidence-1.4.json.bin new file mode 100644 index 00000000..89c09d4f --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_component_evidence-1.4.json.bin @@ -0,0 +1,72 @@ +{ + "components": [ + { + "author": "Test Author", + "bom-ref": "pkg:pypi/setuptools@50.3.2?extension=tar.gz", + "evidence": { + "copyright": [ + { + "text": "Commercial" + }, + { + "text": "Commercial 2" + } + ], + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ] + }, + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ], + "name": "setuptools", + "purl": "pkg:pypi/setuptools@50.3.2?extension=tar.gz", + "type": "library", + "version": "50.3.2" + } + ], + "dependencies": [ + { + "dependsOn": [ + "pkg:pypi/setuptools@50.3.2?extension=tar.gz" + ], + "ref": "myApp" + }, + { + "ref": "pkg:pypi/setuptools@50.3.2?extension=tar.gz" + } + ], + "metadata": { + "component": { + "bom-ref": "myApp", + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ], + "name": "root-component", + "type": "application" + }, + "timestamp": "2023-01-07T13:44:32.312678+00:00", + "tools": [ + { + "name": "product-cbom-generator" + } + ] + }, + "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac", + "version": 1, + "$schema": "http://cyclonedx.org/schema/bom-1.4.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.4" +} \ No newline at end of file diff --git a/tests/_data/snapshots/get_bom_with_component_evidence-1.4.xml.bin b/tests/_data/snapshots/get_bom_with_component_evidence-1.4.xml.bin new file mode 100644 index 00000000..ce6a522b --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_component_evidence-1.4.xml.bin @@ -0,0 +1,49 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + + product-cbom-generator + + + + root-component + + + MIT + + + + + + + Test Author + setuptools + 50.3.2 + + + MIT + + + pkg:pypi/setuptools@50.3.2?extension=tar.gz + + + + MIT + + + + Commercial + Commercial 2 + + + + + + + + + + + diff --git a/tests/_data/snapshots/get_bom_with_component_evidence-1.5.json.bin b/tests/_data/snapshots/get_bom_with_component_evidence-1.5.json.bin new file mode 100644 index 00000000..ed302a76 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_component_evidence-1.5.json.bin @@ -0,0 +1,86 @@ +{ + "components": [ + { + "author": "Test Author", + "bom-ref": "pkg:pypi/setuptools@50.3.2?extension=tar.gz", + "evidence": { + "copyright": [ + { + "text": "Commercial" + }, + { + "text": "Commercial 2" + } + ], + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ] + }, + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ], + "name": "setuptools", + "purl": "pkg:pypi/setuptools@50.3.2?extension=tar.gz", + "type": "library", + "version": "50.3.2" + } + ], + "dependencies": [ + { + "dependsOn": [ + "pkg:pypi/setuptools@50.3.2?extension=tar.gz" + ], + "ref": "myApp" + }, + { + "ref": "pkg:pypi/setuptools@50.3.2?extension=tar.gz" + } + ], + "metadata": { + "component": { + "bom-ref": "myApp", + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ], + "name": "root-component", + "type": "application" + }, + "timestamp": "2023-01-07T13:44:32.312678+00:00", + "tools": { + "components": [ + { + "bom-ref": "cbom:generator", + "name": "product-cbom-generator", + "type": "application" + } + ] + } + }, + "properties": [ + { + "name": "key1", + "value": "val1" + }, + { + "name": "key2", + "value": "val2" + } + ], + "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac", + "version": 1, + "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.5" +} \ No newline at end of file diff --git a/tests/_data/snapshots/get_bom_with_component_evidence-1.5.xml.bin b/tests/_data/snapshots/get_bom_with_component_evidence-1.5.xml.bin new file mode 100644 index 00000000..60625ce1 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_component_evidence-1.5.xml.bin @@ -0,0 +1,55 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + + + product-cbom-generator + + + + + root-component + + + MIT + + + + + + + Test Author + setuptools + 50.3.2 + + + MIT + + + pkg:pypi/setuptools@50.3.2?extension=tar.gz + + + + MIT + + + + Commercial + Commercial 2 + + + + + + + + + + + + val1 + val2 + + diff --git a/tests/_data/snapshots/get_bom_with_component_evidence-1.6.json.bin b/tests/_data/snapshots/get_bom_with_component_evidence-1.6.json.bin new file mode 100644 index 00000000..2d3b716f --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_component_evidence-1.6.json.bin @@ -0,0 +1,128 @@ +{ + "components": [ + { + "author": "Test Author", + "bom-ref": "pkg:pypi/setuptools@50.3.2?extension=tar.gz", + "evidence": { + "callstack": { + "frames": [ + { + "column": 5, + "fullFilename": "path/to/file", + "function": "example_function", + "line": 10, + "module": "example.module", + "package": "example.package", + "parameters": [ + "param1", + "param2" + ] + } + ] + }, + "copyright": [ + { + "text": "Commercial" + }, + { + "text": "Commercial 2" + } + ], + "identity": [ + { + "concludedValue": "example-component", + "confidence": 0.9, + "field": "name", + "methods": [ + { + "confidence": 0.8, + "technique": "source-code-analysis", + "value": "analysis-tool" + } + ], + "tools": [ + "cbom:generator" + ] + } + ], + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ], + "occurrences": [ + { + "additionalContext": "Found in source code", + "line": 42, + "location": "path/to/file", + "offset": 16, + "symbol": "exampleSymbol" + } + ] + }, + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ], + "name": "setuptools", + "purl": "pkg:pypi/setuptools@50.3.2?extension=tar.gz", + "type": "library", + "version": "50.3.2" + } + ], + "dependencies": [ + { + "dependsOn": [ + "pkg:pypi/setuptools@50.3.2?extension=tar.gz" + ], + "ref": "myApp" + }, + { + "ref": "pkg:pypi/setuptools@50.3.2?extension=tar.gz" + } + ], + "metadata": { + "component": { + "bom-ref": "myApp", + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ], + "name": "root-component", + "type": "application" + }, + "timestamp": "2023-01-07T13:44:32.312678+00:00", + "tools": { + "components": [ + { + "bom-ref": "cbom:generator", + "name": "product-cbom-generator", + "type": "application" + } + ] + } + }, + "properties": [ + { + "name": "key1", + "value": "val1" + }, + { + "name": "key2", + "value": "val2" + } + ], + "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac", + "version": 1, + "$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.6" +} \ No newline at end of file diff --git a/tests/_data/snapshots/get_bom_with_component_evidence-1.6.xml.bin b/tests/_data/snapshots/get_bom_with_component_evidence-1.6.xml.bin new file mode 100644 index 00000000..f53e6cd0 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_component_evidence-1.6.xml.bin @@ -0,0 +1,95 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + + + product-cbom-generator + + + + + root-component + + + MIT + + + + + + + Test Author + setuptools + 50.3.2 + + + MIT + + + pkg:pypi/setuptools@50.3.2?extension=tar.gz + + + name + 0.9 + example-component + + + source-code-analysis + 0.8 + analysis-tool + + + + + + + + + path/to/file + 42 + 16 + exampleSymbol + Found in source code + + + + + + example.package + example.module + example_function + + param1 + param2 + + 10 + 5 + path/to/file + + + + + + MIT + + + + Commercial + Commercial 2 + + + + + + + + + + + + val1 + val2 + + diff --git a/tests/test_model_component.py b/tests/test_model_component.py index 05cf278c..af79fb85 100644 --- a/tests/test_model_component.py +++ b/tests/test_model_component.py @@ -16,6 +16,7 @@ # Copyright (c) OWASP Foundation. All Rights Reserved. import datetime +from decimal import Decimal from unittest import TestCase from cyclonedx.model import ( @@ -29,14 +30,21 @@ XsUri, ) from cyclonedx.model.component import ( + AnalysisTechnique, + CallStack, Commit, Component, ComponentEvidence, ComponentType, Diff, + Identity, + IdentityFieldType, + Method, + Occurrence, Patch, PatchClassification, Pedigree, + StackFrame, ) from cyclonedx.model.issue import IssueClassification, IssueType from tests import reorder @@ -290,6 +298,200 @@ class TestModelComponentEvidence(TestCase): def test_no_params(self) -> None: ComponentEvidence() # Does not raise `NoPropertiesProvidedException` + def test_identity(self) -> None: + identity = Identity(field=IdentityFieldType.NAME, confidence=Decimal('1'), concluded_value='test') + ce = ComponentEvidence(identity=[identity]) + self.assertEqual(len(ce.identity), 1) + self.assertEqual(ce.identity.pop().field, 'name') + + def test_identity_multiple(self) -> None: + identities = [ + Identity(field=IdentityFieldType.NAME, confidence=Decimal('1'), concluded_value='test'), + Identity(field=IdentityFieldType.VERSION, confidence=Decimal('0.8'), concluded_value='1.0.0') + ] + ce = ComponentEvidence(identity=identities) + self.assertEqual(len(ce.identity), 2) + + def test_identity_with_methods(self) -> None: + """Test identity with analysis methods""" + methods = [ + Method( + technique=AnalysisTechnique.BINARY_ANALYSIS, # Changed order to test sorting + confidence=Decimal('0.9'), + value='Found in binary' + ), + Method( + technique=AnalysisTechnique.SOURCE_CODE_ANALYSIS, + confidence=Decimal('0.8'), + value='Found in source' + ) + ] + identity = Identity(field='name', confidence=Decimal('1'), methods=methods) + self.assertEqual(len(identity.methods), 2) + sorted_methods = sorted(methods) # Methods should be sorted by technique name + self.assertEqual(list(identity.methods), sorted_methods) + + # Verify first method + method = sorted_methods[0] + self.assertEqual(method.technique, AnalysisTechnique.BINARY_ANALYSIS) + self.assertEqual(method.confidence, Decimal('0.9')) + self.assertEqual(method.value, 'Found in binary') + + def test_method_sorting(self) -> None: + """Test that methods are properly sorted by technique value""" + methods = [ + Method(technique=AnalysisTechnique.SOURCE_CODE_ANALYSIS, confidence=Decimal('0.8')), + Method(technique=AnalysisTechnique.BINARY_ANALYSIS, confidence=Decimal('0.9')), + Method(technique=AnalysisTechnique.ATTESTATION, confidence=Decimal('1.0')) + ] + + sorted_methods = sorted(methods) + self.assertEqual(sorted_methods[0].technique, AnalysisTechnique.ATTESTATION) + self.assertEqual(sorted_methods[1].technique, AnalysisTechnique.BINARY_ANALYSIS) + self.assertEqual(sorted_methods[2].technique, AnalysisTechnique.SOURCE_CODE_ANALYSIS) + + def test_invalid_method_technique(self) -> None: + """Test that invalid technique raises ValueError""" + with self.assertRaises(ValueError): + Method(technique='invalid', confidence=Decimal('0.5')) + + def test_invalid_method_confidence(self) -> None: + """Test that invalid confidence raises ValueError""" + with self.assertRaises(ValueError): + Method(technique=AnalysisTechnique.FILENAME, confidence=Decimal('1.5')) + + def test_occurrences(self) -> None: + occurrence = Occurrence(location='/path/to/file', line=42) + ce = ComponentEvidence(occurrences=[occurrence]) + self.assertEqual(len(ce.occurrences), 1) + self.assertEqual(ce.occurrences.pop().line, 42) + + def test_stackframe(self) -> None: + # Test StackFrame with required fields + frame = StackFrame( + package='com.example', + module='app', + function='main', + parameters=['arg1', 'arg2'], + line=1, + column=10, + full_filename='/path/to/file.py' + ) + self.assertEqual(frame.package, 'com.example') + self.assertEqual(frame.module, 'app') + self.assertEqual(frame.function, 'main') + self.assertEqual(len(frame.parameters), 2) + self.assertEqual(frame.line, 1) + self.assertEqual(frame.column, 10) + self.assertEqual(frame.full_filename, '/path/to/file.py') + + def test_stackframe_module_required(self) -> None: + """Test that module is the only required field""" + frame = StackFrame(module='app') # Only mandatory field + self.assertEqual(frame.module, 'app') + self.assertIsNone(frame.package) + self.assertIsNone(frame.function) + self.assertEqual(len(frame.parameters), 0) + self.assertIsNone(frame.line) + self.assertIsNone(frame.column) + self.assertIsNone(frame.full_filename) + + def test_stackframe_without_module(self) -> None: + """Test that omitting module raises TypeError""" + with self.assertRaises(TypeError): + StackFrame() # Should raise TypeError for missing module + + with self.assertRaises(TypeError): + StackFrame(package='com.example') # Should raise TypeError for missing module + + def test_stackframe_with_none_module(self) -> None: + """Test that setting module as None raises TypeError""" + with self.assertRaises(TypeError): + StackFrame(module=None) # Should raise TypeError for None module + + def test_callstack(self) -> None: + frame = StackFrame( + package='com.example', + module='app', + function='main' + ) + stack = CallStack(frames=[frame]) + ce = ComponentEvidence(callstack=stack) + self.assertIsNotNone(ce.callstack) + self.assertEqual(len(ce.callstack.frames), 1) + + def test_licenses(self) -> None: + from cyclonedx.model.license import DisjunctiveLicense + license = DisjunctiveLicense(id='MIT') + ce = ComponentEvidence(licenses=[license]) + self.assertEqual(len(ce.licenses), 1) + + def test_copyright(self) -> None: + copyright = Copyright(text='(c) 2023') + ce = ComponentEvidence(copyright=[copyright]) + self.assertEqual(len(ce.copyright), 1) + self.assertEqual(ce.copyright.pop().text, '(c) 2023') + + def test_full_evidence(self) -> None: + # Test with all fields populated + identity = Identity(field=IdentityFieldType.NAME, confidence=Decimal('1'), concluded_value='test') + occurrence = Occurrence(location='/path/to/file', line=42) + frame = StackFrame(module='app', function='main', line=1) + stack = CallStack(frames=[frame]) + from cyclonedx.model.license import DisjunctiveLicense + license = DisjunctiveLicense(id='MIT') + copyright = Copyright(text='(c) 2023') + + ce = ComponentEvidence( + identity=[identity], + occurrences=[occurrence], + callstack=stack, + licenses=[license], + copyright=[copyright] + ) + + self.assertEqual(len(ce.identity), 1) + self.assertEqual(len(ce.occurrences), 1) + self.assertIsNotNone(ce.callstack) + self.assertEqual(len(ce.callstack.frames), 1) + self.assertEqual(len(ce.licenses), 1) + self.assertEqual(len(ce.copyright), 1) + + def test_full_evidence_with_complete_stack(self) -> None: + identity = Identity(field=IdentityFieldType.NAME, confidence=Decimal('1'), concluded_value='test') + occurrence = Occurrence(location='/path/to/file', line=42) + + frame = StackFrame( + package='com.example', + module='app', + function='main', + parameters=['arg1', 'arg2'], + line=1, + column=10, + full_filename='/path/to/file.py' + ) + stack = CallStack(frames=[frame]) + + from cyclonedx.model.license import DisjunctiveLicense + license = DisjunctiveLicense(id='MIT') + copyright = Copyright(text='(c) 2023') + + ce = ComponentEvidence( + identity=[identity], + occurrences=[occurrence], + callstack=stack, + licenses=[license], + copyright=[copyright] + ) + + self.assertEqual(len(ce.identity), 1) + self.assertEqual(len(ce.occurrences), 1) + self.assertIsNotNone(ce.callstack) + self.assertEqual(len(ce.callstack.frames), 1) + self.assertEqual(ce.callstack.frames.pop().package, 'com.example') + self.assertEqual(len(ce.licenses), 1) + self.assertEqual(len(ce.copyright), 1) + def test_same_1(self) -> None: ce_1 = ComponentEvidence(copyright=[Copyright(text='Commercial')]) ce_2 = ComponentEvidence(copyright=[Copyright(text='Commercial')]) From a65cc0e61da73074341c16ee53f9109554d3a231 Mon Sep 17 00:00:00 2001 From: Jan Kowalleck Date: Tue, 27 May 2025 11:58:06 +0200 Subject: [PATCH 02/24] prep extraxtion of component_evidence Signed-off-by: Jan Kowalleck --- .../model/{component.py => component_.py} | 0 cyclonedx/model/component_evidence.py | 2467 +++++++++++++++++ 2 files changed, 2467 insertions(+) rename cyclonedx/model/{component.py => component_.py} (100%) create mode 100644 cyclonedx/model/component_evidence.py diff --git a/cyclonedx/model/component.py b/cyclonedx/model/component_.py similarity index 100% rename from cyclonedx/model/component.py rename to cyclonedx/model/component_.py diff --git a/cyclonedx/model/component_evidence.py b/cyclonedx/model/component_evidence.py new file mode 100644 index 00000000..da1ab6e9 --- /dev/null +++ b/cyclonedx/model/component_evidence.py @@ -0,0 +1,2467 @@ +# This file is part of CycloneDX Python Library +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) OWASP Foundation. All Rights Reserved. + +import re +from collections.abc import Iterable +from decimal import Decimal +from enum import Enum +from os.path import exists +from typing import Any, Optional, Union +from warnings import warn +from xml.etree.ElementTree import Element # nosec B405 + +# See https://github.com/package-url/packageurl-python/issues/65 +import py_serializable as serializable +from packageurl import PackageURL +from sortedcontainers import SortedSet + +from .._internal.bom_ref import bom_ref_from_str as _bom_ref_from_str +from .._internal.compare import ComparablePackageURL as _ComparablePackageURL, ComparableTuple as _ComparableTuple +from .._internal.hash import file_sha1sum as _file_sha1sum +from ..exception.model import InvalidOmniBorIdException, InvalidSwhidException +from ..exception.serialization import ( + CycloneDxDeserializationException, + SerializationOfUnexpectedValueException, + SerializationOfUnsupportedComponentTypeException, +) +from ..schema.schema import ( + SchemaVersion1Dot0, + SchemaVersion1Dot1, + SchemaVersion1Dot2, + SchemaVersion1Dot3, + SchemaVersion1Dot4, + SchemaVersion1Dot5, + SchemaVersion1Dot6, +) +from ..serialization import PackageUrl as PackageUrlSH +from . import ( + AttachedText, + Copyright, + ExternalReference, + HashAlgorithm, + HashType, + IdentifiableAction, + Property, + XsUri, + _HashTypeRepositorySerializationHelper, +) +from .bom_ref import BomRef +from .contact import OrganizationalContact, OrganizationalEntity +from .crypto import CryptoProperties +from .dependency import Dependable +from .issue import IssueType +from .license import License, LicenseRepository, _LicenseRepositorySerializationHelper +from .release_note import ReleaseNotes + + +@serializable.serializable_class +class Commit: + """ + Our internal representation of the `commitType` complex type. + + .. note:: + See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.6/xml/#type_commitType + """ + + def __init__( + self, *, + uid: Optional[str] = None, + url: Optional[XsUri] = None, + author: Optional[IdentifiableAction] = None, + committer: Optional[IdentifiableAction] = None, + message: Optional[str] = None, + ) -> None: + self.uid = uid + self.url = url + self.author = author + self.committer = committer + self.message = message + + @property + @serializable.xml_sequence(1) + @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + def uid(self) -> Optional[str]: + """ + A unique identifier of the commit. This may be version control specific. For example, Subversion uses revision + numbers whereas git uses commit hashes. + + Returns: + `str` if set else `None` + """ + return self._uid + + @uid.setter + def uid(self, uid: Optional[str]) -> None: + self._uid = uid + + @property + @serializable.xml_sequence(2) + def url(self) -> Optional[XsUri]: + """ + The URL to the commit. This URL will typically point to a commit in a version control system. + + Returns: + `XsUri` if set else `None` + """ + return self._url + + @url.setter + def url(self, url: Optional[XsUri]) -> None: + self._url = url + + @property + @serializable.xml_sequence(3) + def author(self) -> Optional[IdentifiableAction]: + """ + The author who created the changes in the commit. + + Returns: + `IdentifiableAction` if set else `None` + """ + return self._author + + @author.setter + def author(self, author: Optional[IdentifiableAction]) -> None: + self._author = author + + @property + @serializable.xml_sequence(4) + def committer(self) -> Optional[IdentifiableAction]: + """ + The person who committed or pushed the commit + + Returns: + `IdentifiableAction` if set else `None` + """ + return self._committer + + @committer.setter + def committer(self, committer: Optional[IdentifiableAction]) -> None: + self._committer = committer + + @property + @serializable.xml_sequence(5) + @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + def message(self) -> Optional[str]: + """ + The text description of the contents of the commit. + + Returns: + `str` if set else `None` + """ + return self._message + + @message.setter + def message(self, message: Optional[str]) -> None: + self._message = message + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple(( + self.uid, self.url, + self.author, self.committer, + self.message + )) + + def __eq__(self, other: object) -> bool: + if isinstance(other, Commit): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, Commit): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f'' + + +@serializable.serializable_enum +class IdentityFieldType(str, Enum): + """ + Enum object that defines the permissible field types for Identity. + + .. note:: + See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.6/json/#components_items_evidence_identity + """ + + GROUP = 'group' + NAME = 'name' + VERSION = 'version' + PURL = 'purl' + CPE = 'cpe' + OMNIBOR_ID = 'omniborId' + SWHID = 'swhid' + SWID = 'swid' + HASH = 'hash' + + +@serializable.serializable_enum +class AnalysisTechnique(str, Enum): + """ + Enum object that defines the permissible analysis techniques. + """ + + SOURCE_CODE_ANALYSIS = 'source-code-analysis' + BINARY_ANALYSIS = 'binary-analysis' + MANIFEST_ANALYSIS = 'manifest-analysis' + AST_FINGERPRINT = 'ast-fingerprint' + HASH_COMPARISON = 'hash-comparison' + INSTRUMENTATION = 'instrumentation' + DYNAMIC_ANALYSIS = 'dynamic-analysis' + FILENAME = 'filename' + ATTESTATION = 'attestation' + OTHER = 'other' + + +@serializable.serializable_class +class Method: + """ + Represents a method used to extract and/or analyze evidence. + """ + + def __init__( + self, *, + technique: Union[AnalysisTechnique, str], + confidence: Decimal, + value: Optional[str] = None, + ) -> None: + self.technique = technique + self.confidence = confidence + self.value = value + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'technique') + @serializable.json_name('technique') + @serializable.xml_sequence(1) + def technique(self) -> str: + return self._technique.value + + @technique.setter + def technique(self, technique: Union[AnalysisTechnique, str]) -> None: + if isinstance(technique, str): + try: + technique = AnalysisTechnique(technique) + except ValueError: + raise ValueError( + f'Technique must be one of: {", ".join(t.value for t in AnalysisTechnique)}' + ) + self._technique = technique + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'confidence') + @serializable.json_name('confidence') + @serializable.xml_sequence(2) + def confidence(self) -> Decimal: + return self._confidence + + @confidence.setter + def confidence(self, confidence: Decimal) -> None: + if not 0 <= confidence <= 1: + raise ValueError('Confidence must be between 0 and 1') + self._confidence = confidence + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'value') + @serializable.json_name('value') + @serializable.xml_sequence(3) + def value(self) -> Optional[str]: + return self._value + + @value.setter + def value(self, value: Optional[str]) -> None: + self._value = value + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple( + ( + self.technique, + self.confidence, + self.value, + ) + ) + + def __eq__(self, other: object) -> bool: + if isinstance(other, Method): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, Method): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f'' + + +class _ToolsSerializationHelper(serializable.helpers.BaseHelper): + """ THIS CLASS IS NON-PUBLIC API """ + + @classmethod + def json_normalize(cls, o: Any, *, + view: Optional[type[serializable.ViewType]], + **__: Any) -> Any: + if isinstance(o, SortedSet): + return [str(t) for t in o] # Convert BomRef to string + return o + + @classmethod + def xml_normalize(cls, o: Any, *, + element_name: str, + view: Optional[type[serializable.ViewType]], + xmlns: Optional[str], + **__: Any) -> Optional[Element]: + if len(o) == 0: + return None + + # Create tools element with namespace if provided + tools_elem = Element(f'{{{xmlns}}}tools' if xmlns else 'tools') + for tool in o: + tool_elem = Element(f'{{{xmlns}}}tool' if xmlns else 'tool') + tool_elem.set(f'{{{xmlns}}}ref' if xmlns else 'ref', str(tool)) + tools_elem.append(tool_elem) + return tools_elem + + @classmethod + def json_denormalize(cls, o: Any, **kwargs: Any) -> SortedSet[BomRef]: + if isinstance(o, (list, set, tuple)): + return SortedSet(BomRef(str(t)) for t in o) + return SortedSet() + + @classmethod + def xml_denormalize(cls, o: Element, + default_ns: Optional[str], + **__: Any) -> SortedSet[BomRef]: + repo = [] + tool_tag = f'{{{default_ns}}}tool' if default_ns else 'tool' + ref_attr = f'{{{default_ns}}}ref' if default_ns else 'ref' + for tool_elem in o.findall(f'.//{tool_tag}'): + ref = tool_elem.get(ref_attr) or tool_elem.get('ref') + if ref: + repo.append(BomRef(str(ref))) + else: + raise CycloneDxDeserializationException(f'unexpected: {tool_elem!r}') + return SortedSet(repo) + + +@serializable.serializable_class +class Identity: + """ + Our internal representation of the `identityType` complex type. + + .. note:: + See the CycloneDX Schema definition: hhttps://cyclonedx.org/docs/1.6/json/#components_items_evidence_identity + """ + + def __init__( + self, *, + field: Union[IdentityFieldType, str], # Accept either enum or string + confidence: Optional[Decimal] = None, + concluded_value: Optional[str] = None, + methods: Optional[Iterable[Method]] = None, # Updated type + tools: Optional[Iterable[Union[str, BomRef]]] = None, + ) -> None: + self.field = field + self.confidence = confidence + self.concluded_value = concluded_value + self.methods = methods or [] # type: ignore[assignment] + self.tools = tools or [] # type: ignore[assignment] + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'field') + @serializable.xml_sequence(1) + def field(self) -> str: + return self._field.value + + @field.setter + def field(self, field: Union[IdentityFieldType, str]) -> None: + if isinstance(field, str): + try: + field = IdentityFieldType(field) + except ValueError: + raise ValueError( + f'Field must be one of: {", ".join(f.value for f in IdentityFieldType)}' + ) + self._field = field + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'confidence') + @serializable.xml_sequence(2) + def confidence(self) -> Optional[Decimal]: + """ + Returns the confidence value if set, otherwise None. + """ + return self._confidence + + @confidence.setter + def confidence(self, confidence: Optional[Decimal]) -> None: + """ + Sets the confidence value. Ensures it is between 0 and 1 if provided. + """ + if confidence is not None and not 0 <= confidence <= 1: + raise ValueError('Confidence must be between 0 and 1') + self._confidence = confidence + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'concludedValue') + @serializable.xml_sequence(3) + def concluded_value(self) -> Optional[str]: + return self._concluded_value + + @concluded_value.setter + def concluded_value(self, concluded_value: Optional[str]) -> None: + self._concluded_value = concluded_value + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'method') + @serializable.xml_sequence(4) + def methods(self) -> 'SortedSet[Method]': # Updated return type + return self._methods + + @methods.setter + def methods(self, methods: Iterable[Method]) -> None: # Updated parameter type + self._methods = SortedSet(methods) + + @property + @serializable.type_mapping(_ToolsSerializationHelper) + @serializable.xml_sequence(5) + def tools(self) -> 'SortedSet[BomRef]': + """ + References to the tools used to perform analysis and collect evidence. + Can be either a string reference (refLinkType) or a BOM reference (bomLinkType). + All references are stored and serialized as strings. + + Returns: + Set of tool references as BomRef + """ + return self._tools + + @tools.setter + def tools(self, tools: Iterable[Union[str, BomRef]]) -> None: + """Convert all inputs to BomRef for consistent storage""" + validated = [] + for t in tools: + ref_str = str(t) + if not (XsUri(ref_str).is_bom_link() or len(ref_str) >= 1): + raise ValueError( + f'Invalid tool reference: {ref_str}. Must be a valid BOM reference or BOM-Link.' + ) + validated.append(BomRef(ref_str)) + self._tools = SortedSet(validated) + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple( + ( + self.field, + self.confidence, + self.concluded_value, + _ComparableTuple(self.methods), + _ComparableTuple(self.tools), + ) + ) + + def __eq__(self, other: object) -> bool: + if isinstance(other, Identity): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, Identity): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f'' + + +@serializable.serializable_class +class Occurrence: + """ + Our internal representation of the `occurrenceType` complex type. + + .. note:: + See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.6/json/#components_items_evidence_occurrences + """ + + def __init__( + self, *, + bom_ref: Optional[Union[str, BomRef]] = None, + location: str, + line: Optional[int] = None, + offset: Optional[int] = None, + symbol: Optional[str] = None, + additional_context: Optional[str] = None, + ) -> None: + self.bom_ref = bom_ref # type: ignore[assignment] + self.location = location + self.line = line + self.offset = offset + self.symbol = symbol + self.additional_context = additional_context + + @property + @serializable.json_name('bom-ref') + @serializable.type_mapping(BomRef) + @serializable.xml_attribute() + @serializable.xml_name('bom-ref') + def bom_ref(self) -> Optional[BomRef]: + """ + Reference to a component defined in the BOM. + """ + return self._bom_ref + + @bom_ref.setter + def bom_ref(self, bom_ref: Optional[Union[str, BomRef]]) -> None: + if bom_ref is None: + self._bom_ref = None + return + bom_ref_str = str(bom_ref) + if len(bom_ref_str) < 1: + raise ValueError('bom_ref must be at least 1 character long') + if XsUri(bom_ref_str).is_bom_link(): + raise ValueError("bom_ref SHOULD NOT start with 'urn:cdx:' to avoid conflicts with BOM-Links") + self._bom_ref = BomRef(bom_ref_str) + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'location') + @serializable.xml_sequence(1) + def location(self) -> str: + """ + Location can be a file path, URL, or a unique identifier from a component discovery tool + """ + return self._location + + @location.setter + def location(self, location: str) -> None: + if location is None: + raise TypeError('location is required and cannot be None') + self._location = location + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'line') + @serializable.xml_sequence(2) + def line(self) -> Optional[int]: + """ + The line number in the file where the dependency or reference was detected. + """ + return self._line + + @line.setter + def line(self, line: Optional[int]) -> None: + self._line = line + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'offset') + @serializable.xml_sequence(3) + def offset(self) -> Optional[int]: + """ + The offset location within the file where the dependency or reference was detected. + """ + return self._offset + + @offset.setter + def offset(self, offset: Optional[int]) -> None: + self._offset = offset + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'symbol') + @serializable.xml_sequence(4) + def symbol(self) -> Optional[str]: + """ + Programming language symbol or import name. + """ + return self._symbol + + @symbol.setter + def symbol(self, symbol: Optional[str]) -> None: + self._symbol = symbol + + @property + @serializable.json_name('additionalContext') + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'additionalContext') + @serializable.xml_sequence(5) + def additional_context(self) -> Optional[str]: + """ + Additional context about the occurrence of the component. + """ + return self._additional_context + + @additional_context.setter + def additional_context(self, additional_context: Optional[str]) -> None: + self._additional_context = additional_context + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple( + ( + self.bom_ref, + self.location, + self.line, + self.offset, + self.symbol, + self.additional_context, + ) + ) + + def __eq__(self, other: object) -> bool: + if isinstance(other, Occurrence): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, Occurrence): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f'' + + +@serializable.serializable_class +class StackFrame: + """ + Represents an individual frame in a call stack. + + .. note:: + See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.6/json/#components_items_evidence_callstack + """ + + def __init__( + self, *, + package: Optional[str] = None, + module: str, # module is required + function: Optional[str] = None, + parameters: Optional[Iterable[str]] = None, + line: Optional[int] = None, + column: Optional[int] = None, + full_filename: Optional[str] = None, + ) -> None: + self.package = package + self.module = module + self.function = function + self.parameters = parameters or [] # type: ignore[assignment] + self.line = line + self.column = column + self.full_filename = full_filename + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'package') + @serializable.xml_sequence(1) + def package(self) -> Optional[str]: + """ + The package name. + """ + return self._package + + @package.setter + def package(self, package: Optional[str]) -> None: + """ + Sets the package name. + """ + self._package = package + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'module') + @serializable.xml_sequence(2) + def module(self) -> str: + """ + The module name + """ + return self._module + + @module.setter + def module(self, module: str) -> None: + if module is None: + raise TypeError('module is required and cannot be None') + self._module = module + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'function') + @serializable.xml_sequence(3) + def function(self) -> Optional[str]: + """ + The function name. + """ + return self._function + + @function.setter + def function(self, function: Optional[str]) -> None: + """ + Sets the function name. + """ + self._function = function + + @property + @serializable.json_name('parameters') + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'parameter') + @serializable.xml_sequence(4) + def parameters(self) -> 'SortedSet[str]': + """ + Function parameters + """ + return self._parameters + + @parameters.setter + def parameters(self, parameters: Iterable[str]) -> None: + self._parameters = SortedSet(parameters) + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'line') + @serializable.xml_sequence(5) + def line(self) -> Optional[int]: + """ + The line number + """ + return self._line + + @line.setter + def line(self, line: Optional[int]) -> None: + self._line = line + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'column') + @serializable.xml_sequence(6) + def column(self) -> Optional[int]: + """ + The column number + """ + return self._column + + @column.setter + def column(self, column: Optional[int]) -> None: + self._column = column + + @property + @serializable.json_name('fullFilename') + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'fullFilename') + @serializable.xml_sequence(7) + def full_filename(self) -> Optional[str]: + """ + The full file path + """ + return self._full_filename + + @full_filename.setter + def full_filename(self, full_filename: Optional[str]) -> None: + self._full_filename = full_filename + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple( + ( + self.package, + self.module, + self.function, + _ComparableTuple(self.parameters), + self.line, + self.column, + self.full_filename, + ) + ) + + def __eq__(self, other: object) -> bool: + if isinstance(other, StackFrame): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, StackFrame): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f'' + + +@serializable.serializable_class +class CallStack: + """ + Our internal representation of the `callStackType` complex type. + Contains an array of stack frames describing a call stack from when a component was identified. + + .. note:: + See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.6/json/#components_items_evidence_callstack + """ + + def __init__( + self, *, + frames: Optional[Iterable[StackFrame]] = None, + ) -> None: + self.frames = frames or [] # type:ignore[assignment] + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'frame') + def frames(self) -> 'SortedSet[StackFrame]': + """ + Array of stack frames + """ + return self._frames + + @frames.setter + def frames(self, frames: Iterable[StackFrame]) -> None: + self._frames = SortedSet(frames) + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple( + ( + _ComparableTuple(self.frames), + ) + ) + + def __eq__(self, other: object) -> bool: + if isinstance(other, CallStack): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, CallStack): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f'' + + +@serializable.serializable_class +class ComponentEvidence: + """ + Our internal representation of the `componentEvidenceType` complex type. + + Provides the ability to document evidence collected through various forms of extraction or analysis. + + .. note:: + See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.6/xml/#type_componentEvidenceType + """ + + def __init__( + self, *, + identity: Optional[Iterable[Identity]] = None, + occurrences: Optional[Iterable[Occurrence]] = None, + callstack: Optional[CallStack] = None, + licenses: Optional[Iterable[License]] = None, + copyright: Optional[Iterable[Copyright]] = None, + ) -> None: + self.identity = identity or [] # type:ignore[assignment] + self.occurrences = occurrences or [] # type:ignore[assignment] + self.callstack = callstack + self.licenses = licenses or [] # type:ignore[assignment] + self.copyright = copyright or [] # type:ignore[assignment] + + @property + @serializable.view(SchemaVersion1Dot6) + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'identity') + @serializable.xml_sequence(1) + def identity(self) -> 'SortedSet[Identity]': + """ + Provides a way to identify components via various methods. + Returns SortedSet of identities. + """ + return self._identity + + @identity.setter + def identity(self, identity: Iterable[Identity]) -> None: + self._identity = SortedSet(identity) + + @property + # @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'occurrence') + @serializable.xml_sequence(2) + def occurrences(self) -> 'SortedSet[Occurrence]': + """A list of locations where evidence was obtained from.""" + return self._occurrences + + @occurrences.setter + def occurrences(self, occurrences: Iterable[Occurrence]) -> None: + self._occurrences = SortedSet(occurrences) + + @property + # @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.xml_sequence(3) + def callstack(self) -> Optional[CallStack]: + """ + A representation of a call stack from when the component was identified. + """ + return self._callstack + + @callstack.setter + def callstack(self, callstack: Optional[CallStack]) -> None: + self._callstack = callstack + + @property + @serializable.type_mapping(_LicenseRepositorySerializationHelper) + @serializable.xml_sequence(4) + def licenses(self) -> LicenseRepository: + """ + Optional list of licenses obtained during analysis. + + Returns: + Set of `LicenseChoice` + """ + return self._licenses + + @licenses.setter + def licenses(self, licenses: Iterable[License]) -> None: + self._licenses = LicenseRepository(licenses) + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'text') + @serializable.xml_sequence(5) + def copyright(self) -> 'SortedSet[Copyright]': + """ + Optional list of copyright statements. + + Returns: + Set of `Copyright` + """ + return self._copyright + + @copyright.setter + def copyright(self, copyright: Iterable[Copyright]) -> None: + self._copyright = SortedSet(copyright) + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple( + ( + _ComparableTuple(self.licenses), + _ComparableTuple(self.copyright), + self.callstack, + _ComparableTuple(self.identity), + _ComparableTuple(self.occurrences), + )) + + def __eq__(self, other: object) -> bool: + if isinstance(other, ComponentEvidence): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f'' + + +@serializable.serializable_enum +class ComponentScope(str, Enum): + """ + Enum object that defines the permissable 'scopes' for a Component according to the CycloneDX schema. + + .. note:: + See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.6/#type_scope + """ + # see `_ComponentScopeSerializationHelper.__CASES` for view/case map + REQUIRED = 'required' + OPTIONAL = 'optional' + EXCLUDED = 'excluded' # Only supported in >= 1.1 + + +class _ComponentScopeSerializationHelper(serializable.helpers.BaseHelper): + """ THIS CLASS IS NON-PUBLIC API """ + + __CASES: dict[type[serializable.ViewType], frozenset[ComponentScope]] = dict() + __CASES[SchemaVersion1Dot0] = frozenset({ + ComponentScope.REQUIRED, + ComponentScope.OPTIONAL, + }) + __CASES[SchemaVersion1Dot1] = __CASES[SchemaVersion1Dot0] | { + ComponentScope.EXCLUDED, + } + __CASES[SchemaVersion1Dot2] = __CASES[SchemaVersion1Dot1] + __CASES[SchemaVersion1Dot3] = __CASES[SchemaVersion1Dot2] + __CASES[SchemaVersion1Dot4] = __CASES[SchemaVersion1Dot3] + __CASES[SchemaVersion1Dot5] = __CASES[SchemaVersion1Dot4] + __CASES[SchemaVersion1Dot6] = __CASES[SchemaVersion1Dot5] + + @classmethod + def __normalize(cls, cs: ComponentScope, view: type[serializable.ViewType]) -> Optional[str]: + return cs.value \ + if cs in cls.__CASES.get(view, ()) \ + else None + + @classmethod + def json_normalize(cls, o: Any, *, + view: Optional[type[serializable.ViewType]], + **__: Any) -> Optional[str]: + assert view is not None + return cls.__normalize(o, view) + + @classmethod + def xml_normalize(cls, o: Any, *, + view: Optional[type[serializable.ViewType]], + **__: Any) -> Optional[str]: + assert view is not None + return cls.__normalize(o, view) + + @classmethod + def deserialize(cls, o: Any) -> ComponentScope: + return ComponentScope(o) + + +@serializable.serializable_enum +class ComponentType(str, Enum): + """ + Enum object that defines the permissible 'types' for a Component according to the CycloneDX schema. + + .. note:: + See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.6/#type_classification + """ + # see `_ComponentTypeSerializationHelper.__CASES` for view/case map + APPLICATION = 'application' + CONTAINER = 'container' # Only supported in >= 1.2 + CRYPTOGRAPHIC_ASSET = 'cryptographic-asset' # Only supported in >= 1.6 + DATA = 'data' # Only supported in >= 1.5 + DEVICE = 'device' + DEVICE_DRIVER = 'device-driver' # Only supported in >= 1.5 + FILE = 'file' # Only supported in >= 1.1 + FIRMWARE = 'firmware' # Only supported in >= 1.2 + FRAMEWORK = 'framework' + LIBRARY = 'library' + MACHINE_LEARNING_MODEL = 'machine-learning-model' # Only supported in >= 1.5 + OPERATING_SYSTEM = 'operating-system' + PLATFORM = 'platform' # Only supported in >= 1.5 + + +class _ComponentTypeSerializationHelper(serializable.helpers.BaseHelper): + """ THIS CLASS IS NON-PUBLIC API """ + + __CASES: dict[type[serializable.ViewType], frozenset[ComponentType]] = dict() + __CASES[SchemaVersion1Dot0] = frozenset({ + ComponentType.APPLICATION, + ComponentType.DEVICE, + ComponentType.FRAMEWORK, + ComponentType.LIBRARY, + ComponentType.OPERATING_SYSTEM, + }) + __CASES[SchemaVersion1Dot1] = __CASES[SchemaVersion1Dot0] | { + ComponentType.FILE, + } + __CASES[SchemaVersion1Dot2] = __CASES[SchemaVersion1Dot1] | { + ComponentType.CONTAINER, + ComponentType.FIRMWARE, + } + __CASES[SchemaVersion1Dot3] = __CASES[SchemaVersion1Dot2] + __CASES[SchemaVersion1Dot4] = __CASES[SchemaVersion1Dot3] + __CASES[SchemaVersion1Dot5] = __CASES[SchemaVersion1Dot4] | { + ComponentType.DATA, + ComponentType.DEVICE_DRIVER, + ComponentType.MACHINE_LEARNING_MODEL, + ComponentType.PLATFORM, + } + __CASES[SchemaVersion1Dot6] = __CASES[SchemaVersion1Dot5] | { + ComponentType.CRYPTOGRAPHIC_ASSET, + } + + @classmethod + def __normalize(cls, ct: ComponentType, view: type[serializable.ViewType]) -> Optional[str]: + if ct in cls.__CASES.get(view, ()): + return ct.value + raise SerializationOfUnsupportedComponentTypeException(f'unsupported {ct!r} for view {view!r}') + + @classmethod + def json_normalize(cls, o: Any, *, + view: Optional[type[serializable.ViewType]], + **__: Any) -> Optional[str]: + assert view is not None + return cls.__normalize(o, view) + + @classmethod + def xml_normalize(cls, o: Any, *, + view: Optional[type[serializable.ViewType]], + **__: Any) -> Optional[str]: + assert view is not None + return cls.__normalize(o, view) + + @classmethod + def deserialize(cls, o: Any) -> ComponentType: + return ComponentType(o) + + +@serializable.serializable_class +class Diff: + """ + Our internal representation of the `diffType` complex type. + + .. note:: + See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.6/xml/#type_diffType + """ + + def __init__( + self, *, + text: Optional[AttachedText] = None, + url: Optional[XsUri] = None, + ) -> None: + self.text = text + self.url = url + + @property + def text(self) -> Optional[AttachedText]: + """ + Specifies the optional text of the diff. + + Returns: + `AttachedText` if set else `None` + """ + return self._text + + @text.setter + def text(self, text: Optional[AttachedText]) -> None: + self._text = text + + @property + def url(self) -> Optional[XsUri]: + """ + Specifies the URL to the diff. + + Returns: + `XsUri` if set else `None` + """ + return self._url + + @url.setter + def url(self, url: Optional[XsUri]) -> None: + self._url = url + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple(( + self.url, + self.text, + )) + + def __eq__(self, other: object) -> bool: + if isinstance(other, Diff): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, Diff): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f'' + + +@serializable.serializable_enum +class PatchClassification(str, Enum): + """ + Enum object that defines the permissible `patchClassification`s. + + .. note:: + See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.6/xml/#type_patchClassification + """ + BACKPORT = 'backport' + CHERRY_PICK = 'cherry-pick' + MONKEY = 'monkey' + UNOFFICIAL = 'unofficial' + + +@serializable.serializable_class +class Patch: + """ + Our internal representation of the `patchType` complex type. + + .. note:: + See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.6/xml/#type_patchType + """ + + def __init__( + self, *, + type: PatchClassification, + diff: Optional[Diff] = None, + resolves: Optional[Iterable[IssueType]] = None, + ) -> None: + self.type = type + self.diff = diff + self.resolves = resolves or [] # type:ignore[assignment] + + @property + @serializable.xml_attribute() + def type(self) -> PatchClassification: + """ + Specifies the purpose for the patch including the resolution of defects, security issues, or new behavior or + functionality. + + Returns: + `PatchClassification` + """ + return self._type + + @type.setter + def type(self, type: PatchClassification) -> None: + self._type = type + + @property + def diff(self) -> Optional[Diff]: + """ + The patch file (or diff) that show changes. + + .. note:: + Refer to https://en.wikipedia.org/wiki/Diff. + + Returns: + `Diff` if set else `None` + """ + return self._diff + + @diff.setter + def diff(self, diff: Optional[Diff]) -> None: + self._diff = diff + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'issue') + def resolves(self) -> 'SortedSet[IssueType]': + """ + Optional list of issues resolved by this patch. + + Returns: + Set of `IssueType` + """ + return self._resolves + + @resolves.setter + def resolves(self, resolves: Iterable[IssueType]) -> None: + self._resolves = SortedSet(resolves) + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple(( + self.type, self.diff, + _ComparableTuple(self.resolves) + )) + + def __eq__(self, other: object) -> bool: + if isinstance(other, Patch): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, Patch): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f'' + + +@serializable.serializable_class +class Pedigree: + """ + Our internal representation of the `pedigreeType` complex type. + + Component pedigree is a way to document complex supply chain scenarios where components are created, distributed, + modified, redistributed, combined with other components, etc. Pedigree supports viewing this complex chain from the + beginning, the end, or anywhere in the middle. It also provides a way to document variants where the exact relation + may not be known. + + .. note:: + See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.6/xml/#type_pedigreeType + """ + + def __init__( + self, *, + ancestors: Optional[Iterable['Component']] = None, + descendants: Optional[Iterable['Component']] = None, + variants: Optional[Iterable['Component']] = None, + commits: Optional[Iterable[Commit]] = None, + patches: Optional[Iterable[Patch]] = None, + notes: Optional[str] = None, + ) -> None: + self.ancestors = ancestors or [] # type:ignore[assignment] + self.descendants = descendants or [] # type:ignore[assignment] + self.variants = variants or [] # type:ignore[assignment] + self.commits = commits or [] # type:ignore[assignment] + self.patches = patches or [] # type:ignore[assignment] + self.notes = notes + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'component') + @serializable.xml_sequence(1) + def ancestors(self) -> "SortedSet['Component']": + """ + Describes zero or more components in which a component is derived from. This is commonly used to describe forks + from existing projects where the forked version contains a ancestor node containing the original component it + was forked from. + + For example, Component A is the original component. Component B is the component being used and documented in + the BOM. However, Component B contains a pedigree node with a single ancestor documenting Component A - the + original component from which Component B is derived from. + + Returns: + Set of `Component` + """ + return self._ancestors + + @ancestors.setter + def ancestors(self, ancestors: Iterable['Component']) -> None: + self._ancestors = SortedSet(ancestors) + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'component') + @serializable.xml_sequence(2) + def descendants(self) -> "SortedSet['Component']": + """ + Descendants are the exact opposite of ancestors. This provides a way to document all forks (and their forks) of + an original or root component. + + Returns: + Set of `Component` + """ + return self._descendants + + @descendants.setter + def descendants(self, descendants: Iterable['Component']) -> None: + self._descendants = SortedSet(descendants) + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'component') + @serializable.xml_sequence(3) + def variants(self) -> "SortedSet['Component']": + """ + Variants describe relations where the relationship between the components are not known. For example, if + Component A contains nearly identical code to Component B. They are both related, but it is unclear if one is + derived from the other, or if they share a common ancestor. + + Returns: + Set of `Component` + """ + return self._variants + + @variants.setter + def variants(self, variants: Iterable['Component']) -> None: + self._variants = SortedSet(variants) + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'commit') + @serializable.xml_sequence(4) + def commits(self) -> 'SortedSet[Commit]': + """ + A list of zero or more commits which provide a trail describing how the component deviates from an ancestor, + descendant, or variant. + + Returns: + Set of `Commit` + """ + return self._commits + + @commits.setter + def commits(self, commits: Iterable[Commit]) -> None: + self._commits = SortedSet(commits) + + @property + @serializable.view(SchemaVersion1Dot2) + @serializable.view(SchemaVersion1Dot3) + @serializable.view(SchemaVersion1Dot4) + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'patch') + @serializable.xml_sequence(5) + def patches(self) -> 'SortedSet[Patch]': + """ + A list of zero or more patches describing how the component deviates from an ancestor, descendant, or variant. + Patches may be complimentary to commits or may be used in place of commits. + + Returns: + Set of `Patch` + """ + return self._patches + + @patches.setter + def patches(self, patches: Iterable[Patch]) -> None: + self._patches = SortedSet(patches) + + @property + @serializable.xml_sequence(6) + def notes(self) -> Optional[str]: + """ + Notes, observations, and other non-structured commentary describing the components pedigree. + + Returns: + `str` if set else `None` + """ + return self._notes + + @notes.setter + def notes(self, notes: Optional[str]) -> None: + self._notes = notes + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple(( + _ComparableTuple(self.ancestors), + _ComparableTuple(self.descendants), + _ComparableTuple(self.variants), + _ComparableTuple(self.commits), + _ComparableTuple(self.patches), + self.notes + )) + + def __eq__(self, other: object) -> bool: + if isinstance(other, Pedigree): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f'' + + +@serializable.serializable_class +class Swid: + """ + Our internal representation of the `swidType` complex type. + + .. note:: + See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.6/xml/#type_swidType + """ + + def __init__( + self, *, + tag_id: str, + name: str, + version: Optional[str] = None, + tag_version: Optional[int] = None, + patch: Optional[bool] = None, + text: Optional[AttachedText] = None, + url: Optional[XsUri] = None, + ) -> None: + self.tag_id = tag_id + self.name = name + self.version = version + self.tag_version = tag_version + self.patch = patch + self.text = text + self.url = url + + @property + @serializable.xml_attribute() + def tag_id(self) -> str: + """ + Maps to the tagId of a SoftwareIdentity. + + Returns: + `str` + """ + return self._tag_id + + @tag_id.setter + def tag_id(self, tag_id: str) -> None: + self._tag_id = tag_id + + @property + @serializable.xml_attribute() + def name(self) -> str: + """ + Maps to the name of a SoftwareIdentity. + + Returns: + `str` + """ + return self._name + + @name.setter + def name(self, name: str) -> None: + self._name = name + + @property + @serializable.xml_attribute() + def version(self) -> Optional[str]: + """ + Maps to the version of a SoftwareIdentity. + + Returns: + `str` if set else `None`. + """ + return self._version + + @version.setter + def version(self, version: Optional[str]) -> None: + self._version = version + + @property + @serializable.xml_attribute() + def tag_version(self) -> Optional[int]: + """ + Maps to the tagVersion of a SoftwareIdentity. + + Returns: + `int` if set else `None` + """ + return self._tag_version + + @tag_version.setter + def tag_version(self, tag_version: Optional[int]) -> None: + self._tag_version = tag_version + + @property + @serializable.xml_attribute() + def patch(self) -> Optional[bool]: + """ + Maps to the patch of a SoftwareIdentity. + + Returns: + `bool` if set else `None` + """ + return self._patch + + @patch.setter + def patch(self, patch: Optional[bool]) -> None: + self._patch = patch + + @property + def text(self) -> Optional[AttachedText]: + """ + Specifies the full content of the SWID tag. + + Returns: + `AttachedText` if set else `None` + """ + return self._text + + @text.setter + def text(self, text: Optional[AttachedText]) -> None: + self._text = text + + @property + def url(self) -> Optional[XsUri]: + """ + The URL to the SWID file. + + Returns: + `XsUri` if set else `None` + """ + return self._url + + @url.setter + def url(self, url: Optional[XsUri]) -> None: + self._url = url + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple(( + self.tag_id, + self.name, self.version, + self.tag_version, + self.patch, + self.url, + self.text, + )) + + def __eq__(self, other: object) -> bool: + if isinstance(other, Swid): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f'' + + +@serializable.serializable_class +class OmniborId(serializable.helpers.BaseHelper): + """ + Helper class that allows us to perform validation on data strings that must conform to + https://www.iana.org/assignments/uri-schemes/prov/gitoid. + + """ + + _VALID_OMNIBOR_ID_REGEX = re.compile(r'^gitoid:(blob|tree|commit|tag):sha(1|256):([a-z0-9]+)$') + + def __init__(self, id: str) -> None: + if OmniborId._VALID_OMNIBOR_ID_REGEX.match(id) is None: + raise InvalidOmniBorIdException( + f'Supplied value "{id} does not meet format specification.' + ) + self._id = id + + @property + @serializable.json_name('.') + @serializable.xml_name('.') + def id(self) -> str: + return self._id + + @classmethod + def serialize(cls, o: Any) -> str: + if isinstance(o, OmniborId): + return str(o) + raise SerializationOfUnexpectedValueException( + f'Attempt to serialize a non-OmniBorId: {o!r}') + + @classmethod + def deserialize(cls, o: Any) -> 'OmniborId': + try: + return OmniborId(id=str(o)) + except ValueError as err: + raise CycloneDxDeserializationException( + f'OmniBorId string supplied does not parse: {o!r}' + ) from err + + def __eq__(self, other: Any) -> bool: + if isinstance(other, OmniborId): + return self._id == other._id + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, OmniborId): + return self._id < other._id + return NotImplemented + + def __hash__(self) -> int: + return hash(self._id) + + def __repr__(self) -> str: + return f'' + + def __str__(self) -> str: + return self._id + + +@serializable.serializable_class +class Swhid(serializable.helpers.BaseHelper): + """ + Helper class that allows us to perform validation on data strings that must conform to + https://docs.softwareheritage.org/devel/swh-model/persistent-identifiers.html. + + """ + + _VALID_SWHID_REGEX = re.compile(r'^swh:1:(cnp|rel|rev|dir|cnt):([0-9a-z]{40})(.*)?$') + + def __init__(self, id: str) -> None: + if Swhid._VALID_SWHID_REGEX.match(id) is None: + raise InvalidSwhidException( + f'Supplied value "{id} does not meet format specification.' + ) + self._id = id + + @property + @serializable.json_name('.') + @serializable.xml_name('.') + def id(self) -> str: + return self._id + + @classmethod + def serialize(cls, o: Any) -> str: + if isinstance(o, Swhid): + return str(o) + raise SerializationOfUnexpectedValueException( + f'Attempt to serialize a non-Swhid: {o!r}') + + @classmethod + def deserialize(cls, o: Any) -> 'Swhid': + try: + return Swhid(id=str(o)) + except ValueError as err: + raise CycloneDxDeserializationException( + f'Swhid string supplied does not parse: {o!r}' + ) from err + + def __eq__(self, other: Any) -> bool: + if isinstance(other, Swhid): + return self._id == other._id + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, Swhid): + return self._id < other._id + return NotImplemented + + def __hash__(self) -> int: + return hash(self._id) + + def __repr__(self) -> str: + return f'' + + def __str__(self) -> str: + return self._id + + +@serializable.serializable_class +class Component(Dependable): + """ + This is our internal representation of a Component within a Bom. + + .. note:: + See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.6/#type_component + """ + + @staticmethod + def for_file(absolute_file_path: str, path_for_bom: Optional[str]) -> 'Component': + """ + Helper method to create a Component that represents the provided local file as a Component. + + Args: + absolute_file_path: + Absolute path to the file you wish to represent + path_for_bom: + Optionally, if supplied this is the path that will be used to identify the file in the BOM + + Returns: + `Component` representing the supplied file + """ + if not exists(absolute_file_path): + raise FileExistsError(f'Supplied file path {absolute_file_path!r} does not exist') + + sha1_hash: str = _file_sha1sum(absolute_file_path) + return Component( + name=path_for_bom if path_for_bom else absolute_file_path, + version=f'0.0.0-{sha1_hash[0:12]}', + hashes=[ + HashType(alg=HashAlgorithm.SHA_1, content=sha1_hash) + ], + type=ComponentType.FILE, purl=PackageURL( + type='generic', name=path_for_bom if path_for_bom else absolute_file_path, + version=f'0.0.0-{sha1_hash[0:12]}' + ) + ) + + def __init__( + self, *, + name: str, + type: ComponentType = ComponentType.LIBRARY, + mime_type: Optional[str] = None, + bom_ref: Optional[Union[str, BomRef]] = None, + supplier: Optional[OrganizationalEntity] = None, + publisher: Optional[str] = None, + group: Optional[str] = None, + version: Optional[str] = None, + description: Optional[str] = None, + scope: Optional[ComponentScope] = None, + hashes: Optional[Iterable[HashType]] = None, + licenses: Optional[Iterable[License]] = None, + copyright: Optional[str] = None, + purl: Optional[PackageURL] = None, + external_references: Optional[Iterable[ExternalReference]] = None, + properties: Optional[Iterable[Property]] = None, + release_notes: Optional[ReleaseNotes] = None, + cpe: Optional[str] = None, + swid: Optional[Swid] = None, + pedigree: Optional[Pedigree] = None, + components: Optional[Iterable['Component']] = None, + evidence: Optional[ComponentEvidence] = None, + modified: bool = False, + manufacturer: Optional[OrganizationalEntity] = None, + authors: Optional[Iterable[OrganizationalContact]] = None, + omnibor_ids: Optional[Iterable[OmniborId]] = None, + swhids: Optional[Iterable[Swhid]] = None, + crypto_properties: Optional[CryptoProperties] = None, + tags: Optional[Iterable[str]] = None, + # Deprecated in v1.6 + author: Optional[str] = None, + ) -> None: + self.type = type + self.mime_type = mime_type + self._bom_ref = _bom_ref_from_str(bom_ref) + self.supplier = supplier + self.manufacturer = manufacturer + self.authors = authors or [] # type:ignore[assignment] + self.author = author + self.publisher = publisher + self.group = group + self.name = name + self.version = version + self.description = description + self.scope = scope + self.hashes = hashes or [] # type:ignore[assignment] + self.licenses = licenses or [] # type:ignore[assignment] + self.copyright = copyright + self.cpe = cpe + self.purl = purl + self.omnibor_ids = omnibor_ids or [] # type:ignore[assignment] + self.swhids = swhids or [] # type:ignore[assignment] + self.swid = swid + self.modified = modified + self.pedigree = pedigree + self.external_references = external_references or [] # type:ignore[assignment] + self.properties = properties or [] # type:ignore[assignment] + self.components = components or [] # type:ignore[assignment] + self.evidence = evidence + self.release_notes = release_notes + self.crypto_properties = crypto_properties + self.tags = tags or [] # type:ignore[assignment] + + if modified: + warn('`.component.modified` is deprecated from CycloneDX v1.3 onwards. ' + 'Please use `@.pedigree` instead.', DeprecationWarning) + if author: + warn('`.component.author` is deprecated from CycloneDX v1.6 onwards. ' + 'Please use `@.authors` or `@.manufacturer` instead.', DeprecationWarning) + + @property + @serializable.type_mapping(_ComponentTypeSerializationHelper) + @serializable.xml_attribute() + def type(self) -> ComponentType: + """ + Get the type of this Component. + + Returns: + Declared type of this Component as `ComponentType`. + """ + return self._type + + @type.setter + def type(self, type: ComponentType) -> None: + self._type = type + + @property + @serializable.xml_string(serializable.XmlStringSerializationType.TOKEN) + def mime_type(self) -> Optional[str]: + """ + Get any declared mime-type for this Component. + + When used on file components, the mime-type can provide additional context about the kind of file being + represented such as an image, font, or executable. Some library or framework components may also have an + associated mime-type. + + Returns: + `str` if set else `None` + """ + return self._mime_type + + @mime_type.setter + def mime_type(self, mime_type: Optional[str]) -> None: + self._mime_type = mime_type + + @property + @serializable.json_name('bom-ref') + @serializable.type_mapping(BomRef) + @serializable.view(SchemaVersion1Dot1) + @serializable.view(SchemaVersion1Dot2) + @serializable.view(SchemaVersion1Dot3) + @serializable.view(SchemaVersion1Dot4) + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.xml_attribute() + @serializable.xml_name('bom-ref') + def bom_ref(self) -> BomRef: + """ + An optional identifier which can be used to reference the component elsewhere in the BOM. Every bom-ref MUST be + unique within the BOM. + + Returns: + `BomRef` + """ + return self._bom_ref + + @property + @serializable.view(SchemaVersion1Dot2) + @serializable.view(SchemaVersion1Dot3) + @serializable.view(SchemaVersion1Dot4) + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.xml_sequence(1) + def supplier(self) -> Optional[OrganizationalEntity]: + """ + The organization that supplied the component. The supplier may often be the manufacture, but may also be a + distributor or repackager. + + Returns: + `OrganizationalEntity` if set else `None` + """ + return self._supplier + + @supplier.setter + def supplier(self, supplier: Optional[OrganizationalEntity]) -> None: + self._supplier = supplier + + @property + @serializable.view(SchemaVersion1Dot6) + @serializable.xml_sequence(2) + def manufacturer(self) -> Optional[OrganizationalEntity]: + """ + The organization that created the component. + Manufacturer is common in components created through automated processes. + Components created through manual means may have `@.authors` instead. + + Returns: + `OrganizationalEntity` if set else `None` + """ + return self._manufacturer + + @manufacturer.setter + def manufacturer(self, manufacturer: Optional[OrganizationalEntity]) -> None: + self._manufacturer = manufacturer + + @property + @serializable.view(SchemaVersion1Dot6) + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'author') + @serializable.xml_sequence(3) + def authors(self) -> 'SortedSet[OrganizationalContact]': + """ + The person(s) who created the component. + Authors are common in components created through manual processes. + Components created through automated means may have `@.manufacturer` instead. + + Returns: + `Iterable[OrganizationalContact]` if set else `None` + """ + return self._authors + + @authors.setter + def authors(self, authors: Iterable[OrganizationalContact]) -> None: + self._authors = SortedSet(authors) + + @property + @serializable.view(SchemaVersion1Dot2) + @serializable.view(SchemaVersion1Dot3) + @serializable.view(SchemaVersion1Dot4) + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) # todo: this is deprecated in v1.6? + @serializable.xml_sequence(4) + @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + def author(self) -> Optional[str]: + """ + The person(s) or organization(s) that authored the component. + + Returns: + `str` if set else `None` + """ + return self._author + + @author.setter + def author(self, author: Optional[str]) -> None: + self._author = author + + @property + @serializable.xml_sequence(5) + @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + def publisher(self) -> Optional[str]: + """ + The person(s) or organization(s) that published the component + + Returns: + `str` if set else `None` + """ + return self._publisher + + @publisher.setter + def publisher(self, publisher: Optional[str]) -> None: + self._publisher = publisher + + @property + @serializable.xml_sequence(6) + @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + def group(self) -> Optional[str]: + """ + The grouping name or identifier. This will often be a shortened, single name of the company or project that + produced the component, or the source package or domain name. Whitespace and special characters should be + avoided. + + Examples include: `apache`, `org.apache.commons`, and `apache.org`. + + Returns: + `str` if set else `None` + """ + return self._group + + @group.setter + def group(self, group: Optional[str]) -> None: + self._group = group + + @property + @serializable.xml_sequence(7) + @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + def name(self) -> str: + """ + The name of the component. + + This will often be a shortened, single name of the component. + + Examples: `commons-lang3` and `jquery`. + + Returns: + `str` + """ + return self._name + + @name.setter + def name(self, name: str) -> None: + self._name = name + + @property + @serializable.include_none(SchemaVersion1Dot0, '') + @serializable.include_none(SchemaVersion1Dot1, '') + @serializable.include_none(SchemaVersion1Dot2, '') + @serializable.include_none(SchemaVersion1Dot3, '') + @serializable.xml_sequence(8) + @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + def version(self) -> Optional[str]: + """ + The component version. The version should ideally comply with semantic versioning but is not enforced. + + This is NOT optional for CycloneDX Schema Version < 1.4 but was agreed to default to an empty string where a + version was not supplied for schema versions < 1.4 + + Returns: + Declared version of this Component as `str` or `None` + """ + return self._version + + @version.setter + def version(self, version: Optional[str]) -> None: + if version and len(version) > 1024: + warn('`.component.version`has a maximum length of 1024 from CycloneDX v1.6 onwards.', UserWarning) + self._version = version + + @property + @serializable.xml_sequence(9) + @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + def description(self) -> Optional[str]: + """ + Get the description of this Component. + + Returns: + `str` if set, else `None`. + """ + return self._description + + @description.setter + def description(self, description: Optional[str]) -> None: + self._description = description + + @property + @serializable.type_mapping(_ComponentScopeSerializationHelper) + @serializable.xml_sequence(10) + def scope(self) -> Optional[ComponentScope]: + """ + Specifies the scope of the component. + + If scope is not specified, 'required' scope should be assumed by the consumer of the BOM. + + Returns: + `ComponentScope` or `None` + """ + return self._scope + + @scope.setter + def scope(self, scope: Optional[ComponentScope]) -> None: + self._scope = scope + + @property + @serializable.type_mapping(_HashTypeRepositorySerializationHelper) + @serializable.xml_sequence(11) + def hashes(self) -> 'SortedSet[HashType]': + """ + Optional list of hashes that help specify the integrity of this Component. + + Returns: + Set of `HashType` + """ + return self._hashes + + @hashes.setter + def hashes(self, hashes: Iterable[HashType]) -> None: + self._hashes = SortedSet(hashes) + + @property + @serializable.view(SchemaVersion1Dot1) + @serializable.view(SchemaVersion1Dot2) + @serializable.view(SchemaVersion1Dot3) + @serializable.view(SchemaVersion1Dot4) + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.type_mapping(_LicenseRepositorySerializationHelper) + @serializable.xml_sequence(12) + def licenses(self) -> LicenseRepository: + """ + A optional list of statements about how this Component is licensed. + + Returns: + Set of `LicenseChoice` + """ + return self._licenses + + @licenses.setter + def licenses(self, licenses: Iterable[License]) -> None: + self._licenses = LicenseRepository(licenses) + + @property + @serializable.xml_sequence(13) + @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + def copyright(self) -> Optional[str]: + """ + An optional copyright notice informing users of the underlying claims to copyright ownership in a published + work. + + Returns: + `str` or `None` + """ + return self._copyright + + @copyright.setter + def copyright(self, copyright: Optional[str]) -> None: + self._copyright = copyright + + @property + @serializable.xml_sequence(14) + def cpe(self) -> Optional[str]: + """ + Specifies a well-formed CPE name that conforms to the CPE 2.2 or 2.3 specification. + See https://nvd.nist.gov/products/cpe + + Returns: + `str` if set else `None` + """ + return self._cpe + + @cpe.setter + def cpe(self, cpe: Optional[str]) -> None: + self._cpe = cpe + + @property + @serializable.type_mapping(PackageUrlSH) + @serializable.xml_sequence(15) + def purl(self) -> Optional[PackageURL]: + """ + Specifies the package-url (PURL). + + The purl, if specified, must be valid and conform to the specification defined at: + https://github.com/package-url/purl-spec + + Returns: + `PackageURL` or `None` + """ + return self._purl + + @purl.setter + def purl(self, purl: Optional[PackageURL]) -> None: + self._purl = purl + + @property + @serializable.json_name('omniborId') + @serializable.view(SchemaVersion1Dot6) + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, child_name='omniborId') + @serializable.xml_sequence(16) + def omnibor_ids(self) -> 'SortedSet[OmniborId]': + """ + Specifies the OmniBOR Artifact ID. The OmniBOR, if specified, MUST be valid and conform to the specification + defined at: https://www.iana.org/assignments/uri-schemes/prov/gitoid + + Returns: + `Iterable[str]` or `None` + """ + + return self._omnibor_ids + + @omnibor_ids.setter + def omnibor_ids(self, omnibor_ids: Iterable[OmniborId]) -> None: + self._omnibor_ids = SortedSet(omnibor_ids) + + @property + @serializable.json_name('swhid') + @serializable.view(SchemaVersion1Dot6) + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, child_name='swhid') + @serializable.xml_sequence(17) + def swhids(self) -> 'SortedSet[Swhid]': + """ + Specifies the Software Heritage persistent identifier (SWHID). The SWHID, if specified, MUST be valid and + conform to the specification defined at: + https://docs.softwareheritage.org/devel/swh-model/persistent-identifiers.html + + Returns: + `Iterable[Swhid]` if set else `None` + """ + return self._swhids + + @swhids.setter + def swhids(self, swhids: Iterable[Swhid]) -> None: + self._swhids = SortedSet(swhids) + + @property + @serializable.view(SchemaVersion1Dot2) + @serializable.view(SchemaVersion1Dot3) + @serializable.view(SchemaVersion1Dot4) + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.xml_sequence(18) + def swid(self) -> Optional[Swid]: + """ + Specifies metadata and content for ISO-IEC 19770-2 Software Identification (SWID) Tags. + + Returns: + `Swid` if set else `None` + """ + return self._swid + + @swid.setter + def swid(self, swid: Optional[Swid]) -> None: + self._swid = swid + + @property + @serializable.view(SchemaVersion1Dot0) # todo: Deprecated in v1.3 + @serializable.xml_sequence(19) + def modified(self) -> bool: + return self._modified + + @modified.setter + def modified(self, modified: bool) -> None: + self._modified = modified + + @property + @serializable.view(SchemaVersion1Dot1) + @serializable.view(SchemaVersion1Dot2) + @serializable.view(SchemaVersion1Dot3) + @serializable.view(SchemaVersion1Dot4) + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.xml_sequence(20) + def pedigree(self) -> Optional[Pedigree]: + """ + Component pedigree is a way to document complex supply chain scenarios where components are created, + distributed, modified, redistributed, combined with other components, etc. + + Returns: + `Pedigree` if set else `None` + """ + return self._pedigree + + @pedigree.setter + def pedigree(self, pedigree: Optional[Pedigree]) -> None: + self._pedigree = pedigree + + @property + @serializable.view(SchemaVersion1Dot1) + @serializable.view(SchemaVersion1Dot2) + @serializable.view(SchemaVersion1Dot3) + @serializable.view(SchemaVersion1Dot4) + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'reference') + @serializable.xml_sequence(21) + def external_references(self) -> 'SortedSet[ExternalReference]': + """ + Provides the ability to document external references related to the component or to the project the component + describes. + + Returns: + Set of `ExternalReference` + """ + return self._external_references + + @external_references.setter + def external_references(self, external_references: Iterable[ExternalReference]) -> None: + self._external_references = SortedSet(external_references) + + @property + @serializable.view(SchemaVersion1Dot3) + @serializable.view(SchemaVersion1Dot4) + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'property') + @serializable.xml_sequence(22) + def properties(self) -> 'SortedSet[Property]': + """ + Provides the ability to document properties in a key/value store. This provides flexibility to include data not + officially supported in the standard without having to use additional namespaces or create extensions. + + Return: + Set of `Property` + """ + return self._properties + + @properties.setter + def properties(self, properties: Iterable[Property]) -> None: + self._properties = SortedSet(properties) + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'component') + @serializable.xml_sequence(23) + def components(self) -> "SortedSet['Component']": + """ + A list of software and hardware components included in the parent component. This is not a dependency tree. It + provides a way to specify a hierarchical representation of component assemblies, similar to system -> subsystem + -> parts assembly in physical supply chains. + + Returns: + Set of `Component` + """ + return self._components + + @components.setter + def components(self, components: Iterable['Component']) -> None: + self._components = SortedSet(components) + + @property + @serializable.view(SchemaVersion1Dot3) + @serializable.view(SchemaVersion1Dot4) + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.xml_sequence(24) + def evidence(self) -> Optional[ComponentEvidence]: + """ + Provides the ability to document evidence collected through various forms of extraction or analysis. + + Returns: + `ComponentEvidence` if set else `None` + """ + return self._evidence + + @evidence.setter + def evidence(self, evidence: Optional[ComponentEvidence]) -> None: + self._evidence = evidence + + @property + @serializable.view(SchemaVersion1Dot4) + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.xml_sequence(25) + def release_notes(self) -> Optional[ReleaseNotes]: + """ + Specifies optional release notes. + + Returns: + `ReleaseNotes` or `None` + """ + return self._release_notes + + @release_notes.setter + def release_notes(self, release_notes: Optional[ReleaseNotes]) -> None: + self._release_notes = release_notes + + # @property + # ... + # @serializable.view(SchemaVersion1Dot5) + # @serializable.xml_sequence(22) + # def model_card(self) -> ...: + # ... # TODO since CDX1.5 + # + # @model_card.setter + # def model_card(self, ...) -> None: + # ... # TODO since CDX1.5 + + # @property + # ... + # @serializable.view(SchemaVersion1Dot5) + # @serializable.xml_sequence(23) + # def data(self) -> ...: + # ... # TODO since CDX1.5 + # + # @data.setter + # def data(self, ...) -> None: + # ... # TODO since CDX1.5 + + @property + @serializable.view(SchemaVersion1Dot6) + @serializable.xml_sequence(30) + def crypto_properties(self) -> Optional[CryptoProperties]: + """ + Cryptographic assets have properties that uniquely define them and that make them actionable for further + reasoning. As an example, it makes a difference if one knows the algorithm family (e.g. AES) or the specific + variant or instantiation (e.g. AES-128-GCM). This is because the security level and the algorithm primitive + (authenticated encryption) is only defined by the definition of the algorithm variant. The presence of a weak + cryptographic algorithm like SHA1 vs. HMAC-SHA1 also makes a difference. + + Returns: + `CryptoProperties` or `None` + """ + return self._crypto_properties + + @crypto_properties.setter + def crypto_properties(self, crypto_properties: Optional[CryptoProperties]) -> None: + self._crypto_properties = crypto_properties + + @property + @serializable.view(SchemaVersion1Dot6) + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'tag') + @serializable.xml_sequence(31) + def tags(self) -> 'SortedSet[str]': + """ + Textual strings that aid in discovery, search, and retrieval of the associated object. + Tags often serve as a way to group or categorize similar or related objects by various attributes. + + Returns: + `Iterable[str]` + """ + return self._tags + + @tags.setter + def tags(self, tags: Iterable[str]) -> None: + self._tags = SortedSet(tags) + + def get_all_nested_components(self, include_self: bool = False) -> set['Component']: + components = set() + if include_self: + components.add(self) + + for c in self.components: + components.update(c.get_all_nested_components(include_self=True)) + + return components + + def get_pypi_url(self) -> str: + if self.version: + return f'https://pypi.org/project/{self.name}/{self.version}' + else: + return f'https://pypi.org/project/{self.name}' + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple(( + self.type, self.group, self.name, self.version, + self.bom_ref.value, + None if self.purl is None else _ComparablePackageURL(self.purl), + self.swid, self.cpe, _ComparableTuple(self.swhids), + self.supplier, self.author, self.publisher, + self.description, + self.mime_type, self.scope, _ComparableTuple(self.hashes), + _ComparableTuple(self.licenses), self.copyright, + self.pedigree, + _ComparableTuple(self.external_references), _ComparableTuple(self.properties), + _ComparableTuple(self.components), self.evidence, self.release_notes, self.modified, + _ComparableTuple(self.authors), _ComparableTuple(self.omnibor_ids), self.manufacturer, + self.crypto_properties, _ComparableTuple(self.tags), + )) + + def __eq__(self, other: object) -> bool: + if isinstance(other, Component): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, Component): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f'' From 63c1cc57a589d2db8a717d76d7bb8719d9ffab5f Mon Sep 17 00:00:00 2001 From: Jan Kowalleck Date: Tue, 27 May 2025 11:58:44 +0200 Subject: [PATCH 03/24] prep extraxtion of component_evidence Signed-off-by: Jan Kowalleck --- cyclonedx/model/{component_.py => component.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename cyclonedx/model/{component_.py => component.py} (100%) diff --git a/cyclonedx/model/component_.py b/cyclonedx/model/component.py similarity index 100% rename from cyclonedx/model/component_.py rename to cyclonedx/model/component.py From 7b30eff26458f555fc348cbf2a8f27c3c6054614 Mon Sep 17 00:00:00 2001 From: Jan Kowalleck Date: Tue, 27 May 2025 12:11:36 +0200 Subject: [PATCH 04/24] refactor: compoennt evidence Signed-off-by: Jan Kowalleck --- cyclonedx/model/component.py | 785 +----------- cyclonedx/model/component_evidence.py | 1662 +------------------------ 2 files changed, 6 insertions(+), 2441 deletions(-) diff --git a/cyclonedx/model/component.py b/cyclonedx/model/component.py index da1ab6e9..12d6be7c 100644 --- a/cyclonedx/model/component.py +++ b/cyclonedx/model/component.py @@ -17,12 +17,10 @@ import re from collections.abc import Iterable -from decimal import Decimal from enum import Enum from os.path import exists from typing import Any, Optional, Union from warnings import warn -from xml.etree.ElementTree import Element # nosec B405 # See https://github.com/package-url/packageurl-python/issues/65 import py_serializable as serializable @@ -50,7 +48,6 @@ from ..serialization import PackageUrl as PackageUrlSH from . import ( AttachedText, - Copyright, ExternalReference, HashAlgorithm, HashType, @@ -60,6 +57,7 @@ _HashTypeRepositorySerializationHelper, ) from .bom_ref import BomRef +from .component_evidence import ComponentEvidence from .contact import OrganizationalContact, OrganizationalEntity from .crypto import CryptoProperties from .dependency import Dependable @@ -193,787 +191,6 @@ def __repr__(self) -> str: return f'' -@serializable.serializable_enum -class IdentityFieldType(str, Enum): - """ - Enum object that defines the permissible field types for Identity. - - .. note:: - See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.6/json/#components_items_evidence_identity - """ - - GROUP = 'group' - NAME = 'name' - VERSION = 'version' - PURL = 'purl' - CPE = 'cpe' - OMNIBOR_ID = 'omniborId' - SWHID = 'swhid' - SWID = 'swid' - HASH = 'hash' - - -@serializable.serializable_enum -class AnalysisTechnique(str, Enum): - """ - Enum object that defines the permissible analysis techniques. - """ - - SOURCE_CODE_ANALYSIS = 'source-code-analysis' - BINARY_ANALYSIS = 'binary-analysis' - MANIFEST_ANALYSIS = 'manifest-analysis' - AST_FINGERPRINT = 'ast-fingerprint' - HASH_COMPARISON = 'hash-comparison' - INSTRUMENTATION = 'instrumentation' - DYNAMIC_ANALYSIS = 'dynamic-analysis' - FILENAME = 'filename' - ATTESTATION = 'attestation' - OTHER = 'other' - - -@serializable.serializable_class -class Method: - """ - Represents a method used to extract and/or analyze evidence. - """ - - def __init__( - self, *, - technique: Union[AnalysisTechnique, str], - confidence: Decimal, - value: Optional[str] = None, - ) -> None: - self.technique = technique - self.confidence = confidence - self.value = value - - @property - @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'technique') - @serializable.json_name('technique') - @serializable.xml_sequence(1) - def technique(self) -> str: - return self._technique.value - - @technique.setter - def technique(self, technique: Union[AnalysisTechnique, str]) -> None: - if isinstance(technique, str): - try: - technique = AnalysisTechnique(technique) - except ValueError: - raise ValueError( - f'Technique must be one of: {", ".join(t.value for t in AnalysisTechnique)}' - ) - self._technique = technique - - @property - @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'confidence') - @serializable.json_name('confidence') - @serializable.xml_sequence(2) - def confidence(self) -> Decimal: - return self._confidence - - @confidence.setter - def confidence(self, confidence: Decimal) -> None: - if not 0 <= confidence <= 1: - raise ValueError('Confidence must be between 0 and 1') - self._confidence = confidence - - @property - @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'value') - @serializable.json_name('value') - @serializable.xml_sequence(3) - def value(self) -> Optional[str]: - return self._value - - @value.setter - def value(self, value: Optional[str]) -> None: - self._value = value - - def __comparable_tuple(self) -> _ComparableTuple: - return _ComparableTuple( - ( - self.technique, - self.confidence, - self.value, - ) - ) - - def __eq__(self, other: object) -> bool: - if isinstance(other, Method): - return self.__comparable_tuple() == other.__comparable_tuple() - return False - - def __lt__(self, other: Any) -> bool: - if isinstance(other, Method): - return self.__comparable_tuple() < other.__comparable_tuple() - return NotImplemented - - def __hash__(self) -> int: - return hash(self.__comparable_tuple()) - - def __repr__(self) -> str: - return f'' - - -class _ToolsSerializationHelper(serializable.helpers.BaseHelper): - """ THIS CLASS IS NON-PUBLIC API """ - - @classmethod - def json_normalize(cls, o: Any, *, - view: Optional[type[serializable.ViewType]], - **__: Any) -> Any: - if isinstance(o, SortedSet): - return [str(t) for t in o] # Convert BomRef to string - return o - - @classmethod - def xml_normalize(cls, o: Any, *, - element_name: str, - view: Optional[type[serializable.ViewType]], - xmlns: Optional[str], - **__: Any) -> Optional[Element]: - if len(o) == 0: - return None - - # Create tools element with namespace if provided - tools_elem = Element(f'{{{xmlns}}}tools' if xmlns else 'tools') - for tool in o: - tool_elem = Element(f'{{{xmlns}}}tool' if xmlns else 'tool') - tool_elem.set(f'{{{xmlns}}}ref' if xmlns else 'ref', str(tool)) - tools_elem.append(tool_elem) - return tools_elem - - @classmethod - def json_denormalize(cls, o: Any, **kwargs: Any) -> SortedSet[BomRef]: - if isinstance(o, (list, set, tuple)): - return SortedSet(BomRef(str(t)) for t in o) - return SortedSet() - - @classmethod - def xml_denormalize(cls, o: Element, - default_ns: Optional[str], - **__: Any) -> SortedSet[BomRef]: - repo = [] - tool_tag = f'{{{default_ns}}}tool' if default_ns else 'tool' - ref_attr = f'{{{default_ns}}}ref' if default_ns else 'ref' - for tool_elem in o.findall(f'.//{tool_tag}'): - ref = tool_elem.get(ref_attr) or tool_elem.get('ref') - if ref: - repo.append(BomRef(str(ref))) - else: - raise CycloneDxDeserializationException(f'unexpected: {tool_elem!r}') - return SortedSet(repo) - - -@serializable.serializable_class -class Identity: - """ - Our internal representation of the `identityType` complex type. - - .. note:: - See the CycloneDX Schema definition: hhttps://cyclonedx.org/docs/1.6/json/#components_items_evidence_identity - """ - - def __init__( - self, *, - field: Union[IdentityFieldType, str], # Accept either enum or string - confidence: Optional[Decimal] = None, - concluded_value: Optional[str] = None, - methods: Optional[Iterable[Method]] = None, # Updated type - tools: Optional[Iterable[Union[str, BomRef]]] = None, - ) -> None: - self.field = field - self.confidence = confidence - self.concluded_value = concluded_value - self.methods = methods or [] # type: ignore[assignment] - self.tools = tools or [] # type: ignore[assignment] - - @property - @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'field') - @serializable.xml_sequence(1) - def field(self) -> str: - return self._field.value - - @field.setter - def field(self, field: Union[IdentityFieldType, str]) -> None: - if isinstance(field, str): - try: - field = IdentityFieldType(field) - except ValueError: - raise ValueError( - f'Field must be one of: {", ".join(f.value for f in IdentityFieldType)}' - ) - self._field = field - - @property - @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'confidence') - @serializable.xml_sequence(2) - def confidence(self) -> Optional[Decimal]: - """ - Returns the confidence value if set, otherwise None. - """ - return self._confidence - - @confidence.setter - def confidence(self, confidence: Optional[Decimal]) -> None: - """ - Sets the confidence value. Ensures it is between 0 and 1 if provided. - """ - if confidence is not None and not 0 <= confidence <= 1: - raise ValueError('Confidence must be between 0 and 1') - self._confidence = confidence - - @property - @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'concludedValue') - @serializable.xml_sequence(3) - def concluded_value(self) -> Optional[str]: - return self._concluded_value - - @concluded_value.setter - def concluded_value(self, concluded_value: Optional[str]) -> None: - self._concluded_value = concluded_value - - @property - @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'method') - @serializable.xml_sequence(4) - def methods(self) -> 'SortedSet[Method]': # Updated return type - return self._methods - - @methods.setter - def methods(self, methods: Iterable[Method]) -> None: # Updated parameter type - self._methods = SortedSet(methods) - - @property - @serializable.type_mapping(_ToolsSerializationHelper) - @serializable.xml_sequence(5) - def tools(self) -> 'SortedSet[BomRef]': - """ - References to the tools used to perform analysis and collect evidence. - Can be either a string reference (refLinkType) or a BOM reference (bomLinkType). - All references are stored and serialized as strings. - - Returns: - Set of tool references as BomRef - """ - return self._tools - - @tools.setter - def tools(self, tools: Iterable[Union[str, BomRef]]) -> None: - """Convert all inputs to BomRef for consistent storage""" - validated = [] - for t in tools: - ref_str = str(t) - if not (XsUri(ref_str).is_bom_link() or len(ref_str) >= 1): - raise ValueError( - f'Invalid tool reference: {ref_str}. Must be a valid BOM reference or BOM-Link.' - ) - validated.append(BomRef(ref_str)) - self._tools = SortedSet(validated) - - def __comparable_tuple(self) -> _ComparableTuple: - return _ComparableTuple( - ( - self.field, - self.confidence, - self.concluded_value, - _ComparableTuple(self.methods), - _ComparableTuple(self.tools), - ) - ) - - def __eq__(self, other: object) -> bool: - if isinstance(other, Identity): - return self.__comparable_tuple() == other.__comparable_tuple() - return False - - def __lt__(self, other: Any) -> bool: - if isinstance(other, Identity): - return self.__comparable_tuple() < other.__comparable_tuple() - return NotImplemented - - def __hash__(self) -> int: - return hash(self.__comparable_tuple()) - - def __repr__(self) -> str: - return f'' - - -@serializable.serializable_class -class Occurrence: - """ - Our internal representation of the `occurrenceType` complex type. - - .. note:: - See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.6/json/#components_items_evidence_occurrences - """ - - def __init__( - self, *, - bom_ref: Optional[Union[str, BomRef]] = None, - location: str, - line: Optional[int] = None, - offset: Optional[int] = None, - symbol: Optional[str] = None, - additional_context: Optional[str] = None, - ) -> None: - self.bom_ref = bom_ref # type: ignore[assignment] - self.location = location - self.line = line - self.offset = offset - self.symbol = symbol - self.additional_context = additional_context - - @property - @serializable.json_name('bom-ref') - @serializable.type_mapping(BomRef) - @serializable.xml_attribute() - @serializable.xml_name('bom-ref') - def bom_ref(self) -> Optional[BomRef]: - """ - Reference to a component defined in the BOM. - """ - return self._bom_ref - - @bom_ref.setter - def bom_ref(self, bom_ref: Optional[Union[str, BomRef]]) -> None: - if bom_ref is None: - self._bom_ref = None - return - bom_ref_str = str(bom_ref) - if len(bom_ref_str) < 1: - raise ValueError('bom_ref must be at least 1 character long') - if XsUri(bom_ref_str).is_bom_link(): - raise ValueError("bom_ref SHOULD NOT start with 'urn:cdx:' to avoid conflicts with BOM-Links") - self._bom_ref = BomRef(bom_ref_str) - - @property - @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'location') - @serializable.xml_sequence(1) - def location(self) -> str: - """ - Location can be a file path, URL, or a unique identifier from a component discovery tool - """ - return self._location - - @location.setter - def location(self, location: str) -> None: - if location is None: - raise TypeError('location is required and cannot be None') - self._location = location - - @property - @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'line') - @serializable.xml_sequence(2) - def line(self) -> Optional[int]: - """ - The line number in the file where the dependency or reference was detected. - """ - return self._line - - @line.setter - def line(self, line: Optional[int]) -> None: - self._line = line - - @property - @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'offset') - @serializable.xml_sequence(3) - def offset(self) -> Optional[int]: - """ - The offset location within the file where the dependency or reference was detected. - """ - return self._offset - - @offset.setter - def offset(self, offset: Optional[int]) -> None: - self._offset = offset - - @property - @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'symbol') - @serializable.xml_sequence(4) - def symbol(self) -> Optional[str]: - """ - Programming language symbol or import name. - """ - return self._symbol - - @symbol.setter - def symbol(self, symbol: Optional[str]) -> None: - self._symbol = symbol - - @property - @serializable.json_name('additionalContext') - @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'additionalContext') - @serializable.xml_sequence(5) - def additional_context(self) -> Optional[str]: - """ - Additional context about the occurrence of the component. - """ - return self._additional_context - - @additional_context.setter - def additional_context(self, additional_context: Optional[str]) -> None: - self._additional_context = additional_context - - def __comparable_tuple(self) -> _ComparableTuple: - return _ComparableTuple( - ( - self.bom_ref, - self.location, - self.line, - self.offset, - self.symbol, - self.additional_context, - ) - ) - - def __eq__(self, other: object) -> bool: - if isinstance(other, Occurrence): - return self.__comparable_tuple() == other.__comparable_tuple() - return False - - def __lt__(self, other: Any) -> bool: - if isinstance(other, Occurrence): - return self.__comparable_tuple() < other.__comparable_tuple() - return NotImplemented - - def __hash__(self) -> int: - return hash(self.__comparable_tuple()) - - def __repr__(self) -> str: - return f'' - - -@serializable.serializable_class -class StackFrame: - """ - Represents an individual frame in a call stack. - - .. note:: - See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.6/json/#components_items_evidence_callstack - """ - - def __init__( - self, *, - package: Optional[str] = None, - module: str, # module is required - function: Optional[str] = None, - parameters: Optional[Iterable[str]] = None, - line: Optional[int] = None, - column: Optional[int] = None, - full_filename: Optional[str] = None, - ) -> None: - self.package = package - self.module = module - self.function = function - self.parameters = parameters or [] # type: ignore[assignment] - self.line = line - self.column = column - self.full_filename = full_filename - - @property - @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'package') - @serializable.xml_sequence(1) - def package(self) -> Optional[str]: - """ - The package name. - """ - return self._package - - @package.setter - def package(self, package: Optional[str]) -> None: - """ - Sets the package name. - """ - self._package = package - - @property - @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'module') - @serializable.xml_sequence(2) - def module(self) -> str: - """ - The module name - """ - return self._module - - @module.setter - def module(self, module: str) -> None: - if module is None: - raise TypeError('module is required and cannot be None') - self._module = module - - @property - @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'function') - @serializable.xml_sequence(3) - def function(self) -> Optional[str]: - """ - The function name. - """ - return self._function - - @function.setter - def function(self, function: Optional[str]) -> None: - """ - Sets the function name. - """ - self._function = function - - @property - @serializable.json_name('parameters') - @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'parameter') - @serializable.xml_sequence(4) - def parameters(self) -> 'SortedSet[str]': - """ - Function parameters - """ - return self._parameters - - @parameters.setter - def parameters(self, parameters: Iterable[str]) -> None: - self._parameters = SortedSet(parameters) - - @property - @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'line') - @serializable.xml_sequence(5) - def line(self) -> Optional[int]: - """ - The line number - """ - return self._line - - @line.setter - def line(self, line: Optional[int]) -> None: - self._line = line - - @property - @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'column') - @serializable.xml_sequence(6) - def column(self) -> Optional[int]: - """ - The column number - """ - return self._column - - @column.setter - def column(self, column: Optional[int]) -> None: - self._column = column - - @property - @serializable.json_name('fullFilename') - @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'fullFilename') - @serializable.xml_sequence(7) - def full_filename(self) -> Optional[str]: - """ - The full file path - """ - return self._full_filename - - @full_filename.setter - def full_filename(self, full_filename: Optional[str]) -> None: - self._full_filename = full_filename - - def __comparable_tuple(self) -> _ComparableTuple: - return _ComparableTuple( - ( - self.package, - self.module, - self.function, - _ComparableTuple(self.parameters), - self.line, - self.column, - self.full_filename, - ) - ) - - def __eq__(self, other: object) -> bool: - if isinstance(other, StackFrame): - return self.__comparable_tuple() == other.__comparable_tuple() - return False - - def __lt__(self, other: Any) -> bool: - if isinstance(other, StackFrame): - return self.__comparable_tuple() < other.__comparable_tuple() - return NotImplemented - - def __hash__(self) -> int: - return hash(self.__comparable_tuple()) - - def __repr__(self) -> str: - return f'' - - -@serializable.serializable_class -class CallStack: - """ - Our internal representation of the `callStackType` complex type. - Contains an array of stack frames describing a call stack from when a component was identified. - - .. note:: - See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.6/json/#components_items_evidence_callstack - """ - - def __init__( - self, *, - frames: Optional[Iterable[StackFrame]] = None, - ) -> None: - self.frames = frames or [] # type:ignore[assignment] - - @property - @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'frame') - def frames(self) -> 'SortedSet[StackFrame]': - """ - Array of stack frames - """ - return self._frames - - @frames.setter - def frames(self, frames: Iterable[StackFrame]) -> None: - self._frames = SortedSet(frames) - - def __comparable_tuple(self) -> _ComparableTuple: - return _ComparableTuple( - ( - _ComparableTuple(self.frames), - ) - ) - - def __eq__(self, other: object) -> bool: - if isinstance(other, CallStack): - return self.__comparable_tuple() == other.__comparable_tuple() - return False - - def __lt__(self, other: Any) -> bool: - if isinstance(other, CallStack): - return self.__comparable_tuple() < other.__comparable_tuple() - return NotImplemented - - def __hash__(self) -> int: - return hash(self.__comparable_tuple()) - - def __repr__(self) -> str: - return f'' - - -@serializable.serializable_class -class ComponentEvidence: - """ - Our internal representation of the `componentEvidenceType` complex type. - - Provides the ability to document evidence collected through various forms of extraction or analysis. - - .. note:: - See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.6/xml/#type_componentEvidenceType - """ - - def __init__( - self, *, - identity: Optional[Iterable[Identity]] = None, - occurrences: Optional[Iterable[Occurrence]] = None, - callstack: Optional[CallStack] = None, - licenses: Optional[Iterable[License]] = None, - copyright: Optional[Iterable[Copyright]] = None, - ) -> None: - self.identity = identity or [] # type:ignore[assignment] - self.occurrences = occurrences or [] # type:ignore[assignment] - self.callstack = callstack - self.licenses = licenses or [] # type:ignore[assignment] - self.copyright = copyright or [] # type:ignore[assignment] - - @property - @serializable.view(SchemaVersion1Dot6) - @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'identity') - @serializable.xml_sequence(1) - def identity(self) -> 'SortedSet[Identity]': - """ - Provides a way to identify components via various methods. - Returns SortedSet of identities. - """ - return self._identity - - @identity.setter - def identity(self, identity: Iterable[Identity]) -> None: - self._identity = SortedSet(identity) - - @property - # @serializable.view(SchemaVersion1Dot5) - @serializable.view(SchemaVersion1Dot6) - @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'occurrence') - @serializable.xml_sequence(2) - def occurrences(self) -> 'SortedSet[Occurrence]': - """A list of locations where evidence was obtained from.""" - return self._occurrences - - @occurrences.setter - def occurrences(self, occurrences: Iterable[Occurrence]) -> None: - self._occurrences = SortedSet(occurrences) - - @property - # @serializable.view(SchemaVersion1Dot5) - @serializable.view(SchemaVersion1Dot6) - @serializable.xml_sequence(3) - def callstack(self) -> Optional[CallStack]: - """ - A representation of a call stack from when the component was identified. - """ - return self._callstack - - @callstack.setter - def callstack(self, callstack: Optional[CallStack]) -> None: - self._callstack = callstack - - @property - @serializable.type_mapping(_LicenseRepositorySerializationHelper) - @serializable.xml_sequence(4) - def licenses(self) -> LicenseRepository: - """ - Optional list of licenses obtained during analysis. - - Returns: - Set of `LicenseChoice` - """ - return self._licenses - - @licenses.setter - def licenses(self, licenses: Iterable[License]) -> None: - self._licenses = LicenseRepository(licenses) - - @property - @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'text') - @serializable.xml_sequence(5) - def copyright(self) -> 'SortedSet[Copyright]': - """ - Optional list of copyright statements. - - Returns: - Set of `Copyright` - """ - return self._copyright - - @copyright.setter - def copyright(self, copyright: Iterable[Copyright]) -> None: - self._copyright = SortedSet(copyright) - - def __comparable_tuple(self) -> _ComparableTuple: - return _ComparableTuple( - ( - _ComparableTuple(self.licenses), - _ComparableTuple(self.copyright), - self.callstack, - _ComparableTuple(self.identity), - _ComparableTuple(self.occurrences), - )) - - def __eq__(self, other: object) -> bool: - if isinstance(other, ComponentEvidence): - return self.__comparable_tuple() == other.__comparable_tuple() - return False - - def __hash__(self) -> int: - return hash(self.__comparable_tuple()) - - def __repr__(self) -> str: - return f'' - - @serializable.serializable_enum class ComponentScope(str, Enum): """ diff --git a/cyclonedx/model/component_evidence.py b/cyclonedx/model/component_evidence.py index da1ab6e9..102c7cbb 100644 --- a/cyclonedx/model/component_evidence.py +++ b/cyclonedx/model/component_evidence.py @@ -15,182 +15,23 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright (c) OWASP Foundation. All Rights Reserved. -import re + from collections.abc import Iterable from decimal import Decimal from enum import Enum -from os.path import exists from typing import Any, Optional, Union -from warnings import warn from xml.etree.ElementTree import Element # nosec B405 # See https://github.com/package-url/packageurl-python/issues/65 import py_serializable as serializable -from packageurl import PackageURL from sortedcontainers import SortedSet -from .._internal.bom_ref import bom_ref_from_str as _bom_ref_from_str -from .._internal.compare import ComparablePackageURL as _ComparablePackageURL, ComparableTuple as _ComparableTuple -from .._internal.hash import file_sha1sum as _file_sha1sum -from ..exception.model import InvalidOmniBorIdException, InvalidSwhidException -from ..exception.serialization import ( - CycloneDxDeserializationException, - SerializationOfUnexpectedValueException, - SerializationOfUnsupportedComponentTypeException, -) -from ..schema.schema import ( - SchemaVersion1Dot0, - SchemaVersion1Dot1, - SchemaVersion1Dot2, - SchemaVersion1Dot3, - SchemaVersion1Dot4, - SchemaVersion1Dot5, - SchemaVersion1Dot6, -) -from ..serialization import PackageUrl as PackageUrlSH -from . import ( - AttachedText, - Copyright, - ExternalReference, - HashAlgorithm, - HashType, - IdentifiableAction, - Property, - XsUri, - _HashTypeRepositorySerializationHelper, -) +from .._internal.compare import ComparableTuple as _ComparableTuple +from ..exception.serialization import CycloneDxDeserializationException +from ..schema.schema import SchemaVersion1Dot6 +from . import Copyright, XsUri from .bom_ref import BomRef -from .contact import OrganizationalContact, OrganizationalEntity -from .crypto import CryptoProperties -from .dependency import Dependable -from .issue import IssueType from .license import License, LicenseRepository, _LicenseRepositorySerializationHelper -from .release_note import ReleaseNotes - - -@serializable.serializable_class -class Commit: - """ - Our internal representation of the `commitType` complex type. - - .. note:: - See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.6/xml/#type_commitType - """ - - def __init__( - self, *, - uid: Optional[str] = None, - url: Optional[XsUri] = None, - author: Optional[IdentifiableAction] = None, - committer: Optional[IdentifiableAction] = None, - message: Optional[str] = None, - ) -> None: - self.uid = uid - self.url = url - self.author = author - self.committer = committer - self.message = message - - @property - @serializable.xml_sequence(1) - @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) - def uid(self) -> Optional[str]: - """ - A unique identifier of the commit. This may be version control specific. For example, Subversion uses revision - numbers whereas git uses commit hashes. - - Returns: - `str` if set else `None` - """ - return self._uid - - @uid.setter - def uid(self, uid: Optional[str]) -> None: - self._uid = uid - - @property - @serializable.xml_sequence(2) - def url(self) -> Optional[XsUri]: - """ - The URL to the commit. This URL will typically point to a commit in a version control system. - - Returns: - `XsUri` if set else `None` - """ - return self._url - - @url.setter - def url(self, url: Optional[XsUri]) -> None: - self._url = url - - @property - @serializable.xml_sequence(3) - def author(self) -> Optional[IdentifiableAction]: - """ - The author who created the changes in the commit. - - Returns: - `IdentifiableAction` if set else `None` - """ - return self._author - - @author.setter - def author(self, author: Optional[IdentifiableAction]) -> None: - self._author = author - - @property - @serializable.xml_sequence(4) - def committer(self) -> Optional[IdentifiableAction]: - """ - The person who committed or pushed the commit - - Returns: - `IdentifiableAction` if set else `None` - """ - return self._committer - - @committer.setter - def committer(self, committer: Optional[IdentifiableAction]) -> None: - self._committer = committer - - @property - @serializable.xml_sequence(5) - @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) - def message(self) -> Optional[str]: - """ - The text description of the contents of the commit. - - Returns: - `str` if set else `None` - """ - return self._message - - @message.setter - def message(self, message: Optional[str]) -> None: - self._message = message - - def __comparable_tuple(self) -> _ComparableTuple: - return _ComparableTuple(( - self.uid, self.url, - self.author, self.committer, - self.message - )) - - def __eq__(self, other: object) -> bool: - if isinstance(other, Commit): - return self.__comparable_tuple() == other.__comparable_tuple() - return False - - def __lt__(self, other: Any) -> bool: - if isinstance(other, Commit): - return self.__comparable_tuple() < other.__comparable_tuple() - return NotImplemented - - def __hash__(self) -> int: - return hash(self.__comparable_tuple()) - - def __repr__(self) -> str: - return f'' @serializable.serializable_enum @@ -972,1496 +813,3 @@ def __hash__(self) -> int: def __repr__(self) -> str: return f'' - - -@serializable.serializable_enum -class ComponentScope(str, Enum): - """ - Enum object that defines the permissable 'scopes' for a Component according to the CycloneDX schema. - - .. note:: - See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.6/#type_scope - """ - # see `_ComponentScopeSerializationHelper.__CASES` for view/case map - REQUIRED = 'required' - OPTIONAL = 'optional' - EXCLUDED = 'excluded' # Only supported in >= 1.1 - - -class _ComponentScopeSerializationHelper(serializable.helpers.BaseHelper): - """ THIS CLASS IS NON-PUBLIC API """ - - __CASES: dict[type[serializable.ViewType], frozenset[ComponentScope]] = dict() - __CASES[SchemaVersion1Dot0] = frozenset({ - ComponentScope.REQUIRED, - ComponentScope.OPTIONAL, - }) - __CASES[SchemaVersion1Dot1] = __CASES[SchemaVersion1Dot0] | { - ComponentScope.EXCLUDED, - } - __CASES[SchemaVersion1Dot2] = __CASES[SchemaVersion1Dot1] - __CASES[SchemaVersion1Dot3] = __CASES[SchemaVersion1Dot2] - __CASES[SchemaVersion1Dot4] = __CASES[SchemaVersion1Dot3] - __CASES[SchemaVersion1Dot5] = __CASES[SchemaVersion1Dot4] - __CASES[SchemaVersion1Dot6] = __CASES[SchemaVersion1Dot5] - - @classmethod - def __normalize(cls, cs: ComponentScope, view: type[serializable.ViewType]) -> Optional[str]: - return cs.value \ - if cs in cls.__CASES.get(view, ()) \ - else None - - @classmethod - def json_normalize(cls, o: Any, *, - view: Optional[type[serializable.ViewType]], - **__: Any) -> Optional[str]: - assert view is not None - return cls.__normalize(o, view) - - @classmethod - def xml_normalize(cls, o: Any, *, - view: Optional[type[serializable.ViewType]], - **__: Any) -> Optional[str]: - assert view is not None - return cls.__normalize(o, view) - - @classmethod - def deserialize(cls, o: Any) -> ComponentScope: - return ComponentScope(o) - - -@serializable.serializable_enum -class ComponentType(str, Enum): - """ - Enum object that defines the permissible 'types' for a Component according to the CycloneDX schema. - - .. note:: - See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.6/#type_classification - """ - # see `_ComponentTypeSerializationHelper.__CASES` for view/case map - APPLICATION = 'application' - CONTAINER = 'container' # Only supported in >= 1.2 - CRYPTOGRAPHIC_ASSET = 'cryptographic-asset' # Only supported in >= 1.6 - DATA = 'data' # Only supported in >= 1.5 - DEVICE = 'device' - DEVICE_DRIVER = 'device-driver' # Only supported in >= 1.5 - FILE = 'file' # Only supported in >= 1.1 - FIRMWARE = 'firmware' # Only supported in >= 1.2 - FRAMEWORK = 'framework' - LIBRARY = 'library' - MACHINE_LEARNING_MODEL = 'machine-learning-model' # Only supported in >= 1.5 - OPERATING_SYSTEM = 'operating-system' - PLATFORM = 'platform' # Only supported in >= 1.5 - - -class _ComponentTypeSerializationHelper(serializable.helpers.BaseHelper): - """ THIS CLASS IS NON-PUBLIC API """ - - __CASES: dict[type[serializable.ViewType], frozenset[ComponentType]] = dict() - __CASES[SchemaVersion1Dot0] = frozenset({ - ComponentType.APPLICATION, - ComponentType.DEVICE, - ComponentType.FRAMEWORK, - ComponentType.LIBRARY, - ComponentType.OPERATING_SYSTEM, - }) - __CASES[SchemaVersion1Dot1] = __CASES[SchemaVersion1Dot0] | { - ComponentType.FILE, - } - __CASES[SchemaVersion1Dot2] = __CASES[SchemaVersion1Dot1] | { - ComponentType.CONTAINER, - ComponentType.FIRMWARE, - } - __CASES[SchemaVersion1Dot3] = __CASES[SchemaVersion1Dot2] - __CASES[SchemaVersion1Dot4] = __CASES[SchemaVersion1Dot3] - __CASES[SchemaVersion1Dot5] = __CASES[SchemaVersion1Dot4] | { - ComponentType.DATA, - ComponentType.DEVICE_DRIVER, - ComponentType.MACHINE_LEARNING_MODEL, - ComponentType.PLATFORM, - } - __CASES[SchemaVersion1Dot6] = __CASES[SchemaVersion1Dot5] | { - ComponentType.CRYPTOGRAPHIC_ASSET, - } - - @classmethod - def __normalize(cls, ct: ComponentType, view: type[serializable.ViewType]) -> Optional[str]: - if ct in cls.__CASES.get(view, ()): - return ct.value - raise SerializationOfUnsupportedComponentTypeException(f'unsupported {ct!r} for view {view!r}') - - @classmethod - def json_normalize(cls, o: Any, *, - view: Optional[type[serializable.ViewType]], - **__: Any) -> Optional[str]: - assert view is not None - return cls.__normalize(o, view) - - @classmethod - def xml_normalize(cls, o: Any, *, - view: Optional[type[serializable.ViewType]], - **__: Any) -> Optional[str]: - assert view is not None - return cls.__normalize(o, view) - - @classmethod - def deserialize(cls, o: Any) -> ComponentType: - return ComponentType(o) - - -@serializable.serializable_class -class Diff: - """ - Our internal representation of the `diffType` complex type. - - .. note:: - See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.6/xml/#type_diffType - """ - - def __init__( - self, *, - text: Optional[AttachedText] = None, - url: Optional[XsUri] = None, - ) -> None: - self.text = text - self.url = url - - @property - def text(self) -> Optional[AttachedText]: - """ - Specifies the optional text of the diff. - - Returns: - `AttachedText` if set else `None` - """ - return self._text - - @text.setter - def text(self, text: Optional[AttachedText]) -> None: - self._text = text - - @property - def url(self) -> Optional[XsUri]: - """ - Specifies the URL to the diff. - - Returns: - `XsUri` if set else `None` - """ - return self._url - - @url.setter - def url(self, url: Optional[XsUri]) -> None: - self._url = url - - def __comparable_tuple(self) -> _ComparableTuple: - return _ComparableTuple(( - self.url, - self.text, - )) - - def __eq__(self, other: object) -> bool: - if isinstance(other, Diff): - return self.__comparable_tuple() == other.__comparable_tuple() - return False - - def __lt__(self, other: Any) -> bool: - if isinstance(other, Diff): - return self.__comparable_tuple() < other.__comparable_tuple() - return NotImplemented - - def __hash__(self) -> int: - return hash(self.__comparable_tuple()) - - def __repr__(self) -> str: - return f'' - - -@serializable.serializable_enum -class PatchClassification(str, Enum): - """ - Enum object that defines the permissible `patchClassification`s. - - .. note:: - See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.6/xml/#type_patchClassification - """ - BACKPORT = 'backport' - CHERRY_PICK = 'cherry-pick' - MONKEY = 'monkey' - UNOFFICIAL = 'unofficial' - - -@serializable.serializable_class -class Patch: - """ - Our internal representation of the `patchType` complex type. - - .. note:: - See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.6/xml/#type_patchType - """ - - def __init__( - self, *, - type: PatchClassification, - diff: Optional[Diff] = None, - resolves: Optional[Iterable[IssueType]] = None, - ) -> None: - self.type = type - self.diff = diff - self.resolves = resolves or [] # type:ignore[assignment] - - @property - @serializable.xml_attribute() - def type(self) -> PatchClassification: - """ - Specifies the purpose for the patch including the resolution of defects, security issues, or new behavior or - functionality. - - Returns: - `PatchClassification` - """ - return self._type - - @type.setter - def type(self, type: PatchClassification) -> None: - self._type = type - - @property - def diff(self) -> Optional[Diff]: - """ - The patch file (or diff) that show changes. - - .. note:: - Refer to https://en.wikipedia.org/wiki/Diff. - - Returns: - `Diff` if set else `None` - """ - return self._diff - - @diff.setter - def diff(self, diff: Optional[Diff]) -> None: - self._diff = diff - - @property - @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'issue') - def resolves(self) -> 'SortedSet[IssueType]': - """ - Optional list of issues resolved by this patch. - - Returns: - Set of `IssueType` - """ - return self._resolves - - @resolves.setter - def resolves(self, resolves: Iterable[IssueType]) -> None: - self._resolves = SortedSet(resolves) - - def __comparable_tuple(self) -> _ComparableTuple: - return _ComparableTuple(( - self.type, self.diff, - _ComparableTuple(self.resolves) - )) - - def __eq__(self, other: object) -> bool: - if isinstance(other, Patch): - return self.__comparable_tuple() == other.__comparable_tuple() - return False - - def __lt__(self, other: Any) -> bool: - if isinstance(other, Patch): - return self.__comparable_tuple() < other.__comparable_tuple() - return NotImplemented - - def __hash__(self) -> int: - return hash(self.__comparable_tuple()) - - def __repr__(self) -> str: - return f'' - - -@serializable.serializable_class -class Pedigree: - """ - Our internal representation of the `pedigreeType` complex type. - - Component pedigree is a way to document complex supply chain scenarios where components are created, distributed, - modified, redistributed, combined with other components, etc. Pedigree supports viewing this complex chain from the - beginning, the end, or anywhere in the middle. It also provides a way to document variants where the exact relation - may not be known. - - .. note:: - See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.6/xml/#type_pedigreeType - """ - - def __init__( - self, *, - ancestors: Optional[Iterable['Component']] = None, - descendants: Optional[Iterable['Component']] = None, - variants: Optional[Iterable['Component']] = None, - commits: Optional[Iterable[Commit]] = None, - patches: Optional[Iterable[Patch]] = None, - notes: Optional[str] = None, - ) -> None: - self.ancestors = ancestors or [] # type:ignore[assignment] - self.descendants = descendants or [] # type:ignore[assignment] - self.variants = variants or [] # type:ignore[assignment] - self.commits = commits or [] # type:ignore[assignment] - self.patches = patches or [] # type:ignore[assignment] - self.notes = notes - - @property - @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'component') - @serializable.xml_sequence(1) - def ancestors(self) -> "SortedSet['Component']": - """ - Describes zero or more components in which a component is derived from. This is commonly used to describe forks - from existing projects where the forked version contains a ancestor node containing the original component it - was forked from. - - For example, Component A is the original component. Component B is the component being used and documented in - the BOM. However, Component B contains a pedigree node with a single ancestor documenting Component A - the - original component from which Component B is derived from. - - Returns: - Set of `Component` - """ - return self._ancestors - - @ancestors.setter - def ancestors(self, ancestors: Iterable['Component']) -> None: - self._ancestors = SortedSet(ancestors) - - @property - @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'component') - @serializable.xml_sequence(2) - def descendants(self) -> "SortedSet['Component']": - """ - Descendants are the exact opposite of ancestors. This provides a way to document all forks (and their forks) of - an original or root component. - - Returns: - Set of `Component` - """ - return self._descendants - - @descendants.setter - def descendants(self, descendants: Iterable['Component']) -> None: - self._descendants = SortedSet(descendants) - - @property - @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'component') - @serializable.xml_sequence(3) - def variants(self) -> "SortedSet['Component']": - """ - Variants describe relations where the relationship between the components are not known. For example, if - Component A contains nearly identical code to Component B. They are both related, but it is unclear if one is - derived from the other, or if they share a common ancestor. - - Returns: - Set of `Component` - """ - return self._variants - - @variants.setter - def variants(self, variants: Iterable['Component']) -> None: - self._variants = SortedSet(variants) - - @property - @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'commit') - @serializable.xml_sequence(4) - def commits(self) -> 'SortedSet[Commit]': - """ - A list of zero or more commits which provide a trail describing how the component deviates from an ancestor, - descendant, or variant. - - Returns: - Set of `Commit` - """ - return self._commits - - @commits.setter - def commits(self, commits: Iterable[Commit]) -> None: - self._commits = SortedSet(commits) - - @property - @serializable.view(SchemaVersion1Dot2) - @serializable.view(SchemaVersion1Dot3) - @serializable.view(SchemaVersion1Dot4) - @serializable.view(SchemaVersion1Dot5) - @serializable.view(SchemaVersion1Dot6) - @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'patch') - @serializable.xml_sequence(5) - def patches(self) -> 'SortedSet[Patch]': - """ - A list of zero or more patches describing how the component deviates from an ancestor, descendant, or variant. - Patches may be complimentary to commits or may be used in place of commits. - - Returns: - Set of `Patch` - """ - return self._patches - - @patches.setter - def patches(self, patches: Iterable[Patch]) -> None: - self._patches = SortedSet(patches) - - @property - @serializable.xml_sequence(6) - def notes(self) -> Optional[str]: - """ - Notes, observations, and other non-structured commentary describing the components pedigree. - - Returns: - `str` if set else `None` - """ - return self._notes - - @notes.setter - def notes(self, notes: Optional[str]) -> None: - self._notes = notes - - def __comparable_tuple(self) -> _ComparableTuple: - return _ComparableTuple(( - _ComparableTuple(self.ancestors), - _ComparableTuple(self.descendants), - _ComparableTuple(self.variants), - _ComparableTuple(self.commits), - _ComparableTuple(self.patches), - self.notes - )) - - def __eq__(self, other: object) -> bool: - if isinstance(other, Pedigree): - return self.__comparable_tuple() == other.__comparable_tuple() - return False - - def __hash__(self) -> int: - return hash(self.__comparable_tuple()) - - def __repr__(self) -> str: - return f'' - - -@serializable.serializable_class -class Swid: - """ - Our internal representation of the `swidType` complex type. - - .. note:: - See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.6/xml/#type_swidType - """ - - def __init__( - self, *, - tag_id: str, - name: str, - version: Optional[str] = None, - tag_version: Optional[int] = None, - patch: Optional[bool] = None, - text: Optional[AttachedText] = None, - url: Optional[XsUri] = None, - ) -> None: - self.tag_id = tag_id - self.name = name - self.version = version - self.tag_version = tag_version - self.patch = patch - self.text = text - self.url = url - - @property - @serializable.xml_attribute() - def tag_id(self) -> str: - """ - Maps to the tagId of a SoftwareIdentity. - - Returns: - `str` - """ - return self._tag_id - - @tag_id.setter - def tag_id(self, tag_id: str) -> None: - self._tag_id = tag_id - - @property - @serializable.xml_attribute() - def name(self) -> str: - """ - Maps to the name of a SoftwareIdentity. - - Returns: - `str` - """ - return self._name - - @name.setter - def name(self, name: str) -> None: - self._name = name - - @property - @serializable.xml_attribute() - def version(self) -> Optional[str]: - """ - Maps to the version of a SoftwareIdentity. - - Returns: - `str` if set else `None`. - """ - return self._version - - @version.setter - def version(self, version: Optional[str]) -> None: - self._version = version - - @property - @serializable.xml_attribute() - def tag_version(self) -> Optional[int]: - """ - Maps to the tagVersion of a SoftwareIdentity. - - Returns: - `int` if set else `None` - """ - return self._tag_version - - @tag_version.setter - def tag_version(self, tag_version: Optional[int]) -> None: - self._tag_version = tag_version - - @property - @serializable.xml_attribute() - def patch(self) -> Optional[bool]: - """ - Maps to the patch of a SoftwareIdentity. - - Returns: - `bool` if set else `None` - """ - return self._patch - - @patch.setter - def patch(self, patch: Optional[bool]) -> None: - self._patch = patch - - @property - def text(self) -> Optional[AttachedText]: - """ - Specifies the full content of the SWID tag. - - Returns: - `AttachedText` if set else `None` - """ - return self._text - - @text.setter - def text(self, text: Optional[AttachedText]) -> None: - self._text = text - - @property - def url(self) -> Optional[XsUri]: - """ - The URL to the SWID file. - - Returns: - `XsUri` if set else `None` - """ - return self._url - - @url.setter - def url(self, url: Optional[XsUri]) -> None: - self._url = url - - def __comparable_tuple(self) -> _ComparableTuple: - return _ComparableTuple(( - self.tag_id, - self.name, self.version, - self.tag_version, - self.patch, - self.url, - self.text, - )) - - def __eq__(self, other: object) -> bool: - if isinstance(other, Swid): - return self.__comparable_tuple() == other.__comparable_tuple() - return False - - def __hash__(self) -> int: - return hash(self.__comparable_tuple()) - - def __repr__(self) -> str: - return f'' - - -@serializable.serializable_class -class OmniborId(serializable.helpers.BaseHelper): - """ - Helper class that allows us to perform validation on data strings that must conform to - https://www.iana.org/assignments/uri-schemes/prov/gitoid. - - """ - - _VALID_OMNIBOR_ID_REGEX = re.compile(r'^gitoid:(blob|tree|commit|tag):sha(1|256):([a-z0-9]+)$') - - def __init__(self, id: str) -> None: - if OmniborId._VALID_OMNIBOR_ID_REGEX.match(id) is None: - raise InvalidOmniBorIdException( - f'Supplied value "{id} does not meet format specification.' - ) - self._id = id - - @property - @serializable.json_name('.') - @serializable.xml_name('.') - def id(self) -> str: - return self._id - - @classmethod - def serialize(cls, o: Any) -> str: - if isinstance(o, OmniborId): - return str(o) - raise SerializationOfUnexpectedValueException( - f'Attempt to serialize a non-OmniBorId: {o!r}') - - @classmethod - def deserialize(cls, o: Any) -> 'OmniborId': - try: - return OmniborId(id=str(o)) - except ValueError as err: - raise CycloneDxDeserializationException( - f'OmniBorId string supplied does not parse: {o!r}' - ) from err - - def __eq__(self, other: Any) -> bool: - if isinstance(other, OmniborId): - return self._id == other._id - return False - - def __lt__(self, other: Any) -> bool: - if isinstance(other, OmniborId): - return self._id < other._id - return NotImplemented - - def __hash__(self) -> int: - return hash(self._id) - - def __repr__(self) -> str: - return f'' - - def __str__(self) -> str: - return self._id - - -@serializable.serializable_class -class Swhid(serializable.helpers.BaseHelper): - """ - Helper class that allows us to perform validation on data strings that must conform to - https://docs.softwareheritage.org/devel/swh-model/persistent-identifiers.html. - - """ - - _VALID_SWHID_REGEX = re.compile(r'^swh:1:(cnp|rel|rev|dir|cnt):([0-9a-z]{40})(.*)?$') - - def __init__(self, id: str) -> None: - if Swhid._VALID_SWHID_REGEX.match(id) is None: - raise InvalidSwhidException( - f'Supplied value "{id} does not meet format specification.' - ) - self._id = id - - @property - @serializable.json_name('.') - @serializable.xml_name('.') - def id(self) -> str: - return self._id - - @classmethod - def serialize(cls, o: Any) -> str: - if isinstance(o, Swhid): - return str(o) - raise SerializationOfUnexpectedValueException( - f'Attempt to serialize a non-Swhid: {o!r}') - - @classmethod - def deserialize(cls, o: Any) -> 'Swhid': - try: - return Swhid(id=str(o)) - except ValueError as err: - raise CycloneDxDeserializationException( - f'Swhid string supplied does not parse: {o!r}' - ) from err - - def __eq__(self, other: Any) -> bool: - if isinstance(other, Swhid): - return self._id == other._id - return False - - def __lt__(self, other: Any) -> bool: - if isinstance(other, Swhid): - return self._id < other._id - return NotImplemented - - def __hash__(self) -> int: - return hash(self._id) - - def __repr__(self) -> str: - return f'' - - def __str__(self) -> str: - return self._id - - -@serializable.serializable_class -class Component(Dependable): - """ - This is our internal representation of a Component within a Bom. - - .. note:: - See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.6/#type_component - """ - - @staticmethod - def for_file(absolute_file_path: str, path_for_bom: Optional[str]) -> 'Component': - """ - Helper method to create a Component that represents the provided local file as a Component. - - Args: - absolute_file_path: - Absolute path to the file you wish to represent - path_for_bom: - Optionally, if supplied this is the path that will be used to identify the file in the BOM - - Returns: - `Component` representing the supplied file - """ - if not exists(absolute_file_path): - raise FileExistsError(f'Supplied file path {absolute_file_path!r} does not exist') - - sha1_hash: str = _file_sha1sum(absolute_file_path) - return Component( - name=path_for_bom if path_for_bom else absolute_file_path, - version=f'0.0.0-{sha1_hash[0:12]}', - hashes=[ - HashType(alg=HashAlgorithm.SHA_1, content=sha1_hash) - ], - type=ComponentType.FILE, purl=PackageURL( - type='generic', name=path_for_bom if path_for_bom else absolute_file_path, - version=f'0.0.0-{sha1_hash[0:12]}' - ) - ) - - def __init__( - self, *, - name: str, - type: ComponentType = ComponentType.LIBRARY, - mime_type: Optional[str] = None, - bom_ref: Optional[Union[str, BomRef]] = None, - supplier: Optional[OrganizationalEntity] = None, - publisher: Optional[str] = None, - group: Optional[str] = None, - version: Optional[str] = None, - description: Optional[str] = None, - scope: Optional[ComponentScope] = None, - hashes: Optional[Iterable[HashType]] = None, - licenses: Optional[Iterable[License]] = None, - copyright: Optional[str] = None, - purl: Optional[PackageURL] = None, - external_references: Optional[Iterable[ExternalReference]] = None, - properties: Optional[Iterable[Property]] = None, - release_notes: Optional[ReleaseNotes] = None, - cpe: Optional[str] = None, - swid: Optional[Swid] = None, - pedigree: Optional[Pedigree] = None, - components: Optional[Iterable['Component']] = None, - evidence: Optional[ComponentEvidence] = None, - modified: bool = False, - manufacturer: Optional[OrganizationalEntity] = None, - authors: Optional[Iterable[OrganizationalContact]] = None, - omnibor_ids: Optional[Iterable[OmniborId]] = None, - swhids: Optional[Iterable[Swhid]] = None, - crypto_properties: Optional[CryptoProperties] = None, - tags: Optional[Iterable[str]] = None, - # Deprecated in v1.6 - author: Optional[str] = None, - ) -> None: - self.type = type - self.mime_type = mime_type - self._bom_ref = _bom_ref_from_str(bom_ref) - self.supplier = supplier - self.manufacturer = manufacturer - self.authors = authors or [] # type:ignore[assignment] - self.author = author - self.publisher = publisher - self.group = group - self.name = name - self.version = version - self.description = description - self.scope = scope - self.hashes = hashes or [] # type:ignore[assignment] - self.licenses = licenses or [] # type:ignore[assignment] - self.copyright = copyright - self.cpe = cpe - self.purl = purl - self.omnibor_ids = omnibor_ids or [] # type:ignore[assignment] - self.swhids = swhids or [] # type:ignore[assignment] - self.swid = swid - self.modified = modified - self.pedigree = pedigree - self.external_references = external_references or [] # type:ignore[assignment] - self.properties = properties or [] # type:ignore[assignment] - self.components = components or [] # type:ignore[assignment] - self.evidence = evidence - self.release_notes = release_notes - self.crypto_properties = crypto_properties - self.tags = tags or [] # type:ignore[assignment] - - if modified: - warn('`.component.modified` is deprecated from CycloneDX v1.3 onwards. ' - 'Please use `@.pedigree` instead.', DeprecationWarning) - if author: - warn('`.component.author` is deprecated from CycloneDX v1.6 onwards. ' - 'Please use `@.authors` or `@.manufacturer` instead.', DeprecationWarning) - - @property - @serializable.type_mapping(_ComponentTypeSerializationHelper) - @serializable.xml_attribute() - def type(self) -> ComponentType: - """ - Get the type of this Component. - - Returns: - Declared type of this Component as `ComponentType`. - """ - return self._type - - @type.setter - def type(self, type: ComponentType) -> None: - self._type = type - - @property - @serializable.xml_string(serializable.XmlStringSerializationType.TOKEN) - def mime_type(self) -> Optional[str]: - """ - Get any declared mime-type for this Component. - - When used on file components, the mime-type can provide additional context about the kind of file being - represented such as an image, font, or executable. Some library or framework components may also have an - associated mime-type. - - Returns: - `str` if set else `None` - """ - return self._mime_type - - @mime_type.setter - def mime_type(self, mime_type: Optional[str]) -> None: - self._mime_type = mime_type - - @property - @serializable.json_name('bom-ref') - @serializable.type_mapping(BomRef) - @serializable.view(SchemaVersion1Dot1) - @serializable.view(SchemaVersion1Dot2) - @serializable.view(SchemaVersion1Dot3) - @serializable.view(SchemaVersion1Dot4) - @serializable.view(SchemaVersion1Dot5) - @serializable.view(SchemaVersion1Dot6) - @serializable.xml_attribute() - @serializable.xml_name('bom-ref') - def bom_ref(self) -> BomRef: - """ - An optional identifier which can be used to reference the component elsewhere in the BOM. Every bom-ref MUST be - unique within the BOM. - - Returns: - `BomRef` - """ - return self._bom_ref - - @property - @serializable.view(SchemaVersion1Dot2) - @serializable.view(SchemaVersion1Dot3) - @serializable.view(SchemaVersion1Dot4) - @serializable.view(SchemaVersion1Dot5) - @serializable.view(SchemaVersion1Dot6) - @serializable.xml_sequence(1) - def supplier(self) -> Optional[OrganizationalEntity]: - """ - The organization that supplied the component. The supplier may often be the manufacture, but may also be a - distributor or repackager. - - Returns: - `OrganizationalEntity` if set else `None` - """ - return self._supplier - - @supplier.setter - def supplier(self, supplier: Optional[OrganizationalEntity]) -> None: - self._supplier = supplier - - @property - @serializable.view(SchemaVersion1Dot6) - @serializable.xml_sequence(2) - def manufacturer(self) -> Optional[OrganizationalEntity]: - """ - The organization that created the component. - Manufacturer is common in components created through automated processes. - Components created through manual means may have `@.authors` instead. - - Returns: - `OrganizationalEntity` if set else `None` - """ - return self._manufacturer - - @manufacturer.setter - def manufacturer(self, manufacturer: Optional[OrganizationalEntity]) -> None: - self._manufacturer = manufacturer - - @property - @serializable.view(SchemaVersion1Dot6) - @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'author') - @serializable.xml_sequence(3) - def authors(self) -> 'SortedSet[OrganizationalContact]': - """ - The person(s) who created the component. - Authors are common in components created through manual processes. - Components created through automated means may have `@.manufacturer` instead. - - Returns: - `Iterable[OrganizationalContact]` if set else `None` - """ - return self._authors - - @authors.setter - def authors(self, authors: Iterable[OrganizationalContact]) -> None: - self._authors = SortedSet(authors) - - @property - @serializable.view(SchemaVersion1Dot2) - @serializable.view(SchemaVersion1Dot3) - @serializable.view(SchemaVersion1Dot4) - @serializable.view(SchemaVersion1Dot5) - @serializable.view(SchemaVersion1Dot6) # todo: this is deprecated in v1.6? - @serializable.xml_sequence(4) - @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) - def author(self) -> Optional[str]: - """ - The person(s) or organization(s) that authored the component. - - Returns: - `str` if set else `None` - """ - return self._author - - @author.setter - def author(self, author: Optional[str]) -> None: - self._author = author - - @property - @serializable.xml_sequence(5) - @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) - def publisher(self) -> Optional[str]: - """ - The person(s) or organization(s) that published the component - - Returns: - `str` if set else `None` - """ - return self._publisher - - @publisher.setter - def publisher(self, publisher: Optional[str]) -> None: - self._publisher = publisher - - @property - @serializable.xml_sequence(6) - @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) - def group(self) -> Optional[str]: - """ - The grouping name or identifier. This will often be a shortened, single name of the company or project that - produced the component, or the source package or domain name. Whitespace and special characters should be - avoided. - - Examples include: `apache`, `org.apache.commons`, and `apache.org`. - - Returns: - `str` if set else `None` - """ - return self._group - - @group.setter - def group(self, group: Optional[str]) -> None: - self._group = group - - @property - @serializable.xml_sequence(7) - @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) - def name(self) -> str: - """ - The name of the component. - - This will often be a shortened, single name of the component. - - Examples: `commons-lang3` and `jquery`. - - Returns: - `str` - """ - return self._name - - @name.setter - def name(self, name: str) -> None: - self._name = name - - @property - @serializable.include_none(SchemaVersion1Dot0, '') - @serializable.include_none(SchemaVersion1Dot1, '') - @serializable.include_none(SchemaVersion1Dot2, '') - @serializable.include_none(SchemaVersion1Dot3, '') - @serializable.xml_sequence(8) - @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) - def version(self) -> Optional[str]: - """ - The component version. The version should ideally comply with semantic versioning but is not enforced. - - This is NOT optional for CycloneDX Schema Version < 1.4 but was agreed to default to an empty string where a - version was not supplied for schema versions < 1.4 - - Returns: - Declared version of this Component as `str` or `None` - """ - return self._version - - @version.setter - def version(self, version: Optional[str]) -> None: - if version and len(version) > 1024: - warn('`.component.version`has a maximum length of 1024 from CycloneDX v1.6 onwards.', UserWarning) - self._version = version - - @property - @serializable.xml_sequence(9) - @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) - def description(self) -> Optional[str]: - """ - Get the description of this Component. - - Returns: - `str` if set, else `None`. - """ - return self._description - - @description.setter - def description(self, description: Optional[str]) -> None: - self._description = description - - @property - @serializable.type_mapping(_ComponentScopeSerializationHelper) - @serializable.xml_sequence(10) - def scope(self) -> Optional[ComponentScope]: - """ - Specifies the scope of the component. - - If scope is not specified, 'required' scope should be assumed by the consumer of the BOM. - - Returns: - `ComponentScope` or `None` - """ - return self._scope - - @scope.setter - def scope(self, scope: Optional[ComponentScope]) -> None: - self._scope = scope - - @property - @serializable.type_mapping(_HashTypeRepositorySerializationHelper) - @serializable.xml_sequence(11) - def hashes(self) -> 'SortedSet[HashType]': - """ - Optional list of hashes that help specify the integrity of this Component. - - Returns: - Set of `HashType` - """ - return self._hashes - - @hashes.setter - def hashes(self, hashes: Iterable[HashType]) -> None: - self._hashes = SortedSet(hashes) - - @property - @serializable.view(SchemaVersion1Dot1) - @serializable.view(SchemaVersion1Dot2) - @serializable.view(SchemaVersion1Dot3) - @serializable.view(SchemaVersion1Dot4) - @serializable.view(SchemaVersion1Dot5) - @serializable.view(SchemaVersion1Dot6) - @serializable.type_mapping(_LicenseRepositorySerializationHelper) - @serializable.xml_sequence(12) - def licenses(self) -> LicenseRepository: - """ - A optional list of statements about how this Component is licensed. - - Returns: - Set of `LicenseChoice` - """ - return self._licenses - - @licenses.setter - def licenses(self, licenses: Iterable[License]) -> None: - self._licenses = LicenseRepository(licenses) - - @property - @serializable.xml_sequence(13) - @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) - def copyright(self) -> Optional[str]: - """ - An optional copyright notice informing users of the underlying claims to copyright ownership in a published - work. - - Returns: - `str` or `None` - """ - return self._copyright - - @copyright.setter - def copyright(self, copyright: Optional[str]) -> None: - self._copyright = copyright - - @property - @serializable.xml_sequence(14) - def cpe(self) -> Optional[str]: - """ - Specifies a well-formed CPE name that conforms to the CPE 2.2 or 2.3 specification. - See https://nvd.nist.gov/products/cpe - - Returns: - `str` if set else `None` - """ - return self._cpe - - @cpe.setter - def cpe(self, cpe: Optional[str]) -> None: - self._cpe = cpe - - @property - @serializable.type_mapping(PackageUrlSH) - @serializable.xml_sequence(15) - def purl(self) -> Optional[PackageURL]: - """ - Specifies the package-url (PURL). - - The purl, if specified, must be valid and conform to the specification defined at: - https://github.com/package-url/purl-spec - - Returns: - `PackageURL` or `None` - """ - return self._purl - - @purl.setter - def purl(self, purl: Optional[PackageURL]) -> None: - self._purl = purl - - @property - @serializable.json_name('omniborId') - @serializable.view(SchemaVersion1Dot6) - @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, child_name='omniborId') - @serializable.xml_sequence(16) - def omnibor_ids(self) -> 'SortedSet[OmniborId]': - """ - Specifies the OmniBOR Artifact ID. The OmniBOR, if specified, MUST be valid and conform to the specification - defined at: https://www.iana.org/assignments/uri-schemes/prov/gitoid - - Returns: - `Iterable[str]` or `None` - """ - - return self._omnibor_ids - - @omnibor_ids.setter - def omnibor_ids(self, omnibor_ids: Iterable[OmniborId]) -> None: - self._omnibor_ids = SortedSet(omnibor_ids) - - @property - @serializable.json_name('swhid') - @serializable.view(SchemaVersion1Dot6) - @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, child_name='swhid') - @serializable.xml_sequence(17) - def swhids(self) -> 'SortedSet[Swhid]': - """ - Specifies the Software Heritage persistent identifier (SWHID). The SWHID, if specified, MUST be valid and - conform to the specification defined at: - https://docs.softwareheritage.org/devel/swh-model/persistent-identifiers.html - - Returns: - `Iterable[Swhid]` if set else `None` - """ - return self._swhids - - @swhids.setter - def swhids(self, swhids: Iterable[Swhid]) -> None: - self._swhids = SortedSet(swhids) - - @property - @serializable.view(SchemaVersion1Dot2) - @serializable.view(SchemaVersion1Dot3) - @serializable.view(SchemaVersion1Dot4) - @serializable.view(SchemaVersion1Dot5) - @serializable.view(SchemaVersion1Dot6) - @serializable.xml_sequence(18) - def swid(self) -> Optional[Swid]: - """ - Specifies metadata and content for ISO-IEC 19770-2 Software Identification (SWID) Tags. - - Returns: - `Swid` if set else `None` - """ - return self._swid - - @swid.setter - def swid(self, swid: Optional[Swid]) -> None: - self._swid = swid - - @property - @serializable.view(SchemaVersion1Dot0) # todo: Deprecated in v1.3 - @serializable.xml_sequence(19) - def modified(self) -> bool: - return self._modified - - @modified.setter - def modified(self, modified: bool) -> None: - self._modified = modified - - @property - @serializable.view(SchemaVersion1Dot1) - @serializable.view(SchemaVersion1Dot2) - @serializable.view(SchemaVersion1Dot3) - @serializable.view(SchemaVersion1Dot4) - @serializable.view(SchemaVersion1Dot5) - @serializable.view(SchemaVersion1Dot6) - @serializable.xml_sequence(20) - def pedigree(self) -> Optional[Pedigree]: - """ - Component pedigree is a way to document complex supply chain scenarios where components are created, - distributed, modified, redistributed, combined with other components, etc. - - Returns: - `Pedigree` if set else `None` - """ - return self._pedigree - - @pedigree.setter - def pedigree(self, pedigree: Optional[Pedigree]) -> None: - self._pedigree = pedigree - - @property - @serializable.view(SchemaVersion1Dot1) - @serializable.view(SchemaVersion1Dot2) - @serializable.view(SchemaVersion1Dot3) - @serializable.view(SchemaVersion1Dot4) - @serializable.view(SchemaVersion1Dot5) - @serializable.view(SchemaVersion1Dot6) - @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'reference') - @serializable.xml_sequence(21) - def external_references(self) -> 'SortedSet[ExternalReference]': - """ - Provides the ability to document external references related to the component or to the project the component - describes. - - Returns: - Set of `ExternalReference` - """ - return self._external_references - - @external_references.setter - def external_references(self, external_references: Iterable[ExternalReference]) -> None: - self._external_references = SortedSet(external_references) - - @property - @serializable.view(SchemaVersion1Dot3) - @serializable.view(SchemaVersion1Dot4) - @serializable.view(SchemaVersion1Dot5) - @serializable.view(SchemaVersion1Dot6) - @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'property') - @serializable.xml_sequence(22) - def properties(self) -> 'SortedSet[Property]': - """ - Provides the ability to document properties in a key/value store. This provides flexibility to include data not - officially supported in the standard without having to use additional namespaces or create extensions. - - Return: - Set of `Property` - """ - return self._properties - - @properties.setter - def properties(self, properties: Iterable[Property]) -> None: - self._properties = SortedSet(properties) - - @property - @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'component') - @serializable.xml_sequence(23) - def components(self) -> "SortedSet['Component']": - """ - A list of software and hardware components included in the parent component. This is not a dependency tree. It - provides a way to specify a hierarchical representation of component assemblies, similar to system -> subsystem - -> parts assembly in physical supply chains. - - Returns: - Set of `Component` - """ - return self._components - - @components.setter - def components(self, components: Iterable['Component']) -> None: - self._components = SortedSet(components) - - @property - @serializable.view(SchemaVersion1Dot3) - @serializable.view(SchemaVersion1Dot4) - @serializable.view(SchemaVersion1Dot5) - @serializable.view(SchemaVersion1Dot6) - @serializable.xml_sequence(24) - def evidence(self) -> Optional[ComponentEvidence]: - """ - Provides the ability to document evidence collected through various forms of extraction or analysis. - - Returns: - `ComponentEvidence` if set else `None` - """ - return self._evidence - - @evidence.setter - def evidence(self, evidence: Optional[ComponentEvidence]) -> None: - self._evidence = evidence - - @property - @serializable.view(SchemaVersion1Dot4) - @serializable.view(SchemaVersion1Dot5) - @serializable.view(SchemaVersion1Dot6) - @serializable.xml_sequence(25) - def release_notes(self) -> Optional[ReleaseNotes]: - """ - Specifies optional release notes. - - Returns: - `ReleaseNotes` or `None` - """ - return self._release_notes - - @release_notes.setter - def release_notes(self, release_notes: Optional[ReleaseNotes]) -> None: - self._release_notes = release_notes - - # @property - # ... - # @serializable.view(SchemaVersion1Dot5) - # @serializable.xml_sequence(22) - # def model_card(self) -> ...: - # ... # TODO since CDX1.5 - # - # @model_card.setter - # def model_card(self, ...) -> None: - # ... # TODO since CDX1.5 - - # @property - # ... - # @serializable.view(SchemaVersion1Dot5) - # @serializable.xml_sequence(23) - # def data(self) -> ...: - # ... # TODO since CDX1.5 - # - # @data.setter - # def data(self, ...) -> None: - # ... # TODO since CDX1.5 - - @property - @serializable.view(SchemaVersion1Dot6) - @serializable.xml_sequence(30) - def crypto_properties(self) -> Optional[CryptoProperties]: - """ - Cryptographic assets have properties that uniquely define them and that make them actionable for further - reasoning. As an example, it makes a difference if one knows the algorithm family (e.g. AES) or the specific - variant or instantiation (e.g. AES-128-GCM). This is because the security level and the algorithm primitive - (authenticated encryption) is only defined by the definition of the algorithm variant. The presence of a weak - cryptographic algorithm like SHA1 vs. HMAC-SHA1 also makes a difference. - - Returns: - `CryptoProperties` or `None` - """ - return self._crypto_properties - - @crypto_properties.setter - def crypto_properties(self, crypto_properties: Optional[CryptoProperties]) -> None: - self._crypto_properties = crypto_properties - - @property - @serializable.view(SchemaVersion1Dot6) - @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'tag') - @serializable.xml_sequence(31) - def tags(self) -> 'SortedSet[str]': - """ - Textual strings that aid in discovery, search, and retrieval of the associated object. - Tags often serve as a way to group or categorize similar or related objects by various attributes. - - Returns: - `Iterable[str]` - """ - return self._tags - - @tags.setter - def tags(self, tags: Iterable[str]) -> None: - self._tags = SortedSet(tags) - - def get_all_nested_components(self, include_self: bool = False) -> set['Component']: - components = set() - if include_self: - components.add(self) - - for c in self.components: - components.update(c.get_all_nested_components(include_self=True)) - - return components - - def get_pypi_url(self) -> str: - if self.version: - return f'https://pypi.org/project/{self.name}/{self.version}' - else: - return f'https://pypi.org/project/{self.name}' - - def __comparable_tuple(self) -> _ComparableTuple: - return _ComparableTuple(( - self.type, self.group, self.name, self.version, - self.bom_ref.value, - None if self.purl is None else _ComparablePackageURL(self.purl), - self.swid, self.cpe, _ComparableTuple(self.swhids), - self.supplier, self.author, self.publisher, - self.description, - self.mime_type, self.scope, _ComparableTuple(self.hashes), - _ComparableTuple(self.licenses), self.copyright, - self.pedigree, - _ComparableTuple(self.external_references), _ComparableTuple(self.properties), - _ComparableTuple(self.components), self.evidence, self.release_notes, self.modified, - _ComparableTuple(self.authors), _ComparableTuple(self.omnibor_ids), self.manufacturer, - self.crypto_properties, _ComparableTuple(self.tags), - )) - - def __eq__(self, other: object) -> bool: - if isinstance(other, Component): - return self.__comparable_tuple() == other.__comparable_tuple() - return False - - def __lt__(self, other: Any) -> bool: - if isinstance(other, Component): - return self.__comparable_tuple() < other.__comparable_tuple() - return NotImplemented - - def __hash__(self) -> int: - return hash(self.__comparable_tuple()) - - def __repr__(self) -> str: - return f'' From 45abefecdb01210576780e7b6598e2f78f662335 Mon Sep 17 00:00:00 2001 From: Jan Kowalleck Date: Tue, 27 May 2025 12:12:16 +0200 Subject: [PATCH 05/24] prep extraxtion of component_evidence Signed-off-by: Jan Kowalleck --- ..._component.py => test_model_component_.py} | 0 tests/test_model_component_evidence.py | 680 ++++++++++++++++++ 2 files changed, 680 insertions(+) rename tests/{test_model_component.py => test_model_component_.py} (100%) create mode 100644 tests/test_model_component_evidence.py diff --git a/tests/test_model_component.py b/tests/test_model_component_.py similarity index 100% rename from tests/test_model_component.py rename to tests/test_model_component_.py diff --git a/tests/test_model_component_evidence.py b/tests/test_model_component_evidence.py new file mode 100644 index 00000000..af79fb85 --- /dev/null +++ b/tests/test_model_component_evidence.py @@ -0,0 +1,680 @@ +# This file is part of CycloneDX Python Library +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) OWASP Foundation. All Rights Reserved. + +import datetime +from decimal import Decimal +from unittest import TestCase + +from cyclonedx.model import ( + AttachedText, + Copyright, + Encoding, + ExternalReference, + ExternalReferenceType, + IdentifiableAction, + Property, + XsUri, +) +from cyclonedx.model.component import ( + AnalysisTechnique, + CallStack, + Commit, + Component, + ComponentEvidence, + ComponentType, + Diff, + Identity, + IdentityFieldType, + Method, + Occurrence, + Patch, + PatchClassification, + Pedigree, + StackFrame, +) +from cyclonedx.model.issue import IssueClassification, IssueType +from tests import reorder +from tests._data.models import ( + get_component_setuptools_simple, + get_component_setuptools_simple_no_version, + get_component_toml_with_hashes_with_references, + get_issue_1, + get_issue_2, + get_pedigree_1, + get_swid_1, + get_swid_2, +) + + +class TestModelCommit(TestCase): + + def test_no_parameters(self) -> None: + Commit() # Does not raise `NoPropertiesProvidedException` + + def test_same(self) -> None: + ia_comitter = IdentifiableAction(timestamp=datetime.datetime.utcnow(), name='The Committer') + c1 = Commit(uid='a-random-uid', author=ia_comitter, committer=ia_comitter, message='A commit message') + c2 = Commit(uid='a-random-uid', author=ia_comitter, committer=ia_comitter, message='A commit message') + self.assertEqual(hash(c1), hash(c2)) + self.assertTrue(c1 == c2) + + def test_not_same(self) -> None: + ia_author = IdentifiableAction(timestamp=datetime.datetime.utcnow(), name='The Author') + ia_comitter = IdentifiableAction(timestamp=datetime.datetime.utcnow(), name='The Committer') + c1 = Commit(uid='a-random-uid', author=ia_comitter, committer=ia_comitter, message='A commit message') + c2 = Commit(uid='a-random-uid', author=ia_author, committer=ia_comitter, message='A commit message') + self.assertNotEqual(hash(c1), hash(c2)) + self.assertFalse(c1 == c2) + + def test_sort(self) -> None: + url_a = XsUri('a') + url_b = XsUri('b') + action_a = IdentifiableAction(name='a') + action_b = IdentifiableAction(name='b') + + # expected sort order: ([uid], [url], [author], [committer], [message]) + expected_order = [0, 1, 6, 2, 7, 3, 8, 4, 9, 5, 10] + commits = [ + Commit(uid='a', url=url_a, author=action_a, committer=action_a, message='a'), + Commit(uid='a', url=url_a, author=action_a, committer=action_a, message='b'), + Commit(uid='a', url=url_a, author=action_a, committer=action_b, message='a'), + Commit(uid='a', url=url_a, author=action_b, committer=action_a, message='a'), + Commit(uid='a', url=url_b, author=action_a, committer=action_a, message='a'), + Commit(uid='b', url=url_a, author=action_a, committer=action_a, message='a'), + Commit(uid='a', url=url_a, author=action_a, committer=action_a), + Commit(uid='a', url=url_a, author=action_a), + Commit(uid='a', url=url_a), + Commit(uid='a'), + Commit(message='a'), + ] + sorted_commits = sorted(commits) + expected_commits = reorder(commits, expected_order) + self.assertListEqual(sorted_commits, expected_commits) + + +class TestModelComponent(TestCase): + + def test_empty_basic_component(self) -> None: + c = Component(name='test-component') + self.assertEqual(c.name, 'test-component') + self.assertEqual(c.type, ComponentType.LIBRARY) + self.assertIsNone(c.mime_type) + self.assertIsNone(c.bom_ref.value) + self.assertIsNone(c.supplier) + self.assertIsNone(c.author) + self.assertIsNone(c.publisher) + self.assertIsNone(c.group) + self.assertIsNone(c.version) + self.assertIsNone(c.description) + self.assertIsNone(c.scope) + self.assertSetEqual(c.hashes, set()) + self.assertSetEqual(c.licenses, set()) + self.assertIsNone(c.copyright) + self.assertIsNone(c.purl) + self.assertSetEqual(c.external_references, set()) + self.assertFalse(c.properties) + self.assertIsNone(c.release_notes) + self.assertEqual(len(c.components), 0) + self.assertEqual(len(c.get_all_nested_components(include_self=True)), 1) + + def test_multiple_basic_components(self) -> None: + c1 = Component(name='test-component') + self.assertEqual(c1.name, 'test-component') + self.assertIsNone(c1.version) + self.assertEqual(c1.type, ComponentType.LIBRARY) + self.assertEqual(len(c1.external_references), 0) + self.assertEqual(len(c1.hashes), 0) + + c2 = Component(name='test2-component') + self.assertEqual(c2.name, 'test2-component') + self.assertIsNone(c2.version) + self.assertEqual(c2.type, ComponentType.LIBRARY) + self.assertEqual(len(c2.external_references), 0) + self.assertEqual(len(c2.hashes), 0) + + self.assertNotEqual(c1, c2) + + def test_external_references(self) -> None: + c1 = Component(name='test-component') + c1.external_references.add(ExternalReference( + type=ExternalReferenceType.OTHER, + url=XsUri('https://cyclonedx.org'), + comment='No comment' + )) + self.assertEqual(c1.name, 'test-component') + self.assertIsNone(c1.version) + self.assertEqual(c1.type, ComponentType.LIBRARY) + self.assertEqual(len(c1.external_references), 1) + self.assertEqual(len(c1.hashes), 0) + + c2 = Component(name='test2-component') + self.assertEqual(c2.name, 'test2-component') + self.assertIsNone(c2.version) + self.assertEqual(c2.type, ComponentType.LIBRARY) + self.assertEqual(len(c2.external_references), 0) + self.assertEqual(len(c2.hashes), 0) + + def test_empty_component_with_version(self) -> None: + c = Component(name='test-component', version='1.2.3') + self.assertEqual(c.name, 'test-component') + self.assertEqual(c.version, '1.2.3') + self.assertEqual(c.type, ComponentType.LIBRARY) + self.assertEqual(len(c.external_references), 0) + self.assertEqual(len(c.hashes), 0) + + def test_component_equal_1(self) -> None: + c1 = Component(name='test-component') + c1.external_references.add(ExternalReference( + type=ExternalReferenceType.OTHER, + url=XsUri('https://cyclonedx.org'), + comment='No comment' + )) + c2 = Component(name='test-component') + c2.external_references.add(ExternalReference( + type=ExternalReferenceType.OTHER, + url=XsUri('https://cyclonedx.org'), + comment='No comment' + )) + self.assertEqual(c1, c2) + + def test_component_equal_2(self) -> None: + props: list[Property] = ( + Property(name='prop1', value='val1'), + Property(name='prop2', value='val2'), + ) + c1 = Component( + name='test-component', version='1.2.3', properties=props + ) + c2 = Component( + name='test-component', version='1.2.3', properties=props + ) + self.assertEqual(c1, c2) + + def test_component_equal_3(self) -> None: + c1 = Component( + name='test-component', version='1.2.3', properties=[ + Property(name='prop1', value='val1'), + Property(name='prop2', value='val2') + ] + ) + c2 = Component( + name='test-component', version='1.2.3', properties=[ + Property(name='prop3', value='val3'), + Property(name='prop4', value='val4') + ] + ) + self.assertNotEqual(c1, c2) + + def test_component_equal_4(self) -> None: + c1 = Component( + name='test-component', version='1.2.3', bom_ref='ref1' + ) + c2 = Component( + name='test-component', version='1.2.3', bom_ref='ref2' + ) + self.assertNotEqual(c1, c2) + + def test_same_1(self) -> None: + c1 = get_component_setuptools_simple() + c2 = get_component_setuptools_simple() + self.assertNotEqual(id(c1), id(c2)) + self.assertEqual(hash(c1), hash(c2)) + self.assertTrue(c1 == c2) + + def test_same_2(self) -> None: + c1 = get_component_toml_with_hashes_with_references() + c2 = get_component_toml_with_hashes_with_references() + self.assertNotEqual(id(c1), id(c2)) + self.assertEqual(hash(c1), hash(c2)) + self.assertTrue(c1 == c2) + + def test_same_3(self) -> None: + c1 = get_component_setuptools_simple_no_version() + c2 = get_component_setuptools_simple_no_version() + self.assertNotEqual(id(c1), id(c2)) + self.assertEqual(hash(c1), hash(c2)) + self.assertTrue(c1 == c2) + + def test_not_same_1(self) -> None: + c1 = get_component_setuptools_simple() + c2 = get_component_setuptools_simple_no_version() + self.assertNotEqual(id(c1), id(c2)) + self.assertNotEqual(hash(c1), hash(c2)) + self.assertFalse(c1 == c2) + + def test_sort(self) -> None: + # expected sort order: (type, [group], name, [version]) + expected_order = [6, 4, 5, 3, 2, 1, 0] + components = [ + Component(name='component-c', type=ComponentType.LIBRARY), + Component(name='component-a', type=ComponentType.LIBRARY), + Component(name='component-b', type=ComponentType.LIBRARY, group='group-2'), + Component(name='component-a', type=ComponentType.LIBRARY, group='group-2'), + Component(name='component-a', type=ComponentType.FILE), + Component(name='component-b', type=ComponentType.FILE), + Component(name='component-a', type=ComponentType.FILE, version='1.0.0'), + ] + sorted_components = sorted(components) + expected_components = reorder(components, expected_order) + self.assertListEqual(sorted_components, expected_components) + + def test_nested_components_1(self) -> None: + comp_b = Component(name='comp_b') + comp_c = Component(name='comp_c') + comp_b.components.add(comp_c) + + self.assertEqual(1, len(comp_b.components)) + self.assertEqual(2, len(comp_b.get_all_nested_components(include_self=True))) + self.assertEqual(1, len(comp_b.get_all_nested_components(include_self=False))) + + def test_nested_components_2(self) -> None: + comp_a = Component(name='comp_a') + comp_b = Component(name='comp_b') + comp_c = Component(name='comp_c') + comp_b.components.add(comp_c) + comp_b.components.add(comp_a) + + self.assertEqual(2, len(comp_b.components)) + self.assertEqual(3, len(comp_b.get_all_nested_components(include_self=True))) + self.assertEqual(2, len(comp_b.get_all_nested_components(include_self=False))) + + +class TestModelComponentEvidence(TestCase): + + def test_no_params(self) -> None: + ComponentEvidence() # Does not raise `NoPropertiesProvidedException` + + def test_identity(self) -> None: + identity = Identity(field=IdentityFieldType.NAME, confidence=Decimal('1'), concluded_value='test') + ce = ComponentEvidence(identity=[identity]) + self.assertEqual(len(ce.identity), 1) + self.assertEqual(ce.identity.pop().field, 'name') + + def test_identity_multiple(self) -> None: + identities = [ + Identity(field=IdentityFieldType.NAME, confidence=Decimal('1'), concluded_value='test'), + Identity(field=IdentityFieldType.VERSION, confidence=Decimal('0.8'), concluded_value='1.0.0') + ] + ce = ComponentEvidence(identity=identities) + self.assertEqual(len(ce.identity), 2) + + def test_identity_with_methods(self) -> None: + """Test identity with analysis methods""" + methods = [ + Method( + technique=AnalysisTechnique.BINARY_ANALYSIS, # Changed order to test sorting + confidence=Decimal('0.9'), + value='Found in binary' + ), + Method( + technique=AnalysisTechnique.SOURCE_CODE_ANALYSIS, + confidence=Decimal('0.8'), + value='Found in source' + ) + ] + identity = Identity(field='name', confidence=Decimal('1'), methods=methods) + self.assertEqual(len(identity.methods), 2) + sorted_methods = sorted(methods) # Methods should be sorted by technique name + self.assertEqual(list(identity.methods), sorted_methods) + + # Verify first method + method = sorted_methods[0] + self.assertEqual(method.technique, AnalysisTechnique.BINARY_ANALYSIS) + self.assertEqual(method.confidence, Decimal('0.9')) + self.assertEqual(method.value, 'Found in binary') + + def test_method_sorting(self) -> None: + """Test that methods are properly sorted by technique value""" + methods = [ + Method(technique=AnalysisTechnique.SOURCE_CODE_ANALYSIS, confidence=Decimal('0.8')), + Method(technique=AnalysisTechnique.BINARY_ANALYSIS, confidence=Decimal('0.9')), + Method(technique=AnalysisTechnique.ATTESTATION, confidence=Decimal('1.0')) + ] + + sorted_methods = sorted(methods) + self.assertEqual(sorted_methods[0].technique, AnalysisTechnique.ATTESTATION) + self.assertEqual(sorted_methods[1].technique, AnalysisTechnique.BINARY_ANALYSIS) + self.assertEqual(sorted_methods[2].technique, AnalysisTechnique.SOURCE_CODE_ANALYSIS) + + def test_invalid_method_technique(self) -> None: + """Test that invalid technique raises ValueError""" + with self.assertRaises(ValueError): + Method(technique='invalid', confidence=Decimal('0.5')) + + def test_invalid_method_confidence(self) -> None: + """Test that invalid confidence raises ValueError""" + with self.assertRaises(ValueError): + Method(technique=AnalysisTechnique.FILENAME, confidence=Decimal('1.5')) + + def test_occurrences(self) -> None: + occurrence = Occurrence(location='/path/to/file', line=42) + ce = ComponentEvidence(occurrences=[occurrence]) + self.assertEqual(len(ce.occurrences), 1) + self.assertEqual(ce.occurrences.pop().line, 42) + + def test_stackframe(self) -> None: + # Test StackFrame with required fields + frame = StackFrame( + package='com.example', + module='app', + function='main', + parameters=['arg1', 'arg2'], + line=1, + column=10, + full_filename='/path/to/file.py' + ) + self.assertEqual(frame.package, 'com.example') + self.assertEqual(frame.module, 'app') + self.assertEqual(frame.function, 'main') + self.assertEqual(len(frame.parameters), 2) + self.assertEqual(frame.line, 1) + self.assertEqual(frame.column, 10) + self.assertEqual(frame.full_filename, '/path/to/file.py') + + def test_stackframe_module_required(self) -> None: + """Test that module is the only required field""" + frame = StackFrame(module='app') # Only mandatory field + self.assertEqual(frame.module, 'app') + self.assertIsNone(frame.package) + self.assertIsNone(frame.function) + self.assertEqual(len(frame.parameters), 0) + self.assertIsNone(frame.line) + self.assertIsNone(frame.column) + self.assertIsNone(frame.full_filename) + + def test_stackframe_without_module(self) -> None: + """Test that omitting module raises TypeError""" + with self.assertRaises(TypeError): + StackFrame() # Should raise TypeError for missing module + + with self.assertRaises(TypeError): + StackFrame(package='com.example') # Should raise TypeError for missing module + + def test_stackframe_with_none_module(self) -> None: + """Test that setting module as None raises TypeError""" + with self.assertRaises(TypeError): + StackFrame(module=None) # Should raise TypeError for None module + + def test_callstack(self) -> None: + frame = StackFrame( + package='com.example', + module='app', + function='main' + ) + stack = CallStack(frames=[frame]) + ce = ComponentEvidence(callstack=stack) + self.assertIsNotNone(ce.callstack) + self.assertEqual(len(ce.callstack.frames), 1) + + def test_licenses(self) -> None: + from cyclonedx.model.license import DisjunctiveLicense + license = DisjunctiveLicense(id='MIT') + ce = ComponentEvidence(licenses=[license]) + self.assertEqual(len(ce.licenses), 1) + + def test_copyright(self) -> None: + copyright = Copyright(text='(c) 2023') + ce = ComponentEvidence(copyright=[copyright]) + self.assertEqual(len(ce.copyright), 1) + self.assertEqual(ce.copyright.pop().text, '(c) 2023') + + def test_full_evidence(self) -> None: + # Test with all fields populated + identity = Identity(field=IdentityFieldType.NAME, confidence=Decimal('1'), concluded_value='test') + occurrence = Occurrence(location='/path/to/file', line=42) + frame = StackFrame(module='app', function='main', line=1) + stack = CallStack(frames=[frame]) + from cyclonedx.model.license import DisjunctiveLicense + license = DisjunctiveLicense(id='MIT') + copyright = Copyright(text='(c) 2023') + + ce = ComponentEvidence( + identity=[identity], + occurrences=[occurrence], + callstack=stack, + licenses=[license], + copyright=[copyright] + ) + + self.assertEqual(len(ce.identity), 1) + self.assertEqual(len(ce.occurrences), 1) + self.assertIsNotNone(ce.callstack) + self.assertEqual(len(ce.callstack.frames), 1) + self.assertEqual(len(ce.licenses), 1) + self.assertEqual(len(ce.copyright), 1) + + def test_full_evidence_with_complete_stack(self) -> None: + identity = Identity(field=IdentityFieldType.NAME, confidence=Decimal('1'), concluded_value='test') + occurrence = Occurrence(location='/path/to/file', line=42) + + frame = StackFrame( + package='com.example', + module='app', + function='main', + parameters=['arg1', 'arg2'], + line=1, + column=10, + full_filename='/path/to/file.py' + ) + stack = CallStack(frames=[frame]) + + from cyclonedx.model.license import DisjunctiveLicense + license = DisjunctiveLicense(id='MIT') + copyright = Copyright(text='(c) 2023') + + ce = ComponentEvidence( + identity=[identity], + occurrences=[occurrence], + callstack=stack, + licenses=[license], + copyright=[copyright] + ) + + self.assertEqual(len(ce.identity), 1) + self.assertEqual(len(ce.occurrences), 1) + self.assertIsNotNone(ce.callstack) + self.assertEqual(len(ce.callstack.frames), 1) + self.assertEqual(ce.callstack.frames.pop().package, 'com.example') + self.assertEqual(len(ce.licenses), 1) + self.assertEqual(len(ce.copyright), 1) + + def test_same_1(self) -> None: + ce_1 = ComponentEvidence(copyright=[Copyright(text='Commercial')]) + ce_2 = ComponentEvidence(copyright=[Copyright(text='Commercial')]) + self.assertEqual(hash(ce_1), hash(ce_2)) + self.assertTrue(ce_1 == ce_2) + + def test_same_2(self) -> None: + ce_1 = ComponentEvidence(copyright=[Copyright(text='Commercial'), Copyright(text='Commercial 2')]) + ce_2 = ComponentEvidence(copyright=[Copyright(text='Commercial 2'), Copyright(text='Commercial')]) + self.assertEqual(hash(ce_1), hash(ce_2)) + self.assertTrue(ce_1 == ce_2) + + def test_not_same_1(self) -> None: + ce_1 = ComponentEvidence(copyright=[Copyright(text='Commercial')]) + ce_2 = ComponentEvidence(copyright=[Copyright(text='Commercial 2')]) + self.assertNotEqual(hash(ce_1), hash(ce_2)) + self.assertFalse(ce_1 == ce_2) + + +class TestModelDiff(TestCase): + + def test_no_params(self) -> None: + Diff() # Does not raise `NoPropertiesProvidedException` + + def test_same(self) -> None: + at = AttachedText(content='A very long diff') + diff_1 = Diff(text=at, url=XsUri('https://cyclonedx.org')) + diff_2 = Diff(text=at, url=XsUri('https://cyclonedx.org')) + self.assertEqual(hash(diff_1), hash(diff_2)) + self.assertTrue(diff_1 == diff_2) + + def test_not_same(self) -> None: + at = AttachedText(content='A very long diff') + diff_1 = Diff(text=at, url=XsUri('https://cyclonedx.org/')) + diff_2 = Diff(text=at, url=XsUri('https://cyclonedx.org')) + self.assertNotEqual(hash(diff_1), hash(diff_2)) + self.assertFalse(diff_1 == diff_2) + + def test_sort(self) -> None: + text_a = AttachedText(content='a') + text_b = AttachedText(content='b') + + # expected sort order: ([url], [text]) + expected_order = [1, 0, 5, 2, 3, 4] + diffs = [ + Diff(url=XsUri('a'), text=text_b), + Diff(url=XsUri('a'), text=text_a), + Diff(url=XsUri('b'), text=text_a), + Diff(text=text_a), + Diff(text=text_b), + Diff(url=XsUri('a')), + ] + sorted_diffs = sorted(diffs) + expected_diffs = reorder(diffs, expected_order) + self.assertListEqual(sorted_diffs, expected_diffs) + + +class TestModelAttachedText(TestCase): + + def test_sort(self) -> None: + # expected sort order: (content_type, encoding, content) + expected_order = [0, 2, 4, 1, 3] + text = [ + AttachedText(content='a', content_type='a', encoding=Encoding.BASE_64), + AttachedText(content='a', content_type='b', encoding=Encoding.BASE_64), + AttachedText(content='b', content_type='a', encoding=Encoding.BASE_64), + AttachedText(content='b', content_type='b', encoding=Encoding.BASE_64), + AttachedText(content='a', content_type='a'), + ] + sorted_text = sorted(text) + expected_text = reorder(text, expected_order) + self.assertListEqual(sorted_text, expected_text) + + +class TestModelPatch(TestCase): + + def test_same_1(self) -> None: + p1 = Patch( + type=PatchClassification.BACKPORT, diff=Diff(url=XsUri('https://cyclonedx.org')), + resolves=[get_issue_1(), get_issue_2()] + ) + p2 = Patch( + type=PatchClassification.BACKPORT, diff=Diff(url=XsUri('https://cyclonedx.org')), + resolves=[get_issue_2(), get_issue_1()] + ) + self.assertEqual(hash(p1), hash(p2)) + self.assertNotEqual(id(p1), id(p2)) + self.assertTrue(p1 == p2) + + def test_multiple_times_same(self) -> None: + i = 0 + while i < 1000: + p1 = Patch( + type=PatchClassification.BACKPORT, diff=Diff(url=XsUri('https://cyclonedx.org')), + resolves=[get_issue_1(), get_issue_2()] + ) + p2 = Patch( + type=PatchClassification.BACKPORT, diff=Diff(url=XsUri('https://cyclonedx.org')), + resolves=[get_issue_2(), get_issue_1(), get_issue_1(), get_issue_1(), get_issue_2()] + ) + self.assertEqual(hash(p1), hash(p2)) + self.assertNotEqual(id(p1), id(p2)) + self.assertTrue(p1 == p2) + + i += 1 + + def test_not_same_1(self) -> None: + p1 = Patch( + type=PatchClassification.MONKEY, diff=Diff(url=XsUri('https://cyclonedx.org/')), + resolves=[get_issue_1(), get_issue_2()] + ) + p2 = Patch( + type=PatchClassification.BACKPORT, diff=Diff(url=XsUri('https://cyclonedx.org')), + resolves=[get_issue_2(), get_issue_1()] + ) + self.assertNotEqual(hash(p1), hash(p2)) + self.assertNotEqual(id(p1), id(p2)) + self.assertFalse(p1 == p2) + + def test_sort(self) -> None: + diff_a = Diff(text=AttachedText(content='a')) + diff_b = Diff(text=AttachedText(content='b')) + + resolves_a = [ + IssueType(type=IssueClassification.DEFECT), + IssueType(type=IssueClassification.SECURITY) + ] + + # expected sort order: (type, [diff], sorted(resolves)) + expected_order = [5, 4, 3, 2, 1, 0] + patches = [ + Patch(type=PatchClassification.MONKEY), + Patch(type=PatchClassification.MONKEY, diff=diff_b), + Patch(type=PatchClassification.MONKEY, diff=diff_a), + Patch(type=PatchClassification.MONKEY, diff=diff_a, resolves=resolves_a), + Patch(type=PatchClassification.BACKPORT), + Patch(type=PatchClassification.BACKPORT, diff=diff_a), + ] + sorted_patches = sorted(patches) + expected_patches = reorder(patches, expected_order) + self.assertListEqual(sorted_patches, expected_patches) + + +class TestModelPedigree(TestCase): + + def test_no_params(self) -> None: + Pedigree() # does not raise `NoPropertiesProvidedException` + + def test_same_1(self) -> None: + p1 = get_pedigree_1() + p2 = get_pedigree_1() + self.assertNotEqual(id(p1), id(p2), 'id') + self.assertEqual(hash(p1), hash(p2), 'hash') + self.assertTrue(p1 == p2, 'equal') + + def test_not_same_1(self) -> None: + p1 = get_pedigree_1() + p2 = get_pedigree_1() + p2.notes = 'Some other notes here' + self.assertNotEqual(id(p1), id(p2), 'id') + self.assertNotEqual(hash(p1), hash(p2), 'hash') + self.assertFalse(p1 == p2, 'equal') + + +class TestModelSwid(TestCase): + + def test_same_1(self) -> None: + sw_1 = get_swid_1() + sw_2 = get_swid_1() + self.assertNotEqual(id(sw_1), id(sw_2), 'id') + self.assertEqual(hash(sw_1), hash(sw_2), 'hash') + self.assertTrue(sw_1 == sw_2, 'equal') + + def test_same_2(self) -> None: + sw_1 = get_swid_2() + sw_2 = get_swid_2() + self.assertNotEqual(id(sw_1), id(sw_2), 'id') + self.assertEqual(hash(sw_1), hash(sw_2), 'hash') + self.assertTrue(sw_1 == sw_2, 'equal') + + def test_not_same(self) -> None: + sw_1 = get_swid_1() + sw_2 = get_swid_2() + self.assertNotEqual(id(sw_1), id(sw_2), 'id') + self.assertNotEqual(hash(sw_1), hash(sw_2), 'hash') + self.assertFalse(sw_1 == sw_2, 'equal') From 392e61d43bd6f190c667006952bb5604176661b2 Mon Sep 17 00:00:00 2001 From: Jan Kowalleck Date: Tue, 27 May 2025 12:12:36 +0200 Subject: [PATCH 06/24] prep extraxtion of component_evidence Signed-off-by: Jan Kowalleck --- tests/{test_model_component_.py => test_model_component.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{test_model_component_.py => test_model_component.py} (100%) diff --git a/tests/test_model_component_.py b/tests/test_model_component.py similarity index 100% rename from tests/test_model_component_.py rename to tests/test_model_component.py From cbb7370214b45c83452c1e48944b797498e2e0cd Mon Sep 17 00:00:00 2001 From: Jan Kowalleck Date: Tue, 27 May 2025 12:24:01 +0200 Subject: [PATCH 07/24] refactor: compoennt evidence Signed-off-by: Jan Kowalleck --- tests/_data/models.py | 18 +- tests/test_model_component.py | 238 +------------- tests/test_model_component_evidence.py | 435 +------------------------ 3 files changed, 13 insertions(+), 678 deletions(-) diff --git a/tests/_data/models.py b/tests/_data/models.py index 2ab7b0cd..ea6319c9 100644 --- a/tests/_data/models.py +++ b/tests/_data/models.py @@ -45,26 +45,28 @@ from cyclonedx.model.bom import Bom, BomMetaData from cyclonedx.model.bom_ref import BomRef from cyclonedx.model.component import ( - AnalysisTechnique, - CallStack, Commit, Component, - ComponentEvidence, ComponentScope, ComponentType, Diff, - Identity, - IdentityFieldType, - Method, - Occurrence, OmniborId, Patch, PatchClassification, Pedigree, - StackFrame, Swhid, Swid, ) +from cyclonedx.model.component_evidence import ( + AnalysisTechnique, + CallStack, + ComponentEvidence, + Identity, + IdentityFieldType, + Method, + Occurrence, + StackFrame, +) from cyclonedx.model.contact import OrganizationalContact, OrganizationalEntity, PostalAddress from cyclonedx.model.crypto import ( AlgorithmProperties, diff --git a/tests/test_model_component.py b/tests/test_model_component.py index af79fb85..bc18a10d 100644 --- a/tests/test_model_component.py +++ b/tests/test_model_component.py @@ -16,12 +16,10 @@ # Copyright (c) OWASP Foundation. All Rights Reserved. import datetime -from decimal import Decimal from unittest import TestCase from cyclonedx.model import ( AttachedText, - Copyright, Encoding, ExternalReference, ExternalReferenceType, @@ -29,23 +27,7 @@ Property, XsUri, ) -from cyclonedx.model.component import ( - AnalysisTechnique, - CallStack, - Commit, - Component, - ComponentEvidence, - ComponentType, - Diff, - Identity, - IdentityFieldType, - Method, - Occurrence, - Patch, - PatchClassification, - Pedigree, - StackFrame, -) +from cyclonedx.model.component import Commit, Component, ComponentType, Diff, Patch, PatchClassification, Pedigree from cyclonedx.model.issue import IssueClassification, IssueType from tests import reorder from tests._data.models import ( @@ -293,224 +275,6 @@ def test_nested_components_2(self) -> None: self.assertEqual(2, len(comp_b.get_all_nested_components(include_self=False))) -class TestModelComponentEvidence(TestCase): - - def test_no_params(self) -> None: - ComponentEvidence() # Does not raise `NoPropertiesProvidedException` - - def test_identity(self) -> None: - identity = Identity(field=IdentityFieldType.NAME, confidence=Decimal('1'), concluded_value='test') - ce = ComponentEvidence(identity=[identity]) - self.assertEqual(len(ce.identity), 1) - self.assertEqual(ce.identity.pop().field, 'name') - - def test_identity_multiple(self) -> None: - identities = [ - Identity(field=IdentityFieldType.NAME, confidence=Decimal('1'), concluded_value='test'), - Identity(field=IdentityFieldType.VERSION, confidence=Decimal('0.8'), concluded_value='1.0.0') - ] - ce = ComponentEvidence(identity=identities) - self.assertEqual(len(ce.identity), 2) - - def test_identity_with_methods(self) -> None: - """Test identity with analysis methods""" - methods = [ - Method( - technique=AnalysisTechnique.BINARY_ANALYSIS, # Changed order to test sorting - confidence=Decimal('0.9'), - value='Found in binary' - ), - Method( - technique=AnalysisTechnique.SOURCE_CODE_ANALYSIS, - confidence=Decimal('0.8'), - value='Found in source' - ) - ] - identity = Identity(field='name', confidence=Decimal('1'), methods=methods) - self.assertEqual(len(identity.methods), 2) - sorted_methods = sorted(methods) # Methods should be sorted by technique name - self.assertEqual(list(identity.methods), sorted_methods) - - # Verify first method - method = sorted_methods[0] - self.assertEqual(method.technique, AnalysisTechnique.BINARY_ANALYSIS) - self.assertEqual(method.confidence, Decimal('0.9')) - self.assertEqual(method.value, 'Found in binary') - - def test_method_sorting(self) -> None: - """Test that methods are properly sorted by technique value""" - methods = [ - Method(technique=AnalysisTechnique.SOURCE_CODE_ANALYSIS, confidence=Decimal('0.8')), - Method(technique=AnalysisTechnique.BINARY_ANALYSIS, confidence=Decimal('0.9')), - Method(technique=AnalysisTechnique.ATTESTATION, confidence=Decimal('1.0')) - ] - - sorted_methods = sorted(methods) - self.assertEqual(sorted_methods[0].technique, AnalysisTechnique.ATTESTATION) - self.assertEqual(sorted_methods[1].technique, AnalysisTechnique.BINARY_ANALYSIS) - self.assertEqual(sorted_methods[2].technique, AnalysisTechnique.SOURCE_CODE_ANALYSIS) - - def test_invalid_method_technique(self) -> None: - """Test that invalid technique raises ValueError""" - with self.assertRaises(ValueError): - Method(technique='invalid', confidence=Decimal('0.5')) - - def test_invalid_method_confidence(self) -> None: - """Test that invalid confidence raises ValueError""" - with self.assertRaises(ValueError): - Method(technique=AnalysisTechnique.FILENAME, confidence=Decimal('1.5')) - - def test_occurrences(self) -> None: - occurrence = Occurrence(location='/path/to/file', line=42) - ce = ComponentEvidence(occurrences=[occurrence]) - self.assertEqual(len(ce.occurrences), 1) - self.assertEqual(ce.occurrences.pop().line, 42) - - def test_stackframe(self) -> None: - # Test StackFrame with required fields - frame = StackFrame( - package='com.example', - module='app', - function='main', - parameters=['arg1', 'arg2'], - line=1, - column=10, - full_filename='/path/to/file.py' - ) - self.assertEqual(frame.package, 'com.example') - self.assertEqual(frame.module, 'app') - self.assertEqual(frame.function, 'main') - self.assertEqual(len(frame.parameters), 2) - self.assertEqual(frame.line, 1) - self.assertEqual(frame.column, 10) - self.assertEqual(frame.full_filename, '/path/to/file.py') - - def test_stackframe_module_required(self) -> None: - """Test that module is the only required field""" - frame = StackFrame(module='app') # Only mandatory field - self.assertEqual(frame.module, 'app') - self.assertIsNone(frame.package) - self.assertIsNone(frame.function) - self.assertEqual(len(frame.parameters), 0) - self.assertIsNone(frame.line) - self.assertIsNone(frame.column) - self.assertIsNone(frame.full_filename) - - def test_stackframe_without_module(self) -> None: - """Test that omitting module raises TypeError""" - with self.assertRaises(TypeError): - StackFrame() # Should raise TypeError for missing module - - with self.assertRaises(TypeError): - StackFrame(package='com.example') # Should raise TypeError for missing module - - def test_stackframe_with_none_module(self) -> None: - """Test that setting module as None raises TypeError""" - with self.assertRaises(TypeError): - StackFrame(module=None) # Should raise TypeError for None module - - def test_callstack(self) -> None: - frame = StackFrame( - package='com.example', - module='app', - function='main' - ) - stack = CallStack(frames=[frame]) - ce = ComponentEvidence(callstack=stack) - self.assertIsNotNone(ce.callstack) - self.assertEqual(len(ce.callstack.frames), 1) - - def test_licenses(self) -> None: - from cyclonedx.model.license import DisjunctiveLicense - license = DisjunctiveLicense(id='MIT') - ce = ComponentEvidence(licenses=[license]) - self.assertEqual(len(ce.licenses), 1) - - def test_copyright(self) -> None: - copyright = Copyright(text='(c) 2023') - ce = ComponentEvidence(copyright=[copyright]) - self.assertEqual(len(ce.copyright), 1) - self.assertEqual(ce.copyright.pop().text, '(c) 2023') - - def test_full_evidence(self) -> None: - # Test with all fields populated - identity = Identity(field=IdentityFieldType.NAME, confidence=Decimal('1'), concluded_value='test') - occurrence = Occurrence(location='/path/to/file', line=42) - frame = StackFrame(module='app', function='main', line=1) - stack = CallStack(frames=[frame]) - from cyclonedx.model.license import DisjunctiveLicense - license = DisjunctiveLicense(id='MIT') - copyright = Copyright(text='(c) 2023') - - ce = ComponentEvidence( - identity=[identity], - occurrences=[occurrence], - callstack=stack, - licenses=[license], - copyright=[copyright] - ) - - self.assertEqual(len(ce.identity), 1) - self.assertEqual(len(ce.occurrences), 1) - self.assertIsNotNone(ce.callstack) - self.assertEqual(len(ce.callstack.frames), 1) - self.assertEqual(len(ce.licenses), 1) - self.assertEqual(len(ce.copyright), 1) - - def test_full_evidence_with_complete_stack(self) -> None: - identity = Identity(field=IdentityFieldType.NAME, confidence=Decimal('1'), concluded_value='test') - occurrence = Occurrence(location='/path/to/file', line=42) - - frame = StackFrame( - package='com.example', - module='app', - function='main', - parameters=['arg1', 'arg2'], - line=1, - column=10, - full_filename='/path/to/file.py' - ) - stack = CallStack(frames=[frame]) - - from cyclonedx.model.license import DisjunctiveLicense - license = DisjunctiveLicense(id='MIT') - copyright = Copyright(text='(c) 2023') - - ce = ComponentEvidence( - identity=[identity], - occurrences=[occurrence], - callstack=stack, - licenses=[license], - copyright=[copyright] - ) - - self.assertEqual(len(ce.identity), 1) - self.assertEqual(len(ce.occurrences), 1) - self.assertIsNotNone(ce.callstack) - self.assertEqual(len(ce.callstack.frames), 1) - self.assertEqual(ce.callstack.frames.pop().package, 'com.example') - self.assertEqual(len(ce.licenses), 1) - self.assertEqual(len(ce.copyright), 1) - - def test_same_1(self) -> None: - ce_1 = ComponentEvidence(copyright=[Copyright(text='Commercial')]) - ce_2 = ComponentEvidence(copyright=[Copyright(text='Commercial')]) - self.assertEqual(hash(ce_1), hash(ce_2)) - self.assertTrue(ce_1 == ce_2) - - def test_same_2(self) -> None: - ce_1 = ComponentEvidence(copyright=[Copyright(text='Commercial'), Copyright(text='Commercial 2')]) - ce_2 = ComponentEvidence(copyright=[Copyright(text='Commercial 2'), Copyright(text='Commercial')]) - self.assertEqual(hash(ce_1), hash(ce_2)) - self.assertTrue(ce_1 == ce_2) - - def test_not_same_1(self) -> None: - ce_1 = ComponentEvidence(copyright=[Copyright(text='Commercial')]) - ce_2 = ComponentEvidence(copyright=[Copyright(text='Commercial 2')]) - self.assertNotEqual(hash(ce_1), hash(ce_2)) - self.assertFalse(ce_1 == ce_2) - - class TestModelDiff(TestCase): def test_no_params(self) -> None: diff --git a/tests/test_model_component_evidence.py b/tests/test_model_component_evidence.py index af79fb85..846213f3 100644 --- a/tests/test_model_component_evidence.py +++ b/tests/test_model_component_evidence.py @@ -15,282 +15,20 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright (c) OWASP Foundation. All Rights Reserved. -import datetime from decimal import Decimal from unittest import TestCase -from cyclonedx.model import ( - AttachedText, - Copyright, - Encoding, - ExternalReference, - ExternalReferenceType, - IdentifiableAction, - Property, - XsUri, -) -from cyclonedx.model.component import ( +from cyclonedx.model import Copyright +from cyclonedx.model.component_evidence import ( AnalysisTechnique, CallStack, - Commit, - Component, ComponentEvidence, - ComponentType, - Diff, Identity, IdentityFieldType, Method, Occurrence, - Patch, - PatchClassification, - Pedigree, StackFrame, ) -from cyclonedx.model.issue import IssueClassification, IssueType -from tests import reorder -from tests._data.models import ( - get_component_setuptools_simple, - get_component_setuptools_simple_no_version, - get_component_toml_with_hashes_with_references, - get_issue_1, - get_issue_2, - get_pedigree_1, - get_swid_1, - get_swid_2, -) - - -class TestModelCommit(TestCase): - - def test_no_parameters(self) -> None: - Commit() # Does not raise `NoPropertiesProvidedException` - - def test_same(self) -> None: - ia_comitter = IdentifiableAction(timestamp=datetime.datetime.utcnow(), name='The Committer') - c1 = Commit(uid='a-random-uid', author=ia_comitter, committer=ia_comitter, message='A commit message') - c2 = Commit(uid='a-random-uid', author=ia_comitter, committer=ia_comitter, message='A commit message') - self.assertEqual(hash(c1), hash(c2)) - self.assertTrue(c1 == c2) - - def test_not_same(self) -> None: - ia_author = IdentifiableAction(timestamp=datetime.datetime.utcnow(), name='The Author') - ia_comitter = IdentifiableAction(timestamp=datetime.datetime.utcnow(), name='The Committer') - c1 = Commit(uid='a-random-uid', author=ia_comitter, committer=ia_comitter, message='A commit message') - c2 = Commit(uid='a-random-uid', author=ia_author, committer=ia_comitter, message='A commit message') - self.assertNotEqual(hash(c1), hash(c2)) - self.assertFalse(c1 == c2) - - def test_sort(self) -> None: - url_a = XsUri('a') - url_b = XsUri('b') - action_a = IdentifiableAction(name='a') - action_b = IdentifiableAction(name='b') - - # expected sort order: ([uid], [url], [author], [committer], [message]) - expected_order = [0, 1, 6, 2, 7, 3, 8, 4, 9, 5, 10] - commits = [ - Commit(uid='a', url=url_a, author=action_a, committer=action_a, message='a'), - Commit(uid='a', url=url_a, author=action_a, committer=action_a, message='b'), - Commit(uid='a', url=url_a, author=action_a, committer=action_b, message='a'), - Commit(uid='a', url=url_a, author=action_b, committer=action_a, message='a'), - Commit(uid='a', url=url_b, author=action_a, committer=action_a, message='a'), - Commit(uid='b', url=url_a, author=action_a, committer=action_a, message='a'), - Commit(uid='a', url=url_a, author=action_a, committer=action_a), - Commit(uid='a', url=url_a, author=action_a), - Commit(uid='a', url=url_a), - Commit(uid='a'), - Commit(message='a'), - ] - sorted_commits = sorted(commits) - expected_commits = reorder(commits, expected_order) - self.assertListEqual(sorted_commits, expected_commits) - - -class TestModelComponent(TestCase): - - def test_empty_basic_component(self) -> None: - c = Component(name='test-component') - self.assertEqual(c.name, 'test-component') - self.assertEqual(c.type, ComponentType.LIBRARY) - self.assertIsNone(c.mime_type) - self.assertIsNone(c.bom_ref.value) - self.assertIsNone(c.supplier) - self.assertIsNone(c.author) - self.assertIsNone(c.publisher) - self.assertIsNone(c.group) - self.assertIsNone(c.version) - self.assertIsNone(c.description) - self.assertIsNone(c.scope) - self.assertSetEqual(c.hashes, set()) - self.assertSetEqual(c.licenses, set()) - self.assertIsNone(c.copyright) - self.assertIsNone(c.purl) - self.assertSetEqual(c.external_references, set()) - self.assertFalse(c.properties) - self.assertIsNone(c.release_notes) - self.assertEqual(len(c.components), 0) - self.assertEqual(len(c.get_all_nested_components(include_self=True)), 1) - - def test_multiple_basic_components(self) -> None: - c1 = Component(name='test-component') - self.assertEqual(c1.name, 'test-component') - self.assertIsNone(c1.version) - self.assertEqual(c1.type, ComponentType.LIBRARY) - self.assertEqual(len(c1.external_references), 0) - self.assertEqual(len(c1.hashes), 0) - - c2 = Component(name='test2-component') - self.assertEqual(c2.name, 'test2-component') - self.assertIsNone(c2.version) - self.assertEqual(c2.type, ComponentType.LIBRARY) - self.assertEqual(len(c2.external_references), 0) - self.assertEqual(len(c2.hashes), 0) - - self.assertNotEqual(c1, c2) - - def test_external_references(self) -> None: - c1 = Component(name='test-component') - c1.external_references.add(ExternalReference( - type=ExternalReferenceType.OTHER, - url=XsUri('https://cyclonedx.org'), - comment='No comment' - )) - self.assertEqual(c1.name, 'test-component') - self.assertIsNone(c1.version) - self.assertEqual(c1.type, ComponentType.LIBRARY) - self.assertEqual(len(c1.external_references), 1) - self.assertEqual(len(c1.hashes), 0) - - c2 = Component(name='test2-component') - self.assertEqual(c2.name, 'test2-component') - self.assertIsNone(c2.version) - self.assertEqual(c2.type, ComponentType.LIBRARY) - self.assertEqual(len(c2.external_references), 0) - self.assertEqual(len(c2.hashes), 0) - - def test_empty_component_with_version(self) -> None: - c = Component(name='test-component', version='1.2.3') - self.assertEqual(c.name, 'test-component') - self.assertEqual(c.version, '1.2.3') - self.assertEqual(c.type, ComponentType.LIBRARY) - self.assertEqual(len(c.external_references), 0) - self.assertEqual(len(c.hashes), 0) - - def test_component_equal_1(self) -> None: - c1 = Component(name='test-component') - c1.external_references.add(ExternalReference( - type=ExternalReferenceType.OTHER, - url=XsUri('https://cyclonedx.org'), - comment='No comment' - )) - c2 = Component(name='test-component') - c2.external_references.add(ExternalReference( - type=ExternalReferenceType.OTHER, - url=XsUri('https://cyclonedx.org'), - comment='No comment' - )) - self.assertEqual(c1, c2) - - def test_component_equal_2(self) -> None: - props: list[Property] = ( - Property(name='prop1', value='val1'), - Property(name='prop2', value='val2'), - ) - c1 = Component( - name='test-component', version='1.2.3', properties=props - ) - c2 = Component( - name='test-component', version='1.2.3', properties=props - ) - self.assertEqual(c1, c2) - - def test_component_equal_3(self) -> None: - c1 = Component( - name='test-component', version='1.2.3', properties=[ - Property(name='prop1', value='val1'), - Property(name='prop2', value='val2') - ] - ) - c2 = Component( - name='test-component', version='1.2.3', properties=[ - Property(name='prop3', value='val3'), - Property(name='prop4', value='val4') - ] - ) - self.assertNotEqual(c1, c2) - - def test_component_equal_4(self) -> None: - c1 = Component( - name='test-component', version='1.2.3', bom_ref='ref1' - ) - c2 = Component( - name='test-component', version='1.2.3', bom_ref='ref2' - ) - self.assertNotEqual(c1, c2) - - def test_same_1(self) -> None: - c1 = get_component_setuptools_simple() - c2 = get_component_setuptools_simple() - self.assertNotEqual(id(c1), id(c2)) - self.assertEqual(hash(c1), hash(c2)) - self.assertTrue(c1 == c2) - - def test_same_2(self) -> None: - c1 = get_component_toml_with_hashes_with_references() - c2 = get_component_toml_with_hashes_with_references() - self.assertNotEqual(id(c1), id(c2)) - self.assertEqual(hash(c1), hash(c2)) - self.assertTrue(c1 == c2) - - def test_same_3(self) -> None: - c1 = get_component_setuptools_simple_no_version() - c2 = get_component_setuptools_simple_no_version() - self.assertNotEqual(id(c1), id(c2)) - self.assertEqual(hash(c1), hash(c2)) - self.assertTrue(c1 == c2) - - def test_not_same_1(self) -> None: - c1 = get_component_setuptools_simple() - c2 = get_component_setuptools_simple_no_version() - self.assertNotEqual(id(c1), id(c2)) - self.assertNotEqual(hash(c1), hash(c2)) - self.assertFalse(c1 == c2) - - def test_sort(self) -> None: - # expected sort order: (type, [group], name, [version]) - expected_order = [6, 4, 5, 3, 2, 1, 0] - components = [ - Component(name='component-c', type=ComponentType.LIBRARY), - Component(name='component-a', type=ComponentType.LIBRARY), - Component(name='component-b', type=ComponentType.LIBRARY, group='group-2'), - Component(name='component-a', type=ComponentType.LIBRARY, group='group-2'), - Component(name='component-a', type=ComponentType.FILE), - Component(name='component-b', type=ComponentType.FILE), - Component(name='component-a', type=ComponentType.FILE, version='1.0.0'), - ] - sorted_components = sorted(components) - expected_components = reorder(components, expected_order) - self.assertListEqual(sorted_components, expected_components) - - def test_nested_components_1(self) -> None: - comp_b = Component(name='comp_b') - comp_c = Component(name='comp_c') - comp_b.components.add(comp_c) - - self.assertEqual(1, len(comp_b.components)) - self.assertEqual(2, len(comp_b.get_all_nested_components(include_self=True))) - self.assertEqual(1, len(comp_b.get_all_nested_components(include_self=False))) - - def test_nested_components_2(self) -> None: - comp_a = Component(name='comp_a') - comp_b = Component(name='comp_b') - comp_c = Component(name='comp_c') - comp_b.components.add(comp_c) - comp_b.components.add(comp_a) - - self.assertEqual(2, len(comp_b.components)) - self.assertEqual(3, len(comp_b.get_all_nested_components(include_self=True))) - self.assertEqual(2, len(comp_b.get_all_nested_components(include_self=False))) class TestModelComponentEvidence(TestCase): @@ -509,172 +247,3 @@ def test_not_same_1(self) -> None: ce_2 = ComponentEvidence(copyright=[Copyright(text='Commercial 2')]) self.assertNotEqual(hash(ce_1), hash(ce_2)) self.assertFalse(ce_1 == ce_2) - - -class TestModelDiff(TestCase): - - def test_no_params(self) -> None: - Diff() # Does not raise `NoPropertiesProvidedException` - - def test_same(self) -> None: - at = AttachedText(content='A very long diff') - diff_1 = Diff(text=at, url=XsUri('https://cyclonedx.org')) - diff_2 = Diff(text=at, url=XsUri('https://cyclonedx.org')) - self.assertEqual(hash(diff_1), hash(diff_2)) - self.assertTrue(diff_1 == diff_2) - - def test_not_same(self) -> None: - at = AttachedText(content='A very long diff') - diff_1 = Diff(text=at, url=XsUri('https://cyclonedx.org/')) - diff_2 = Diff(text=at, url=XsUri('https://cyclonedx.org')) - self.assertNotEqual(hash(diff_1), hash(diff_2)) - self.assertFalse(diff_1 == diff_2) - - def test_sort(self) -> None: - text_a = AttachedText(content='a') - text_b = AttachedText(content='b') - - # expected sort order: ([url], [text]) - expected_order = [1, 0, 5, 2, 3, 4] - diffs = [ - Diff(url=XsUri('a'), text=text_b), - Diff(url=XsUri('a'), text=text_a), - Diff(url=XsUri('b'), text=text_a), - Diff(text=text_a), - Diff(text=text_b), - Diff(url=XsUri('a')), - ] - sorted_diffs = sorted(diffs) - expected_diffs = reorder(diffs, expected_order) - self.assertListEqual(sorted_diffs, expected_diffs) - - -class TestModelAttachedText(TestCase): - - def test_sort(self) -> None: - # expected sort order: (content_type, encoding, content) - expected_order = [0, 2, 4, 1, 3] - text = [ - AttachedText(content='a', content_type='a', encoding=Encoding.BASE_64), - AttachedText(content='a', content_type='b', encoding=Encoding.BASE_64), - AttachedText(content='b', content_type='a', encoding=Encoding.BASE_64), - AttachedText(content='b', content_type='b', encoding=Encoding.BASE_64), - AttachedText(content='a', content_type='a'), - ] - sorted_text = sorted(text) - expected_text = reorder(text, expected_order) - self.assertListEqual(sorted_text, expected_text) - - -class TestModelPatch(TestCase): - - def test_same_1(self) -> None: - p1 = Patch( - type=PatchClassification.BACKPORT, diff=Diff(url=XsUri('https://cyclonedx.org')), - resolves=[get_issue_1(), get_issue_2()] - ) - p2 = Patch( - type=PatchClassification.BACKPORT, diff=Diff(url=XsUri('https://cyclonedx.org')), - resolves=[get_issue_2(), get_issue_1()] - ) - self.assertEqual(hash(p1), hash(p2)) - self.assertNotEqual(id(p1), id(p2)) - self.assertTrue(p1 == p2) - - def test_multiple_times_same(self) -> None: - i = 0 - while i < 1000: - p1 = Patch( - type=PatchClassification.BACKPORT, diff=Diff(url=XsUri('https://cyclonedx.org')), - resolves=[get_issue_1(), get_issue_2()] - ) - p2 = Patch( - type=PatchClassification.BACKPORT, diff=Diff(url=XsUri('https://cyclonedx.org')), - resolves=[get_issue_2(), get_issue_1(), get_issue_1(), get_issue_1(), get_issue_2()] - ) - self.assertEqual(hash(p1), hash(p2)) - self.assertNotEqual(id(p1), id(p2)) - self.assertTrue(p1 == p2) - - i += 1 - - def test_not_same_1(self) -> None: - p1 = Patch( - type=PatchClassification.MONKEY, diff=Diff(url=XsUri('https://cyclonedx.org/')), - resolves=[get_issue_1(), get_issue_2()] - ) - p2 = Patch( - type=PatchClassification.BACKPORT, diff=Diff(url=XsUri('https://cyclonedx.org')), - resolves=[get_issue_2(), get_issue_1()] - ) - self.assertNotEqual(hash(p1), hash(p2)) - self.assertNotEqual(id(p1), id(p2)) - self.assertFalse(p1 == p2) - - def test_sort(self) -> None: - diff_a = Diff(text=AttachedText(content='a')) - diff_b = Diff(text=AttachedText(content='b')) - - resolves_a = [ - IssueType(type=IssueClassification.DEFECT), - IssueType(type=IssueClassification.SECURITY) - ] - - # expected sort order: (type, [diff], sorted(resolves)) - expected_order = [5, 4, 3, 2, 1, 0] - patches = [ - Patch(type=PatchClassification.MONKEY), - Patch(type=PatchClassification.MONKEY, diff=diff_b), - Patch(type=PatchClassification.MONKEY, diff=diff_a), - Patch(type=PatchClassification.MONKEY, diff=diff_a, resolves=resolves_a), - Patch(type=PatchClassification.BACKPORT), - Patch(type=PatchClassification.BACKPORT, diff=diff_a), - ] - sorted_patches = sorted(patches) - expected_patches = reorder(patches, expected_order) - self.assertListEqual(sorted_patches, expected_patches) - - -class TestModelPedigree(TestCase): - - def test_no_params(self) -> None: - Pedigree() # does not raise `NoPropertiesProvidedException` - - def test_same_1(self) -> None: - p1 = get_pedigree_1() - p2 = get_pedigree_1() - self.assertNotEqual(id(p1), id(p2), 'id') - self.assertEqual(hash(p1), hash(p2), 'hash') - self.assertTrue(p1 == p2, 'equal') - - def test_not_same_1(self) -> None: - p1 = get_pedigree_1() - p2 = get_pedigree_1() - p2.notes = 'Some other notes here' - self.assertNotEqual(id(p1), id(p2), 'id') - self.assertNotEqual(hash(p1), hash(p2), 'hash') - self.assertFalse(p1 == p2, 'equal') - - -class TestModelSwid(TestCase): - - def test_same_1(self) -> None: - sw_1 = get_swid_1() - sw_2 = get_swid_1() - self.assertNotEqual(id(sw_1), id(sw_2), 'id') - self.assertEqual(hash(sw_1), hash(sw_2), 'hash') - self.assertTrue(sw_1 == sw_2, 'equal') - - def test_same_2(self) -> None: - sw_1 = get_swid_2() - sw_2 = get_swid_2() - self.assertNotEqual(id(sw_1), id(sw_2), 'id') - self.assertEqual(hash(sw_1), hash(sw_2), 'hash') - self.assertTrue(sw_1 == sw_2, 'equal') - - def test_not_same(self) -> None: - sw_1 = get_swid_1() - sw_2 = get_swid_2() - self.assertNotEqual(id(sw_1), id(sw_2), 'id') - self.assertNotEqual(hash(sw_1), hash(sw_2), 'hash') - self.assertFalse(sw_1 == sw_2, 'equal') From ec89ae7a63b9f83cf61d3a00f11f0ee712676fde Mon Sep 17 00:00:00 2001 From: Jan Kowalleck Date: Tue, 27 May 2025 17:08:12 +0200 Subject: [PATCH 08/24] wip Signed-off-by: Jan Kowalleck --- cyclonedx/exception/model.py | 12 + cyclonedx/model/component_evidence.py | 342 ++++++++++--------------- tests/_data/models.py | 19 +- tests/test_model_component_evidence.py | 51 ++-- 4 files changed, 176 insertions(+), 248 deletions(-) diff --git a/cyclonedx/exception/model.py b/cyclonedx/exception/model.py index 3484b606..f3986eb9 100644 --- a/cyclonedx/exception/model.py +++ b/cyclonedx/exception/model.py @@ -30,6 +30,10 @@ class CycloneDxModelException(CycloneDxException): pass +class InvalidValueException(CycloneDxModelException): + pass + + class InvalidLocaleTypeException(CycloneDxModelException): """ Raised when the supplied locale does not conform to ISO-639 specification. @@ -131,3 +135,11 @@ class InvalidCreIdException(CycloneDxModelException): as defined at https://opencre.org/ """ pass + + +class InvalidConfidenceException(CycloneDxModelException): + """ + Raised when an invalid value is provided for a Confidence. + The confidence of the evidence from 0 - 1, where 1 is 100% confidence. + """ + pass diff --git a/cyclonedx/model/component_evidence.py b/cyclonedx/model/component_evidence.py index 102c7cbb..f0e928a2 100644 --- a/cyclonedx/model/component_evidence.py +++ b/cyclonedx/model/component_evidence.py @@ -16,26 +16,28 @@ # Copyright (c) OWASP Foundation. All Rights Reserved. -from collections.abc import Iterable +from collections.abc import Iterable, Generator from decimal import Decimal from enum import Enum from typing import Any, Optional, Union -from xml.etree.ElementTree import Element # nosec B405 +from xml.etree.ElementTree import Element as XmlElement # See https://github.com/package-url/packageurl-python/issues/65 import py_serializable as serializable from sortedcontainers import SortedSet +from ..exception.serialization import SerializationOfUnexpectedValueException +from .._internal.bom_ref import bom_ref_from_str as _bom_ref_from_str from .._internal.compare import ComparableTuple as _ComparableTuple -from ..exception.serialization import CycloneDxDeserializationException -from ..schema.schema import SchemaVersion1Dot6 -from . import Copyright, XsUri +from ..exception.model import InvalidConfidenceException, InvalidValueException +from ..schema.schema import SchemaVersion1Dot5, SchemaVersion1Dot6 +from . import Copyright from .bom_ref import BomRef from .license import License, LicenseRepository, _LicenseRepositorySerializationHelper @serializable.serializable_enum -class IdentityFieldType(str, Enum): +class IdentityField(str, Enum): """ Enum object that defines the permissible field types for Identity. @@ -80,7 +82,7 @@ class Method: def __init__( self, *, - technique: Union[AnalysisTechnique, str], + technique: AnalysisTechnique, confidence: Decimal, value: Optional[str] = None, ) -> None: @@ -89,39 +91,30 @@ def __init__( self.value = value @property - @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'technique') - @serializable.json_name('technique') @serializable.xml_sequence(1) - def technique(self) -> str: - return self._technique.value + def technique(self) -> AnalysisTechnique: + return self._technique @technique.setter - def technique(self, technique: Union[AnalysisTechnique, str]) -> None: - if isinstance(technique, str): - try: - technique = AnalysisTechnique(technique) - except ValueError: - raise ValueError( - f'Technique must be one of: {", ".join(t.value for t in AnalysisTechnique)}' - ) + def technique(self, technique: AnalysisTechnique) -> None: self._technique = technique @property - @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'confidence') - @serializable.json_name('confidence') @serializable.xml_sequence(2) def confidence(self) -> Decimal: + """ + The confidence of the evidence from 0 - 1, where 1 is 100% confidence. + Confidence is specific to the technique used. Each technique of analysis can have independent confidence. + """ return self._confidence @confidence.setter def confidence(self, confidence: Decimal) -> None: - if not 0 <= confidence <= 1: - raise ValueError('Confidence must be between 0 and 1') + if not (0 <= confidence <= 1): + raise InvalidConfidenceException(f'confidence {confidence!r} is invalid') self._confidence = confidence @property - @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'value') - @serializable.json_name('value') @serializable.xml_sequence(3) def value(self) -> Optional[str]: return self._value @@ -131,13 +124,11 @@ def value(self, value: Optional[str]) -> None: self._value = value def __comparable_tuple(self) -> _ComparableTuple: - return _ComparableTuple( - ( - self.technique, - self.confidence, - self.value, - ) - ) + return _ComparableTuple(( + self.technique, + self.confidence, + self.value, + )) def __eq__(self, other: object) -> bool: if isinstance(other, Method): @@ -156,54 +147,37 @@ def __repr__(self) -> str: return f'' -class _ToolsSerializationHelper(serializable.helpers.BaseHelper): +class _IdentityToolRepositorySerializationHelper(serializable.helpers.BaseHelper): """ THIS CLASS IS NON-PUBLIC API """ @classmethod - def json_normalize(cls, o: Any, *, - view: Optional[type[serializable.ViewType]], - **__: Any) -> Any: - if isinstance(o, SortedSet): - return [str(t) for t in o] # Convert BomRef to string - return o + def json_serialize(cls, o: Iterable['BomRef']) -> tuple[str]: + return tuple(i.value for i in o) + + @classmethod + def json_deserialize(cls, o: Iterable[str]) -> tuple[BomRef]: + return tuple(BomRef(value=i) for i in o) @classmethod - def xml_normalize(cls, o: Any, *, - element_name: str, - view: Optional[type[serializable.ViewType]], + def xml_normalize(cls, o: Iterable[BomRef], *, xmlns: Optional[str], - **__: Any) -> Optional[Element]: + **kwargs: Any) -> Optional[XmlElement]: + o = tuple(o) if len(o) == 0: return None - - # Create tools element with namespace if provided - tools_elem = Element(f'{{{xmlns}}}tools' if xmlns else 'tools') - for tool in o: - tool_elem = Element(f'{{{xmlns}}}tool' if xmlns else 'tool') - tool_elem.set(f'{{{xmlns}}}ref' if xmlns else 'ref', str(tool)) - tools_elem.append(tool_elem) - return tools_elem - - @classmethod - def json_denormalize(cls, o: Any, **kwargs: Any) -> SortedSet[BomRef]: - if isinstance(o, (list, set, tuple)): - return SortedSet(BomRef(str(t)) for t in o) - return SortedSet() + elem_s = XmlElement(f'{{{xmlns}}}tools' if xmlns else 'tools') + elem_s.extend( + XmlElement( + f'{{{xmlns}}}tool' if xmlns else 'tool', + {'ref': t.value} + ) for t in o if t) + return elem_s @classmethod - def xml_denormalize(cls, o: Element, + def xml_denormalize(cls, o: 'XmlElement', *, default_ns: Optional[str], - **__: Any) -> SortedSet[BomRef]: - repo = [] - tool_tag = f'{{{default_ns}}}tool' if default_ns else 'tool' - ref_attr = f'{{{default_ns}}}ref' if default_ns else 'ref' - for tool_elem in o.findall(f'.//{tool_tag}'): - ref = tool_elem.get(ref_attr) or tool_elem.get('ref') - if ref: - repo.append(BomRef(str(ref))) - else: - raise CycloneDxDeserializationException(f'unexpected: {tool_elem!r}') - return SortedSet(repo) + **__: Any) -> tuple[BomRef]: + return tuple(BomRef(value=t.get('ref')) for t in o) @serializable.serializable_class @@ -212,16 +186,16 @@ class Identity: Our internal representation of the `identityType` complex type. .. note:: - See the CycloneDX Schema definition: hhttps://cyclonedx.org/docs/1.6/json/#components_items_evidence_identity + See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.6/json/#components_items_evidence_identity """ def __init__( self, *, - field: Union[IdentityFieldType, str], # Accept either enum or string + field: IdentityField, confidence: Optional[Decimal] = None, concluded_value: Optional[str] = None, - methods: Optional[Iterable[Method]] = None, # Updated type - tools: Optional[Iterable[Union[str, BomRef]]] = None, + methods: Optional[Iterable[Method]] = None, + tools: Optional[Iterable[BomRef]] = None, ) -> None: self.field = field self.confidence = confidence @@ -230,42 +204,30 @@ def __init__( self.tools = tools or [] # type: ignore[assignment] @property - @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'field') @serializable.xml_sequence(1) - def field(self) -> str: - return self._field.value + def field(self) -> IdentityField: + return self._field @field.setter - def field(self, field: Union[IdentityFieldType, str]) -> None: - if isinstance(field, str): - try: - field = IdentityFieldType(field) - except ValueError: - raise ValueError( - f'Field must be one of: {", ".join(f.value for f in IdentityFieldType)}' - ) + def field(self, field: IdentityField) -> None: self._field = field @property - @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'confidence') @serializable.xml_sequence(2) def confidence(self) -> Optional[Decimal]: """ - Returns the confidence value if set, otherwise None. + The overall confidence of the evidence from 0 - 1, where 1 is 100% confidence. """ return self._confidence @confidence.setter def confidence(self, confidence: Optional[Decimal]) -> None: - """ - Sets the confidence value. Ensures it is between 0 and 1 if provided. - """ - if confidence is not None and not 0 <= confidence <= 1: - raise ValueError('Confidence must be between 0 and 1') + if confidence is not None and not (0 <= confidence <= 1): + raise InvalidConfidenceException(f'{confidence} in invalid') self._confidence = confidence @property - @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'concludedValue') + @serializable.view(SchemaVersion1Dot6) @serializable.xml_sequence(3) def concluded_value(self) -> Optional[str]: return self._concluded_value @@ -277,50 +239,34 @@ def concluded_value(self, concluded_value: Optional[str]) -> None: @property @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'method') @serializable.xml_sequence(4) - def methods(self) -> 'SortedSet[Method]': # Updated return type + def methods(self) -> 'SortedSet[Method]': return self._methods @methods.setter - def methods(self, methods: Iterable[Method]) -> None: # Updated parameter type + def methods(self, methods: Iterable[Method]) -> None: self._methods = SortedSet(methods) @property - @serializable.type_mapping(_ToolsSerializationHelper) + @serializable.type_mapping(_IdentityToolRepositorySerializationHelper) @serializable.xml_sequence(5) def tools(self) -> 'SortedSet[BomRef]': """ References to the tools used to perform analysis and collect evidence. - Can be either a string reference (refLinkType) or a BOM reference (bomLinkType). - All references are stored and serialized as strings. - - Returns: - Set of tool references as BomRef """ return self._tools @tools.setter - def tools(self, tools: Iterable[Union[str, BomRef]]) -> None: - """Convert all inputs to BomRef for consistent storage""" - validated = [] - for t in tools: - ref_str = str(t) - if not (XsUri(ref_str).is_bom_link() or len(ref_str) >= 1): - raise ValueError( - f'Invalid tool reference: {ref_str}. Must be a valid BOM reference or BOM-Link.' - ) - validated.append(BomRef(ref_str)) - self._tools = SortedSet(validated) + def tools(self, tools: Iterable[BomRef]) -> None: + self._tools = SortedSet(tools) def __comparable_tuple(self) -> _ComparableTuple: - return _ComparableTuple( - ( - self.field, - self.confidence, - self.concluded_value, - _ComparableTuple(self.methods), - _ComparableTuple(self.tools), - ) - ) + return _ComparableTuple(( + self.field, + self.confidence, + self.concluded_value, + _ComparableTuple(self.methods), + _ComparableTuple(self.tools), + )) def __eq__(self, other: object) -> bool: if isinstance(other, Identity): @@ -336,7 +282,9 @@ def __hash__(self) -> int: return hash(self.__comparable_tuple()) def __repr__(self) -> str: - return f'' + return f'' @serializable.serializable_class @@ -357,7 +305,7 @@ def __init__( symbol: Optional[str] = None, additional_context: Optional[str] = None, ) -> None: - self.bom_ref = bom_ref # type: ignore[assignment] + self._bom_ref = _bom_ref_from_str(bom_ref) self.location = location self.line = line self.offset = offset @@ -365,30 +313,21 @@ def __init__( self.additional_context = additional_context @property - @serializable.json_name('bom-ref') @serializable.type_mapping(BomRef) - @serializable.xml_attribute() + @serializable.json_name('bom-ref') @serializable.xml_name('bom-ref') - def bom_ref(self) -> Optional[BomRef]: + @serializable.xml_attribute() + def bom_ref(self) -> BomRef: """ - Reference to a component defined in the BOM. + An optional identifier which can be used to reference the requirement elsewhere in the BOM. + Every bom-ref MUST be unique within the BOM. + + Returns: + `BomRef` """ return self._bom_ref - @bom_ref.setter - def bom_ref(self, bom_ref: Optional[Union[str, BomRef]]) -> None: - if bom_ref is None: - self._bom_ref = None - return - bom_ref_str = str(bom_ref) - if len(bom_ref_str) < 1: - raise ValueError('bom_ref must be at least 1 character long') - if XsUri(bom_ref_str).is_bom_link(): - raise ValueError("bom_ref SHOULD NOT start with 'urn:cdx:' to avoid conflicts with BOM-Links") - self._bom_ref = BomRef(bom_ref_str) - @property - @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'location') @serializable.xml_sequence(1) def location(self) -> str: """ @@ -398,12 +337,10 @@ def location(self) -> str: @location.setter def location(self, location: str) -> None: - if location is None: - raise TypeError('location is required and cannot be None') self._location = location @property - @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'line') + @serializable.view(SchemaVersion1Dot6) @serializable.xml_sequence(2) def line(self) -> Optional[int]: """ @@ -413,10 +350,12 @@ def line(self) -> Optional[int]: @line.setter def line(self, line: Optional[int]) -> None: + if line is not None and line < 0: + raise InvalidValueException(f'line {line!r} must not be lower than zero') self._line = line @property - @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'offset') + @serializable.view(SchemaVersion1Dot6) @serializable.xml_sequence(3) def offset(self) -> Optional[int]: """ @@ -426,10 +365,12 @@ def offset(self) -> Optional[int]: @offset.setter def offset(self, offset: Optional[int]) -> None: + if offset is not None and offset < 0: + raise InvalidValueException(f'offset {offset!r} must not be lower than zero') self._offset = offset @property - @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'symbol') + @serializable.view(SchemaVersion1Dot6) @serializable.xml_sequence(4) def symbol(self) -> Optional[str]: """ @@ -442,8 +383,7 @@ def symbol(self, symbol: Optional[str]) -> None: self._symbol = symbol @property - @serializable.json_name('additionalContext') - @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'additionalContext') + @serializable.view(SchemaVersion1Dot6) @serializable.xml_sequence(5) def additional_context(self) -> Optional[str]: """ @@ -456,16 +396,14 @@ def additional_context(self, additional_context: Optional[str]) -> None: self._additional_context = additional_context def __comparable_tuple(self) -> _ComparableTuple: - return _ComparableTuple( - ( - self.bom_ref, - self.location, - self.line, - self.offset, - self.symbol, - self.additional_context, - ) - ) + return _ComparableTuple(( + self.bom_ref, + self.location, + self.line, + self.offset, + self.symbol, + self.additional_context, + )) def __eq__(self, other: object) -> bool: if isinstance(other, Occurrence): @@ -485,7 +423,7 @@ def __repr__(self) -> str: @serializable.serializable_class -class StackFrame: +class CallStackFrame: """ Represents an individual frame in a call stack. @@ -495,8 +433,8 @@ class StackFrame: def __init__( self, *, + module: str, package: Optional[str] = None, - module: str, # module is required function: Optional[str] = None, parameters: Optional[Iterable[str]] = None, line: Optional[int] = None, @@ -512,7 +450,6 @@ def __init__( self.full_filename = full_filename @property - @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'package') @serializable.xml_sequence(1) def package(self) -> Optional[str]: """ @@ -528,7 +465,6 @@ def package(self, package: Optional[str]) -> None: self._package = package @property - @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'module') @serializable.xml_sequence(2) def module(self) -> str: """ @@ -538,12 +474,9 @@ def module(self) -> str: @module.setter def module(self, module: str) -> None: - if module is None: - raise TypeError('module is required and cannot be None') self._module = module @property - @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'function') @serializable.xml_sequence(3) def function(self) -> Optional[str]: """ @@ -559,7 +492,6 @@ def function(self, function: Optional[str]) -> None: self._function = function @property - @serializable.json_name('parameters') @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'parameter') @serializable.xml_sequence(4) def parameters(self) -> 'SortedSet[str]': @@ -573,7 +505,6 @@ def parameters(self, parameters: Iterable[str]) -> None: self._parameters = SortedSet(parameters) @property - @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'line') @serializable.xml_sequence(5) def line(self) -> Optional[int]: """ @@ -586,7 +517,6 @@ def line(self, line: Optional[int]) -> None: self._line = line @property - @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'column') @serializable.xml_sequence(6) def column(self) -> Optional[int]: """ @@ -599,8 +529,6 @@ def column(self, column: Optional[int]) -> None: self._column = column @property - @serializable.json_name('fullFilename') - @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'fullFilename') @serializable.xml_sequence(7) def full_filename(self) -> Optional[str]: """ @@ -613,25 +541,23 @@ def full_filename(self, full_filename: Optional[str]) -> None: self._full_filename = full_filename def __comparable_tuple(self) -> _ComparableTuple: - return _ComparableTuple( - ( - self.package, - self.module, - self.function, - _ComparableTuple(self.parameters), - self.line, - self.column, - self.full_filename, - ) - ) + return _ComparableTuple(( + self.package, + self.module, + self.function, + _ComparableTuple(self.parameters), + self.line, + self.column, + self.full_filename, + )) def __eq__(self, other: object) -> bool: - if isinstance(other, StackFrame): + if isinstance(other, CallStackFrame): return self.__comparable_tuple() == other.__comparable_tuple() return False def __lt__(self, other: Any) -> bool: - if isinstance(other, StackFrame): + if isinstance(other, CallStackFrame): return self.__comparable_tuple() < other.__comparable_tuple() return NotImplemented @@ -639,7 +565,7 @@ def __hash__(self) -> int: return hash(self.__comparable_tuple()) def __repr__(self) -> str: - return f'' + return f'' @serializable.serializable_class @@ -654,28 +580,26 @@ class CallStack: def __init__( self, *, - frames: Optional[Iterable[StackFrame]] = None, + frames: Optional[Iterable[CallStackFrame]] = None, ) -> None: self.frames = frames or [] # type:ignore[assignment] @property @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'frame') - def frames(self) -> 'SortedSet[StackFrame]': + def frames(self) -> 'List[CallStackFrame]': """ Array of stack frames """ return self._frames @frames.setter - def frames(self, frames: Iterable[StackFrame]) -> None: - self._frames = SortedSet(frames) + def frames(self, frames: Iterable[CallStackFrame]) -> None: + self._frames = list(frames) def __comparable_tuple(self) -> _ComparableTuple: - return _ComparableTuple( - ( - _ComparableTuple(self.frames), - ) - ) + return _ComparableTuple(( + _ComparableTuple(self.frames), + )) def __eq__(self, other: object) -> bool: if isinstance(other, CallStack): @@ -688,7 +612,11 @@ def __lt__(self, other: Any) -> bool: return NotImplemented def __hash__(self) -> int: - return hash(self.__comparable_tuple()) + h = self.__comparable_tuple() + try: + return hash(h) + except TypeError as e: + raise e def __repr__(self) -> str: return f'' @@ -707,7 +635,7 @@ class ComponentEvidence: def __init__( self, *, - identity: Optional[Iterable[Identity]] = None, + identity: Optional[Union[Iterable[Identity], Identity]] = None, occurrences: Optional[Iterable[Occurrence]] = None, callstack: Optional[CallStack] = None, licenses: Optional[Iterable[License]] = None, @@ -720,6 +648,7 @@ def __init__( self.copyright = copyright or [] # type:ignore[assignment] @property + @serializable.view(SchemaVersion1Dot5) @serializable.view(SchemaVersion1Dot6) @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'identity') @serializable.xml_sequence(1) @@ -731,11 +660,15 @@ def identity(self) -> 'SortedSet[Identity]': return self._identity @identity.setter - def identity(self, identity: Iterable[Identity]) -> None: - self._identity = SortedSet(identity) + def identity(self, identity: Union[Iterable[Identity], Identity]) -> None: + self._identity = SortedSet( + (Identity,) # convert to iterable + if isinstance(identity, Identity) + else identity # is iterable already + ) @property - # @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot5) @serializable.view(SchemaVersion1Dot6) @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'occurrence') @serializable.xml_sequence(2) @@ -748,7 +681,7 @@ def occurrences(self, occurrences: Iterable[Occurrence]) -> None: self._occurrences = SortedSet(occurrences) @property - # @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot5) @serializable.view(SchemaVersion1Dot6) @serializable.xml_sequence(3) def callstack(self) -> Optional[CallStack]: @@ -794,14 +727,13 @@ def copyright(self, copyright: Iterable[Copyright]) -> None: self._copyright = SortedSet(copyright) def __comparable_tuple(self) -> _ComparableTuple: - return _ComparableTuple( - ( - _ComparableTuple(self.licenses), - _ComparableTuple(self.copyright), - self.callstack, - _ComparableTuple(self.identity), - _ComparableTuple(self.occurrences), - )) + return _ComparableTuple(( + _ComparableTuple(self.licenses), + _ComparableTuple(self.copyright), + self.callstack, + _ComparableTuple(self.identity), + _ComparableTuple(self.occurrences), + )) def __eq__(self, other: object) -> bool: if isinstance(other, ComponentEvidence): diff --git a/tests/_data/models.py b/tests/_data/models.py index ea6319c9..b75eb511 100644 --- a/tests/_data/models.py +++ b/tests/_data/models.py @@ -21,7 +21,7 @@ from datetime import datetime, timezone from decimal import Decimal from inspect import getmembers, isfunction -from typing import Any, Optional +from typing import Any, Optional, Union from uuid import UUID # See https://github.com/package-url/packageurl-python/issues/65 @@ -62,10 +62,10 @@ CallStack, ComponentEvidence, Identity, - IdentityFieldType, + IdentityField, Method, Occurrence, - StackFrame, + CallStackFrame, ) from cyclonedx.model.contact import OrganizationalContact, OrganizationalEntity, PostalAddress from cyclonedx.model.crypto import ( @@ -776,23 +776,24 @@ def get_component_setuptools_complete(include_pedigree: bool = True) -> Componen return component -def get_component_evidence_basic(tools: Iterable[Tool]) -> ComponentEvidence: +def get_component_evidence_basic(tools: Iterable[Component]) -> ComponentEvidence: """ Returns a basic ComponentEvidence object for testing. """ return ComponentEvidence( identity=[ Identity( - field=IdentityFieldType.NAME, + field=IdentityField.NAME, confidence=Decimal('0.9'), concluded_value='example-component', methods=[ Method( technique=AnalysisTechnique.SOURCE_CODE_ANALYSIS, - confidence=Decimal('0.8'), value='analysis-tool' - ) + confidence=Decimal('0.8'), + value='analysis-tool' + ), ], - tools=[tool.bom_ref for tool in tools] + tools=(tool.bom_ref for tool in tools) ) ], occurrences=[ @@ -806,7 +807,7 @@ def get_component_evidence_basic(tools: Iterable[Tool]) -> ComponentEvidence: ], callstack=CallStack( frames=[ - StackFrame( + CallStackFrame( package='example.package', module='example.module', function='example_function', diff --git a/tests/test_model_component_evidence.py b/tests/test_model_component_evidence.py index 846213f3..abb1550f 100644 --- a/tests/test_model_component_evidence.py +++ b/tests/test_model_component_evidence.py @@ -24,11 +24,12 @@ CallStack, ComponentEvidence, Identity, - IdentityFieldType, + IdentityField, Method, Occurrence, - StackFrame, + CallStackFrame, ) +from cyclonedx.exception.model import InvalidConfidenceException class TestModelComponentEvidence(TestCase): @@ -37,15 +38,15 @@ def test_no_params(self) -> None: ComponentEvidence() # Does not raise `NoPropertiesProvidedException` def test_identity(self) -> None: - identity = Identity(field=IdentityFieldType.NAME, confidence=Decimal('1'), concluded_value='test') + identity = Identity(field=IdentityField.NAME, confidence=Decimal('1'), concluded_value='test') ce = ComponentEvidence(identity=[identity]) self.assertEqual(len(ce.identity), 1) self.assertEqual(ce.identity.pop().field, 'name') def test_identity_multiple(self) -> None: identities = [ - Identity(field=IdentityFieldType.NAME, confidence=Decimal('1'), concluded_value='test'), - Identity(field=IdentityFieldType.VERSION, confidence=Decimal('0.8'), concluded_value='1.0.0') + Identity(field=IdentityField.NAME, confidence=Decimal('1'), concluded_value='test'), + Identity(field=IdentityField.VERSION, confidence=Decimal('0.8'), concluded_value='1.0.0') ] ce = ComponentEvidence(identity=identities) self.assertEqual(len(ce.identity), 2) @@ -88,14 +89,9 @@ def test_method_sorting(self) -> None: self.assertEqual(sorted_methods[1].technique, AnalysisTechnique.BINARY_ANALYSIS) self.assertEqual(sorted_methods[2].technique, AnalysisTechnique.SOURCE_CODE_ANALYSIS) - def test_invalid_method_technique(self) -> None: - """Test that invalid technique raises ValueError""" - with self.assertRaises(ValueError): - Method(technique='invalid', confidence=Decimal('0.5')) - def test_invalid_method_confidence(self) -> None: """Test that invalid confidence raises ValueError""" - with self.assertRaises(ValueError): + with self.assertRaises(InvalidConfidenceException): Method(technique=AnalysisTechnique.FILENAME, confidence=Decimal('1.5')) def test_occurrences(self) -> None: @@ -104,9 +100,9 @@ def test_occurrences(self) -> None: self.assertEqual(len(ce.occurrences), 1) self.assertEqual(ce.occurrences.pop().line, 42) - def test_stackframe(self) -> None: - # Test StackFrame with required fields - frame = StackFrame( + def test_CallStackFrame(self) -> None: + # Test CallStackFrame with required fields + frame = CallStackFrame( package='com.example', module='app', function='main', @@ -123,9 +119,9 @@ def test_stackframe(self) -> None: self.assertEqual(frame.column, 10) self.assertEqual(frame.full_filename, '/path/to/file.py') - def test_stackframe_module_required(self) -> None: + def test_CallStackFrame_module_required(self) -> None: """Test that module is the only required field""" - frame = StackFrame(module='app') # Only mandatory field + frame = CallStackFrame(module='app') # Only mandatory field self.assertEqual(frame.module, 'app') self.assertIsNone(frame.package) self.assertIsNone(frame.function) @@ -134,21 +130,8 @@ def test_stackframe_module_required(self) -> None: self.assertIsNone(frame.column) self.assertIsNone(frame.full_filename) - def test_stackframe_without_module(self) -> None: - """Test that omitting module raises TypeError""" - with self.assertRaises(TypeError): - StackFrame() # Should raise TypeError for missing module - - with self.assertRaises(TypeError): - StackFrame(package='com.example') # Should raise TypeError for missing module - - def test_stackframe_with_none_module(self) -> None: - """Test that setting module as None raises TypeError""" - with self.assertRaises(TypeError): - StackFrame(module=None) # Should raise TypeError for None module - def test_callstack(self) -> None: - frame = StackFrame( + frame = CallStackFrame( package='com.example', module='app', function='main' @@ -172,9 +155,9 @@ def test_copyright(self) -> None: def test_full_evidence(self) -> None: # Test with all fields populated - identity = Identity(field=IdentityFieldType.NAME, confidence=Decimal('1'), concluded_value='test') + identity = Identity(field=IdentityField.NAME, confidence=Decimal('1'), concluded_value='test') occurrence = Occurrence(location='/path/to/file', line=42) - frame = StackFrame(module='app', function='main', line=1) + frame = CallStackFrame(module='app', function='main', line=1) stack = CallStack(frames=[frame]) from cyclonedx.model.license import DisjunctiveLicense license = DisjunctiveLicense(id='MIT') @@ -196,10 +179,10 @@ def test_full_evidence(self) -> None: self.assertEqual(len(ce.copyright), 1) def test_full_evidence_with_complete_stack(self) -> None: - identity = Identity(field=IdentityFieldType.NAME, confidence=Decimal('1'), concluded_value='test') + identity = Identity(field=IdentityField.NAME, confidence=Decimal('1'), concluded_value='test') occurrence = Occurrence(location='/path/to/file', line=42) - frame = StackFrame( + frame = CallStackFrame( package='com.example', module='app', function='main', From da433b67bed7310e4d480cd58e3a8bffc29d6f9c Mon Sep 17 00:00:00 2001 From: Jan Kowalleck Date: Tue, 27 May 2025 18:25:19 +0200 Subject: [PATCH 09/24] tests Signed-off-by: Jan Kowalleck --- tests/_data/models.py | 4 +- tests/test_model_component_evidence.py | 67 ++++++++++++++------------ 2 files changed, 37 insertions(+), 34 deletions(-) diff --git a/tests/_data/models.py b/tests/_data/models.py index b75eb511..9d7989e6 100644 --- a/tests/_data/models.py +++ b/tests/_data/models.py @@ -21,7 +21,7 @@ from datetime import datetime, timezone from decimal import Decimal from inspect import getmembers, isfunction -from typing import Any, Optional, Union +from typing import Any, Optional from uuid import UUID # See https://github.com/package-url/packageurl-python/issues/65 @@ -60,12 +60,12 @@ from cyclonedx.model.component_evidence import ( AnalysisTechnique, CallStack, + CallStackFrame, ComponentEvidence, Identity, IdentityField, Method, Occurrence, - CallStackFrame, ) from cyclonedx.model.contact import OrganizationalContact, OrganizationalEntity, PostalAddress from cyclonedx.model.crypto import ( diff --git a/tests/test_model_component_evidence.py b/tests/test_model_component_evidence.py index abb1550f..f4561cbb 100644 --- a/tests/test_model_component_evidence.py +++ b/tests/test_model_component_evidence.py @@ -18,18 +18,18 @@ from decimal import Decimal from unittest import TestCase +from cyclonedx.exception.model import InvalidConfidenceException from cyclonedx.model import Copyright from cyclonedx.model.component_evidence import ( AnalysisTechnique, CallStack, + CallStackFrame, ComponentEvidence, Identity, IdentityField, Method, Occurrence, - CallStackFrame, ) -from cyclonedx.exception.model import InvalidConfidenceException class TestModelComponentEvidence(TestCase): @@ -100,36 +100,6 @@ def test_occurrences(self) -> None: self.assertEqual(len(ce.occurrences), 1) self.assertEqual(ce.occurrences.pop().line, 42) - def test_CallStackFrame(self) -> None: - # Test CallStackFrame with required fields - frame = CallStackFrame( - package='com.example', - module='app', - function='main', - parameters=['arg1', 'arg2'], - line=1, - column=10, - full_filename='/path/to/file.py' - ) - self.assertEqual(frame.package, 'com.example') - self.assertEqual(frame.module, 'app') - self.assertEqual(frame.function, 'main') - self.assertEqual(len(frame.parameters), 2) - self.assertEqual(frame.line, 1) - self.assertEqual(frame.column, 10) - self.assertEqual(frame.full_filename, '/path/to/file.py') - - def test_CallStackFrame_module_required(self) -> None: - """Test that module is the only required field""" - frame = CallStackFrame(module='app') # Only mandatory field - self.assertEqual(frame.module, 'app') - self.assertIsNone(frame.package) - self.assertIsNone(frame.function) - self.assertEqual(len(frame.parameters), 0) - self.assertIsNone(frame.line) - self.assertIsNone(frame.column) - self.assertIsNone(frame.full_filename) - def test_callstack(self) -> None: frame = CallStackFrame( package='com.example', @@ -230,3 +200,36 @@ def test_not_same_1(self) -> None: ce_2 = ComponentEvidence(copyright=[Copyright(text='Commercial 2')]) self.assertNotEqual(hash(ce_1), hash(ce_2)) self.assertFalse(ce_1 == ce_2) + + +class TestModelCallStackFrame(TestCase): + + def test_fields(self) -> None: + # Test CallStackFrame with required fields + frame = CallStackFrame( + package='com.example', + module='app', + function='main', + parameters=['arg1', 'arg2'], + line=1, + column=10, + full_filename='/path/to/file.py' + ) + self.assertEqual(frame.package, 'com.example') + self.assertEqual(frame.module, 'app') + self.assertEqual(frame.function, 'main') + self.assertEqual(len(frame.parameters), 2) + self.assertEqual(frame.line, 1) + self.assertEqual(frame.column, 10) + self.assertEqual(frame.full_filename, '/path/to/file.py') + + def test_module_required(self) -> None: + """Test that module is the only required field""" + frame = CallStackFrame(module='app') # Only mandatory field + self.assertEqual(frame.module, 'app') + self.assertIsNone(frame.package) + self.assertIsNone(frame.function) + self.assertEqual(len(frame.parameters), 0) + self.assertIsNone(frame.line) + self.assertIsNone(frame.column) + self.assertIsNone(frame.full_filename) From 564b3e6a3df4086a6946973c6727d155c269b319 Mon Sep 17 00:00:00 2001 From: Jan Kowalleck Date: Tue, 27 May 2025 19:16:29 +0200 Subject: [PATCH 10/24] wip Signed-off-by: Jan Kowalleck --- cyclonedx/model/component_evidence.py | 83 +++++++++++++++---- tests/_data/models.py | 15 +++- ...t_bom_with_component_evidence-1.5.json.bin | 35 ++++++++ ...t_bom_with_component_evidence-1.6.json.bin | 15 ++++ 4 files changed, 132 insertions(+), 16 deletions(-) diff --git a/cyclonedx/model/component_evidence.py b/cyclonedx/model/component_evidence.py index f0e928a2..2e499bd2 100644 --- a/cyclonedx/model/component_evidence.py +++ b/cyclonedx/model/component_evidence.py @@ -16,17 +16,18 @@ # Copyright (c) OWASP Foundation. All Rights Reserved. -from collections.abc import Iterable, Generator +from collections.abc import Iterable from decimal import Decimal from enum import Enum +from json import loads as json_loads from typing import Any, Optional, Union +from warnings import warn from xml.etree.ElementTree import Element as XmlElement # See https://github.com/package-url/packageurl-python/issues/65 import py_serializable as serializable from sortedcontainers import SortedSet -from ..exception.serialization import SerializationOfUnexpectedValueException from .._internal.bom_ref import bom_ref_from_str as _bom_ref_from_str from .._internal.compare import ComparableTuple as _ComparableTuple from ..exception.model import InvalidConfidenceException, InvalidValueException @@ -151,12 +152,12 @@ class _IdentityToolRepositorySerializationHelper(serializable.helpers.BaseHelper """ THIS CLASS IS NON-PUBLIC API """ @classmethod - def json_serialize(cls, o: Iterable['BomRef']) -> tuple[str]: - return tuple(i.value for i in o) + def json_serialize(cls, o: Iterable['BomRef']) -> tuple[str, ...]: + return tuple(t.value for t in o if t.value) @classmethod - def json_deserialize(cls, o: Iterable[str]) -> tuple[BomRef]: - return tuple(BomRef(value=i) for i in o) + def json_deserialize(cls, o: Iterable[str]) -> tuple[BomRef, ...]: + return tuple(BomRef(value=t) for t in o) @classmethod def xml_normalize(cls, o: Iterable[BomRef], *, @@ -166,17 +167,17 @@ def xml_normalize(cls, o: Iterable[BomRef], *, if len(o) == 0: return None elem_s = XmlElement(f'{{{xmlns}}}tools' if xmlns else 'tools') + tool_name = f'{{{xmlns}}}tool' if xmlns else 'tool' + ref_name = f'{{{xmlns}}}ref' if xmlns else 'ref' elem_s.extend( - XmlElement( - f'{{{xmlns}}}tool' if xmlns else 'tool', - {'ref': t.value} - ) for t in o if t) + XmlElement(tool_name, {ref_name: t.value}) \ + for t in o if t.value) return elem_s @classmethod def xml_denormalize(cls, o: 'XmlElement', *, default_ns: Optional[str], - **__: Any) -> tuple[BomRef]: + **__: Any) -> tuple[BomRef, ...]: return tuple(BomRef(value=t.get('ref')) for t in o) @@ -287,6 +288,58 @@ def __repr__(self) -> str: f' methods={self.methods}, tools={self.tools}>' +class _IdentityRepositorySerializationHelper(serializable.helpers.BaseHelper): + """ THIS CLASS IS NON-PUBLIC API """ + + @classmethod + def json_normalize(cls, o: Iterable[Identity], *, + view: Optional[type['serializable.ViewType']], + **__: Any) -> Optional[Any]: + o = tuple(o) + if l := len(o) == 0: + return None + if view is SchemaVersion1Dot5: + if l >= 1: + warn(f'serialization omitted some identity evidences due to unsupported amount: {o!r}', + category=UserWarning, stacklevel=0) + return json_loads(o[0].as_json(view)) # type:ignore[attr-defined] + return tuple(json_loads(i.as_json(view)) for i in o) # type:ignore[attr-defined] + + @classmethod + def json_deserialize(cls, o: Any) -> tuple[Identity]: + if isinstance(o, list): + return tuple(Identity.from_json(i) for i in o) # type:ignore[attr-defined] + return (Identity.from_json(o),) # type:ignore[attr-defined] + + @classmethod + def xml_normalize(cls, o: Iterable[Identity], *, + element_name: str, + view: Optional[type['serializable.ViewType']], + xmlns: Optional[str], + **__: Any) -> Optional[XmlElement]: + o = tuple(o) + if l := len(o) == 0: + return None + if view is SchemaVersion1Dot5: + if l >= 1: + warn(f'serialization omitted some identity evidences due to unsupported amount: {o!r}', + category=UserWarning, stacklevel=0) + o = (o[0],) + elem_s = XmlElement(f'{{{xmlns}}}' if xmlns else '') + elem_s.extend(i.as_xml( # type:ignore[attr-defined] + view, + as_string=False, + element_name=element_name, xmlns=xmlns + ) for i in o) + return elem_s + + @classmethod + def xml_denormalize(cls, o: 'XmlElement', *, + default_ns: Optional[str], + **__: Any) -> Identity: + return Identity.from_xml(o, default_ns) # type:ignore[attr-defined,no-any-return] + + @serializable.serializable_class class Occurrence: """ @@ -586,7 +639,7 @@ def __init__( @property @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'frame') - def frames(self) -> 'List[CallStackFrame]': + def frames(self) -> 'list[CallStackFrame]': """ Array of stack frames """ @@ -650,7 +703,7 @@ def __init__( @property @serializable.view(SchemaVersion1Dot5) @serializable.view(SchemaVersion1Dot6) - @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'identity') + @serializable.type_mapping(_IdentityRepositorySerializationHelper) @serializable.xml_sequence(1) def identity(self) -> 'SortedSet[Identity]': """ @@ -662,9 +715,9 @@ def identity(self) -> 'SortedSet[Identity]': @identity.setter def identity(self, identity: Union[Iterable[Identity], Identity]) -> None: self._identity = SortedSet( - (Identity,) # convert to iterable + (identity,) if isinstance(identity, Identity) - else identity # is iterable already + else identity ) @property diff --git a/tests/_data/models.py b/tests/_data/models.py index 9d7989e6..8d3a089d 100644 --- a/tests/_data/models.py +++ b/tests/_data/models.py @@ -794,7 +794,20 @@ def get_component_evidence_basic(tools: Iterable[Component]) -> ComponentEvidenc ), ], tools=(tool.bom_ref for tool in tools) - ) + ), + Identity( + field=IdentityField.HASH, + confidence=Decimal('0.1'), + concluded_value='example-hash', + methods=[ + Method( + technique=AnalysisTechnique.ATTESTATION, + confidence=Decimal('0.1'), + value='analysis-tool' + ), + ], + tools=(tool.bom_ref for tool in tools) + ), ], occurrences=[ Occurrence( diff --git a/tests/_data/snapshots/get_bom_with_component_evidence-1.5.json.bin b/tests/_data/snapshots/get_bom_with_component_evidence-1.5.json.bin index ed302a76..927c25de 100644 --- a/tests/_data/snapshots/get_bom_with_component_evidence-1.5.json.bin +++ b/tests/_data/snapshots/get_bom_with_component_evidence-1.5.json.bin @@ -4,6 +4,22 @@ "author": "Test Author", "bom-ref": "pkg:pypi/setuptools@50.3.2?extension=tar.gz", "evidence": { + "callstack": { + "frames": [ + { + "column": 5, + "fullFilename": "path/to/file", + "function": "example_function", + "line": 10, + "module": "example.module", + "package": "example.package", + "parameters": [ + "param1", + "param2" + ] + } + ] + }, "copyright": [ { "text": "Commercial" @@ -12,12 +28,31 @@ "text": "Commercial 2" } ], + "identity": { + "confidence": 0.1, + "field": "hash", + "methods": [ + { + "confidence": 0.1, + "technique": "attestation", + "value": "analysis-tool" + } + ], + "tools": [ + "cbom:generator" + ] + }, "licenses": [ { "license": { "id": "MIT" } } + ], + "occurrences": [ + { + "location": "path/to/file" + } ] }, "licenses": [ diff --git a/tests/_data/snapshots/get_bom_with_component_evidence-1.6.json.bin b/tests/_data/snapshots/get_bom_with_component_evidence-1.6.json.bin index 2d3b716f..ceeb6976 100644 --- a/tests/_data/snapshots/get_bom_with_component_evidence-1.6.json.bin +++ b/tests/_data/snapshots/get_bom_with_component_evidence-1.6.json.bin @@ -29,6 +29,21 @@ } ], "identity": [ + { + "concludedValue": "example-hash", + "confidence": 0.1, + "field": "hash", + "methods": [ + { + "confidence": 0.1, + "technique": "attestation", + "value": "analysis-tool" + } + ], + "tools": [ + "cbom:generator" + ] + }, { "concludedValue": "example-component", "confidence": 0.9, From 23fdd946e2eddd007a74118c2b4b7c6ab114d929 Mon Sep 17 00:00:00 2001 From: Jan Kowalleck Date: Tue, 27 May 2025 19:20:13 +0200 Subject: [PATCH 11/24] wip Signed-off-by: Jan Kowalleck --- cyclonedx/model/component_evidence.py | 64 +++------------------------ 1 file changed, 6 insertions(+), 58 deletions(-) diff --git a/cyclonedx/model/component_evidence.py b/cyclonedx/model/component_evidence.py index 2e499bd2..ecb1fa4e 100644 --- a/cyclonedx/model/component_evidence.py +++ b/cyclonedx/model/component_evidence.py @@ -19,9 +19,7 @@ from collections.abc import Iterable from decimal import Decimal from enum import Enum -from json import loads as json_loads from typing import Any, Optional, Union -from warnings import warn from xml.etree.ElementTree import Element as XmlElement # See https://github.com/package-url/packageurl-python/issues/65 @@ -170,7 +168,7 @@ def xml_normalize(cls, o: Iterable[BomRef], *, tool_name = f'{{{xmlns}}}tool' if xmlns else 'tool' ref_name = f'{{{xmlns}}}ref' if xmlns else 'ref' elem_s.extend( - XmlElement(tool_name, {ref_name: t.value}) \ + XmlElement(tool_name, {ref_name: t.value}) for t in o if t.value) return elem_s @@ -284,60 +282,8 @@ def __hash__(self) -> int: def __repr__(self) -> str: return f'' - - -class _IdentityRepositorySerializationHelper(serializable.helpers.BaseHelper): - """ THIS CLASS IS NON-PUBLIC API """ - - @classmethod - def json_normalize(cls, o: Iterable[Identity], *, - view: Optional[type['serializable.ViewType']], - **__: Any) -> Optional[Any]: - o = tuple(o) - if l := len(o) == 0: - return None - if view is SchemaVersion1Dot5: - if l >= 1: - warn(f'serialization omitted some identity evidences due to unsupported amount: {o!r}', - category=UserWarning, stacklevel=0) - return json_loads(o[0].as_json(view)) # type:ignore[attr-defined] - return tuple(json_loads(i.as_json(view)) for i in o) # type:ignore[attr-defined] - - @classmethod - def json_deserialize(cls, o: Any) -> tuple[Identity]: - if isinstance(o, list): - return tuple(Identity.from_json(i) for i in o) # type:ignore[attr-defined] - return (Identity.from_json(o),) # type:ignore[attr-defined] - - @classmethod - def xml_normalize(cls, o: Iterable[Identity], *, - element_name: str, - view: Optional[type['serializable.ViewType']], - xmlns: Optional[str], - **__: Any) -> Optional[XmlElement]: - o = tuple(o) - if l := len(o) == 0: - return None - if view is SchemaVersion1Dot5: - if l >= 1: - warn(f'serialization omitted some identity evidences due to unsupported amount: {o!r}', - category=UserWarning, stacklevel=0) - o = (o[0],) - elem_s = XmlElement(f'{{{xmlns}}}' if xmlns else '') - elem_s.extend(i.as_xml( # type:ignore[attr-defined] - view, - as_string=False, - element_name=element_name, xmlns=xmlns - ) for i in o) - return elem_s - - @classmethod - def xml_denormalize(cls, o: 'XmlElement', *, - default_ns: Optional[str], - **__: Any) -> Identity: - return Identity.from_xml(o, default_ns) # type:ignore[attr-defined,no-any-return] + f' concludedValue={self.concluded_value},' \ + f' methods={self.methods}, tools={self.tools}>' @serializable.serializable_class @@ -703,8 +649,10 @@ def __init__( @property @serializable.view(SchemaVersion1Dot5) @serializable.view(SchemaVersion1Dot6) - @serializable.type_mapping(_IdentityRepositorySerializationHelper) + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'identity') @serializable.xml_sequence(1) + # TODO: CDX 1.5 knows only one identity, all versions later known multiple ... + # TODO: need to fix the serializatoin/normlaization def identity(self) -> 'SortedSet[Identity]': """ Provides a way to identify components via various methods. From 88bcc203dfa14fd38e3df3baab9844a0156260fc Mon Sep 17 00:00:00 2001 From: Jan Kowalleck Date: Tue, 27 May 2025 19:22:47 +0200 Subject: [PATCH 12/24] wip Signed-off-by: Jan Kowalleck --- .../get_bom_with_component_evidence-1.6.xml.bin | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/_data/snapshots/get_bom_with_component_evidence-1.6.xml.bin b/tests/_data/snapshots/get_bom_with_component_evidence-1.6.xml.bin index f53e6cd0..40dfb764 100644 --- a/tests/_data/snapshots/get_bom_with_component_evidence-1.6.xml.bin +++ b/tests/_data/snapshots/get_bom_with_component_evidence-1.6.xml.bin @@ -30,6 +30,21 @@ pkg:pypi/setuptools@50.3.2?extension=tar.gz + + hash + 0.1 + example-hash + + + attestation + 0.1 + analysis-tool + + + + + + name 0.9 From 9382ad40873c8e5a4e6f433f9a95dfa4ef67d0a4 Mon Sep 17 00:00:00 2001 From: Arun Date: Thu, 29 May 2025 10:53:01 +0530 Subject: [PATCH 13/24] Fix for pipeline failures, improvement for Identity, spec v1.5, v1.6 Signed-off-by: Arun --- cyclonedx/model/component_evidence.py | 44 ++++++++++++++++--- tests/_data/models.py | 13 ------ ...et_bom_with_component_evidence-1.5.xml.bin | 35 +++++++++++++++ ...t_bom_with_component_evidence-1.6.json.bin | 15 ------- ...et_bom_with_component_evidence-1.6.xml.bin | 15 ------- 5 files changed, 72 insertions(+), 50 deletions(-) diff --git a/cyclonedx/model/component_evidence.py b/cyclonedx/model/component_evidence.py index ecb1fa4e..541593e2 100644 --- a/cyclonedx/model/component_evidence.py +++ b/cyclonedx/model/component_evidence.py @@ -19,8 +19,9 @@ from collections.abc import Iterable from decimal import Decimal from enum import Enum +from json import loads as json_loads from typing import Any, Optional, Union -from xml.etree.ElementTree import Element as XmlElement +from xml.etree.ElementTree import Element as XmlElement # nosec B405 # See https://github.com/package-url/packageurl-python/issues/65 import py_serializable as serializable @@ -579,21 +580,22 @@ class CallStack: def __init__( self, *, - frames: Optional[Iterable[CallStackFrame]] = None, + frames: Optional[SortedSet[CallStackFrame]] = None, ) -> None: self.frames = frames or [] # type:ignore[assignment] @property @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'frame') - def frames(self) -> 'list[CallStackFrame]': + @serializable.xml_sequence(1) + def frames(self) -> 'SortedSet[CallStackFrame]': """ Array of stack frames """ return self._frames @frames.setter - def frames(self, frames: Iterable[CallStackFrame]) -> None: - self._frames = list(frames) + def frames(self, frames: SortedSet[CallStackFrame]) -> None: + self._frames = frames def __comparable_tuple(self) -> _ComparableTuple: return _ComparableTuple(( @@ -621,6 +623,33 @@ def __repr__(self) -> str: return f'' +class _IdentitySerializationHelper(serializable.helpers.BaseHelper): + """THIS CLASS IS NON-PUBLIC API""" + + @classmethod + def json_normalize(cls, o: SortedSet[Identity], *, + view: Optional[type[serializable.ViewType]], + **__: Any) -> Any: + if not o: + return None + + # For Schema 1.5 JSON, return first identity as a single object + if view and issubclass(view, SchemaVersion1Dot5): + first_identity = next(iter(o)) + return json_loads(first_identity.as_json(view_=view)) # type: ignore[attr-defined] + + # For Schema 1.6 and others, return array of all identities + return [json_loads(identity.as_json(view_=view)) for identity in o] # type: ignore[attr-defined] + + @classmethod + def json_denormalize(cls, o: Any, **__: Any) -> SortedSet[Identity]: + if isinstance(o, dict): # Single Identity object (Schema 1.5) + return SortedSet([Identity.from_json(o)]) # type: ignore[attr-defined] + elif isinstance(o, (list, tuple)): # Array of Identity objects (Schema 1.6) + return SortedSet(Identity.from_json(i) for i in o) # type: ignore[attr-defined] + return SortedSet() + + @serializable.serializable_class class ComponentEvidence: """ @@ -649,10 +678,11 @@ def __init__( @property @serializable.view(SchemaVersion1Dot5) @serializable.view(SchemaVersion1Dot6) - @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'identity') @serializable.xml_sequence(1) + @serializable.type_mapping(_IdentitySerializationHelper) + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'identity') # TODO: CDX 1.5 knows only one identity, all versions later known multiple ... - # TODO: need to fix the serializatoin/normlaization + # TODO: need to fix the serialization/normalization def identity(self) -> 'SortedSet[Identity]': """ Provides a way to identify components via various methods. diff --git a/tests/_data/models.py b/tests/_data/models.py index 8d3a089d..88b4393d 100644 --- a/tests/_data/models.py +++ b/tests/_data/models.py @@ -782,19 +782,6 @@ def get_component_evidence_basic(tools: Iterable[Component]) -> ComponentEvidenc """ return ComponentEvidence( identity=[ - Identity( - field=IdentityField.NAME, - confidence=Decimal('0.9'), - concluded_value='example-component', - methods=[ - Method( - technique=AnalysisTechnique.SOURCE_CODE_ANALYSIS, - confidence=Decimal('0.8'), - value='analysis-tool' - ), - ], - tools=(tool.bom_ref for tool in tools) - ), Identity( field=IdentityField.HASH, confidence=Decimal('0.1'), diff --git a/tests/_data/snapshots/get_bom_with_component_evidence-1.5.xml.bin b/tests/_data/snapshots/get_bom_with_component_evidence-1.5.xml.bin index 60625ce1..32aa5e81 100644 --- a/tests/_data/snapshots/get_bom_with_component_evidence-1.5.xml.bin +++ b/tests/_data/snapshots/get_bom_with_component_evidence-1.5.xml.bin @@ -30,6 +30,41 @@ pkg:pypi/setuptools@50.3.2?extension=tar.gz + + hash + 0.1 + + + attestation + 0.1 + analysis-tool + + + + + + + + + path/to/file + + + + + + example.package + example.module + example_function + + param1 + param2 + + 10 + 5 + path/to/file + + + MIT diff --git a/tests/_data/snapshots/get_bom_with_component_evidence-1.6.json.bin b/tests/_data/snapshots/get_bom_with_component_evidence-1.6.json.bin index ceeb6976..ec3c6bc1 100644 --- a/tests/_data/snapshots/get_bom_with_component_evidence-1.6.json.bin +++ b/tests/_data/snapshots/get_bom_with_component_evidence-1.6.json.bin @@ -43,21 +43,6 @@ "tools": [ "cbom:generator" ] - }, - { - "concludedValue": "example-component", - "confidence": 0.9, - "field": "name", - "methods": [ - { - "confidence": 0.8, - "technique": "source-code-analysis", - "value": "analysis-tool" - } - ], - "tools": [ - "cbom:generator" - ] } ], "licenses": [ diff --git a/tests/_data/snapshots/get_bom_with_component_evidence-1.6.xml.bin b/tests/_data/snapshots/get_bom_with_component_evidence-1.6.xml.bin index 40dfb764..4b6fad4a 100644 --- a/tests/_data/snapshots/get_bom_with_component_evidence-1.6.xml.bin +++ b/tests/_data/snapshots/get_bom_with_component_evidence-1.6.xml.bin @@ -45,21 +45,6 @@ - - name - 0.9 - example-component - - - source-code-analysis - 0.8 - analysis-tool - - - - - - path/to/file From 1a7b41fa46dc64e722567c676bec1a7129a4b132 Mon Sep 17 00:00:00 2001 From: Jan Kowalleck Date: Sat, 31 May 2025 10:17:08 +0200 Subject: [PATCH 14/24] tests: bring backtest case with multiple evidence identities Signed-off-by: Jan Kowalleck --- tests/_data/models.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/_data/models.py b/tests/_data/models.py index 88b4393d..8d3a089d 100644 --- a/tests/_data/models.py +++ b/tests/_data/models.py @@ -782,6 +782,19 @@ def get_component_evidence_basic(tools: Iterable[Component]) -> ComponentEvidenc """ return ComponentEvidence( identity=[ + Identity( + field=IdentityField.NAME, + confidence=Decimal('0.9'), + concluded_value='example-component', + methods=[ + Method( + technique=AnalysisTechnique.SOURCE_CODE_ANALYSIS, + confidence=Decimal('0.8'), + value='analysis-tool' + ), + ], + tools=(tool.bom_ref for tool in tools) + ), Identity( field=IdentityField.HASH, confidence=Decimal('0.1'), From 95df588b3749dd484ff1a54c30fd7901ad7bf6c0 Mon Sep 17 00:00:00 2001 From: Jan Kowalleck Date: Sat, 31 May 2025 10:22:15 +0200 Subject: [PATCH 15/24] modified `_IdentityToolRepositorySerializationHelper` Signed-off-by: Jan Kowalleck --- cyclonedx/model/component_evidence.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/cyclonedx/model/component_evidence.py b/cyclonedx/model/component_evidence.py index 541593e2..5d2328c6 100644 --- a/cyclonedx/model/component_evidence.py +++ b/cyclonedx/model/component_evidence.py @@ -151,12 +151,12 @@ class _IdentityToolRepositorySerializationHelper(serializable.helpers.BaseHelper """ THIS CLASS IS NON-PUBLIC API """ @classmethod - def json_serialize(cls, o: Iterable['BomRef']) -> tuple[str, ...]: - return tuple(t.value for t in o if t.value) + def json_serialize(cls, o: Iterable['BomRef']) -> list[str]: + return [t.value for t in o if t.value] @classmethod - def json_deserialize(cls, o: Iterable[str]) -> tuple[BomRef, ...]: - return tuple(BomRef(value=t) for t in o) + def json_deserialize(cls, o: Iterable[str]) -> list[BomRef]: + return [BomRef(value=t) for t in o] @classmethod def xml_normalize(cls, o: Iterable[BomRef], *, @@ -176,8 +176,8 @@ def xml_normalize(cls, o: Iterable[BomRef], *, @classmethod def xml_denormalize(cls, o: 'XmlElement', *, default_ns: Optional[str], - **__: Any) -> tuple[BomRef, ...]: - return tuple(BomRef(value=t.get('ref')) for t in o) + **__: Any) -> list[BomRef]: + return [BomRef(value=t.get('ref')) for t in o] @serializable.serializable_class From b66ecc38ae0ee3bce70a9a6b24775b6620a5555c Mon Sep 17 00:00:00 2001 From: Jan Kowalleck Date: Sat, 31 May 2025 10:27:35 +0200 Subject: [PATCH 16/24] reverted callstack frames as lists Signed-off-by: Jan Kowalleck --- cyclonedx/model/component_evidence.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cyclonedx/model/component_evidence.py b/cyclonedx/model/component_evidence.py index 5d2328c6..d2d90cb3 100644 --- a/cyclonedx/model/component_evidence.py +++ b/cyclonedx/model/component_evidence.py @@ -580,22 +580,22 @@ class CallStack: def __init__( self, *, - frames: Optional[SortedSet[CallStackFrame]] = None, + frames: Optional[Iterable[CallStackFrame]] = None, ) -> None: self.frames = frames or [] # type:ignore[assignment] @property @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'frame') @serializable.xml_sequence(1) - def frames(self) -> 'SortedSet[CallStackFrame]': + def frames(self) -> 'list[CallStackFrame]': """ Array of stack frames """ return self._frames @frames.setter - def frames(self, frames: SortedSet[CallStackFrame]) -> None: - self._frames = frames + def frames(self, frames: Iterable[CallStackFrame]) -> None: + self._frames = list(frames) def __comparable_tuple(self) -> _ComparableTuple: return _ComparableTuple(( From 47a1ef4f577082026fc343347c6a63680b4cf3e9 Mon Sep 17 00:00:00 2001 From: Jan Kowalleck Date: Sat, 31 May 2025 10:30:07 +0200 Subject: [PATCH 17/24] clean `_IdentityRepositorySerializationHelper` Signed-off-by: Jan Kowalleck --- cyclonedx/model/component_evidence.py | 22 +++++++++---------- ...t_bom_with_component_evidence-1.6.json.bin | 15 +++++++++++++ ...et_bom_with_component_evidence-1.6.xml.bin | 15 +++++++++++++ 3 files changed, 40 insertions(+), 12 deletions(-) diff --git a/cyclonedx/model/component_evidence.py b/cyclonedx/model/component_evidence.py index d2d90cb3..cc5708f0 100644 --- a/cyclonedx/model/component_evidence.py +++ b/cyclonedx/model/component_evidence.py @@ -623,31 +623,29 @@ def __repr__(self) -> str: return f'' -class _IdentitySerializationHelper(serializable.helpers.BaseHelper): +class _IdentityRepositorySerializationHelper(serializable.helpers.BaseHelper): """THIS CLASS IS NON-PUBLIC API""" @classmethod def json_normalize(cls, o: SortedSet[Identity], *, view: Optional[type[serializable.ViewType]], - **__: Any) -> Any: + **__: Any) -> Union[dict,list[dict],None]: if not o: return None - - # For Schema 1.5 JSON, return first identity as a single object - if view and issubclass(view, SchemaVersion1Dot5): - first_identity = next(iter(o)) + if view and view is SchemaVersion1Dot5: + # For Schema 1.5 JSON, return first identity as a single object + first_identity = o[0] return json_loads(first_identity.as_json(view_=view)) # type: ignore[attr-defined] - # For Schema 1.6 and others, return array of all identities return [json_loads(identity.as_json(view_=view)) for identity in o] # type: ignore[attr-defined] @classmethod - def json_denormalize(cls, o: Any, **__: Any) -> SortedSet[Identity]: + def json_denormalize(cls, o: Any, **__: Any) -> Optional[list[Identity]]: if isinstance(o, dict): # Single Identity object (Schema 1.5) - return SortedSet([Identity.from_json(o)]) # type: ignore[attr-defined] + return [Identity.from_json(o)] # type: ignore[attr-defined] elif isinstance(o, (list, tuple)): # Array of Identity objects (Schema 1.6) - return SortedSet(Identity.from_json(i) for i in o) # type: ignore[attr-defined] - return SortedSet() + return [Identity.from_json(i) for i in o] # type: ignore[attr-defined] + return None @serializable.serializable_class @@ -679,7 +677,7 @@ def __init__( @serializable.view(SchemaVersion1Dot5) @serializable.view(SchemaVersion1Dot6) @serializable.xml_sequence(1) - @serializable.type_mapping(_IdentitySerializationHelper) + @serializable.type_mapping(_IdentityRepositorySerializationHelper) @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'identity') # TODO: CDX 1.5 knows only one identity, all versions later known multiple ... # TODO: need to fix the serialization/normalization diff --git a/tests/_data/snapshots/get_bom_with_component_evidence-1.6.json.bin b/tests/_data/snapshots/get_bom_with_component_evidence-1.6.json.bin index ec3c6bc1..ceeb6976 100644 --- a/tests/_data/snapshots/get_bom_with_component_evidence-1.6.json.bin +++ b/tests/_data/snapshots/get_bom_with_component_evidence-1.6.json.bin @@ -43,6 +43,21 @@ "tools": [ "cbom:generator" ] + }, + { + "concludedValue": "example-component", + "confidence": 0.9, + "field": "name", + "methods": [ + { + "confidence": 0.8, + "technique": "source-code-analysis", + "value": "analysis-tool" + } + ], + "tools": [ + "cbom:generator" + ] } ], "licenses": [ diff --git a/tests/_data/snapshots/get_bom_with_component_evidence-1.6.xml.bin b/tests/_data/snapshots/get_bom_with_component_evidence-1.6.xml.bin index 4b6fad4a..40dfb764 100644 --- a/tests/_data/snapshots/get_bom_with_component_evidence-1.6.xml.bin +++ b/tests/_data/snapshots/get_bom_with_component_evidence-1.6.xml.bin @@ -45,6 +45,21 @@ + + name + 0.9 + example-component + + + source-code-analysis + 0.8 + analysis-tool + + + + + + path/to/file From 5de7307013e552c7368122336a1b269615244711 Mon Sep 17 00:00:00 2001 From: Jan Kowalleck Date: Sat, 31 May 2025 11:21:31 +0200 Subject: [PATCH 18/24] fix component evidence serialization Signed-off-by: Jan Kowalleck --- cyclonedx/model/component.py | 3 +- cyclonedx/model/component_evidence.py | 69 +++++++++++++++++---------- 2 files changed, 45 insertions(+), 27 deletions(-) diff --git a/cyclonedx/model/component.py b/cyclonedx/model/component.py index 12d6be7c..5790db6b 100644 --- a/cyclonedx/model/component.py +++ b/cyclonedx/model/component.py @@ -57,7 +57,7 @@ _HashTypeRepositorySerializationHelper, ) from .bom_ref import BomRef -from .component_evidence import ComponentEvidence +from .component_evidence import ComponentEvidence, _ComponentEvidenceSerializationHelper from .contact import OrganizationalContact, OrganizationalEntity from .crypto import CryptoProperties from .dependency import Dependable @@ -1542,6 +1542,7 @@ def components(self, components: Iterable['Component']) -> None: @serializable.view(SchemaVersion1Dot5) @serializable.view(SchemaVersion1Dot6) @serializable.xml_sequence(24) + @serializable.type_mapping(_ComponentEvidenceSerializationHelper) def evidence(self) -> Optional[ComponentEvidence]: """ Provides the ability to document evidence collected through various forms of extraction or analysis. diff --git a/cyclonedx/model/component_evidence.py b/cyclonedx/model/component_evidence.py index cc5708f0..e63ed9f8 100644 --- a/cyclonedx/model/component_evidence.py +++ b/cyclonedx/model/component_evidence.py @@ -20,7 +20,8 @@ from decimal import Decimal from enum import Enum from json import loads as json_loads -from typing import Any, Optional, Union +from typing import Any, Optional, Union, Type +from warnings import warn from xml.etree.ElementTree import Element as XmlElement # nosec B405 # See https://github.com/package-url/packageurl-python/issues/65 @@ -623,30 +624,6 @@ def __repr__(self) -> str: return f'' -class _IdentityRepositorySerializationHelper(serializable.helpers.BaseHelper): - """THIS CLASS IS NON-PUBLIC API""" - - @classmethod - def json_normalize(cls, o: SortedSet[Identity], *, - view: Optional[type[serializable.ViewType]], - **__: Any) -> Union[dict,list[dict],None]: - if not o: - return None - if view and view is SchemaVersion1Dot5: - # For Schema 1.5 JSON, return first identity as a single object - first_identity = o[0] - return json_loads(first_identity.as_json(view_=view)) # type: ignore[attr-defined] - # For Schema 1.6 and others, return array of all identities - return [json_loads(identity.as_json(view_=view)) for identity in o] # type: ignore[attr-defined] - - @classmethod - def json_denormalize(cls, o: Any, **__: Any) -> Optional[list[Identity]]: - if isinstance(o, dict): # Single Identity object (Schema 1.5) - return [Identity.from_json(o)] # type: ignore[attr-defined] - elif isinstance(o, (list, tuple)): # Array of Identity objects (Schema 1.6) - return [Identity.from_json(i) for i in o] # type: ignore[attr-defined] - return None - @serializable.serializable_class class ComponentEvidence: @@ -677,7 +654,6 @@ def __init__( @serializable.view(SchemaVersion1Dot5) @serializable.view(SchemaVersion1Dot6) @serializable.xml_sequence(1) - @serializable.type_mapping(_IdentityRepositorySerializationHelper) @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'identity') # TODO: CDX 1.5 knows only one identity, all versions later known multiple ... # TODO: need to fix the serialization/normalization @@ -774,3 +750,44 @@ def __hash__(self) -> int: def __repr__(self) -> str: return f'' + +class _ComponentEvidenceSerializationHelper(serializable.helpers.BaseHelper): + """THIS CLASS IS NON-PUBLIC API""" + + @classmethod + def json_normalize(cls, o: ComponentEvidence, *, + view: Optional[type[serializable.ViewType]], + **__: Any) -> Union[dict,list[dict],None]: + data:dict[str, Any] = json_loads( o.as_json(view)) + if view is SchemaVersion1Dot5: + identities = data.get('identity', []) + if il:=len(identities) > 1: + warn(f'CycloneDX 1.5 does not support multiple identity items; dropping {il-1} items.') + data['identity'] = identities[0] + return data + + @classmethod + def json_denormalize(cls, o: dict[str, Any], **__: Any) -> Optional[list[Identity]]: + return ComponentEvidence.from_json(o) + + @classmethod + def xml_normalize(cls, o: ComponentEvidence, *, + element_name: str, + view: Optional[Type['serializable.ViewType']], + xmlns: Optional[str], + **__: Any) -> Optional['XmlElement']: + normalized: 'XmlElement' = o.as_xml(view, False, element_name, xmlns) + if view is SchemaVersion1Dot5: + identities = normalized.findall(f'./{{{xmlns}}}identity' if xmlns else './identity') + if il:=len(identities) > 1: + warn(f'CycloneDX 1.5 does not support multiple identity items; dropping {il-1} items.') + for i in identities[1:]: + normalized.remove(i) + return normalized + + @classmethod + def xml_denormalize(cls, o: 'XmlElement', *, + default_ns: Optional[str], + **__: Any) -> Any: + return ComponentEvidence.from_xml(o, default_ns) + From 0ee583eff791a5966be8037ddf2857dded79edb3 Mon Sep 17 00:00:00 2001 From: Jan Kowalleck Date: Sat, 31 May 2025 11:25:19 +0200 Subject: [PATCH 19/24] tidy Signed-off-by: Jan Kowalleck --- cyclonedx/model/component_evidence.py | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/cyclonedx/model/component_evidence.py b/cyclonedx/model/component_evidence.py index e63ed9f8..25d93e0f 100644 --- a/cyclonedx/model/component_evidence.py +++ b/cyclonedx/model/component_evidence.py @@ -20,7 +20,7 @@ from decimal import Decimal from enum import Enum from json import loads as json_loads -from typing import Any, Optional, Union, Type +from typing import Any, Optional, Type, Union from warnings import warn from xml.etree.ElementTree import Element as XmlElement # nosec B405 @@ -624,7 +624,6 @@ def __repr__(self) -> str: return f'' - @serializable.serializable_class class ComponentEvidence: """ @@ -751,24 +750,25 @@ def __hash__(self) -> int: def __repr__(self) -> str: return f'' + class _ComponentEvidenceSerializationHelper(serializable.helpers.BaseHelper): """THIS CLASS IS NON-PUBLIC API""" @classmethod def json_normalize(cls, o: ComponentEvidence, *, view: Optional[type[serializable.ViewType]], - **__: Any) -> Union[dict,list[dict],None]: - data:dict[str, Any] = json_loads( o.as_json(view)) + **__: Any) -> dict[str, Any]: + data: dict[str, Any] = json_loads(o.as_json(view)) # type:ignore[attr-defined] if view is SchemaVersion1Dot5: identities = data.get('identity', []) - if il:=len(identities) > 1: - warn(f'CycloneDX 1.5 does not support multiple identity items; dropping {il-1} items.') + if il := len(identities) > 1: + warn(f'CycloneDX 1.5 does not support multiple identity items; dropping {il - 1} items.') data['identity'] = identities[0] return data @classmethod - def json_denormalize(cls, o: dict[str, Any], **__: Any) -> Optional[list[Identity]]: - return ComponentEvidence.from_json(o) + def json_denormalize(cls, o: dict[str, Any], **__: Any) -> Any: + return ComponentEvidence.from_json(o) # type:ignore[attr-defined] @classmethod def xml_normalize(cls, o: ComponentEvidence, *, @@ -776,11 +776,11 @@ def xml_normalize(cls, o: ComponentEvidence, *, view: Optional[Type['serializable.ViewType']], xmlns: Optional[str], **__: Any) -> Optional['XmlElement']: - normalized: 'XmlElement' = o.as_xml(view, False, element_name, xmlns) + normalized: 'XmlElement' = o.as_xml(view, False, element_name, xmlns) # type:ignore[attr-defined] if view is SchemaVersion1Dot5: identities = normalized.findall(f'./{{{xmlns}}}identity' if xmlns else './identity') - if il:=len(identities) > 1: - warn(f'CycloneDX 1.5 does not support multiple identity items; dropping {il-1} items.') + if il := len(identities) > 1: + warn(f'CycloneDX 1.5 does not support multiple identity items; dropping {il - 1} items.') for i in identities[1:]: normalized.remove(i) return normalized @@ -789,5 +789,4 @@ def xml_normalize(cls, o: ComponentEvidence, *, def xml_denormalize(cls, o: 'XmlElement', *, default_ns: Optional[str], **__: Any) -> Any: - return ComponentEvidence.from_xml(o, default_ns) - + return ComponentEvidence.from_xml(o, default_ns) # type:ignore[attr-defined] From 78fd4dbf304d0568228b481379b0c6d7cf5d85f6 Mon Sep 17 00:00:00 2001 From: Jan Kowalleck Date: Sat, 31 May 2025 11:35:57 +0200 Subject: [PATCH 20/24] cleanup Signed-off-by: Jan Kowalleck --- cyclonedx/model/component_evidence.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/cyclonedx/model/component_evidence.py b/cyclonedx/model/component_evidence.py index 25d93e0f..4cf6bf82 100644 --- a/cyclonedx/model/component_evidence.py +++ b/cyclonedx/model/component_evidence.py @@ -557,16 +557,14 @@ def __eq__(self, other: object) -> bool: return self.__comparable_tuple() == other.__comparable_tuple() return False - def __lt__(self, other: Any) -> bool: - if isinstance(other, CallStackFrame): - return self.__comparable_tuple() < other.__comparable_tuple() - return NotImplemented - def __hash__(self) -> int: return hash(self.__comparable_tuple()) def __repr__(self) -> str: - return f'' + return '' @serializable.serializable_class From 7aef6e153440946b6abbf49b968557da9fa7ea3e Mon Sep 17 00:00:00 2001 From: Jan Kowalleck Date: Sat, 31 May 2025 11:44:48 +0200 Subject: [PATCH 21/24] typing for serialization lib Signed-off-by: Jan Kowalleck --- cyclonedx/model/component_evidence.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cyclonedx/model/component_evidence.py b/cyclonedx/model/component_evidence.py index 4cf6bf82..c0a03388 100644 --- a/cyclonedx/model/component_evidence.py +++ b/cyclonedx/model/component_evidence.py @@ -20,7 +20,7 @@ from decimal import Decimal from enum import Enum from json import loads as json_loads -from typing import Any, Optional, Type, Union +from typing import Any, List, Optional, Type, Union from warnings import warn from xml.etree.ElementTree import Element as XmlElement # nosec B405 @@ -586,7 +586,7 @@ def __init__( @property @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'frame') @serializable.xml_sequence(1) - def frames(self) -> 'list[CallStackFrame]': + def frames(self) -> 'List[CallStackFrame]': """ Array of stack frames """ From de6d628af3f60397f5c7284ff174eeeffbc7a0d5 Mon Sep 17 00:00:00 2001 From: Jan Kowalleck Date: Sat, 31 May 2025 12:01:14 +0200 Subject: [PATCH 22/24] remove TODO Signed-off-by: Jan Kowalleck --- cyclonedx/model/component_evidence.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/cyclonedx/model/component_evidence.py b/cyclonedx/model/component_evidence.py index c0a03388..ae93409b 100644 --- a/cyclonedx/model/component_evidence.py +++ b/cyclonedx/model/component_evidence.py @@ -652,8 +652,6 @@ def __init__( @serializable.view(SchemaVersion1Dot6) @serializable.xml_sequence(1) @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'identity') - # TODO: CDX 1.5 knows only one identity, all versions later known multiple ... - # TODO: need to fix the serialization/normalization def identity(self) -> 'SortedSet[Identity]': """ Provides a way to identify components via various methods. From c4a021827537b010fb6fc293d08422608394a454 Mon Sep 17 00:00:00 2001 From: Jan Kowalleck Date: Mon, 2 Jun 2025 07:20:41 +0200 Subject: [PATCH 23/24] style: remove no longer used typehint-ignores Signed-off-by: Jan Kowalleck --- cyclonedx/model/bom.py | 2 +- cyclonedx/model/component.py | 30 +++++++++++++-------------- cyclonedx/model/component_evidence.py | 16 +++++++------- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/cyclonedx/model/bom.py b/cyclonedx/model/bom.py index 4152f83c..669f0f74 100644 --- a/cyclonedx/model/bom.py +++ b/cyclonedx/model/bom.py @@ -716,7 +716,7 @@ def validate(self) -> bool: # 3. If a LicenseExpression is set, then there must be no other license. # see https://github.com/CycloneDX/specification/pull/205 elem: Union[BomMetaData, Component, Service] - for elem in chain( # type: ignore[assignment] + for elem in chain( # type:ignore[assignment] [self.metadata], self.metadata.component.get_all_nested_components(include_self=True) if self.metadata.component else [], chain.from_iterable(c.get_all_nested_components(include_self=True) for c in self.components), diff --git a/cyclonedx/model/component.py b/cyclonedx/model/component.py index 5790db6b..b0e24005 100644 --- a/cyclonedx/model/component.py +++ b/cyclonedx/model/component.py @@ -425,7 +425,7 @@ def __init__( ) -> None: self.type = type self.diff = diff - self.resolves = resolves or [] # type:ignore[assignment] + self.resolves = resolves or [] @property @serializable.xml_attribute() @@ -521,11 +521,11 @@ def __init__( patches: Optional[Iterable[Patch]] = None, notes: Optional[str] = None, ) -> None: - self.ancestors = ancestors or [] # type:ignore[assignment] - self.descendants = descendants or [] # type:ignore[assignment] - self.variants = variants or [] # type:ignore[assignment] - self.commits = commits or [] # type:ignore[assignment] - self.patches = patches or [] # type:ignore[assignment] + self.ancestors = ancestors or [] + self.descendants = descendants or [] + self.variants = variants or [] + self.commits = commits or [] + self.patches = patches or [] self.notes = notes @property @@ -1009,7 +1009,7 @@ def __init__( self._bom_ref = _bom_ref_from_str(bom_ref) self.supplier = supplier self.manufacturer = manufacturer - self.authors = authors or [] # type:ignore[assignment] + self.authors = authors or [] self.author = author self.publisher = publisher self.group = group @@ -1017,23 +1017,23 @@ def __init__( self.version = version self.description = description self.scope = scope - self.hashes = hashes or [] # type:ignore[assignment] - self.licenses = licenses or [] # type:ignore[assignment] + self.hashes = hashes or [] + self.licenses = licenses or [] self.copyright = copyright self.cpe = cpe self.purl = purl - self.omnibor_ids = omnibor_ids or [] # type:ignore[assignment] - self.swhids = swhids or [] # type:ignore[assignment] + self.omnibor_ids = omnibor_ids or [] + self.swhids = swhids or [] self.swid = swid self.modified = modified self.pedigree = pedigree - self.external_references = external_references or [] # type:ignore[assignment] - self.properties = properties or [] # type:ignore[assignment] - self.components = components or [] # type:ignore[assignment] + self.external_references = external_references or [] + self.properties = properties or [] + self.components = components or [] self.evidence = evidence self.release_notes = release_notes self.crypto_properties = crypto_properties - self.tags = tags or [] # type:ignore[assignment] + self.tags = tags or [] if modified: warn('`.component.modified` is deprecated from CycloneDX v1.3 onwards. ' diff --git a/cyclonedx/model/component_evidence.py b/cyclonedx/model/component_evidence.py index ae93409b..ae1ada8f 100644 --- a/cyclonedx/model/component_evidence.py +++ b/cyclonedx/model/component_evidence.py @@ -201,8 +201,8 @@ def __init__( self.field = field self.confidence = confidence self.concluded_value = concluded_value - self.methods = methods or [] # type: ignore[assignment] - self.tools = tools or [] # type: ignore[assignment] + self.methods = methods or [] + self.tools = tools or [] @property @serializable.xml_sequence(1) @@ -445,7 +445,7 @@ def __init__( self.package = package self.module = module self.function = function - self.parameters = parameters or [] # type: ignore[assignment] + self.parameters = parameters or [] self.line = line self.column = column self.full_filename = full_filename @@ -581,7 +581,7 @@ def __init__( self, *, frames: Optional[Iterable[CallStackFrame]] = None, ) -> None: - self.frames = frames or [] # type:ignore[assignment] + self.frames = frames or [] @property @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'frame') @@ -641,11 +641,11 @@ def __init__( licenses: Optional[Iterable[License]] = None, copyright: Optional[Iterable[Copyright]] = None, ) -> None: - self.identity = identity or [] # type:ignore[assignment] - self.occurrences = occurrences or [] # type:ignore[assignment] + self.identity = identity or [] + self.occurrences = occurrences or [] self.callstack = callstack - self.licenses = licenses or [] # type:ignore[assignment] - self.copyright = copyright or [] # type:ignore[assignment] + self.licenses = licenses or [] + self.copyright = copyright or [] @property @serializable.view(SchemaVersion1Dot5) From 8aac64397e93c23b964c83c00d62286c661376d1 Mon Sep 17 00:00:00 2001 From: Jan Kowalleck Date: Thu, 5 Jun 2025 13:01:42 +0200 Subject: [PATCH 24/24] style: upgrade code style Signed-off-by: Jan Kowalleck --- cyclonedx/model/component_evidence.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cyclonedx/model/component_evidence.py b/cyclonedx/model/component_evidence.py index ae1ada8f..2b5f1240 100644 --- a/cyclonedx/model/component_evidence.py +++ b/cyclonedx/model/component_evidence.py @@ -20,7 +20,7 @@ from decimal import Decimal from enum import Enum from json import loads as json_loads -from typing import Any, List, Optional, Type, Union +from typing import Any, List, Optional, Union from warnings import warn from xml.etree.ElementTree import Element as XmlElement # nosec B405 @@ -769,7 +769,7 @@ def json_denormalize(cls, o: dict[str, Any], **__: Any) -> Any: @classmethod def xml_normalize(cls, o: ComponentEvidence, *, element_name: str, - view: Optional[Type['serializable.ViewType']], + view: Optional[type['serializable.ViewType']], xmlns: Optional[str], **__: Any) -> Optional['XmlElement']: normalized: 'XmlElement' = o.as_xml(view, False, element_name, xmlns) # type:ignore[attr-defined]