diff --git a/cyclonedx_py/_internal/environment.py b/cyclonedx_py/_internal/environment.py index 9a9bb088..3792e2a3 100644 --- a/cyclonedx_py/_internal/environment.py +++ b/cyclonedx_py/_internal/environment.py @@ -16,8 +16,10 @@ # Copyright (c) OWASP Foundation. All Rights Reserved. +import re from argparse import OPTIONAL, ArgumentParser -from importlib.metadata import distributions +from base64 import b64encode +from importlib.metadata import Distribution, distributions from json import loads from os import getcwd, name as os_name from os.path import exists, isdir, join @@ -26,8 +28,9 @@ from textwrap import dedent from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Tuple -from cyclonedx.model import Property -from cyclonedx.model.component import Component, ComponentType +from cyclonedx.model import AttachedText, Encoding, Property +from cyclonedx.model.component import Component, ComponentEvidence, ComponentType +from cyclonedx.model.license import DisjunctiveLicense from packageurl import PackageURL from packaging.requirements import Requirement @@ -47,6 +50,8 @@ T_AllComponents = Dict[str, Tuple['Component', Iterable[Requirement]]] +LICENSE_FILE_REGEX = re.compile('LICEN[CS]E.*|COPYING.*') + class EnvironmentBB(BomBuilder): @@ -106,6 +111,11 @@ def make_argument_parser(**kwargs: Any) -> 'ArgumentParser': # `--local` If in a virtualenv that has global access, do not list globally-installed packages. # `--user` Only output packages installed in user-site. # `--path ` Restrict to the specified installation path for listing packages + p.add_argument('--collect-evidence', + help='Whether to collect license evidence from components', + action='store_true', + dest='collect_evidence', + default=False) p.add_argument('python', metavar='', help='Python interpreter', @@ -115,8 +125,10 @@ def make_argument_parser(**kwargs: Any) -> 'ArgumentParser': def __init__(self, *, logger: 'Logger', + collect_evidence: bool, **__: Any) -> None: self._logger = logger + self._collect_evidence = collect_evidence def __call__(self, *, # type:ignore[override] python: Optional[str], @@ -144,6 +156,24 @@ def __call__(self, *, # type:ignore[override] self.__add_components(bom, rc, path=path) return bom + @staticmethod + def __collect_license_evidence(dist: Distribution) -> Optional[ComponentEvidence]: + if not (dist_files := dist.files): + return None + + if not (license_files := [f for f in dist_files if LICENSE_FILE_REGEX.match(f.name)]): + return None + + licenses = [] + for license_file in license_files: + license_name = f'License detected in {license_file}' + encoded_content = b64encode(license_file.read_binary()).decode('ascii') + license_text = AttachedText(content=encoded_content, encoding=Encoding.BASE_64) + license = DisjunctiveLicense(name=license_name, text=license_text) + licenses.append(license) + + return ComponentEvidence(licenses=licenses) + def __add_components(self, bom: 'Bom', rc: Optional[Tuple['Component', Iterable['Requirement']]], **kwargs: Any) -> None: @@ -165,6 +195,10 @@ def __add_components(self, bom: 'Bom', # path of dist-package on disc? naaa... a package may have multiple files/folders on disc ) del dist_meta, dist_name, dist_version + + if self._collect_evidence and (evidence := self.__collect_license_evidence(dist)): + component.evidence = evidence + self.__component_add_extred_and_purl(component, packagesource4dist(dist)) all_components[normalize_packagename(component.name)] = ( component,