Repository: osbuild/osbuild
Pull request #2023 (2025-02-17 13:36:54)

Make sure that SBOM license fields contain valid SPDX license expressions (HMS-5524)
Previously, the SBOM implementation would take the `License` field from the RPM package metadata and use it as `licenseDeclared` in the SPDX SBOM. This was an intentional shortcut in the PoC implementation. Nevertheless, the SPDX specification requires that the value be a valid SPDX license expression. However, this is not guaranteed for `License` values in RPMs.

Make sure that SBOM license fields contain valid SPDX license expressions by:

- Parse the `License` value using the `license_expression` Python package. If it succeeds, use the value as is in the SBOM. If the parsing fails, the license value is wrapped as "extracted licensing info" and referenced by the License ref ID. License ref IDs are generated as a hash of the license value. If two packages use the same SPDX non-compliant license expression, they will use the same License ref ID.
- If the `license_expression` Python package is not installed (it is not packaged in CS / RHEL, only in EPEL), all License expressions from the RPM `License` field will be wrapped as "extracted licensing info" because their validity cannot be assessed.
- RPM `License` values that are SPDX License ref IDs (start with `LicenseRef-`) are always used as is to prevent them from being wrapped as "extracted licensing info" and referred to by yet another License ref ID.

Cover the functionality with tests.

/jira-epic COMPOSER-2405

