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..93854485c6 100644
--- a/pyproject-scancode-toolkit-mini.toml
+++ b/pyproject-scancode-toolkit-mini.toml
@@ -264,6 +264,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..af028d9f6c 100644
--- a/pyproject-scancode-toolkit.toml
+++ b/pyproject-scancode-toolkit.toml
@@ -265,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.toml b/pyproject.toml
index f2371c6bb2..c363c1815b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -270,6 +270,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/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..5ad0dafdf0
--- /dev/null
+++ b/src/scancode/plugin_lkm.py
@@ -0,0 +1,192 @@
+#
+# 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 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'
+ using a custom minimal ELF parser.
+
+ 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.
+ """
+ import struct
+
+ metadata: Dict[str, List[str]] = {}
+ raw_bytes = b''
+
+ try:
+ with open(location, 'rb') as f:
+ e_ident = f.read(16)
+ if len(e_ident) == 16 and e_ident[0:4] == b'\x7fELF':
+ ei_class = e_ident[4] # 1 = 32-bit, 2 = 64-bit
+ ei_data = e_ident[5] # 1 = LSB, 2 = MSB
+
+ if ei_class in (1, 2) and ei_data in (1, 2):
+ endian = '<' if ei_data == 1 else '>'
+
+ if ei_class == 1:
+ # 32-bit ELF
+ f.seek(32)
+ header_data = f.read(20)
+ if len(header_data) == 20:
+ e_shoff, _, _, _, _, e_shentsize, e_shnum, e_shstrndx = struct.unpack(
+ endian + 'IIHHHHHH', header_data
+ )
+ else:
+ # 64-bit ELF
+ f.seek(40)
+ header_data = f.read(24)
+ if len(header_data) == 24:
+ e_shoff, _, _, _, _, e_shentsize, e_shnum, e_shstrndx = struct.unpack(
+ endian + 'QIHHHHHH', header_data
+ )
+
+ if e_shnum > 0 and e_shentsize > 0:
+ f.seek(e_shoff)
+ section_headers_data = f.read(e_shnum * e_shentsize)
+ if len(section_headers_data) == e_shnum * e_shentsize:
+
+ # Helper to parse section header at index
+ def get_section_header(idx):
+ offset = idx * e_shentsize
+ entry_data = section_headers_data[offset:offset + e_shentsize]
+ if len(entry_data) < e_shentsize:
+ return None
+ if ei_class == 1:
+ sh_name, _, _, _, sh_offset, sh_size = struct.unpack(
+ endian + 'IIIIII', entry_data[:24]
+ )
+ else:
+ sh_name, _, _, _, sh_offset, sh_size = struct.unpack(
+ endian + 'IIQQQQ', entry_data[:40]
+ )
+ return sh_name, sh_offset, sh_size
+
+ shstr_header = get_section_header(e_shstrndx)
+ if shstr_header:
+ _, shstr_offset, shstr_size = shstr_header
+ f.seek(shstr_offset)
+ shstr_table = f.read(shstr_size)
+ if len(shstr_table) == shstr_size:
+ for i in range(e_shnum):
+ header = get_section_header(i)
+ if not header:
+ continue
+ sh_name, sh_offset, sh_size = header
+
+ end = shstr_table.find(b'\x00', sh_name)
+ if end != -1:
+ name = shstr_table[sh_name:end].decode('utf-8', errors='ignore')
+ else:
+ name = shstr_table[sh_name:].decode('utf-8', errors='ignore')
+
+ if name == '.modinfo':
+ f.seek(sh_offset)
+ raw_bytes = f.read(sh_size)
+ break
+ except Exception:
+ 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