diff --git a/AUTHORS.rst b/AUTHORS.rst
index 9a8224dc7d..d84a325506 100644
--- a/AUTHORS.rst
+++ b/AUTHORS.rst
@@ -27,6 +27,7 @@ The following organizations or individuals have contributed to ScanCode:
- Daniel Eder @daniel-eder
- Dan Kegel @dankegel
- Dennis Clark @DennisClark
+- Diana Galeana @diana-galeana
- Divyansh Sharma @Divyansh2512
- Duncan Howe @Duncan-Howe
- Felix Kauselmann @selmf
@@ -61,6 +62,7 @@ The following organizations or individuals have contributed to ScanCode:
- Nisha Kumar @nishakm
- Nishchith Shetty @inishchith
- Nitish Sharma @nitish81299
+- Octavio Valdivia Mendoza @OctavioValdiviaMendoza
- Paul Gier @pgier
- Philippe Ombredanne @pombredanne
- Pi Delport @PiDelport
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index d9a5a6b402..98efe78b41 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -8,6 +8,9 @@ Next release
``licensedcode-data``.
https://github.com/aboutcode-org/scancode-toolkit/pull/5056
+- Add a file-level scan plugin for compiled Linux Kernel Module (``.ko``)
+ files that extracts metadata from the ELF ``.modinfo`` section.
+
v33.0.0rc1 - 2026-05-14
------------------------
diff --git a/azure-pipelines.yml b/azure-pipelines.yml
index b0abbc0ec6..b5259c0e4f 100644
--- a/azure-pipelines.yml
+++ b/azure-pipelines.yml
@@ -111,8 +111,8 @@ jobs:
# and we use the special --test-suite=plugins marker for these
# tests
additional_license_combined: |
- venv/bin/pip install tests/licensedcode/data/additional_licenses/additional_plugin_1/
- venv/bin/pip install tests/licensedcode/data/additional_licenses/additional_plugin_2/
+ venv/bin/pip install --no-deps tests/licensedcode/data/additional_licenses/additional_plugin_1/
+ venv/bin/pip install --no-deps tests/licensedcode/data/additional_licenses/additional_plugin_2/
venv/bin/scancode-reindex-licenses \
--additional-directory tests/licensedcode/data/additional_licenses/additional_dir/
venv/bin/pytest -vvs --test-suite=plugins \
diff --git a/pyproject-scancode-toolkit-mini.toml b/pyproject-scancode-toolkit-mini.toml
index b40f4d07fe..3ed8d62dfa 100644
--- a/pyproject-scancode-toolkit-mini.toml
+++ b/pyproject-scancode-toolkit-mini.toml
@@ -88,6 +88,7 @@ dependencies = [
"plugincode >= 32.0.0",
"publicsuffix2",
"pyahocorasick >= 2.3.0",
+ "pyelftools >= 0.32",
"pygmars >= 1.0.0",
"pygments >= 1.0.0",
"pymaven_patch >= 0.2.8",
@@ -264,6 +265,7 @@ facet = "summarycode.facet:AddFacet"
# module for details and doc.
[project.entry-points.scancode_scan]
info = "scancode.plugin_info:InfoScanner"
+lkm = "scancode.plugin_lkm:LinuxKernelModuleScanner"
licenses = "licensedcode.plugin_license:LicenseScanner"
copyrights = "cluecode.plugin_copyright:CopyrightScanner"
packages = "packagedcode.plugin_package:PackageScanner"
diff --git a/pyproject-scancode-toolkit.toml b/pyproject-scancode-toolkit.toml
index 29f8157b8d..f72e91721d 100644
--- a/pyproject-scancode-toolkit.toml
+++ b/pyproject-scancode-toolkit.toml
@@ -88,6 +88,7 @@ dependencies = [
"plugincode >= 32.0.0",
"publicsuffix2",
"pyahocorasick >= 2.3.0",
+ "pyelftools >= 0.32",
"pygmars >= 1.0.0",
"pygments >= 1.0.0",
"pymaven_patch >= 0.2.8",
@@ -265,6 +266,7 @@ facet = "summarycode.facet:AddFacet"
# module for details and doc.
[project.entry-points.scancode_scan]
info = "scancode.plugin_info:InfoScanner"
+lkm = "scancode.plugin_lkm:LinuxKernelModuleScanner"
licenses = "licensedcode.plugin_license:LicenseScanner"
copyrights = "cluecode.plugin_copyright:CopyrightScanner"
packages = "packagedcode.plugin_package:PackageScanner"
diff --git a/pyproject.toml b/pyproject.toml
index f2371c6bb2..af2dd0e574 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -89,6 +89,7 @@ dependencies = [
"plugincode >= 32.0.0",
"publicsuffix2",
"pyahocorasick >= 2.3.0",
+ "pyelftools >= 0.32",
"pygmars >= 1.0.0",
"pygments >= 1.0.0",
"pymaven_patch >= 0.2.8",
@@ -270,6 +271,7 @@ facet = "summarycode.facet:AddFacet"
# module for details and doc.
[project.entry-points.scancode_scan]
info = "scancode.plugin_info:InfoScanner"
+lkm = "scancode.plugin_lkm:LinuxKernelModuleScanner"
licenses = "licensedcode.plugin_license:LicenseScanner"
copyrights = "cluecode.plugin_copyright:CopyrightScanner"
packages = "packagedcode.plugin_package:PackageScanner"
diff --git a/requirements.txt b/requirements.txt
index e534652d52..05175e3b58 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -63,6 +63,7 @@ publicsuffix2==2.20191221
py==1.11.0
pyahocorasick==2.3.1
pycparser==2.23
+pyelftools==0.32
pygmars==1.0.0
Pygments==2.13.0
pymaven-patch==0.3.2
diff --git a/src/packagedcode/__init__.py b/src/packagedcode/__init__.py
index fc1e490eef..fa5c85eb07 100644
--- a/src/packagedcode/__init__.py
+++ b/src/packagedcode/__init__.py
@@ -234,7 +234,7 @@
debian.DebianInstalledStatusDatabaseHandler,
rpm.RpmLicenseFilesHandler,
- rpm.RpmMarinerContainerManifestHandler
+ rpm.RpmMarinerContainerManifestHandler,
]
if on_linux:
diff --git a/src/scancode/plugin_lkm.py b/src/scancode/plugin_lkm.py
new file mode 100644
index 0000000000..0286587c81
--- /dev/null
+++ b/src/scancode/plugin_lkm.py
@@ -0,0 +1,129 @@
+#
+# Copyright (c) nexB Inc. and others. All rights reserved.
+# ScanCode is a trademark of nexB Inc.
+# SPDX-License-Identifier: Apache-2.0
+# See http://www.apache.org/licenses/LICENSE-2.0 for the license text.
+# See https://github.com/nexB/scancode-toolkit for support or download.
+# See https://aboutcode.org for more information about nexB OSS projects.
+#
+
+from typing import Dict
+from typing import List
+
+import attr
+
+from commoncode.cliutils import OTHER_SCAN_GROUP
+from commoncode.cliutils import PluggableCommandLineOption
+from elftools.common.exceptions import ELFError
+from elftools.elf.elffile import ELFFile
+from plugincode.scan import ScanPlugin
+from plugincode.scan import scan_impl
+
+
+@scan_impl
+class LinuxKernelModuleScanner(ScanPlugin):
+ """
+ Scan Linux kernel module files for metadata stored in their ELF
+ '.modinfo' section.
+ """
+
+ resource_attributes = dict(
+ linux_kernel_module=attr.ib(default=None, repr=False),
+ )
+
+ run_order = 9
+ sort_order = 9
+
+ options = [
+ PluggableCommandLineOption(
+ ('--lkm',),
+ is_flag=True,
+ default=False,
+ help='Scan Linux kernel module files for .modinfo metadata.',
+ help_group=OTHER_SCAN_GROUP,
+ )
+ ]
+
+ def is_enabled(self, lkm, **kwargs):
+ return lkm
+
+ def get_scanner(self, **kwargs):
+ return scan_linux_kernel_module
+
+
+def scan_linux_kernel_module(location, **kwargs):
+ """
+ Return a mapping of Linux kernel module metadata found in the '.ko' file
+ at 'location'. Return an empty mapping for other files and for files that
+ do not contain usable '.modinfo' metadata.
+ """
+ if not location.lower().endswith('.ko'):
+ return {}
+
+ metadata = extract_modinfo(location)
+ if not metadata:
+ return {}
+
+ # The .modinfo "depends" value is a comma-separated list of required
+ # kernel module names, not a list of package-management dependencies.
+ metadata['depends'] = get_dependency_names(metadata)
+
+ return dict(linux_kernel_module=metadata)
+
+
+def extract_modinfo(location: str) -> Dict[str, List[str]]:
+ """
+ Extract '.modinfo' metadata from the Linux kernel module file at 'location'.
+
+ Return '.modinfo' metadata as a mapping of keys to lists of values.
+
+ Multiple values are preserved because fields such as 'author', 'alias',
+ and 'firmware' may occur more than once.
+ """
+ metadata: Dict[str, List[str]] = {}
+
+ try:
+ with open(location, 'rb') as module_file:
+ elf_file = ELFFile(module_file)
+ modinfo_section = elf_file.get_section_by_name('.modinfo')
+ if modinfo_section is None:
+ return {}
+
+ raw_bytes = modinfo_section.data()
+
+ except (ELFError, OSError):
+ return {}
+
+ # Entries in .modinfo are NUL-terminated key=value strings.
+ for raw_entry in raw_bytes.split(b'\x00'):
+ if not raw_entry:
+ continue
+
+ entry = raw_entry.decode('utf-8', errors='replace')
+ if '=' not in entry:
+ continue
+
+ key, value = entry.split('=', 1)
+ if not key:
+ continue
+
+ metadata.setdefault(key, []).append(value)
+
+ return metadata
+
+
+def get_dependency_names(metadata: Dict[str, List[str]]) -> List[str]:
+ """
+ Return normalized kernel module dependency names from '.modinfo' metadata.
+ The 'depends' field is a comma-separated list of required module names.
+ """
+ dependency_names = []
+
+ for depends_entry in metadata.get('depends', []):
+ dependency_names.extend(
+ dependency.strip()
+ for dependency in depends_entry.split(',')
+ if dependency.strip()
+ )
+
+ return dependency_names
diff --git a/tests/scancode/data/help/help.txt b/tests/scancode/data/help/help.txt
index 52bc2e0ce7..667000ccd5 100644
--- a/tests/scancode/data/help/help.txt
+++ b/tests/scancode/data/help/help.txt
@@ -23,6 +23,7 @@ Options:
other scans:
-i, --info Scan for file information (size, checksums, etc).
--generated Classify automatically generated code files with a flag.
+ --lkm Scan Linux kernel module files for .modinfo metadata.
-e, --email Scan for emails.
-u, --url Scan for urls.
diff --git a/tests/scancode/data/help/help_linux.txt b/tests/scancode/data/help/help_linux.txt
index 9630f39fb0..9506b8948b 100644
--- a/tests/scancode/data/help/help_linux.txt
+++ b/tests/scancode/data/help/help_linux.txt
@@ -25,6 +25,7 @@ Options:
other scans:
-i, --info Scan for file information (size, checksums, etc).
--generated Classify automatically generated code files with a flag.
+ --lkm Scan Linux kernel module files for .modinfo metadata.
-e, --email Scan for emails.
-u, --url Scan for urls.
diff --git a/tests/scancode/test_plugin_lkm.py b/tests/scancode/test_plugin_lkm.py
new file mode 100644
index 0000000000..953286921a
--- /dev/null
+++ b/tests/scancode/test_plugin_lkm.py
@@ -0,0 +1,127 @@
+#
+# Copyright (c) nexB Inc. and others. All rights reserved.
+# ScanCode is a trademark of nexB Inc.
+# SPDX-License-Identifier: Apache-2.0
+# See http://www.apache.org/licenses/LICENSE-2.0 for the license text.
+# See https://github.com/nexB/scancode-toolkit for support or download.
+# See https://aboutcode.org for more information about nexB OSS projects.
+#
+
+import json
+from pathlib import Path
+
+from scancode import plugin_lkm
+from scancode.cli_test_utils import run_scan_click
+
+
+class TestLinuxKernelModuleScanner:
+
+ test_data_dir = (
+ Path(__file__).resolve().parents[2]
+ / 'tests'
+ / 'licensedcode'
+ / 'data'
+ / 'query'
+ )
+
+ def test_extract_modinfo(self):
+ test_file = self.test_data_dir / 'eeepc_acpi.ko'
+
+ metadata = plugin_lkm.extract_modinfo(str(test_file))
+
+ assert 'license' in metadata
+ assert 'description' in metadata
+ assert 'author' in metadata
+
+ def test_get_dependency_names(self):
+ metadata = {
+ 'depends': [
+ 'usbcore,cfg80211,mac80211',
+ ]
+ }
+
+ result = plugin_lkm.get_dependency_names(metadata)
+
+ assert result == [
+ 'usbcore',
+ 'cfg80211',
+ 'mac80211',
+ ]
+
+ def test_get_dependency_names_handles_multiple_entries(self):
+ metadata = {
+ 'depends': [
+ 'usbcore,cfg80211',
+ 'mac80211,netdev',
+ ]
+ }
+
+ result = plugin_lkm.get_dependency_names(metadata)
+
+ assert result == [
+ 'usbcore',
+ 'cfg80211',
+ 'mac80211',
+ 'netdev',
+ ]
+
+ def test_scan_unrelated_file_returns_empty_mapping(self):
+ test_file = self.test_data_dir / 'apache-2.0.LICENSE'
+
+ result = plugin_lkm.scan_linux_kernel_module(str(test_file))
+
+ assert result == {}
+
+ def test_scan_invalid_lkm_returns_empty_mapping(self, tmp_path):
+ test_file = tmp_path / 'fake.ko'
+ test_file.write_bytes(b'not an elf file')
+
+ result = plugin_lkm.scan_linux_kernel_module(str(test_file))
+
+ assert result == {}
+
+ def test_scan_linux_kernel_module(self):
+ test_file = self.test_data_dir / 'eeepc_acpi.ko'
+
+ result = plugin_lkm.scan_linux_kernel_module(str(test_file))
+
+ assert list(result) == ['linux_kernel_module']
+
+ metadata = result['linux_kernel_module']
+ assert metadata['license'] == ['GPL']
+ assert metadata['description'] == ['Asus EeePC Hotkey Driver']
+ assert metadata['author'] == [
+ 'Julien Lerouge, Karol Kozimor, Eric Cooper',
+ ]
+ assert metadata['depends'] == []
+ assert metadata['srcversion'] == ['7FD8A46D43685ADB0819A28']
+ assert metadata['vermagic'] == [
+ '2.6.24-19-generic SMP mod_unload 586 ',
+ ]
+
+ def test_plugin_supplies_file_scanner(self):
+ scanner = plugin_lkm.LinuxKernelModuleScanner()
+
+ assert scanner.get_scanner() is plugin_lkm.scan_linux_kernel_module
+ assert scanner.is_enabled(lkm=True)
+ assert not scanner.is_enabled(lkm=False)
+ assert list(scanner.resource_attributes) == ['linux_kernel_module']
+
+ def test_lkm_scan_adds_file_metadata_without_package_data(self, tmp_path):
+ test_file = self.test_data_dir / 'eeepc_acpi.ko'
+ result_file = tmp_path / 'lkm-scan.json'
+
+ run_scan_click([
+ '--lkm',
+ '--json-pp',
+ str(result_file),
+ str(test_file),
+ ])
+
+ with result_file.open(encoding='utf-8') as results:
+ scanned_file = json.load(results)['files'][0]
+
+ metadata = scanned_file['linux_kernel_module']
+ assert metadata['license'] == ['GPL']
+ assert metadata['depends'] == []
+ assert 'package_data' not in scanned_file