JIRA: [HMS-5524](https://issues.redhat.com/browse/HMS-5524)


Patch:
diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
index 240f6986..de38c968 100644
--- a/.github/workflows/check.yml
+++ b/.github/workflows/check.yml
@@ -25,7 +25,7 @@ jobs:
       - name: "Run Linters"
         uses: osbuild/containers/src/actions/privdocker@552e30cf1b4ed19c6ddaa57f96c342b3dff4227b
         with:
-          image: ghcr.io/osbuild/osbuild-ci:latest-202501201758
+          image: ghcr.io/osbuild/osbuild-ci:latest-202502250751
           run: |
             make lint
 
diff --git a/.github/workflows/generate.yml b/.github/workflows/generate.yml
index 0082eb11..aba0a99d 100644
--- a/.github/workflows/generate.yml
+++ b/.github/workflows/generate.yml
@@ -49,7 +49,7 @@ jobs:
     - name: "Regenerate Test Data"
       uses: osbuild/containers/src/actions/privdocker@552e30cf1b4ed19c6ddaa57f96c342b3dff4227b
       with:
-        image: ghcr.io/osbuild/osbuild-ci:latest-202501201758
+        image: ghcr.io/osbuild/osbuild-ci:latest-202502250751
         run: |
           make test-data
           git diff --exit-code -- ./test/data
diff --git a/.mypy.ini b/.mypy.ini
index c1af4012..1a0e5d67 100644
--- a/.mypy.ini
+++ b/.mypy.ini
@@ -18,3 +18,5 @@ ignore_missing_imports = True
 [mypy-libdnf5.*]
 ignore_missing_imports = True
 
+[mypy-license_expression.*]
+ignore_missing_imports = True
diff --git a/osbuild.spec b/osbuild.spec
index a711950e..c8196959 100644
--- a/osbuild.spec
+++ b/osbuild.spec
@@ -1,7 +1,7 @@
 %global         forgeurl https://github.com/osbuild/osbuild
 %global         selinuxtype targeted
 
-Version:        142
+Version:        143
 
 %forgemeta
 
@@ -148,6 +148,13 @@ Requires: python3-libdnf5 >= 5.2.1
 Requires: python3-dnf
 %endif
 
+%if 0%{?fedora}
+# RHEL / CS does not have python3-license-expression
+# It is needed for validating license expressions in RPM packages when generating SBOMs
+# While SBOMs can be generated also without this package, it is recommended to have it.
+Recommends: python3-license-expression
+%endif
+
 # osbuild 125 added a new "solver" field and osbuild-composer only
 # supports this since 116
 Conflicts: osbuild-composer <= 115
diff --git a/osbuild/__init__.py b/osbuild/__init__.py
index d2c0488d..aaa57b2e 100644
--- a/osbuild/__init__.py
+++ b/osbuild/__init__.py
@@ -10,7 +10,7 @@ independent of osbuild but used across the osbuild codebase.
 
 from .pipeline import Manifest, Pipeline, Stage
 
-__version__ = "142"
+__version__ = "143"
 
 __all__ = [
     "Manifest",
diff --git a/osbuild/solver/dnf.py b/osbuild/solver/dnf.py
index 13affb9c..99217d5e 100755
--- a/osbuild/solver/dnf.py
+++ b/osbuild/solver/dnf.py
@@ -17,7 +17,7 @@ from osbuild.util.sbom.spdx import sbom_pkgset_to_spdx2_doc
 
 
 class DNF(SolverBase):
-    def __init__(self, request, persistdir, cache_dir):
+    def __init__(self, request, persistdir, cache_dir, license_index_path=None):
         arch = request["arch"]
         releasever = request.get("releasever")
         module_platform_id = request["module_platform_id"]
@@ -93,6 +93,9 @@ class DNF(SolverBase):
         # enable module resolving
         self.base_module = dnf.module.module_base.ModuleBase(self.base)
 
+        # Custom license index file path use for SBOM generation
+        self.license_index_path = license_index_path
+
     @staticmethod
     def _dnfrepo(desc, parent_conf=None):
         """Makes a dnf.repo.Repo out of a JSON repository description"""
@@ -167,15 +170,14 @@ class DNF(SolverBase):
     def _timestamp_to_rfc3339(timestamp):
         return datetime.utcfromtimestamp(timestamp).strftime('%Y-%m-%dT%H:%M:%SZ')
 
-    @staticmethod
-    def _sbom_for_pkgset(pkgset: List[dnf.package.Package]) -> Dict:
+    def _sbom_for_pkgset(self, pkgset: List[dnf.package.Package]) -> Dict:
         """
         Create an SBOM document for the given package set.
 
         For now, only SPDX v2 is supported.
         """
         pkgset = dnf_pkgset_to_sbom_pkgset(pkgset)
-        spdx_doc = sbom_pkgset_to_spdx2_doc(pkgset)
+        spdx_doc = sbom_pkgset_to_spdx2_doc(pkgset, self.license_index_path)
         return spdx_doc.to_dict()
 
     def dump(self):
diff --git a/osbuild/solver/dnf5.py b/osbuild/solver/dnf5.py
index 9b7a2271..58e33e71 100755
--- a/osbuild/solver/dnf5.py
+++ b/osbuild/solver/dnf5.py
@@ -56,7 +56,7 @@ class DNF5(SolverBase):
     """
 
     # pylint: disable=too-many-arguments
-    def __init__(self, request, persistdir, cachedir):
+    def __init__(self, request, persistdir, cachedir, license_index_path=None):
         arch = request["arch"]
         releasever = request.get("releasever")
         module_platform_id = request["module_platform_id"]
@@ -167,6 +167,9 @@ class DNF5(SolverBase):
         except RuntimeError as e:
             raise RepoError(e) from e
 
+        # Custom license index file path use for SBOM generation
+        self.license_index_path = license_index_path
+
     _BASEARCH_MAP = _invert({
         'aarch64': ('aarch64',),
         'alpha': ('alpha', 'alphaev4', 'alphaev45', 'alphaev5', 'alphaev56',
@@ -278,15 +281,14 @@ class DNF5(SolverBase):
     def _timestamp_to_rfc3339(timestamp):
         return datetime.utcfromtimestamp(timestamp).strftime('%Y-%m-%dT%H:%M:%SZ')
 
-    @staticmethod
-    def _sbom_for_pkgset(pkgset: List[dnf5.rpm.Package]) -> Dict:
+    def _sbom_for_pkgset(self, pkgset: List[dnf5.rpm.Package]) -> Dict:
         """
         Create an SBOM document for the given package set.
 
         For now, only SPDX v2 is supported.
         """
         pkgset = dnf_pkgset_to_sbom_pkgset(pkgset)
-        spdx_doc = sbom_pkgset_to_spdx2_doc(pkgset)
+        spdx_doc = sbom_pkgset_to_spdx2_doc(pkgset, self.license_index_path)
         return spdx_doc.to_dict()
 
     def dump(self):
diff --git a/osbuild/util/sbom/spdx.py b/osbuild/util/sbom/spdx.py
index 94eb787e..541452b3 100644
--- a/osbuild/util/sbom/spdx.py
+++ b/osbuild/util/sbom/spdx.py
@@ -1,11 +1,79 @@
+import os
 from datetime import datetime
-from typing import List, Union
+from typing import Dict, List, Optional, Union
 from uuid import uuid4
 
 import osbuild
 import osbuild.util.sbom.model as sbom_model
 import osbuild.util.sbom.spdx2 as spdx2
 
+try:
+    from license_expression import ExpressionError, get_spdx_licensing
+except ImportError:
+    get_spdx_licensing = None
+    ExpressionError = None
+
+
+class SpdxLicenseExpressionCreator:
+    """
+    Class for creating SPDX license expressions from license strings.
+
+    This class uses the license-expression package to parse license strings and convert them to SPDX license, if
+    possible.
+
+    The class object also keeps track of all extracted licensing information objects that were created during the
+    conversion process. The extracted licensing information objects are stored in a dictionary, where the key is the
+    license reference ID and the value is the ExtractedLicensingInfo object.
+    """
+
+    def __init__(self, license_index_location=None):
+        self._extracted_license_infos: Dict[str, spdx2.ExtractedLicensingInfo] = {}
+        self._spdx_licensing = None
+
+        if get_spdx_licensing:
+            if license_index_location:
+                self._spdx_licensing = get_spdx_licensing(license_index_location)
+            else:
+                self._spdx_licensing = get_spdx_licensing()
+        elif license_index_location:
+            raise ValueError("The license-expression package is not available. "
+                             "Specify the license index location has no effect.")
+
+    def _to_extracted_license_info(self, license_str: str) -> spdx2.ExtractedLicensingInfo:
+        eli = spdx2.ExtractedLicensingInfo(license_str)
+        return self._extracted_license_infos.setdefault(eli.license_ref_id, eli)
+
+    def ensure_license_expression(self, license_str: str) -> Union[str, spdx2.ExtractedLicensingInfo]:
+        """
+        Convert a license string to a valid SPDX license expression or wrap it in an ExtractedLicensingInfo object.
+
+        This function uses the license-expression package to parse the license string and convert it to an SPDX license
+        expression. If the license string can't be parsed and converted to an SPDX license expression, it is wrapped in an
+        ExtractedLicensingInfo object.
+
+        If the license-expression package is not available, the license string is always wrapped in an
+        ExtractedLicensingInfo object.
+
+        License strings that are already SPDX license ref IDs are returned as is.
+        """
+        if license_str.startswith("LicenseRef-"):
+            # The license string is already an SPDX license ref ID.
+            return license_str
+
+        if self._spdx_licensing is None:
+            return self._to_extracted_license_info(license_str)
+
+        try:
+            return str(self._spdx_licensing.parse(license_str, validate=True, strict=True))
+        except ExpressionError:
+            return self._to_extracted_license_info(license_str)
+
+    def extracted_license_infos(self) -> List[spdx2.ExtractedLicensingInfo]:
+        """
+        Return a list of all extracted licensing information objects that were created during the conversion process.
+        """
+        return list(self._extracted_license_infos.values())
+
 
 def spdx2_checksum_algorithm(algorithm: sbom_model.ChecksumAlgorithm) -> spdx2.ChecksumAlgorithm:
     if algorithm == sbom_model.ChecksumAlgorithm.SHA1:
@@ -41,9 +109,12 @@ def create_spdx2_document():
     return doc
 
 
-def sbom_pkgset_to_spdx2_doc(pkgset: List[sbom_model.BasePackage]) -> spdx2.Document:
+def sbom_pkgset_to_spdx2_doc(
+        pkgset: List[sbom_model.BasePackage],
+        license_index_location: Optional[os.PathLike] = None) -> spdx2.Document:
     doc = create_spdx2_document()
     relationships = []
+    license_expr_creator = SpdxLicenseExpressionCreator(license_index_location)
 
     for pkg in pkgset:
 
@@ -51,13 +122,15 @@ def sbom_pkgset_to_spdx2_doc(pkgset: List[sbom_model.BasePackage]) -> spdx2.Docu
         if pkg.download_url:
             download_location = pkg.download_url
 
+        license_declared = license_expr_creator.ensure_license_expression(pkg.license_declared)
+
         p = spdx2.Package(
             spdx_id=f"SPDXRef-{pkg.uuid()}",
             name=pkg.name,
             download_location=download_location,
             version=pkg.version,
             files_analyzed=False,
-            license_declared=pkg.license_declared,
+            license_declared=license_declared,
             external_references=[
                 spdx2.ExternalPackageRef(
                     category=spdx2.ExternalPackageRefCategory.PACKAGE_MANAGER,
@@ -120,4 +193,8 @@ def sbom_pkgset_to_spdx2_doc(pkgset: List[sbom_model.BasePackage]) -> spdx2.Docu
 
     doc.relationships = relationships
 
+    extracted_license_infos = license_expr_creator.extracted_license_infos()
+    if len(extracted_license_infos) > 0:
+        doc.extracted_licensing_infos = extracted_license_infos
+
     return doc
diff --git a/osbuild/util/sbom/spdx2/__init__.py b/osbuild/util/sbom/spdx2/__init__.py
index cc6d3647..a01aee7c 100644
--- a/osbuild/util/sbom/spdx2/__init__.py
+++ b/osbuild/util/sbom/spdx2/__init__.py
@@ -9,6 +9,7 @@ from .model import (
     Document,
     ExternalPackageRef,
     ExternalPackageRefCategory,
+    ExtractedLicensingInfo,
     NoAssertionValue,
     NoneValue,
     Package,
@@ -24,6 +25,7 @@ __all__ = [
     "CreatorType",
     "Document",
     "ExternalPackageRef",
+    "ExtractedLicensingInfo",
     "ExternalPackageRefCategory",
     "NoAssertionValue",
     "NoneValue",
diff --git a/osbuild/util/sbom/spdx2/model.py b/osbuild/util/sbom/spdx2/model.py
index 2de5bbbd..b47f1cf4 100644
--- a/osbuild/util/sbom/spdx2/model.py
+++ b/osbuild/util/sbom/spdx2/model.py
@@ -3,6 +3,7 @@ A base implementation of SPDX 2.3 model, as described on:
 https://spdx.github.io/spdx-spec/v2.3/
 """
 
+import hashlib
 import re
 from datetime import datetime, timezone
 from enum import Enum, auto
@@ -216,6 +217,60 @@ class Checksum():
         }
 
 
+def normalize_name_for_license_id(name: str) -> str:
+    """
+    Normalize a license name to be used within an SPDX license ID.
+
+    The function does the following things:
+    - Ensures that the returned string contains only letters, numbers, "." and/or "-".
+        All other characters are replaced with "-".
+    - Deduplicates consecutive "." and "-" characters.
+
+    See also:
+    https://spdx.github.io/spdx-spec/v2.3/other-licensing-information-detected/#1011-description:
+    """
+    normalized_name = re.sub(r"[^a-zA-Z0-9.-]", "-", name)
+    normalized_name = re.sub(r"([.-])\1+", r"\1", normalized_name)
+    return normalized_name
+
+
+def generate_license_id(extracted_text: str, name: Optional[str] = None) -> str:
+    """
+    Generate a unique SPDX license ID by hashing the extracted text using SHA-256.
+
+    If a license name is provided, include it in the license ID.
+    """
+    extracted_text_hash = hashlib.sha256(extracted_text.encode()).hexdigest()
+    if name is not None:
+        return f"LicenseRef-{normalize_name_for_license_id(name)}-{extracted_text_hash}"
+    return f"LicenseRef-{extracted_text_hash}"
+
+
+class ExtractedLicensingInfo():
+    """
+    Represents extracted licensing information for a license not on the SPDX License List.
+
+    https://spdx.github.io/spdx-spec/v2.3/other-licensing-information-detected/
+    """
+
+    def __init__(self, extracted_text: str, name: Optional[str] = None) -> None:
+        self.extracted_text = extracted_text
+        self.name = name
+        self.license_ref_id = generate_license_id(self.extracted_text, self.name)
+
+    def __str__(self):
+        return self.license_ref_id
+
+    def to_dict(self):
+        d = {
+            "licenseId": self.license_ref_id,
+            "extractedText": self.extracted_text,
+        }
+        if self.name:
+            d["name"] = self.name
+        return d
+
+
 # pylint: disable=too-many-instance-attributes
 class Package(EntityWithSpdxId):
     """Represents an SPDX package."""
@@ -230,7 +285,7 @@ class Package(EntityWithSpdxId):
         checksums: Optional[List[Checksum]] = None,
         homepage: Optional[Union[str, NoAssertionValue, NoneValue]] = None,
         source_info: Optional[str] = None,
-        license_declared: Optional[Union[str, NoAssertionValue, NoneValue]] = None,
+        license_declared: Optional[Union[str, ExtractedLicensingInfo, NoAssertionValue, NoneValue]] = None,
         summary: Optional[str] = None,
         description: Optional[str] = None,
         external_references: Optional[List[ExternalPackageRef]] = None,
@@ -324,15 +379,19 @@ class Document():
         creation_info: CreationInfo,
         packages: Optional[List[Package]] = None,
         relationships: Optional[List[Relationship]] = None,
+        extracted_licensing_infos: Optional[List[ExtractedLicensingInfo]] = None,
     ) -> None:
         self.creation_info = creation_info
         self.packages = packages or []
         self.relationships = relationships or []
