feat: read what a Linux kernel has built and loaded, once for every driver

A kernel CVE's exploitability often hangs on code that is not there: a module
neither loaded nor shipped, an option the kernel was built without. netOrk's
KB precondition vocabulary asks exactly that (kernel_module, kernel_config).

Reading it is the same on every Linux host, so the command and its parse live
here and a driver supplies only the transport:

- KERNEL_FACTS_COMMAND: one read-only POSIX sh line, no privileges. Release,
  /proc/modules, modules.builtin, modules.dep and the build configuration
  (/boot/config-* or /proc/config.gz). The report is framed, gzipped and
  base64-encoded, so nothing in it can look like a shell prompt to a
  screen-scraping transport, and ~300 kB of configuration crosses as a fifth.
- parse_kernel_facts(): a section the command could not print comes back None,
  never empty -- "could not read" and "read, and nothing there" must stay apart.
- module_name(): no path, no .ko suffix, "-" folded to "_", as the kernel does.
- KernelFactsMixin, in the template form: get_kernel_facts() is concrete,
  _run_kernel_facts_command() is the driver's hook. Mixed in by the drivers
  that can, not by OSDriver -- a Windows host is an OS driver too, and
  hasattr(driver, "get_kernel_facts") has to stay truthful.
- KernelFactsDict in models.py. Version 2.1.0.
This commit is contained in:
2026-10-05 06:17:30 +02:00
parent 97e7ede131
commit 536ffcf6e1
6 changed files with 349 additions and 1 deletions
+151
View File
@@ -0,0 +1,151 @@
"""get_kernel_facts: what the running kernel has built and loaded.
A kernel CVE's preconditions ask whether a module is loaded or a build option
set. Reading that is the same on every Linux host -- one read-only command and
its parse -- so both live here once, and a driver only carries the command
across (#268 in netOrk).
"""
from __future__ import annotations
import base64
import gzip
import os
import subprocess
import pytest
from napalm_device_types import OSDriver
from napalm_device_types.kernel import (
KERNEL_FACTS_COMMAND,
KernelFactsMixin,
module_name,
parse_kernel_facts,
)
REPORT = """[release]
6.1.0-25-amd64
[loaded]
tipc
nf_tables
[builtin]
kernel/net/ipv4/tcp_cubic.ko
kernel/drivers/char/tpm/tpm-tis.ko
[available]
kernel/net/tipc/tipc.ko.xz
kernel/net/can/can-raw.ko.zst
kernel/net/netfilter/nf_tables.ko
[config]
CONFIG_TIPC=m
CONFIG_BPF_JIT=y
CONFIG_DEFAULT_HOSTNAME="(none)"
CONFIG_HZ=250
"""
def _wire(report: str, *, noise: str = "") -> str:
"""The report as the command prints it: framed, gzipped, base64 in lines."""
payload = base64.encodebytes(gzip.compress(report.encode())).decode()
return f"{noise}KFACTS_BEGIN\n{payload}KFACTS_END\n"
class TestParsing:
def test_every_section_is_read(self):
facts = parse_kernel_facts(_wire(REPORT))
assert facts["release"] == "6.1.0-25-amd64"
assert facts["loaded"] == ["nf_tables", "tipc"]
assert facts["builtin"] == ["tcp_cubic", "tpm_tis"]
assert facts["available"] == ["can_raw", "nf_tables", "tipc"]
assert facts["config"] == {
"CONFIG_TIPC": "m",
"CONFIG_BPF_JIT": "y",
"CONFIG_DEFAULT_HOSTNAME": "(none)",
"CONFIG_HZ": "250",
}
def test_a_section_never_printed_is_none_not_empty(self):
"""``None`` is "could not read"; an empty list would claim "read it,
and there is nothing" -- and that is what turns a module into
``not_met`` downstream."""
facts = parse_kernel_facts(_wire("[release]\n6.1.0\n[loaded]\n"))
assert facts["loaded"] == []
assert facts["builtin"] is None
assert facts["available"] is None
assert facts["config"] is None
def test_whatever_surrounds_the_frame_is_ignored(self):
"""A screen-scraping transport may echo the command or a banner."""
noise = "Last login: today\nprintf '%s%s\\n' KFACTS_ BEGIN; ...\n"
assert parse_kernel_facts(_wire(REPORT, noise=noise))["release"] == "6.1.0-25-amd64"
def test_output_without_the_frame_raises(self):
with pytest.raises(ValueError):
parse_kernel_facts("sh: gzip: not found\n")
def test_a_damaged_payload_raises(self):
with pytest.raises(ValueError):
parse_kernel_facts("KFACTS_BEGIN\nnot base64 at all!\nKFACTS_END\n")
class TestModuleNames:
@pytest.mark.parametrize(
"raw, name",
[
("tipc", "tipc"),
("kernel/net/tipc/tipc.ko", "tipc"),
("kernel/net/tipc/tipc.ko.zst", "tipc"),
("kernel/net/can/can-raw.ko.xz", "can_raw"),
("CAN-RAW", "can_raw"),
(" nf_tables ", "nf_tables"),
],
)
def test_dash_and_underscore_are_one_name(self, raw, name):
"""The kernel treats ``-`` and ``_`` in module names as the same."""
assert module_name(raw) == name
class TestTheCommand:
def test_the_frame_is_not_in_the_command_itself(self):
"""An echoing transport prints the command back; the markers must only
appear once the command has run."""
assert "KFACTS_BEGIN" not in KERNEL_FACTS_COMMAND
assert "KFACTS_END" not in KERNEL_FACTS_COMMAND
def test_it_writes_nothing(self):
for verb in ("modprobe", "insmod", "rmmod", "sudo", " > ", ">>"):
assert verb not in KERNEL_FACTS_COMMAND
@pytest.mark.skipif(os.uname().sysname != "Linux", reason="reads a Linux kernel")
def test_it_runs_and_parses_on_this_host(self):
out = subprocess.run(
["sh", "-c", KERNEL_FACTS_COMMAND], capture_output=True, text=True, timeout=60
).stdout
facts = parse_kernel_facts(out)
assert facts["release"] == os.uname().release
assert facts["loaded"] is None or all(isinstance(m, str) for m in facts["loaded"])
class TestTheTemplate:
def test_a_driver_supplies_only_the_transport(self):
class Driver(KernelFactsMixin):
def _run_kernel_facts_command(self, command: str) -> str:
self.sent = command
return _wire(REPORT)
driver = Driver()
facts = driver.get_kernel_facts()
assert driver.sent == KERNEL_FACTS_COMMAND
assert facts["release"] == "6.1.0-25-amd64"
def test_not_every_os_driver_has_it(self):
"""A Windows host is an OSDriver too, and has no Linux kernel to read:
``hasattr`` has to stay a truthful answer, so the drivers that can mix
this in themselves."""
assert not issubclass(OSDriver, KernelFactsMixin)
assert not hasattr(OSDriver, "get_kernel_facts")