From 351c378295ec159f2cb179f49824f343ddcafa7e Mon Sep 17 00:00:00 2001 From: Elton Chang <80650461+Notle-eltoN@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:49:45 -0700 Subject: [PATCH] feat: gather component identity evidence Add opt-in manifest analysis for declared component names and versions across all four builders and root project metadata. Preserve default output and existing license evidence. Fixes #829 Signed-off-by: Elton Chang <80650461+Notle-eltoN@users.noreply.github.com> --- cyclonedx_py/_internal/cli.py | 5 + cyclonedx_py/_internal/environment.py | 7 + cyclonedx_py/_internal/pipenv.py | 12 +- cyclonedx_py/_internal/poetry.py | 9 +- cyclonedx_py/_internal/requirements.py | 9 +- cyclonedx_py/_internal/utils/identity.py | 43 ++++++ cyclonedx_py/_internal/utils/poetry.py | 7 +- cyclonedx_py/_internal/utils/pyproject.py | 13 +- docs/usage.rst | 35 +++++ pyproject.toml | 2 +- tests/integration/test_identity_evidence.py | 156 ++++++++++++++++++++ tests/unit/test_utils_identity.py | 116 +++++++++++++++ 12 files changed, 406 insertions(+), 8 deletions(-) create mode 100644 cyclonedx_py/_internal/utils/identity.py create mode 100644 tests/integration/test_identity_evidence.py create mode 100644 tests/unit/test_utils_identity.py diff --git a/cyclonedx_py/_internal/cli.py b/cyclonedx_py/_internal/cli.py index c7b4a983..e153fd05 100644 --- a/cyclonedx_py/_internal/cli.py +++ b/cyclonedx_py/_internal/cli.py @@ -66,6 +66,11 @@ def make_argument_parser(cls, sco: ArgumentParser, **kwargs: Any) -> ArgumentPar # region SubCOmmand op = ArgumentParser(add_help=False) + op.add_argument('--gather-identity-evidence', + help='Record the metadata sources of component names and versions.', + action='store_true', + dest='gather_identity_evidence', + default=False) op.add_argument('--short-PURLs', help='Omit all qualifiers from PackageURLs.\n' 'This causes information loss in trade-off shorter PURLs, ' diff --git a/cyclonedx_py/_internal/environment.py b/cyclonedx_py/_internal/environment.py index b0eb7f9e..ec3a4338 100644 --- a/cyclonedx_py/_internal/environment.py +++ b/cyclonedx_py/_internal/environment.py @@ -36,6 +36,7 @@ from . import BomBuilder, PropertyName, PurlTypePypi from .cli_common import add_argument_mc_type, add_argument_pyproject from .utils.cdx import licenses_fixup, make_bom +from .utils.identity import make_identity_evidence from .utils.packaging import metadata2extrefs, metadata2licenses, normalize_packagename from .utils.pep610 import PackageSourceArchive, PackageSourceVcs, packagesource2extref, packagesource4dist from .utils.pep639 import dist2licenses_from_files as pep639_dist2licenses_from_files @@ -137,9 +138,11 @@ def make_argument_parser(**kwargs: Any) -> 'ArgumentParser': def __init__(self, *, logger: 'Logger', gather_license_texts: bool, + gather_identity_evidence: bool = False, **__: Any) -> None: self._logger = logger self._gather_license_texts = gather_license_texts + self._gather_identity_evidence = gather_identity_evidence def __call__(self, *, # type:ignore[override] import_site: bool, @@ -154,6 +157,7 @@ def __call__(self, *, # type:ignore[override] root_c = pyproject2component(pyproject, ctype=mc_type, fpath=pyproject_file, gather_license_texts=self._gather_license_texts, + gather_identity_evidence=self._gather_identity_evidence, logger=self._logger) root_c.bom_ref.value = 'root-component' root_d = tuple(pyproject2dependencies(pyproject)) @@ -186,6 +190,9 @@ def __add_components(self, bom: 'Bom', bom_ref=f'{dist_name}=={dist_version}', name=dist_name, version=dist_version, + evidence=make_identity_evidence( + name=dist_name, version=dist_version, source='Python distribution metadata', + ) if self._gather_identity_evidence else None, description=dist_meta['Summary'] if 'Summary' in dist_meta else None, external_references=metadata2extrefs(dist_meta), # path of dist-package on disc? naaa... a package may have multiple files/folders on disc diff --git a/cyclonedx_py/_internal/pipenv.py b/cyclonedx_py/_internal/pipenv.py index 0c1d5997..2437fe73 100644 --- a/cyclonedx_py/_internal/pipenv.py +++ b/cyclonedx_py/_internal/pipenv.py @@ -33,6 +33,7 @@ from .cli_common import add_argument_mc_type, add_argument_pyproject from .utils.args import arparse_split from .utils.cdx import make_bom +from .utils.identity import make_identity_evidence from .utils.packaging import normalize_packagename from .utils.pyproject import pyproject_file2component from .utils.secret import redact_auth_from_url @@ -92,9 +93,11 @@ def make_argument_parser(**kwargs: Any) -> 'ArgumentParser': def __init__(self, *, logger: 'Logger', pypi_url: Optional[str], + gather_identity_evidence: bool = False, **__: Any) -> None: self._logger = logger self._pypi_url = pypi_url or None # ignore empty strings + self._gather_identity_evidence = gather_identity_evidence def __call__(self, *, # type:ignore[override] project_directory: str, @@ -133,7 +136,8 @@ def __call__(self, *, # type:ignore[override] rc = None else: rc = pyproject_file2component(pyproject_file, ctype=mc_type, - gather_license_texts=False, logger=self._logger) + gather_license_texts=False, logger=self._logger, + gather_identity_evidence=self._gather_identity_evidence) rc.bom_ref.value = 'root-component' return self._make_bom(rc, @@ -173,6 +177,12 @@ def _make_bom(self, root_c: Optional['Component'], type=ComponentType.LIBRARY, name=package_name, version=package_data['version'][2:] if 'version' in package_data else None, + evidence=make_identity_evidence( + name=package_name, + version=package_data['version'][2:] + if package_data.get('version', '').startswith('==') else None, + source='Pipfile.lock', + ) if self._gather_identity_evidence else None, external_references=self.__make_extrefs(package_name, package_data, source_urls), ) component.purl = PackageURL( diff --git a/cyclonedx_py/_internal/poetry.py b/cyclonedx_py/_internal/poetry.py index d3eaa493..83980268 100644 --- a/cyclonedx_py/_internal/poetry.py +++ b/cyclonedx_py/_internal/poetry.py @@ -34,6 +34,7 @@ from . import BomBuilder, PropertyName, PurlTypePypi from .cli_common import add_argument_mc_type from .utils.cdx import make_bom +from .utils.identity import make_identity_evidence from .utils.packaging import normalize_packagename from .utils.poetry import poetry2component from .utils.secret import redact_auth_from_url @@ -159,8 +160,10 @@ def make_argument_parser(**kwargs: Any) -> 'ArgumentParser': def __init__(self, *, logger: 'Logger', + gather_identity_evidence: bool = False, **__: Any) -> None: self._logger = logger + self._gather_identity_evidence = gather_identity_evidence def __call__(self, *, # type:ignore[override] project_directory: str, @@ -258,7 +261,8 @@ def _make_bom(self, project: 'T_NameDict', locker: 'T_NameDict', po_cfg = project['tool']['poetry'] - bom.metadata.component = root_c = poetry2component(po_cfg, ctype=mc_type) + bom.metadata.component = root_c = poetry2component( + po_cfg, ctype=mc_type, gather_identity_evidence=self._gather_identity_evidence) root_c.bom_ref.value = root_c.name root_c.properties.update( Property( @@ -408,6 +412,9 @@ def __make_component4lock(self, package: 'T_NameDict') -> 'Component': bom_ref=f'{package["name"]}@{package["version"]}', name=package['name'], version=package.get('version'), + evidence=make_identity_evidence( + name=package['name'], version=package.get('version'), source='poetry.lock', + ) if self._gather_identity_evidence else None, description=package.get('description'), scope=ComponentScope.OPTIONAL if package.get('optional') else None, external_references=self.__extrefs4lock(package), diff --git a/cyclonedx_py/_internal/requirements.py b/cyclonedx_py/_internal/requirements.py index 43b243be..3725c66a 100644 --- a/cyclonedx_py/_internal/requirements.py +++ b/cyclonedx_py/_internal/requirements.py @@ -33,6 +33,7 @@ from . import BomBuilder, PropertyName, PurlTypePypi from .cli_common import add_argument_mc_type, add_argument_pyproject from .utils.cdx import make_bom +from .utils.identity import make_identity_evidence from .utils.io import io2file from .utils.packaging import normalize_packagename from .utils.pyproject import pyproject_file2component @@ -103,10 +104,12 @@ def __init__(self, *, logger: 'Logger', index_url: str, extra_index_urls: Iterable[str], + gather_identity_evidence: bool = False, **__: Any) -> None: self._logger = logger self._index_url = index_url self._extra_index_urls = tuple(extra_index_urls) + self._gather_identity_evidence = gather_identity_evidence def __call__(self, *, # type:ignore[override] requirements_file: str, @@ -117,7 +120,8 @@ def __call__(self, *, # type:ignore[override] rc = None else: rc = pyproject_file2component(pyproject_file, ctype=mc_type, - gather_license_texts=False, logger=self._logger) + gather_license_texts=False, logger=self._logger, + gather_identity_evidence=self._gather_identity_evidence) rc.bom_ref.value = 'root-component' if requirements_file == '-': @@ -220,6 +224,9 @@ def _make_component(self, req: 'InstallRequirement', type=ComponentType.LIBRARY, name=name or 'unknown', version=version, + evidence=make_identity_evidence( + name=name, version=version, source=f'requirements line {req.line_number}', + ) if self._gather_identity_evidence and not (req.is_url or is_local) else None, purl=PackageURL( type=PurlTypePypi, name=req.name, diff --git a/cyclonedx_py/_internal/utils/identity.py b/cyclonedx_py/_internal/utils/identity.py new file mode 100644 index 00000000..732a7880 --- /dev/null +++ b/cyclonedx_py/_internal/utils/identity.py @@ -0,0 +1,43 @@ +# This file is part of CycloneDX Python +# +# 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. + + +"""Identity evidence for fields read directly from package metadata.""" + +from decimal import Decimal +from typing import Optional + +from cyclonedx.model.component_evidence import AnalysisTechnique, ComponentEvidence, Identity, IdentityField, Method + + +def make_identity_evidence(*, name: Optional[str], version: Optional[str], source: str) -> Optional[ComponentEvidence]: + """Record the metadata source of nonempty name and version fields. + + Method confidence describes extracting the declared field without inference; + it does not assert that the package or its metadata is authentic. + """ + identities = [ + Identity( + field=field, + concluded_value=value, + methods=(Method(technique=AnalysisTechnique.MANIFEST_ANALYSIS, + confidence=Decimal(1), value=source),), + ) + for field, value in ((IdentityField.NAME, name), (IdentityField.VERSION, version)) + if value + ] + return ComponentEvidence(identity=identities) if identities else None diff --git a/cyclonedx_py/_internal/utils/poetry.py b/cyclonedx_py/_internal/utils/poetry.py index 78adbb90..38a4045f 100644 --- a/cyclonedx_py/_internal/utils/poetry.py +++ b/cyclonedx_py/_internal/utils/poetry.py @@ -33,6 +33,7 @@ from packaging.requirements import Requirement from .cdx import licenses_fixup, url_label_to_ert +from .identity import make_identity_evidence from .pep621 import classifiers2licenses if TYPE_CHECKING: @@ -62,11 +63,15 @@ def poetry2extrefs(poetry: dict[str, Any]) -> Generator['ExternalReference', Non pass -def poetry2component(poetry: dict[str, Any], *, ctype: 'ComponentType') -> 'Component': +def poetry2component(poetry: dict[str, Any], *, ctype: 'ComponentType', + gather_identity_evidence: bool = False) -> 'Component': component = Component( type=ctype, name=poetry['name'], version=poetry.get('version'), + evidence=make_identity_evidence( + name=poetry['name'], version=poetry.get('version'), source='pyproject.toml', + ) if gather_identity_evidence else None, description=poetry.get('description'), external_references=poetry2extrefs(poetry), # TODO add more properties according to spec diff --git a/cyclonedx_py/_internal/utils/pyproject.py b/cyclonedx_py/_internal/utils/pyproject.py index 127c0619..eaef31f0 100644 --- a/cyclonedx_py/_internal/utils/pyproject.py +++ b/cyclonedx_py/_internal/utils/pyproject.py @@ -25,6 +25,7 @@ from cyclonedx.factory.license import LicenseFactory from .cdx import licenses_fixup +from .identity import make_identity_evidence from .pep621 import ( project2component as pep621_project2component, project2dependencies as pep621_project2dependencies, @@ -45,13 +46,17 @@ def pyproject2component(data: dict[str, Any], *, ctype: 'ComponentType', fpath: str, gather_license_texts: bool, - logger: 'Logger' + logger: 'Logger', + gather_identity_evidence: bool = False, ) -> 'Component': tool = data.get('tool', {}) if poetry := tool.get('poetry'): - return poetry2component(poetry, ctype=ctype) + return poetry2component(poetry, ctype=ctype, gather_identity_evidence=gather_identity_evidence) if project := data.get('project'): component = pep621_project2component(project, ctype=ctype) + if gather_identity_evidence: + component.evidence = make_identity_evidence( + name=project['name'], version=component.version, source='pyproject.toml') # region licenses lfac = LicenseFactory() component.licenses.update(pep639_project2licenses(project, lfac, gather_license_texts, @@ -79,12 +84,14 @@ def pyproject_load(pyproject_file: str) -> dict[str, Any]: def pyproject_file2component(pyproject_file: str, *, ctype: 'ComponentType', gather_license_texts: bool, - logger: 'Logger' + logger: 'Logger', + gather_identity_evidence: bool = False, ) -> 'Component': return pyproject2component( pyproject_load(pyproject_file), ctype=ctype, fpath=pyproject_file, gather_license_texts=gather_license_texts, + gather_identity_evidence=gather_identity_evidence, logger=logger ) diff --git a/docs/usage.rst b/docs/usage.rst index ad6eef21..be67da2f 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -38,6 +38,29 @@ Example usage: save SBOM in CycloneDX 1.6 XML format, generated from current pyt cyclonedx-py environment --spec-version 1.6 --output-format XML --output-file my-sbom.xml +Identity evidence +----------------- + +Use ``--gather-identity-evidence`` with any subcommand to record the sources of +component names and concrete versions in ``evidence.identity``. For example: + +.. code-block:: shell + + cyclonedx-py requirements requirements.txt --gather-identity-evidence + +Sources are Python distribution metadata, requirements line numbers, ``Pipfile.lock``, +``poetry.lock``, and ``pyproject.toml`` for a supplied root component. +Each declared field uses the ``manifest-analysis`` technique. Method confidence of +``1`` describes direct extraction of the declared value; it does not authenticate +the package or its metadata. Missing or unpinned versions are omitted. Names and +versions inferred from requirements URLs or local paths are not recorded as evidence. +Source labels do not include absolute paths or requirement contents. + +CycloneDX 1.6 and later retain both name and version identities, including their +concluded values. CycloneDX 1.5 retains only the name identity and its method; +earlier versions omit identity evidence. Evidence gathering is disabled by default. + + For Python (virtual) environment -------------------------------- @@ -57,6 +80,7 @@ The full documentation can be issued by running with ``environment --help``: $ cyclonedx-py environment --help usage: cyclonedx-py environment [-h] [-v] [--gather-license-texts] + [--gather-identity-evidence] [--short-PURLs] [--output-reproducible] [--validate | --no-validate] [-o ] [--sv ] [--of ] @@ -80,6 +104,8 @@ The full documentation can be issued by running with ``environment --help``: --mc-type Type of the main component. {choices: application, firmware, library} (default: application) + --gather-identity-evidence + Record the metadata sources of component names and versions. --short-PURLs Omit all qualifiers from PackageURLs. This causes information loss in trade-off shorter PURLs, which might improve ingesting these strings. --sv , --spec-version @@ -222,6 +248,7 @@ The full documentation can be issued by running with ``pipenv --help``: $ cyclonedx-py pipenv --help usage: cyclonedx-py pipenv [-h] [-v] + [--gather-identity-evidence] [--short-PURLs] [--output-reproducible] [--validate | --no-validate] [-o ] [--sv ] [--of ] @@ -252,6 +279,8 @@ The full documentation can be issued by running with ``pipenv --help``: --mc-type Type of the main component. {choices: application, firmware, library} (default: application) + --gather-identity-evidence + Record the metadata sources of component names and versions. --short-PURLs Omit all qualifiers from PackageURLs. This causes information loss in trade-off shorter PURLs, which might improve ingesting these strings. --sv , --spec-version @@ -295,6 +324,7 @@ The full documentation can be issued by running with ``poetry --help``: $ cyclonedx-py poetry --help usage: cyclonedx-py poetry [-h] [-v] + [--gather-identity-evidence] [--short-PURLs] [--output-reproducible] [--validate | --no-validate] [-o ] [--sv ] [--of ] @@ -328,6 +358,8 @@ The full documentation can be issued by running with ``poetry --help``: --mc-type Type of the main component. {choices: application, firmware, library} (default: application) + --gather-identity-evidence + Record the metadata sources of component names and versions. --short-PURLs Omit all qualifiers from PackageURLs. This causes information loss in trade-off shorter PURLs, which might improve ingesting these strings. --sv , --spec-version @@ -369,6 +401,7 @@ The full documentation can be issued by running with ``requirements --help``: $ cyclonedx-py requirements --help usage: cyclonedx-py requirements [-h] [-v] + [--gather-identity-evidence] [--short-PURLs] [--output-reproducible] [--validate | --no-validate] [-o ] [--sv ] [--of ] @@ -400,6 +433,8 @@ The full documentation can be issued by running with ``requirements --help``: --mc-type Type of the main component. {choices: application, firmware, library} (default: application) + --gather-identity-evidence + Record the metadata sources of component names and versions. --short-PURLs Omit all qualifiers from PackageURLs. This causes information loss in trade-off shorter PURLs, which might improve ingesting these strings. --sv , --spec-version diff --git a/pyproject.toml b/pyproject.toml index 0d26394a..be60f4dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,7 +70,7 @@ cyclonedx-py = "cyclonedx_py._internal.cli:run" [tool.poetry.dependencies] python = ">=3.9, <4" -cyclonedx-python-lib = { version = ">=8, <12", extras = ["validation"] } +cyclonedx-python-lib = { version = ">=10.2, <12", extras = ["validation"] } packageurl-python = ">=0.11, <2" # keep in sync with same dep in `cyclonedx-python-lib` pip-requirements-parser = ">=32, <33" packaging = ">=22, <27" diff --git a/tests/integration/test_identity_evidence.py b/tests/integration/test_identity_evidence.py new file mode 100644 index 00000000..3f3ffec8 --- /dev/null +++ b/tests/integration/test_identity_evidence.py @@ -0,0 +1,156 @@ +# This file is part of CycloneDX Python +# +# 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 importlib.metadata import PathDistribution +from json import loads as json_loads +from pathlib import Path +from shutil import copyfile +from tempfile import TemporaryDirectory +from typing import Any +from unittest import TestCase +from unittest.mock import patch + +from ddt import data, ddt + +from tests import INFILES_DIRECTORY +from tests.integration import run_cli + +_BUILDERS = ('environment', 'requirements', 'pipenv', 'poetry') + + +@ddt +class TestIdentityEvidence(TestCase): + + def setUp(self) -> None: + temporary_directory = TemporaryDirectory() + self.addCleanup(temporary_directory.cleanup) + self._directory = Path(temporary_directory.name) + infiles = Path(INFILES_DIRECTORY) + pyproject = str(infiles / 'requirements' / 'pyproject.toml') + + dist_info = self._directory / 'example_package-1.2.3.dist-info' + dist_info.mkdir() + (dist_info / 'METADATA').write_text( + 'Metadata-Version: 2.1\nName: Example-Package\nVersion: 1.2.3\n', encoding='utf8') + self._distribution = PathDistribution(dist_info) + + pipenv_project = self._directory / 'pipenv' + pipenv_project.mkdir() + copyfile(infiles / 'pipenv' / 'default-and-dev' / 'Pipfile.lock', pipenv_project / 'Pipfile.lock') + + poetry_project = self._directory / 'poetry' + poetry_project.mkdir() + poetry_fixture = infiles / 'poetry' / 'main-and-dev' + # Copy the source manifest directly: fixture symlinks may be checked out + # as plain files on Windows. + copyfile(poetry_fixture / 'pyproject-proto.toml', poetry_project / 'pyproject.toml') + copyfile(poetry_fixture / 'lock21' / 'poetry.lock', poetry_project / 'poetry.lock') + + self._arguments = { + 'environment': ('--pyproject', pyproject), + 'requirements': ('--pyproject', pyproject, str(infiles / 'requirements' / 'frozen.txt')), + 'pipenv': ('--pyproject', pyproject, str(pipenv_project)), + 'poetry': (str(poetry_project),), + } + + def _run_json(self, builder: str, *, gather: bool = False, schema: str = '1.6') -> dict[str, Any]: + with patch('cyclonedx_py._internal.environment.distributions', return_value=(self._distribution,)): + result, output, error = run_cli( + builder, + '--of', 'JSON', '--sv', schema, '--output-reproducible', '-o=-', + *(('--gather-identity-evidence',) if gather else ()), + *self._arguments[builder], + ) + self.assertEqual(0, result, error) + return json_loads(output) + + def _assert_identity(self, component: dict[str, Any], source: str) -> None: + identities = {identity['field']: identity for identity in component['evidence']['identity']} + self.assertEqual({'name', 'version'}, identities.keys()) + for field, identity in identities.items(): + with self.subTest(field=field, component=component['name']): + self.assertEqual(component[field], identity['concludedValue']) + self.assertNotIn('confidence', identity) + self.assertEqual([{ + 'technique': 'manifest-analysis', 'confidence': 1, 'value': source, + }], identity['methods']) + + @data(*_BUILDERS) + def test_opt_in_records_declared_identity(self, builder: str) -> None: + bom = self._run_json(builder, gather=True) + components = bom['components'] + self.assertTrue(components) + for component in components: + with self.subTest(component=component['name']): + if builder == 'requirements': + source = f"requirements line {component['bom-ref'].removeprefix('requirements-L')}" + else: + source = { + 'environment': 'Python distribution metadata', + 'pipenv': 'Pipfile.lock', + 'poetry': 'poetry.lock', + }[builder] + self._assert_identity(component, source) + self._assert_identity(bom['metadata']['component'], 'pyproject.toml') + previous_evidence = self._run_json(builder)['metadata']['component'].get('evidence', {}) + self.assertEqual(previous_evidence.get('licenses'), bom['metadata']['component']['evidence'].get('licenses')) + + @data(*_BUILDERS) + def test_default_does_not_add_identity_evidence(self, builder: str) -> None: + bom = self._run_json(builder) + for component in (*bom['components'], bom['metadata']['component']): + with self.subTest(component=component['name']): + self.assertNotIn('identity', component.get('evidence', {})) + + @data(*_BUILDERS) + def test_schema_before_identity_support_omits_identity(self, builder: str) -> None: + bom = self._run_json(builder, gather=True, schema='1.4') + for component in (*bom['components'], bom['metadata']['component']): + with self.subTest(component=component['name']): + self.assertNotIn('identity', component.get('evidence', {})) + + def test_requirements_do_not_conclude_inferred_identity(self) -> None: + requirements = self._directory / 'requirements.txt' + requirements.write_text( + 'unpinned>=1.0\nhttps://example.invalid/inferred_package-2.0-py3-none-any.whl\n', encoding='utf8') + self._arguments['requirements'] = (str(requirements),) + + components = {component['bom-ref']: component for component in self._run_json( + 'requirements', gather=True)['components']} + + unpinned = components['requirements-L1'] + self.assertNotIn('version', unpinned) + self.assertEqual([{ + 'field': 'name', + 'concludedValue': 'unpinned', + 'methods': [{'technique': 'manifest-analysis', 'confidence': 1, 'value': 'requirements line 1'}], + }], unpinned['evidence']['identity']) + self.assertNotIn('evidence', components['requirements-L2']) + + def test_pipenv_only_concludes_exact_versions(self) -> None: + lockfile = self._directory / 'pipenv' / 'Pipfile.lock' + lockfile.write_text( + '{"_meta": {"sources": []}, "default": {"unpinned": {}, "ranged": {"version": ">=1.2"}}}', + encoding='utf8') + + components = self._run_json('pipenv', gather=True)['components'] + + self.assertEqual({'unpinned', 'ranged'}, {component['name'] for component in components}) + for component in components: + with self.subTest(component=component['name']): + self.assertEqual({'name'}, {identity['field'] for identity in component['evidence']['identity']}) diff --git a/tests/unit/test_utils_identity.py b/tests/unit/test_utils_identity.py new file mode 100644 index 00000000..d1b2b552 --- /dev/null +++ b/tests/unit/test_utils_identity.py @@ -0,0 +1,116 @@ +# This file is part of CycloneDX Python +# +# 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 json import loads as json_loads +from unittest import TestCase +from xml.etree.ElementTree import fromstring as xml_fromstring # nosec:B405 + +from cyclonedx.model.bom import Bom +from cyclonedx.model.component import Component, ComponentType +from cyclonedx.model.component_evidence import AnalysisTechnique, IdentityField +from cyclonedx.output import make_outputter +from cyclonedx.schema import OutputFormat, SchemaVersion +from cyclonedx.validation import make_schemabased_validator + +from cyclonedx_py._internal.utils.identity import make_identity_evidence + + +class TestIdentityEvidence(TestCase): + def test_records_declared_values_and_source(self) -> None: + evidence = make_identity_evidence(name='Some_Package', version='01.0+local', source='poetry.lock') + self.assertIsNotNone(evidence) + identities = {identity.field: identity for identity in evidence.identity} + self.assertEqual({IdentityField.NAME, IdentityField.VERSION}, set(identities)) + for field, value in ((IdentityField.NAME, 'Some_Package'), (IdentityField.VERSION, '01.0+local')): + with self.subTest(field=field): + identity = identities[field] + self.assertEqual(value, identity.concluded_value) + self.assertIsNone(identity.confidence) + self.assertFalse(identity.tools) + self.assertEqual(1, len(identity.methods)) + method = next(iter(identity.methods)) + self.assertIs(AnalysisTechnique.MANIFEST_ANALYSIS, method.technique) + self.assertEqual(Decimal(1), method.confidence) + self.assertEqual('poetry.lock', method.value) + + def test_omits_missing_fields(self) -> None: + for name, version, expected_field in ( + ('package', None, IdentityField.NAME), + ('package', '', IdentityField.NAME), + (None, '1.0', IdentityField.VERSION), + ('', '1.0', IdentityField.VERSION), + ): + with self.subTest(name=name, version=version): + evidence = make_identity_evidence(name=name, version=version, source='package metadata') + self.assertIsNotNone(evidence) + self.assertEqual([expected_field], [identity.field for identity in evidence.identity]) + + def test_no_evidence_without_values(self) -> None: + for name, version in ((None, None), ('', ''), (None, ''), ('', None)): + with self.subTest(name=name, version=version): + self.assertIsNone(make_identity_evidence(name=name, version=version, source='package metadata')) + + def test_schema_serialization(self) -> None: + component = Component( + name='example', version='1.0', type=ComponentType.LIBRARY, + evidence=make_identity_evidence(name='example', version='1.0', source='poetry.lock'), + ) + schemas = (schema for schema in SchemaVersion + if tuple(map(int, schema.to_version().split('.'))) >= (1, 4)) + for schema in schemas: + for output_format in (OutputFormat.JSON, OutputFormat.XML): + with self.subTest(schema=schema, output_format=output_format): + outputter = make_outputter( + bom=Bom(components=[component]), output_format=output_format, schema_version=schema, + ) + if schema is SchemaVersion.V1_5: + with self.assertWarnsRegex(UserWarning, 'does not support multiple identity items'): + output = outputter.output_as_string() + else: + output = outputter.output_as_string() + self.assertFalse(make_schemabased_validator(output_format, schema).validate_str(output)) + expected_fields = [] if schema is SchemaVersion.V1_4 else ( + ['name'] if schema is SchemaVersion.V1_5 else ['name', 'version']) + if output_format is OutputFormat.JSON: + raw = json_loads(output)['components'][0].get('evidence', {}).get('identity', []) + identities = [raw] if isinstance(raw, dict) else raw + self.assertEqual(expected_fields, [identity['field'] for identity in identities]) + for identity in identities: + if schema is SchemaVersion.V1_5: + self.assertNotIn('concludedValue', identity) + else: + self.assertEqual({'name': 'example', 'version': '1.0'}[identity['field']], + identity['concludedValue']) + self.assertEqual([{'technique': 'manifest-analysis', 'confidence': 1, + 'value': 'poetry.lock'}], identity['methods']) + else: + # Only parse the XML generated above from a fixed in-memory component. + identities = xml_fromstring(output).findall('.//{*}identity') # nosec:B314 + self.assertEqual(expected_fields, [identity.findtext('{*}field') for identity in identities]) + for identity in identities: + concluded = identity.findtext('{*}concludedValue') + if schema is SchemaVersion.V1_5: + self.assertIsNone(concluded) + else: + self.assertEqual({'name': 'example', 'version': '1.0'}[ + identity.findtext('{*}field')], concluded) + methods = identity.findall('{*}methods/{*}method') + self.assertEqual(1, len(methods)) + self.assertEqual('manifest-analysis', methods[0].findtext('{*}technique')) + self.assertEqual('poetry.lock', methods[0].findtext('{*}value'))