+        self.extracted_licensing_infos = extracted_licensing_infos or []
 
     def to_dict(self):
         d = self.creation_info.to_dict()
         for package in self.packages:
             d.setdefault("packages", []).append(package.to_dict())
+        for extracted_licensing_info in self.extracted_licensing_infos:
+            d.setdefault("hasExtractedLicensingInfos", []).append(extracted_licensing_info.to_dict())
         for relationship in self.relationships:
             d.setdefault("relationships", []).append(relationship.to_dict())
         return d
diff --git a/setup.py b/setup.py
index 91cb2f60..8dac754b 100644
--- a/setup.py
+++ b/setup.py
@@ -2,7 +2,7 @@ import setuptools
 
 setuptools.setup(
     name="osbuild",
-    version="142",
+    version="143",
     description="A build system for OS images",
     packages=[
         "osbuild",
diff --git a/tools/osbuild-depsolve-dnf b/tools/osbuild-depsolve-dnf
index 3877d32f..186121aa 100755
--- a/tools/osbuild-depsolve-dnf
+++ b/tools/osbuild-depsolve-dnf
@@ -20,7 +20,8 @@ config = {"use_dnf5": False}
 config_path = os.environ.get("OSBUILD_SOLVER_CONFIG") or "/usr/lib/osbuild/solver.json"
 try:
     with open(config_path, encoding="utf-8") as f:
-        config = json.load(f)
+        loaded_config = json.load(f)
+        config.update(loaded_config)
 except FileNotFoundError:
     pass
 
@@ -58,7 +59,7 @@ def solve(request, cache_dir):
 
     with tempfile.TemporaryDirectory() as persistdir:
         try:
-            solver = Solver(request, persistdir, cache_dir)
+            solver = Solver(request, persistdir, cache_dir, config.get("license_index_path"))
             if command == "dump":
                 result = solver.dump()
             elif command == "depsolve":
diff --git a/tox.ini b/tox.ini
index 87a5e7ce..e2b862ae 100644
--- a/tox.ini
+++ b/tox.ini
@@ -19,6 +19,7 @@ deps =
     iniparse
     pyyaml
     toml
+    license_expression
     pykickstart
     # required by pykickstart but not pulled in automatically :/
     requests


Test patch:
diff --git a/.github/workflows/test-on-centos.yml b/.github/workflows/test-on-centos.yml
index 0ae08679..3fd650a1 100644
--- a/.github/workflows/test-on-centos.yml
+++ b/.github/workflows/test-on-centos.yml
@@ -22,7 +22,7 @@ jobs:
       - name: "Run in container"
         uses: addnab/docker-run-action@v3
         with:
-          image: quay.io/osbuild/osbuild-ci-c${{ matrix.centos.version }}s:latest-202501201758
+          image: quay.io/osbuild/osbuild-ci-c${{ matrix.centos.version }}s:latest-202502250751
           options: --privileged -v ${{ github.workspace }}:/osbuild --workdir /osbuild
           run: |
             python3 -m pytest \
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 034a844c..e3886e60 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -30,7 +30,7 @@ jobs:
     - name: "Run"
       uses: osbuild/containers/src/actions/privdocker@552e30cf1b4ed19c6ddaa57f96c342b3dff4227b
       with:
-        image: ghcr.io/osbuild/osbuild-ci:latest-202501201758
+        image: ghcr.io/osbuild/osbuild-ci:latest-202502250751
         run: |
           # Hacky replacement of container storage driver:
           # The default overlayfs doesn't work in the runner, so let's change
@@ -69,7 +69,7 @@ jobs:
           # Using 4 workers is a bit arbitrary, "auto" is probably too aggressive.
           TEST_WORKERS: "-n 4"
         with:
-          image: ghcr.io/osbuild/osbuild-ci:latest-202501201758
+          image: ghcr.io/osbuild/osbuild-ci:latest-202502250751
           run: |
             OSBUILD_TEST_STORE="${{ env.OSBUILD_TEST_STORE }}" \
             tox -e "py36" -- ${{ env.TEST_WORKERS }} test.run.test_assemblers
diff --git a/test/data/spdx/README.md b/test/data/spdx/README.md
new file mode 100644
index 00000000..869560a0
--- /dev/null
+++ b/test/data/spdx/README.md
@@ -0,0 +1,4 @@
+# This directory contains SPDX-related files used for testing
+
+- `spdx-schema-v2.3.1.json` is the SPDX 2.3.1 JSON schema file from the [spdx-spec upstream](https://github.com/spdx/spdx-spec/blob/62f3e7cbd448a19f0099fdbd707d8b9b4fbddfdd/schemas/spdx-schema.json)
+- `custom-license-index.json` is a custom license DB JSON file which includes only definition for `MIT` license and a new non-existent license `GPLv2` at the end of the file. The latest actual license DB JSON files can be downloaded from the [ScanCode LicenseDB JSON index](https://scancode-licensedb.aboutcode.org/index.json). See also [SPDX licenses](https://spdx.org/licenses/) and [ScanCode LicenseDB help](https://scancode-licensedb.aboutcode.org/help.html).
diff --git a/test/data/spdx/custom-license-index.json b/test/data/spdx/custom-license-index.json
new file mode 100644
index 00000000..9f298a45
--- /dev/null
+++ b/test/data/spdx/custom-license-index.json
@@ -0,0 +1,31 @@
+[
+  {
+    "license_key": "mit",
+    "category": "Permissive",
+    "spdx_license_key": "MIT",
+    "other_spdx_license_keys": [
+      "LicenseRef-MIT-Bootstrap",
+      "LicenseRef-MIT-Discord",
+      "LicenseRef-MIT-TC",
+      "LicenseRef-MIT-Diehl"
+    ],
+    "is_exception": false,
+    "is_deprecated": false,
+    "json": "mit.json",
+    "yaml": "mit.yml",
+    "html": "mit.html",
+    "license": "mit.LICENSE"
+  },
+  {
+    "license_key": "GPLv2",
+    "category": "Permissive",
+    "spdx_license_key": "GPLv2",
+    "other_spdx_license_keys": [],
+    "is_exception": false,
+    "is_deprecated": false,
+    "json": "GPLv2.json",
+    "yaml": "GPLv2.yml",
+    "html": "GPLv2.html",
+    "license": "GPLv2.LICENSE"
+  }
+]
diff --git a/test/mod/test_util_sbom_spdx.py b/test/mod/test_util_sbom_spdx.py
index 8fd15375..6ea904a6 100644
--- a/test/mod/test_util_sbom_spdx.py
+++ b/test/mod/test_util_sbom_spdx.py
@@ -3,11 +3,63 @@ import os
 import pytest
 
 import osbuild
-from osbuild.util.sbom.spdx import create_spdx2_document, sbom_pkgset_to_spdx2_doc, spdx2_checksum_algorithm
-from osbuild.util.sbom.spdx2.model import CreatorType, ExternalPackageRefCategory, RelationshipType
-
-testutil_dnf4 = pytest.importorskip("osbuild.testutil.dnf4")
-bom_dnf = pytest.importorskip("osbuild.util.sbom.dnf")
+from osbuild.util.sbom.spdx import (
+    SpdxLicenseExpressionCreator,
+    create_spdx2_document,
+    sbom_pkgset_to_spdx2_doc,
+    spdx2_checksum_algorithm,
+)
+from osbuild.util.sbom.spdx2.model import (
+    CreatorType,
+    ExternalPackageRefCategory,
+    ExtractedLicensingInfo,
+    RelationshipType,
+)
+
+from ..test import patch_license_expression
+
+CUSTOM_LICENSE_DB_LOCATION = "./test/data/spdx/custom-license-index.json"
+
+
+@pytest.mark.parametrize("licensing_available", (True, False))
+def test_spdxlicenseexpressionfactory_license_expression_availability(licensing_available):
+    with patch_license_expression(licensing_available) as mocked_licensing:
+        lf = SpdxLicenseExpressionCreator()
+        license_expression = lf.ensure_license_expression("MIT")
+
+        license_expression2 = lf.ensure_license_expression("LicenseRef-123")
+        assert license_expression2 == "LicenseRef-123"
+
+        if licensing_available:
+            assert mocked_licensing is not None
+            # The license string should be a SPDX license expression string.
+            assert license_expression == "MIT"
+            assert len(lf.extracted_license_infos()) == 0
+        else:
+            assert mocked_licensing is None
+            # The license string should be wrapped in an ExtractedLicensingInfo,
+            # because the license-expression package is not available.
+            assert isinstance(license_expression, ExtractedLicensingInfo)
+            assert str(license_expression).startswith("LicenseRef-")
+            assert license_expression.extracted_text == "MIT"
+            assert len(lf.extracted_license_infos()) == 1
+
+
+@pytest.mark.parametrize("licensing_available", (True, False))
+def test_spdxlicenseexpressionfactory_custom_license_index(licensing_available):
+    with patch_license_expression(licensing_available) as mocked_licensing:
+        if licensing_available:
+            assert mocked_licensing is not None
+            lf = SpdxLicenseExpressionCreator(CUSTOM_LICENSE_DB_LOCATION)
+            license_text = "GPLv2"
+            license_expression = lf.ensure_license_expression(license_text)
+            assert license_expression == license_text
+            assert len(lf.extracted_license_infos()) == 0
+        else:
+            assert mocked_licensing is None
+            with pytest.raises(ValueError, match="The license-expression package is not available. " +
+                               "Specify the license index location has no effect."):
+                lf = SpdxLicenseExpressionCreator(CUSTOM_LICENSE_DB_LOCATION)
 
 
 def test_create_spdx2_document():
@@ -36,41 +88,82 @@ def test_create_spdx2_document():
     assert doc1_dict == doc2_dict
 
 
-def test_sbom_pkgset_to_spdx2_doc():
+@pytest.mark.parametrize("licensing_available", (True, False))
+@pytest.mark.parametrize("license_index_location", (None, CUSTOM_LICENSE_DB_LOCATION))
+def test_sbom_pkgset_to_spdx2_doc(licensing_available, license_index_location):
+    testutil_dnf4 = pytest.importorskip("osbuild.testutil.dnf4")
+    bom_dnf = pytest.importorskip("osbuild.util.sbom.dnf")
+
     dnf_pkgset = testutil_dnf4.depsolve_pkgset([os.path.abspath("./test/data/testrepos/baseos")], ["bash"])
     bom_pkgset = bom_dnf.dnf_pkgset_to_sbom_pkgset(dnf_pkgset)
-    doc = sbom_pkgset_to_spdx2_doc(bom_pkgset)
-
-    assert len(doc.packages) == len(bom_pkgset)
-    for spdx_pkg, bom_pkg in zip(doc.packages, bom_pkgset):
-        assert spdx_pkg.spdx_id == f"SPDXRef-{bom_pkg.uuid()}"
-        assert spdx_pkg.name == bom_pkg.name
-        assert spdx_pkg.version == bom_pkg.version
-        assert not spdx_pkg.files_analyzed
-        assert spdx_pkg.license_declared == bom_pkg.license_declared
-        assert spdx_pkg.download_location == bom_pkg.download_url
-        assert spdx_pkg.homepage == bom_pkg.homepage
-        assert spdx_pkg.summary == bom_pkg.summary
-        assert spdx_pkg.description == bom_pkg.description
-        assert spdx_pkg.source_info == bom_pkg.source_info()
-        assert spdx_pkg.built_date == bom_pkg.build_date
-
-        assert len(spdx_pkg.checksums) == 1
-        assert spdx_pkg.checksums[0].algorithm == spdx2_checksum_algorithm(list(bom_pkg.checksums.keys())[0])
-        assert spdx_pkg.checksums[0].value == list(bom_pkg.checksums.values())[0]
-
-        assert len(spdx_pkg.external_references) == 1
-        assert spdx_pkg.external_references[0].category == ExternalPackageRefCategory.PACKAGE_MANAGER
-        assert spdx_pkg.external_references[0].reference_type == "purl"
-        assert spdx_pkg.external_references[0].locator == bom_pkg.purl()
-
-    assert len([rel for rel in doc.relationships if rel.relationship_type ==
-                RelationshipType.DESCRIBES]) == len(bom_pkgset)
-
-    deps_count = sum(len(bom_pkg.depends_on) for bom_pkg in bom_pkgset)
-    assert len([rel for rel in doc.relationships if rel.relationship_type ==
-                RelationshipType.DEPENDS_ON]) == deps_count
-
-    optional_deps_count = sum(len(bom_pkg.optional_depends_on) for bom_pkg in bom_pkgset)
-    assert len([rel for rel in doc.relationships if rel.relationship_type ==
-                RelationshipType.OPTIONAL_DEPENDENCY_OF]) == optional_deps_count
+
+    with patch_license_expression(licensing_available) as _:
+        extracted_licensing_infos = set()
+
+        try:
+            doc = sbom_pkgset_to_spdx2_doc(bom_pkgset, license_index_location)
+        except ValueError:
+            # ValueError can be raised only if the license-expression package is not available
+            # and a custom license index file is used.
+            if not licensing_available and license_index_location:
+                return
+        else:
+            if not licensing_available and license_index_location:
+                pytest.fail("Expected a ValueError to be raised when the license-expression package is not available.")
+
+        assert len(doc.packages) == len(bom_pkgset)
+        for spdx_pkg, bom_pkg in zip(doc.packages, bom_pkgset):
+            assert spdx_pkg.spdx_id == f"SPDXRef-{bom_pkg.uuid()}"
+            assert spdx_pkg.name == bom_pkg.name
+            assert spdx_pkg.version == bom_pkg.version
+            assert not spdx_pkg.files_analyzed
+            assert spdx_pkg.download_location == bom_pkg.download_url
+            assert spdx_pkg.homepage == bom_pkg.homepage
+            assert spdx_pkg.summary == bom_pkg.summary
+            assert spdx_pkg.description == bom_pkg.description
+            assert spdx_pkg.source_info == bom_pkg.source_info()
+            assert spdx_pkg.built_date == bom_pkg.build_date
+
+            # If the license-expression package is available and no custom license index file is used, only the "MIT"
+            # license is converted as a valid SPDX license expression for our testing package set. Otherwise, also the
+            # "GPLv2" license is converted as a valid SPDX license expression.
+            valid_licenses = ["MIT"]
+            if license_index_location:
+                valid_licenses.append("GPLv2")
+
+            if licensing_available and bom_pkg.license_declared in valid_licenses:
+                assert isinstance(spdx_pkg.license_declared, str)
+                assert spdx_pkg.license_declared in valid_licenses
+            # If the license-expression package is not available, all licenses are converted
+            # to SPDX license references.
+            # The same applies to all licenses that are not "MIT" if the package is available,
+            # because the testing package set contains only "MIT" as a valid SPDX license expression.
+            else:
+                assert isinstance(spdx_pkg.license_declared, ExtractedLicensingInfo)
+                assert str(spdx_pkg.license_declared).startswith("LicenseRef-")
+                assert spdx_pkg.license_declared.extracted_text == bom_pkg.license_declared
+                extracted_licensing_infos.add(spdx_pkg.license_declared)
+
+            assert len(spdx_pkg.checksums) == 1
+            assert spdx_pkg.checksums[0].algorithm == spdx2_checksum_algorithm(list(bom_pkg.checksums.keys())[0])
+            assert spdx_pkg.checksums[0].value == list(bom_pkg.checksums.values())[0]
+
+            assert len(spdx_pkg.external_references) == 1
+            assert spdx_pkg.external_references[0].category == ExternalPackageRefCategory.PACKAGE_MANAGER
+            assert spdx_pkg.external_references[0].reference_type == "purl"
+            assert spdx_pkg.external_references[0].locator == bom_pkg.purl()
+
+        assert len([rel for rel in doc.relationships if rel.relationship_type ==
+                    RelationshipType.DESCRIBES]) == len(bom_pkgset)
+
+        deps_count = sum(len(bom_pkg.depends_on) for bom_pkg in bom_pkgset)
+        assert len([rel for rel in doc.relationships if rel.relationship_type ==
+                    RelationshipType.DEPENDS_ON]) == deps_count
+
+        optional_deps_count = sum(len(bom_pkg.optional_depends_on) for bom_pkg in bom_pkgset)
+        assert len([rel for rel in doc.relationships if rel.relationship_type ==
+                    RelationshipType.OPTIONAL_DEPENDENCY_OF]) == optional_deps_count
+
+        assert len(extracted_licensing_infos) > 0
+        assert sorted(extracted_licensing_infos, key=lambda x: x.license_ref_id) == \
+            sorted(doc.extracted_licensing_infos, key=lambda x: x.license_ref_id)
diff --git a/test/mod/test_util_sbom_spdx2_model.py b/test/mod/test_util_sbom_spdx2_model.py
index 0c4ec8cf..cfd28af3 100644
--- a/test/mod/test_util_sbom_spdx2_model.py
+++ b/test/mod/test_util_sbom_spdx2_model.py
@@ -1,3 +1,5 @@
+# pylint: disable=protected-access
+import hashlib
 import json
 from datetime import datetime
 
@@ -15,12 +17,15 @@ from osbuild.util.sbom.spdx2.model import (
     EntityWithSpdxId,
     ExternalPackageRef,
     ExternalPackageRefCategory,
+    ExtractedLicensingInfo,
     NoAssertionValue,
     NoneValue,
     Package,
     Relationship,
     RelationshipType,
     datetime_to_iso8601,
+    generate_license_id,
+    normalize_name_for_license_id,
 )
 
 zoneinfo = pytest.importorskip("zoneinfo")
@@ -231,6 +236,72 @@ def test_checksum_to_dict():
     }
 
 
+@pytest.mark.parametrize("name,expected_str", (
+    ("MIT", "MIT"),
+    ("MIT-2.0", "MIT-2.0"),
+    ("My License", "My-License"),
+    ("%$MIT))  ((@\",))", "-MIT-"),
+    ("MIT.and more-12345", "MIT.and-more-12345"),
+    ("......-----------.......---------", ".-.-"),
+    ("", ""))
+)
+def test_normalize_name_for_license_id(name, expected_str):
+    assert normalize_name_for_license_id(name) == expected_str
+
+
+@pytest.mark.parametrize("extracted_text,name", (
+    ("MIT", "test"),
+    ("MIT", None),
+    ("MIT and GPLv2", "MIT and GPLv2"))
+)
+def test_generate_license_id(extracted_text, name):
+    extracted_text_hash = hashlib.sha256(extracted_text.encode()).hexdigest()
+    expect = f"LicenseRef-{normalize_name_for_license_id(name)}-{extracted_text_hash}" \
+        if name else f"LicenseRef-{extracted_text_hash}"
+    assert generate_license_id(extracted_text, name) == expect
+
+
+# The __str__ just returns the license ID, which is generated by the _generate_license_id method
+# tested above. So, the test case looks the same for now.
+@pytest.mark.parametrize("extracted_text,name", (
+    ("MIT", "test"),
+    ("MIT", None),
+    ("MIT and GPLv2", "MIT and GPLv2"))
+)
+def test_extracted_licensing_info___str__(extracted_text, name):
+    extracted_text_hash = hashlib.sha256(extracted_text.encode()).hexdigest()
+    expect = f"LicenseRef-{normalize_name_for_license_id(name)}-{extracted_text_hash}" \
+        if name else f"LicenseRef-{extracted_text_hash}"
+    assert str(ExtractedLicensingInfo(extracted_text, name)) == expect
+
+
+@pytest.mark.parametrize("test_case", (
+    {
+        "instance_args": {
+            "extracted_text": "MIT and GPLv2",
+        },
+        "expected": {
+            "licenseId": "LicenseRef-7805d4303e817ddd5f86dcf6541af84daac5c5b4a8ad1fb4cd14def8a4ca3d13",
+            "extractedText": "MIT and GPLv2"
+        }
+    },
+    {
+        "instance_args": {
+            "extracted_text": "MIT and GPLv2",
+            "name": "MIT and GPLv2",
+        },
+        "expected": {
+            "licenseId": "LicenseRef-MIT-and-GPLv2-7805d4303e817ddd5f86dcf6541af84daac5c5b4a8ad1fb4cd14def8a4ca3d13",
+            "extractedText": "MIT and GPLv2",
+            "name": "MIT and GPLv2"
+        }
+    }
+))
+def test_extracted_licensing_info_to_dict(test_case):
+    p = ExtractedLicensingInfo(**test_case["instance_args"])
+    assert p.to_dict() == test_case["expected"]
+
+
 @pytest.mark.parametrize("test_case", (
     {
         "instance_args": {
@@ -308,6 +379,58 @@ def test_checksum_to_dict():
             ],
             "builtDate": "2024-11-15T13:33:59Z"
         }
+    },
+    # Test with ExtractedLicensingInfo
+    {
+        "instance_args": {
+            "spdx_id": "SPDXRef-package-1.2.3",
+            "name": "package",
+            "download_location": NoneValue(),
+            "files_analyzed": False,
+            "checksums": [
+                Checksum(ChecksumAlgorithm.SHA256, "123456")
+            ],
+            "version": "1.2.3",
+            "homepage": "https://example.org/package",
+            "source_info": "https://example.org/package-1.2.3.src.rpm",
+            "license_declared": ExtractedLicensingInfo("MIT and GPLv2"),
+            "summary": "A sample package",
+            "description": "A sample package description",
+            "external_references": [
+                ExternalPackageRef(
+                    ExternalPackageRefCategory.PACKAGE_MANAGER,
+                    "purl",
+                    "pkg:rpm:/example/package@1.2.3-1?arch=x86_64"
+                )
+            ],
+            "built_date": datetime(2024, 11, 15, 14, 33, 59, tzinfo=zoneinfo.ZoneInfo("Europe/Prague"))
+        },
+        "expected": {
+            "SPDXID": "SPDXRef-package-1.2.3",
+            "name": "package",
+            "downloadLocation": "NONE",
+            "filesAnalyzed": False,
+            "checksums": [
+                {
+                    "algorithm": "SHA256",
+                    "checksumValue": "123456"
+                }
+            ],
+            "versionInfo": "1.2.3",
+            "homepage": "https://example.org/package",
+            "sourceInfo": "https://example.org/package-1.2.3.src.rpm",
+            "licenseDeclared": "LicenseRef-7805d4303e817ddd5f86dcf6541af84daac5c5b4a8ad1fb4cd14def8a4ca3d13",
+            "summary": "A sample package",
+            "description": "A sample package description",
+            "externalRefs": [
+                {
+                    "referenceCategory": "PACKAGE-MANAGER",
+                    "referenceType": "purl",
+                    "referenceLocator": "pkg:rpm:/example/package@1.2.3-1?arch=x86_64"
+                }
+            ],
+            "builtDate": "2024-11-15T13:33:59Z"
+        }
     }
 ))
 def test_package_to_dict(test_case):
@@ -464,6 +587,147 @@ def test_relationship_to_dict(test_case):
                 }
             ]
         }
