Source code for ntia_conformance_checker.bsi_checker

# SPDX-FileCopyrightText: 2026 SPDX contributors
# SPDX-FileType: SOURCE
# SPDX-License-Identifier: Apache-2.0

"""BSI Minimum Elements checking functionality."""

from __future__ import annotations

from typing import Any

from spdx_python_model.bindings import v3_0_1 as spdx3

from .base_checker import BaseChecker
from .spdx3_utils import (
    get_dependency_relationships_completeness,
    get_distribution_artifacts_map,
    has_sha512_hash,
    iter_objects_with_property,
    iter_relationships_by_type,
)


# pylint: disable=too-many-instance-attributes
[docs] class BSIChecker(BaseChecker): """ BSI Minimum Elements check according to BSI TR-03183-2 v2.1.0. See: https://www.bsi.bund.de/SharedDocs/Downloads/EN/BSI/Publications/TechGuidelines/TR03183/BSI-TR-03183-2_v2_1_0.html """ MIN_ELEMENTS = [ "creator", "name", "version", "filename", "distribution_licenses", "sha512_hash", "executable_property", "archive_property", "structured_property", ] _COMPONENTS_WITHOUT_INFO = { **BaseChecker._COMPONENTS_WITHOUT_INFO, "creator": ( "components_without_creators", "Components missing a creator (Email/URL)", ), "filename": ( "components_without_filenames", "Components missing a distribution filename", ), "distribution_licenses": ( "components_without_concluded_licenses", "Components missing distribution licenses", ), "sha512_hash": ( "components_without_sha512_hashes", "Components missing SHA-512 hash", ), "executable_property": ( "components_without_executable_prop", "Components missing executable property", ), "archive_property": ( "components_without_archive_prop", "Components missing archive property", ), "structured_property": ( "components_without_structured_prop", "Components missing structured property", ), } def __init__( self, file: str, validate: bool = True, compliance: str = "bsi", sbom_spec: str = "spdx3", ): """ Initialize the BSI Minimum Element Checker. Args: file (str): The name of the file to be checked. validate (bool): Whether to validate the file. compliance (str): The compliance standard to be used. sbom_spec (str): The SBOM specification to be used. """ if compliance not in {"bsi"}: raise ValueError("Only BSI compliance is supported.") self.components_without_creators: list[tuple[str, str]] = [] self.components_without_filenames: list[tuple[str, str]] = [] self.components_without_sha512_hashes: list[tuple[str, str]] = [] self.components_without_executable_prop: list[tuple[str, str]] = [] self.components_without_archive_prop: list[tuple[str, str]] = [] self.components_without_structured_prop: list[tuple[str, str]] = [] # Initialize Additional/Optional lists (Warnings) self.components_without_source_code_uris: list[tuple[str, str]] = [] self.components_without_deployable_uris: list[tuple[str, str]] = [] self.components_without_unique_identifiers: list[tuple[str, str]] = [] self.components_without_original_licenses: list[tuple[str, str]] = [] self.components_without_effective_licenses: list[tuple[str, str]] = [] self.components_without_source_code_hashes: list[tuple[str, str]] = [] self.components_without_security_txt: list[tuple[str, str]] = [] self.components_without_bom_references: list[tuple[str, str]] = [] self.doc_creator: bool = False self.doc_uri: bool = False self.dependency_completeness: bool = False super().__init__( file=file, validate=validate, compliance=compliance, sbom_spec=sbom_spec ) if self.doc: self.compliant = self.check_compliance() self.table_elements = [ ("Creator of the SBOM", self.doc_creator), ("Timestamp", self.doc_timestamp), ("Dependency Completeness", self.dependency_completeness), ("Component Names", not bool(self.components_without_names)), ("Component Versions", not bool(self.components_without_versions)), ("Component Creators", not bool(self.components_without_creators)), ("Distribution Filenames", not bool(self.components_without_filenames)), ( "Executable Properties", not bool(self.components_without_executable_prop), ), ("Archive Properties", not bool(self.components_without_archive_prop)), ( "Structured Properties", not bool(self.components_without_structured_prop), ), ( "Distribution Licenses", not bool(self.components_without_concluded_licenses), ), ( "Deployable Component Hashes", not bool(self.components_without_sha512_hashes), ), ( "Source Code URIs (Warning)", not bool(self.components_without_source_code_uris), ), ( "Deployable Form URIs (Warning)", not bool(self.components_without_deployable_uris), ), ( "Other Unique Identifiers (Warning)", not bool(self.components_without_unique_identifiers), ), ( "Original Licenses (Warning)", not bool(self.components_without_original_licenses), ), ( "Effective Licenses (Optional)", not bool(self.components_without_effective_licenses), ), ( "Source Code Hashes (Optional)", not bool(self.components_without_source_code_hashes), ), ( "Security.txt URLs (Optional)", not bool(self.components_without_security_txt), ), ( "BOM References (Optional)", not bool(self.components_without_bom_references), ), ]
[docs] def check_compliance(self) -> bool: """Check overall compliance with BSI minimum elements.""" # Execute Document-Level Checks self.doc_creator = self.check_doc_creator() self.doc_uri = self.check_sbom_uri() self.doc_timestamp = self.check_timestamp() # Execute Component-Level Checks self.dependency_completeness = self.check_dependency_completeness() self.components_without_names = self.get_components_without_names() self.components_without_versions = self.get_components_without_versions() # Run BSI-specific overrides and new methods self.components_without_concluded_licenses = ( self.get_components_without_concluded_licenses() ) self.components_without_creators = self.get_components_without_creators() self.components_without_filenames = self.get_components_without_filenames() self.components_without_executable_prop = ( self.get_components_without_executable_prop() ) self.components_without_archive_prop = ( self.get_components_without_archive_prop() ) self.components_without_structured_prop = ( self.get_components_without_structured_prop() ) self.components_without_sha512_hashes = ( self.get_components_without_sha512_hashes() ) # Run Additional/Optional checks self.components_without_source_code_uris = ( self.get_components_without_source_code_uris() ) self.components_without_deployable_uris = ( self.get_components_without_deployable_uris() ) self.components_without_unique_identifiers = ( self.get_components_without_unique_identifiers() ) self.components_without_original_licenses = ( self.get_components_without_original_licenses() ) self.components_without_effective_licenses = ( self.get_components_without_effective_licenses() ) self.components_without_source_code_hashes = ( self.get_components_without_source_code_hashes() ) self.components_without_security_txt = ( self.get_components_without_security_txt() ) self.components_without_bom_references = ( self.get_components_without_bom_references() ) # Refresh the aggregated tracking list used by the base class reporter self.all_components_without_info = self._get_all_components_without_info() return all( [ self.doc_creator, self.doc_timestamp, self.doc_uri, self.dependency_completeness, not bool(self.all_components_without_info), ] )
@staticmethod def _is_valid_creator( creator: Any, doc: spdx3.SHACLObjectSet, url_id_types: tuple[str, ...] ) -> bool: """Validate if a creator object has a valid email or URL identifier.""" creator_obj = ( creator if not isinstance(creator, str) else doc.find_by_id(creator) ) if not isinstance(creator_obj, (spdx3.Person, spdx3.Organization)): return False for ext_id in getattr(creator_obj, "externalIdentifier", []): id_type = str(getattr(ext_id, "externalIdentifierType", "")).lower() id_val = getattr(ext_id, "identifier", "") if not id_val: continue if "email" in id_type and "@" in id_val: return True if any(t in id_type for t in url_id_types) and id_val.startswith( ("http://", "https://") ): return True return False
[docs] def check_doc_creator(self) -> bool: """ Check if the document creator has a valid email or URL. """ if not isinstance(self.doc, spdx3.SHACLObjectSet): return False spdx3_doc = getattr(self, "_BaseChecker__spdx3_doc", None) if not spdx3_doc: return False creation_info = getattr(spdx3_doc, "creationInfo", None) if not creation_info: return False creators = getattr(creation_info, "createdBy", []) if not creators: return False return any( self._is_valid_creator(c, self.doc, ("urlscheme", "other")) for c in creators )
[docs] def check_sbom_uri(self) -> bool: """ Check if the SBOM-URI exists. """ if not isinstance(self.doc, spdx3.SHACLObjectSet): return False spdx3_doc = getattr(self, "_BaseChecker__spdx3_doc", None) if not spdx3_doc: return False root_elements = getattr(spdx3_doc, "rootElement", []) for root in root_elements: root_obj = root if not isinstance(root, str) else self.doc.find_by_id(root) # The rootElement (software_Sbom or software_Package) MUST have a valid spdxId. if isinstance(root_obj, (spdx3.software_Sbom, spdx3.software_Package)): spdx_id = getattr(root_obj, "spdxId", None) if spdx_id and isinstance(spdx_id, str) and spdx_id.strip(): return True return False
[docs] def get_components_without_creators(self) -> list[tuple[str, str]]: """ Check if each component creator has a valid email or URL. """ missing: list[tuple[str, str]] = [] if not isinstance(self.doc, spdx3.SHACLObjectSet): return missing # Iterate through all packages in the reachable graph for name, spdx_id, originated_by_list in iter_objects_with_property( self.doc, spdx3.software_Package, "originatedBy", self.reachable_component_ids, ): creators = originated_by_list or [] has_valid = any( self._is_valid_creator(c, self.doc, ("other", "urlscheme")) for c in creators ) if not has_valid: missing.append((name, spdx_id)) return missing
[docs] def get_components_without_filenames(self) -> list[tuple[str, str]]: """ Check if each package has a linked software_File with a valid name. """ missing: list[tuple[str, str]] = [] if not isinstance(self.doc, spdx3.SHACLObjectSet): return missing artifacts_map = get_distribution_artifacts_map(self.doc) for name, spdx_id, _ in iter_objects_with_property( self.doc, spdx3.software_Package, "spdxId", self.reachable_component_ids ): has_filename = False linked_files = artifacts_map.get(spdx_id, []) for file_obj in linked_files: file_name = getattr(file_obj, "name", "") if file_name and isinstance(file_name, str) and file_name.strip(): has_filename = True break if not has_filename: missing.append((name, spdx_id)) return missing
[docs] def check_dependency_completeness(self) -> bool: """ Check if dependency enumerations clearly indicate their completeness. """ if not isinstance(self.doc, spdx3.SHACLObjectSet): return False completeness_map = get_dependency_relationships_completeness(self.doc) for _, completeness_list in completeness_map.items(): for completeness_val in completeness_list: if completeness_val not in ("complete", "incomplete", "noAssertion"): return False return True
[docs] def get_components_without_concluded_licenses(self) -> list[tuple[str, str]]: """ Overrides BaseChecker to enforce BSI document Table 9 and Section 6.1 rules: 1. Target MUST be simplelicensing_LicenseExpression. 2. Must use valid SPDX license expressions, Scancode, or custom LicenseRefs. """ missing: list[tuple[str, str]] = [] if not isinstance(self.doc, spdx3.SHACLObjectSet): return missing valid_license_ids = set() for from_id, to_ids in iter_relationships_by_type( self.doc, "hasConcludedLicense" ): has_valid_expression = False for target_id in to_ids: obj = self.doc.find_by_id(target_id) if obj and isinstance(obj, spdx3.simplelicensing_LicenseExpression): expr = getattr(obj, "simplelicensing_licenseExpression", "") if ( expr and isinstance(expr, str) and expr.strip() != "NoAssertionLicense" ): has_valid_expression = True break if has_valid_expression: valid_license_ids.add(from_id) # Check packages against the valid license list for name, spdx_id, _ in iter_objects_with_property( self.doc, spdx3.software_Package, "spdxId", self.reachable_component_ids ): if spdx_id not in valid_license_ids: missing.append((name, spdx_id)) return missing
[docs] def get_components_without_sha512_hashes(self) -> list[tuple[str, str]]: """ Check if the deployable component has a SHA-512 hash. """ missing: list[tuple[str, str]] = [] if not isinstance(self.doc, spdx3.SHACLObjectSet): return missing artifacts_map = get_distribution_artifacts_map(self.doc) for name, spdx_id, _ in iter_objects_with_property( self.doc, spdx3.software_Package, "spdxId", self.reachable_component_ids ): linked_files = artifacts_map.get(spdx_id, []) # If no file is linked, skip file level checks. if not linked_files: continue has_hash = False for file_obj in linked_files: if has_sha512_hash(file_obj): has_hash = True break if not has_hash: missing.append((name, spdx_id)) return missing
[docs] def get_components_without_executable_prop(self) -> list[tuple[str, str]]: """ BSI Requirement: Omit 'executable' if non-executable. Since omission represents 'False', a static checker cannot fail a component for missing this tag. Always compliant. """ return []
[docs] def get_components_without_archive_prop(self) -> list[tuple[str, str]]: """ BSI Requirement: Omit 'archive' if non-archive. Since omission represents 'False', a static checker cannot fail a component for missing this tag. Always compliant. """ return []
[docs] def get_components_without_structured_prop(self) -> list[tuple[str, str]]: """ Check if the component declares a structured property. BSI Requirement: MUST indicate 'container' (structured) or 'firmware' (unstructured). """ missing: list[tuple[str, str]] = [] if not isinstance(self.doc, spdx3.SHACLObjectSet): return missing artifacts_map = get_distribution_artifacts_map(self.doc) for name, spdx_id, _ in iter_objects_with_property( self.doc, spdx3.software_Package, "spdxId", self.reachable_component_ids ): linked_files = artifacts_map.get(spdx_id, []) # If no file is linked, skip file level checks. if not linked_files: continue has_structured_prop = False for file_obj in linked_files: purposes = getattr(file_obj, "software_additionalPurpose", []) # Convert IRIs/Enums to strings and check for BSI keywords purposes_str = [str(p).lower() for p in purposes] if any("container" in p or "firmware" in p for p in purposes_str): has_structured_prop = True break if not has_structured_prop: missing.append((name, spdx_id)) return missing
[docs] def get_components_without_source_code_uris(self) -> list[tuple[str, str]]: """ Check if the component has a Source code URI. BSI Requirement: MUST be included IF it exists (Additional Data Field). """ missing: list[tuple[str, str]] = [] if not isinstance(self.doc, spdx3.SHACLObjectSet): return missing valid_package_ids = set() for from_id, to_ids in iter_relationships_by_type(self.doc, "generates"): is_valid_source = False obj = self.doc.find_by_id(from_id) if obj and isinstance( obj, ( spdx3.software_SoftwareArtifact, spdx3.software_File, spdx3.software_Snippet, spdx3.software_Package, spdx3.ai_AIPackage, spdx3.dataset_DatasetPackage, ), ): raw_purpose = getattr(obj, "software_primaryPurpose", None) or getattr( obj, "primaryPurpose", None ) purposes = ( raw_purpose if isinstance(raw_purpose, list) else [raw_purpose] ) purposes_str = [str(p).lower() for p in purposes] if any("source" in p for p in purposes_str): if getattr(obj, "externalRef", []): is_valid_source = True if is_valid_source: for target_id in to_ids: valid_package_ids.add(target_id) for name, spdx_id, _ in iter_objects_with_property( self.doc, spdx3.software_Package, "spdxId", self.reachable_component_ids ): if spdx_id not in valid_package_ids: missing.append((name, spdx_id)) return missing
[docs] def get_components_without_deployable_uris(self) -> list[tuple[str, str]]: """ Check if the component has a URI of the deployable form. BSI Requirement: MUST be included IF it exists (Additional Data Field). """ missing: list[tuple[str, str]] = [] if not isinstance(self.doc, spdx3.SHACLObjectSet): return missing artifacts_map = get_distribution_artifacts_map(self.doc) for name, spdx_id, _ in iter_objects_with_property( self.doc, spdx3.software_Package, "spdxId", self.reachable_component_ids ): linked_files = artifacts_map.get(spdx_id, []) if not linked_files: continue has_deployable_uri = False for file_obj in linked_files: # Note: BSI TR-03183-2 example shows 'binaryArtifact' as a direct property, # but SPDX 3.0.1 defines it strictly as an ExternalRefType. for ext_ref in getattr(file_obj, "externalRef", []): ref_type = str(getattr(ext_ref, "externalRefType", "")).lower() if "binaryartifact" in ref_type: has_deployable_uri = True break if has_deployable_uri: break if not has_deployable_uri: missing.append((name, spdx_id)) return missing
[docs] def get_components_without_unique_identifiers(self) -> list[tuple[str, str]]: """ Check if the component has additional unique identifiers (CPE, SWID, PURL). BSI Requirement: MUST be included IF they exist (Additional Data Field). """ missing: list[tuple[str, str]] = [] if not isinstance(self.doc, spdx3.SHACLObjectSet): return missing # Valid BSI external identifier types valid_types = {"cpe22", "cpe23", "swid", "packageurl"} for name, spdx_id, _ in iter_objects_with_property( self.doc, spdx3.software_Package, "spdxId", self.reachable_component_ids ): has_valid_id = False obj = self.doc.find_by_id(spdx_id) if obj: ext_ids = getattr(obj, "externalIdentifier", []) for ext_id in ext_ids: id_type = str(getattr(ext_id, "externalIdentifierType", "")).lower() # Extract the base type name from the IRI/Enum id_type_clean = id_type.rsplit("/", maxsplit=1)[-1] if id_type_clean in valid_types: has_valid_id = True break if not has_valid_id: missing.append((name, spdx_id)) return missing
[docs] def get_components_without_original_licenses(self) -> list[tuple[str, str]]: """ Check if original licences are included. BSI Requirement: MUST be included IF they exist (Additional Data Field). """ missing: list[tuple[str, str]] = [] if not isinstance(self.doc, spdx3.SHACLObjectSet): return missing valid_license_ids = set() for from_id, to_ids in iter_relationships_by_type( self.doc, "hasDeclaredLicense" ): has_valid_expression = False for target_id in to_ids: obj = self.doc.find_by_id(target_id) if obj and isinstance(obj, spdx3.simplelicensing_LicenseExpression): expr = getattr(obj, "simplelicensing_licenseExpression", "") if ( expr and isinstance(expr, str) and expr.strip() != "NoAssertionLicense" ): has_valid_expression = True break if has_valid_expression: valid_license_ids.add(from_id) for name, spdx_id, _ in iter_objects_with_property( self.doc, spdx3.software_Package, "spdxId", self.reachable_component_ids ): if spdx_id not in valid_license_ids: missing.append((name, spdx_id)) return missing
[docs] def get_components_without_effective_licenses(self) -> list[tuple[str, str]]: """ Check if the effective licence is included. BSI Requirement: MAY be included (Optional Data Field). """ missing: list[tuple[str, str]] = [] if not isinstance(self.doc, spdx3.SHACLObjectSet): return missing valid_license_ids = set() for rel in self.doc.foreach_type(spdx3.Relationship): rel_type = getattr(rel, "relationshipType", "") if ( rel_type and str(rel_type).rsplit("/", maxsplit=1)[-1].lower() == "other" ): comment = getattr(rel, "comment", "") if comment and "hasEffectiveLicense" in str(comment): from_obj = getattr(rel, "from_", None) from_id = ( from_obj if isinstance(from_obj, str) else getattr(from_obj, "spdxId", "") ) if from_id: valid_license_ids.add(from_id) for name, spdx_id, _ in iter_objects_with_property( self.doc, spdx3.software_Package, "spdxId", self.reachable_component_ids ): if spdx_id not in valid_license_ids: missing.append((name, spdx_id)) return missing
[docs] def get_components_without_source_code_hashes(self) -> list[tuple[str, str]]: """ Check if the source code of the component has a SHA-512 hash. BSI Requirement: MAY be included (Optional Data Field). """ missing: list[tuple[str, str]] = [] if not isinstance(self.doc, spdx3.SHACLObjectSet): return missing valid_package_ids = set() for from_id, to_ids in iter_relationships_by_type(self.doc, "generates"): has_hash = False obj = self.doc.find_by_id(from_id) if obj and isinstance( obj, ( spdx3.software_SoftwareArtifact, spdx3.software_File, spdx3.software_Snippet, spdx3.software_Package, spdx3.ai_AIPackage, spdx3.dataset_DatasetPackage, ), ): raw_purpose = getattr(obj, "software_primaryPurpose", None) or getattr( obj, "primaryPurpose", None ) purposes = ( raw_purpose if isinstance(raw_purpose, list) else [raw_purpose] ) purposes_str = [str(p).lower() for p in purposes] if any("source" in p for p in purposes_str): if has_sha512_hash(obj): has_hash = True if has_hash: valid_package_ids.update(to_ids) for name, spdx_id, _ in iter_objects_with_property( self.doc, spdx3.software_Package, "spdxId", self.reachable_component_ids ): if spdx_id not in valid_package_ids: missing.append((name, spdx_id)) return missing
[docs] def get_components_without_security_txt(self) -> list[tuple[str, str]]: """ Check if the component contains a URL to security.txt. BSI Requirement: MAY be included (Optional Data Field). """ missing: list[tuple[str, str]] = [] if not isinstance(self.doc, spdx3.SHACLObjectSet): return missing for name, spdx_id, _ in iter_objects_with_property( self.doc, spdx3.software_Package, "spdxId", self.reachable_component_ids ): has_security = False obj = self.doc.find_by_id(spdx_id) if obj: for ext_ref in getattr(obj, "externalRef", []): ref_type = str(getattr(ext_ref, "externalRefType", "")).lower() if "securityother" in ref_type: has_security = True break if not has_security: missing.append((name, spdx_id)) return missing
[docs] def get_components_without_bom_references(self) -> list[tuple[str, str]]: """ Check if the component references another BOM. BSI Requirement: MAY be included (Optional Data Field). """ missing: list[tuple[str, str]] = [] if not isinstance(self.doc, spdx3.SHACLObjectSet): return missing valid_package_ids = set() for from_id, to_ids in iter_relationships_by_type(self.doc, "describes"): has_bom_ref = False obj = self.doc.find_by_id(from_id) if obj and isinstance(obj, spdx3.software_Sbom): for ext_ref in getattr(obj, "externalRef", []): ref_type = str(getattr(ext_ref, "externalRefType", "")).lower() if "buildmeta" in ref_type: has_bom_ref = True break if has_bom_ref: valid_package_ids.update(to_ids) for name, spdx_id, _ in iter_objects_with_property( self.doc, spdx3.software_Package, "spdxId", self.reachable_component_ids ): if spdx_id not in valid_package_ids: missing.append((name, spdx_id)) return missing