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/pyproject-scancode-toolkit-mini.toml b/pyproject-scancode-toolkit-mini.toml index a816bb2de7..7233bad1ba 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", diff --git a/pyproject-scancode-toolkit.toml b/pyproject-scancode-toolkit.toml index 407d65b9c4..83788d10ce 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", @@ -266,6 +267,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 cbd405f2bd..9fde2bf6b6 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", @@ -271,6 +272,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..41392b8378 --- /dev/null +++ b/src/scancode/plugin_lkm.py @@ -0,0 +1,130 @@ +# +# 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]: + """ + Returns a list of strings, each being a kernel module name + Normalized fields to a list of strings derived from the '.modinfo' metadata. The 'depends' field is a + comma-separated list of required kernel 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/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