+    },
+    # Test with ExtractedLicensingInfo
+    {
+        "instance_args": {
+            "creation_info": CreationInfo(
+                "SPDX-2.3",
+                "SPDXRef-DOCUMENT",
+                "Sample-Document",
+                "https://example.com",
+                [Creator(CreatorType.TOOL, "Sample-Tool-123")],
+                datetime(2024, 11, 15, 14, 33, 59, tzinfo=zoneinfo.ZoneInfo("Europe/Prague")),
+                "Public Domain"
+            ),
+            "packages": [
+                Package(
+                    "SPDXRef-packageA-1.2.3",
+                    "package",
+                    "https://example.org/packageA-1.2.3.rpm"
+                ),
+                Package(
+                    "SPDXRef-packageB-3.2.1",
+                    "package",
+                    "https://example.org/packageB-3.2.1.rpm"
+                ),
+                Package(
+                    "SPDXRef-package-1.2.3",
+                    "package",
+                    NoneValue(),
+                    "1.2.3",
+                    False,
+                    [Checksum(ChecksumAlgorithm.SHA256, "123456")],
+                    "https://example.org/package",
+                    "https://example.org/package-1.2.3.src.rpm",
+                    ExtractedLicensingInfo("MIT and GPLv2"),
+                    "A sample package",
+                    "A sample package description",
+                    [
+                        ExternalPackageRef(
+                            ExternalPackageRefCategory.PACKAGE_MANAGER,
+                            "purl",
+                            "pkg:rpm:/example/package@1.2.3-1?arch=x86_64"
+                        )
+                    ],
+                    datetime(2024, 11, 15, 14, 33, 59, tzinfo=zoneinfo.ZoneInfo("Europe/Prague"))
+                ),
+            ],
+            "relationships": [
+                Relationship(
+                    "SPDXRef-DOCUMENT",
+                    RelationshipType.DESCRIBES,
+                    "SPDXRef-packageA-1.2.3"
+                ),
+                Relationship(
+                    "SPDXRef-DOCUMENT",
+                    RelationshipType.DESCRIBES,
+                    "SPDXRef-packageB-3.2.1"
+                ),
+                Relationship(
+                    "SPDXRef-packageA-1.2.3",
+                    RelationshipType.DEPENDS_ON,
+                    "SPDXRef-packageB-3.2.1"
+                )
+            ],
+            "extracted_licensing_infos": [
+                ExtractedLicensingInfo("MIT and GPLv2")
+            ]
+        },
+        "expected": {
+            "spdxVersion": "SPDX-2.3",
+            "SPDXID": "SPDXRef-DOCUMENT",
+            "name": "Sample-Document",
+            "dataLicense": "Public Domain",
+            "documentNamespace": "https://example.com",
+            "creationInfo": {
+                "created": "2024-11-15T13:33:59Z",
+                "creators": [
+                    "Tool: Sample-Tool-123"
+                ]
+            },
+            "packages": [
+                {
+                    "SPDXID": "SPDXRef-packageA-1.2.3",
+                    "name": "package",
+                    "downloadLocation": "https://example.org/packageA-1.2.3.rpm"
+                },
+                {
+                    "SPDXID": "SPDXRef-packageB-3.2.1",
+                    "name": "package",
+                    "downloadLocation": "https://example.org/packageB-3.2.1.rpm"
+                },
+                {
+                    "SPDXID": "SPDXRef-package-1.2.3",
+                    "name": "package",
+                    "downloadLocation": "NONE",
+                    "filesAnalyzed": False,
+                    "checksums": [
+                        {
+                            "algorithm": "SHA256",
+                            "checksumValue": "123456"
+                        }
+                    ],
+                    "versionInfo": "1.2.3",
+                    "homepage": "https://example.org/package",
+                    "sourceInfo": "https://example.org/package-1.2.3.src.rpm",
+                    "licenseDeclared": "LicenseRef-7805d4303e817ddd5f86dcf6541af84daac5c5b4a8ad1fb4cd14def8a4ca3d13",
+                    "summary": "A sample package",
+                    "description": "A sample package description",
+                    "externalRefs": [
+                        {
+                            "referenceCategory": "PACKAGE-MANAGER",
+                            "referenceType": "purl",
+                            "referenceLocator": "pkg:rpm:/example/package@1.2.3-1?arch=x86_64"
+                        }
+                    ],
+                    "builtDate": "2024-11-15T13:33:59Z"
+                }
+            ],
+            "relationships": [
+                {
+                    "spdxElementId": "SPDXRef-DOCUMENT",
+                    "relationshipType": "DESCRIBES",
+                    "relatedSpdxElement": "SPDXRef-packageA-1.2.3"
+                },
+                {
+                    "spdxElementId": "SPDXRef-DOCUMENT",
+                    "relationshipType": "DESCRIBES",
+                    "relatedSpdxElement": "SPDXRef-packageB-3.2.1"
+                },
+                {
+                    "spdxElementId": "SPDXRef-packageA-1.2.3",
+                    "relationshipType": "DEPENDS_ON",
+                    "relatedSpdxElement": "SPDXRef-packageB-3.2.1"
+                }
+            ],
+            "hasExtractedLicensingInfos": [
+                {
+                    'extractedText': 'MIT and GPLv2',
+                    'licenseId': 'LicenseRef-7805d4303e817ddd5f86dcf6541af84daac5c5b4a8ad1fb4cd14def8a4ca3d13',
+                }
+            ]
+        }
     }
 ))
 def test_document_to_dict(test_case):
