Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions cyclonedx_py/_internal/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, '
Expand Down
7 changes: 7 additions & 0 deletions cyclonedx_py/_internal/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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))
Expand Down Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion cyclonedx_py/_internal/pipenv.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
9 changes: 8 additions & 1 deletion cyclonedx_py/_internal/poetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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),
Expand Down
9 changes: 8 additions & 1 deletion cyclonedx_py/_internal/requirements.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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 == '-':
Expand Down Expand Up @@ -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,
Expand Down
43 changes: 43 additions & 0 deletions cyclonedx_py/_internal/utils/identity.py
Original file line number Diff line number Diff line change
@@ -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
7 changes: 6 additions & 1 deletion cyclonedx_py/_internal/utils/poetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
13 changes: 10 additions & 3 deletions cyclonedx_py/_internal/utils/pyproject.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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
)

Expand Down
35 changes: 35 additions & 0 deletions docs/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
--------------------------------

Expand All @@ -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 <file>] [--sv <version>] [--of <format>]
Expand All @@ -80,6 +104,8 @@ The full documentation can be issued by running with ``environment --help``:
--mc-type <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 <version>, --spec-version <version>
Expand Down Expand Up @@ -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 <file>] [--sv <version>] [--of <format>]
Expand Down Expand Up @@ -252,6 +279,8 @@ The full documentation can be issued by running with ``pipenv --help``:
--mc-type <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 <version>, --spec-version <version>
Expand Down Expand Up @@ -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 <file>] [--sv <version>] [--of <format>]
Expand Down Expand Up @@ -328,6 +358,8 @@ The full documentation can be issued by running with ``poetry --help``:
--mc-type <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 <version>, --spec-version <version>
Expand Down Expand Up @@ -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 <file>] [--sv <version>] [--of <format>]
Expand Down Expand Up @@ -400,6 +433,8 @@ The full documentation can be issued by running with ``requirements --help``:
--mc-type <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 <version>, --spec-version <version>
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading