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.py b/cyclonedx/model/component.py index 88d5a292..b0e24005 100644 --- a/cyclonedx/model/component.py +++ b/cyclonedx/model/component.py @@ -48,7 +48,6 @@ from ..serialization import PackageUrl as PackageUrlSH from . import ( AttachedText, - Copyright, ExternalReference, HashAlgorithm, HashType, @@ -58,6 +57,7 @@ _HashTypeRepositorySerializationHelper, ) from .bom_ref import BomRef +from .component_evidence import ComponentEvidence, _ComponentEvidenceSerializationHelper from .contact import OrganizationalContact, OrganizationalEntity from .crypto import CryptoProperties from .dependency import Dependable @@ -191,108 +191,6 @@ 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, *, - licenses: Optional[Iterable[License]] = None, - copyright: Optional[Iterable[Copyright]] = None, - ) -> None: - self.licenses = licenses or [] - self.copyright = copyright or [] - - # @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(SchemaVersion1Dot5) - # @serializable.xml_sequence(2) - # def occurrences(self) -> ...: - # ... # TODO since CDX1.5 - # - # @occurrences.setter - # def occurrences(self, ...) -> None: - # ... # TODO since CDX1.5 - - # @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 - - @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), - )) - - 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): """ @@ -1644,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 new file mode 100644 index 00000000..2b5f1240 --- /dev/null +++ b/cyclonedx/model/component_evidence.py @@ -0,0 +1,788 @@ +# 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. + + +from collections.abc import Iterable +from decimal import Decimal +from enum import Enum +from json import loads as json_loads +from typing import Any, List, Optional, Union +from warnings import warn +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 +from sortedcontainers import SortedSet + +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 +from ..schema.schema import SchemaVersion1Dot5, SchemaVersion1Dot6 +from . import Copyright +from .bom_ref import BomRef +from .license import License, LicenseRepository, _LicenseRepositorySerializationHelper + + +@serializable.serializable_enum +class IdentityField(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: AnalysisTechnique, + confidence: Decimal, + value: Optional[str] = None, + ) -> None: + self.technique = technique + self.confidence = confidence + self.value = value + + @property + @serializable.xml_sequence(1) + def technique(self) -> AnalysisTechnique: + return self._technique + + @technique.setter + def technique(self, technique: AnalysisTechnique) -> None: + self._technique = technique + + @property + @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 InvalidConfidenceException(f'confidence {confidence!r} is invalid') + self._confidence = confidence + + @property + @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 _IdentityToolRepositorySerializationHelper(serializable.helpers.BaseHelper): + """ THIS CLASS IS NON-PUBLIC API """ + + @classmethod + 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]) -> list[BomRef]: + return [BomRef(value=t) for t in o] + + @classmethod + def xml_normalize(cls, o: Iterable[BomRef], *, + xmlns: Optional[str], + **kwargs: Any) -> Optional[XmlElement]: + o = tuple(o) + 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(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) -> list[BomRef]: + return [BomRef(value=t.get('ref')) for t in o] + + +@serializable.serializable_class +class Identity: + """ + Our internal representation of the `identityType` complex type. + + .. note:: + See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.6/json/#components_items_evidence_identity + """ + + def __init__( + self, *, + field: IdentityField, + confidence: Optional[Decimal] = None, + concluded_value: Optional[str] = None, + methods: Optional[Iterable[Method]] = None, + tools: Optional[Iterable[BomRef]] = None, + ) -> None: + self.field = field + self.confidence = confidence + self.concluded_value = concluded_value + self.methods = methods or [] + self.tools = tools or [] + + @property + @serializable.xml_sequence(1) + def field(self) -> IdentityField: + return self._field + + @field.setter + def field(self, field: IdentityField) -> None: + self._field = field + + @property + @serializable.xml_sequence(2) + def confidence(self) -> Optional[Decimal]: + """ + 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: + if confidence is not None and not (0 <= confidence <= 1): + raise InvalidConfidenceException(f'{confidence} in invalid') + self._confidence = confidence + + @property + @serializable.view(SchemaVersion1Dot6) + @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]': + return self._methods + + @methods.setter + def methods(self, methods: Iterable[Method]) -> None: + self._methods = SortedSet(methods) + + @property + @serializable.type_mapping(_IdentityToolRepositorySerializationHelper) + @serializable.xml_sequence(5) + def tools(self) -> 'SortedSet[BomRef]': + """ + References to the tools used to perform analysis and collect evidence. + """ + return self._tools + + @tools.setter + 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), + )) + + 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_from_str(bom_ref) + self.location = location + self.line = line + self.offset = offset + self.symbol = symbol + self.additional_context = additional_context + + @property + @serializable.type_mapping(BomRef) + @serializable.json_name('bom-ref') + @serializable.xml_name('bom-ref') + @serializable.xml_attribute() + def bom_ref(self) -> BomRef: + """ + 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 + + @property + @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: + self._location = location + + @property + @serializable.view(SchemaVersion1Dot6) + @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: + 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.view(SchemaVersion1Dot6) + @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: + 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.view(SchemaVersion1Dot6) + @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.view(SchemaVersion1Dot6) + @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 CallStackFrame: + """ + 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, *, + module: str, + package: Optional[str] = None, + 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 [] + self.line = line + self.column = column + self.full_filename = full_filename + + @property + @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_sequence(2) + def module(self) -> str: + """ + The module name + """ + return self._module + + @module.setter + def module(self, module: str) -> None: + self._module = module + + @property + @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.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_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_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.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, CallStackFrame): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return '' + + +@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[CallStackFrame]] = None, + ) -> None: + self.frames = frames or [] + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'frame') + @serializable.xml_sequence(1) + def frames(self) -> 'List[CallStackFrame]': + """ + Array of stack frames + """ + return self._frames + + @frames.setter + def frames(self, frames: Iterable[CallStackFrame]) -> None: + self._frames = list(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: + h = self.__comparable_tuple() + try: + return hash(h) + except TypeError as e: + raise e + + 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[Union[Iterable[Identity], 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 [] + self.occurrences = occurrences or [] + self.callstack = callstack + self.licenses = licenses or [] + self.copyright = copyright or [] + + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.xml_sequence(1) + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'identity') + 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: Union[Iterable[Identity], Identity]) -> None: + self._identity = SortedSet( + (identity,) + if isinstance(identity, Identity) + else 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'' + + +class _ComponentEvidenceSerializationHelper(serializable.helpers.BaseHelper): + """THIS CLASS IS NON-PUBLIC API""" + + @classmethod + def json_normalize(cls, o: ComponentEvidence, *, + view: Optional[type[serializable.ViewType]], + **__: 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.') + data['identity'] = identities[0] + return data + + @classmethod + 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, *, + element_name: str, + 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] + 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) # type:ignore[attr-defined] diff --git a/tests/_data/models.py b/tests/_data/models.py index 24ec42eb..8d3a089d 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 @@ -46,7 +47,6 @@ from cyclonedx.model.component import ( Commit, Component, - ComponentEvidence, ComponentScope, ComponentType, Diff, @@ -57,6 +57,16 @@ Swhid, Swid, ) +from cyclonedx.model.component_evidence import ( + AnalysisTechnique, + CallStack, + CallStackFrame, + ComponentEvidence, + Identity, + IdentityField, + Method, + Occurrence, +) from cyclonedx.model.contact import OrganizationalContact, OrganizationalEntity, PostalAddress from cyclonedx.model.crypto import ( AlgorithmProperties, @@ -455,6 +465,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 +776,68 @@ def get_component_setuptools_complete(include_pedigree: bool = True) -> Componen return component +def get_component_evidence_basic(tools: Iterable[Component]) -> ComponentEvidence: + """ + Returns a basic ComponentEvidence object for testing. + """ + 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'), + 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( + location='path/to/file', + line=42, + offset=16, + symbol='exampleSymbol', + additional_context='Found in source code', + ) + ], + callstack=CallStack( + frames=[ + CallStackFrame( + 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..927c25de --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_component_evidence-1.5.json.bin @@ -0,0 +1,121 @@ +{ + "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": { + "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": [ + { + "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..32aa5e81 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_component_evidence-1.5.xml.bin @@ -0,0 +1,90 @@ + + + + 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 + + + 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 + + + + 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..ceeb6976 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_component_evidence-1.6.json.bin @@ -0,0 +1,143 @@ +{ + "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-hash", + "confidence": 0.1, + "field": "hash", + "methods": [ + { + "confidence": 0.1, + "technique": "attestation", + "value": "analysis-tool" + } + ], + "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": [ + { + "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..40dfb764 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_component_evidence-1.6.xml.bin @@ -0,0 +1,110 @@ + + + + 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 + + + hash + 0.1 + example-hash + + + attestation + 0.1 + analysis-tool + + + + + + + + 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..bc18a10d 100644 --- a/tests/test_model_component.py +++ b/tests/test_model_component.py @@ -20,7 +20,6 @@ from cyclonedx.model import ( AttachedText, - Copyright, Encoding, ExternalReference, ExternalReferenceType, @@ -28,16 +27,7 @@ Property, XsUri, ) -from cyclonedx.model.component import ( - Commit, - Component, - ComponentEvidence, - ComponentType, - Diff, - Patch, - PatchClassification, - Pedigree, -) +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 ( @@ -285,30 +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_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 new file mode 100644 index 00000000..f4561cbb --- /dev/null +++ b/tests/test_model_component_evidence.py @@ -0,0 +1,235 @@ +# 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. + +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, +) + + +class TestModelComponentEvidence(TestCase): + + def test_no_params(self) -> None: + ComponentEvidence() # Does not raise `NoPropertiesProvidedException` + + def test_identity(self) -> None: + 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=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) + + 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_confidence(self) -> None: + """Test that invalid confidence raises ValueError""" + with self.assertRaises(InvalidConfidenceException): + 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_callstack(self) -> None: + frame = CallStackFrame( + 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=IdentityField.NAME, confidence=Decimal('1'), concluded_value='test') + occurrence = Occurrence(location='/path/to/file', line=42) + frame = CallStackFrame(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=IdentityField.NAME, confidence=Decimal('1'), concluded_value='test') + occurrence = Occurrence(location='/path/to/file', line=42) + + frame = CallStackFrame( + 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 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)