diff --git a/test/test.py b/test/test.py
index 018bc4e0..fe2eec92 100644
--- a/test/test.py
+++ b/test/test.py
@@ -10,6 +10,9 @@ import subprocess
 import sys
 import tempfile
 import unittest
+from unittest.mock import patch
+
+import pytest
 
 import osbuild.meta
 from osbuild.objectstore import ObjectStore
@@ -496,3 +499,40 @@ class OSBuild(contextlib.AbstractContextManager):
             "cp", "--reflink=auto", "-a",
             os.path.join(from_path, "."), to_path
         ], check=True)
+
+
+class patch_license_expression:
+    """
+    Context manager to patch the license-expression package availability.
+
+    The context manager simulates the unavailability of the license-expression package by mocking the
+    `get_spdx_licensing()` module-level function. If the package should be made available
+    and it is available on the system, the function is passed through. Otherwise, pytest.skip() is called.
+    """
+
+    PATCH_TARGET = "osbuild.util.sbom.spdx.get_spdx_licensing"
+
+    def __init__(self, make_package_available):
+        self.make_package_available = make_package_available
+        self.patcher = None
+
+    def __enter__(self):
+        get_spdx_licensing = None
+        try:
+            # pylint: disable=import-outside-toplevel
+            from license_expression import get_spdx_licensing
+        except ImportError:
+            pass
+
+        if self.make_package_available:
+            if get_spdx_licensing:
+                self.patcher = patch(self.PATCH_TARGET, new=get_spdx_licensing)
+            else:
+                pytest.skip("The license-expression package is not available.")
+        else:
+            # The package is either not available or should be made unavailable, so make sure the function var is None.
+            self.patcher = patch(self.PATCH_TARGET, new=None)
+        return self.patcher.start()
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        self.patcher.stop()
diff --git a/tools/test/test_depsolve.py b/tools/test/test_depsolve.py
index 21c5f735..dcde3b6e 100644
--- a/tools/test/test_depsolve.py
+++ b/tools/test/test_depsolve.py
@@ -40,6 +40,23 @@ def assert_dnf():
         raise RuntimeError("Cannot import dnf")
 
 
+def is_license_expression_available():
+    """
+    Check if the license-expression package is available.
+
+    The check is not done by importing the package in the current Python environment, because it may be
+    running inside a virtual environment where the package is / is not installed. Instead, the check is done by
+    running a Python script outside the virtual environment.
+
+    For the same reason, we don't use `sys.executable` to run the script, because it may point to a different
+    Python interpreter than the one that will be used when `osbuild-depsolve-dnf` is executed.
+    """
+    cmd = ["/usr/bin/python3", "-c", "from license_expression import get_spdx_licensing as _"]
+    if sp.run(cmd, check=False).returncode != 0:
+        return False
+    return True
+
+
 def depsolve(transactions, cache_dir, dnf_config=None, repos=None, root_dir=None,
              opt_metadata=None, with_sbom=False) -> Tuple[dict, int]:
     if not repos and not root_dir:
@@ -1523,18 +1540,27 @@ def test_depsolve_config_combos(tmp_path, repo_servers, dnf_config, detect_fn):
                 assert res["solver"] == "dnf"
 
 
+# pylint: disable=too-many-branches
+@pytest.mark.parametrize("custom_license_db", [None, "./test/data/spdx/custom-license-index.json"])
 @pytest.mark.parametrize("with_sbom", [False, True])
 @pytest.mark.parametrize("dnf_config, detect_fn", [
     ({}, assert_dnf),
     ({"use_dnf5": False}, assert_dnf),
     ({"use_dnf5": True}, assert_dnf5),
 ], ids=["no-config", "dnf4", "dnf5"])
-def test_depsolve_sbom(tmp_path, repo_servers, dnf_config, detect_fn, with_sbom):
+def test_depsolve_sbom(tmp_path, repo_servers, dnf_config, detect_fn, with_sbom, custom_license_db):
     try:
         detect_fn()
     except RuntimeError as e:
         pytest.skip(str(e))
 
+    if custom_license_db:
+        if not is_license_expression_available():
+            pytest.skip("license_expression python module is not available")
+
+        dnf_config = dnf_config.copy()
+        dnf_config["license_index_path"] = custom_license_db
+
     test_case = depsolve_test_case_basic_2pkgs_2repos
     transactions = test_case["transactions"]
     repo_configs = get_test_case_repo_configs(test_case, repo_servers)
@@ -1548,6 +1574,7 @@ def test_depsolve_sbom(tmp_path, repo_servers, dnf_config, detect_fn, with_sbom)
     for repo in res["repos"].values():
         assert repo["gpgkeys"] == [TEST_KEY + repo["id"]]
         assert repo["sslverify"] is False
+
     if with_sbom:
         assert "sbom" in res
 
@@ -1560,6 +1587,25 @@ def test_depsolve_sbom(tmp_path, repo_servers, dnf_config, detect_fn, with_sbom)
         spdx_validator.validate(res["sbom"])
 
         assert {pkg["name"] for pkg in res["sbom"]["packages"]} == test_case["results"]["packages"]
+
+        license_expressions = [pkg["licenseDeclared"] for pkg in res["sbom"]["packages"]]
+        license_refs = [le for le in license_expressions if le.startswith("LicenseRef-")]
+        non_license_refs = [le for le in license_expressions if not le.startswith("LicenseRef-")]
+        if not is_license_expression_available():
+            # all license expressions shhould be converted to ExtractedLicensingInfo
+            assert len(license_refs) == len(license_expressions)
+            assert len(non_license_refs) == 0
+        else:
+            # some license expressions should not be converted to ExtractedLicensingInfo
+            assert len(license_refs) < len(license_expressions)
+            if custom_license_db:
+                assert len(non_license_refs) == 5
+                # "GPLv2" is not a valid SPDX license expression, but it is added in our custom license db
+                assert "GPLv2" in non_license_refs
+            else:
+                assert len(non_license_refs) == 2
+                assert "GPLv2" not in non_license_refs
+
     else:
         assert "sbom" not in res