Skip to content

Commit

Permalink
feat: converted kernel config plugin to new base class WIP
Browse files Browse the repository at this point in the history
TODO: fix depending plugins
  • Loading branch information
jstucke committed Jan 22, 2025
1 parent ccb8283 commit 25edd22
Show file tree
Hide file tree
Showing 4 changed files with 152 additions and 109 deletions.
176 changes: 99 additions & 77 deletions src/plugins/analysis/kernel_config/code/kernel_config.py
Original file line number Diff line number Diff line change
@@ -1,78 +1,107 @@
from __future__ import annotations

import re
from typing import TYPE_CHECKING
from pathlib import Path
from typing import TYPE_CHECKING, List, Optional

from analysis.PluginBase import AnalysisBasePlugin
from pydantic import BaseModel
from semver import Version

from analysis.plugin import AnalysisPluginV0, Tag
from analysis.plugin.compat import AnalysisBasePluginAdapterMixin
from helperFunctions.tag import TagColor
from plugins.analysis.kernel_config.internal.checksec_check_kernel import CHECKSEC_PATH, check_kernel_config
from plugins.analysis.kernel_config.internal.decomp import decompress
from plugins.analysis.kernel_config.internal.kernel_config_hardening_check import check_kernel_hardening
from plugins.analysis.kernel_config.internal.kernel_config_hardening_check import (
HardeningCheckResult,
check_kernel_hardening,
)
from plugins.mime_blacklists import MIME_BLACKLIST_NON_EXECUTABLE

if TYPE_CHECKING:
from objects.file import FileObject
from io import FileIO

MAGIC_WORD = b'IKCFG_ST\037\213'
from plugins.analysis.file_type.code.file_type import AnalysisPlugin as FileTypePlugin
from plugins.analysis.software_components.code.software_components import AnalysisPlugin as SoftwarePlugin

MAGIC_WORD = b'IKCFG_ST\037\213'

class AnalysisPlugin(AnalysisBasePlugin):
NAME = 'kernel_config'
DESCRIPTION = 'Heuristics to find and analyze Linux Kernel configurations via checksec and kconfig-hardened-check'
MIME_BLACKLIST = MIME_BLACKLIST_NON_EXECUTABLE
DEPENDENCIES = ['file_type', 'software_components'] # noqa: RUF012
VERSION = '0.3.1'
FILE = __file__

def additional_setup(self):
class CheckSec(BaseModel):
kernel: dict
selinux: dict


class AnalysisPlugin(AnalysisPluginV0, AnalysisBasePluginAdapterMixin):
class Schema(BaseModel):
is_kernel_config: bool
kernel_config: Optional[str] = None
checksec: Optional[CheckSec] = None
hardening: List[HardeningCheckResult]

def __init__(self):
super().__init__(
metadata=(
self.MetaData(
name='kernel_config',
dependencies=['file_type', 'software_components'],
description=(
'Heuristics to find and analyze Linux Kernel configurations via checksec and '
'kconfig-hardened-check'
),
mime_blacklist=MIME_BLACKLIST_NON_EXECUTABLE,
version=Version(1, 0, 0),
Schema=self.Schema,
)
)
)
if not CHECKSEC_PATH.is_file():
raise RuntimeError(f'checksec not found at path {CHECKSEC_PATH}. Please re-run the backend installation.')
self.config_pattern = re.compile(r'^(CONFIG|# CONFIG)[_ -]\w[\w -]*=(\d+|[ymn])$', re.MULTILINE)
self.kernel_pattern_new = re.compile(r'^# Linux.* Kernel Configuration$', re.MULTILINE)
self.kernel_pattern_old = re.compile(r'^# Linux kernel version: [\d.]+$', re.MULTILINE)

def process_object(self, file_object: FileObject) -> FileObject:
file_object.processed_analysis[self.NAME] = {}

if self.object_mime_is_plaintext(file_object) and (
self.has_kconfig_type(file_object) or self.probably_kernel_config(file_object.binary)
):
self.add_kernel_config_to_analysis(file_object, file_object.binary)
elif file_object.file_name == 'configs.ko' or self.object_is_kernel_image(file_object):
maybe_config = self.try_object_extract_ikconfig(file_object.binary)
if self.probably_kernel_config(maybe_config):
self.add_kernel_config_to_analysis(file_object, maybe_config)

file_object.processed_analysis[self.NAME]['summary'] = self._get_summary(
file_object.processed_analysis[self.NAME]
def analyze(self, file_handle: FileIO, virtual_file_path: dict, analyses: dict[str, BaseModel]) -> Schema:
file_content = file_handle.read()

kernel_config: str | None = None
if self._is_kconfig(file_content, analyses['file_type']):
kernel_config = file_content.decode(errors='replace')
elif self._contains_kconfig(analyses['software_components'], virtual_file_path):
maybe_config = try_extracting_kconfig(file_content)
if self._is_probably_kconfig(maybe_config):
kernel_config = maybe_config.decode(errors='replace')

return self.Schema(
is_kernel_config=kernel_config is not None,
kernel_config=kernel_config,
checksec=check_kernel_config(kernel_config) if kernel_config else None,
hardening=check_kernel_hardening(kernel_config) if kernel_config else None,
)

if 'kernel_config' in file_object.processed_analysis[self.NAME]:
file_object.processed_analysis[self.NAME]['checksec'] = check_kernel_config(
file_object.processed_analysis[self.NAME]['kernel_config']
)
file_object.processed_analysis[self.NAME]['hardening'] = check_kernel_hardening(
file_object.processed_analysis[self.NAME]['kernel_config']
)
@staticmethod
def _contains_kconfig(software_analysis: SoftwarePlugin.Schema, vfp_dict: dict[str, list[str]]):
return _has_filename('configs.ko', vfp_dict) or object_is_kernel_image(software_analysis)

return file_object
def _is_kconfig(self, file_content: bytes, file_type_analysis: FileTypePlugin.Schema):
return file_type_analysis.mime == 'text/plain' and (
self._has_kconfig_type(file_type_analysis) or self._is_probably_kconfig(file_content)
)

@staticmethod
def has_kconfig_type(file_object: FileObject) -> bool:
file_type_str = file_object.processed_analysis.get('file_type', {}).get('result', {}).get('full', '')
return 'Linux make config' in file_type_str
def _has_kconfig_type(file_type_analysis: FileTypePlugin.Schema) -> bool:
return 'Linux make config' in file_type_analysis.full

@staticmethod
def _get_summary(results: dict) -> list[str]:
if 'is_kernel_config' in results and results['is_kernel_config'] is True:
return ['Kernel Config']
return []
def summarize(self, result: Schema) -> list[str]:
return ['Kernel Config'] if result.is_kernel_config else []

def add_kernel_config_to_analysis(self, file_object: FileObject, config_bytes: bytes):
file_object.processed_analysis[self.NAME]['is_kernel_config'] = True
file_object.processed_analysis[self.NAME]['kernel_config'] = config_bytes.decode()
self.add_analysis_tag(file_object, 'IKCONFIG', 'Kernel Configuration')
def get_tags(self, result: Schema, summary: list[str]) -> list[Tag]:
del summary
if result.is_kernel_config:
return [Tag(name='IKCONFIG', value='Kernel Configuration', color=TagColor.BLUE)]
return []

def probably_kernel_config(self, raw_data: bytes) -> bool:
def _is_probably_kconfig(self, raw_data: bytes) -> bool:
try:
content = raw_data.decode()
except UnicodeDecodeError:
Expand All @@ -83,38 +112,31 @@ def probably_kernel_config(self, raw_data: bytes) -> bool:

return len(kernel_config_banner) > 0 and len(config_directives) > 0

@staticmethod
def try_object_extract_ikconfig(raw_data: bytes) -> bytes:
container = raw_data
if raw_data.find(MAGIC_WORD) < 0:
# ikconfig is encapsulated in compression container => absence of magic word
inner = decompress(container)
if len(inner) == 0:
return b''
container = inner[0]

start_offset = container.find(MAGIC_WORD)
if start_offset < 0:

def try_extracting_kconfig(raw_data: bytes) -> bytes:
container = raw_data
if raw_data.find(MAGIC_WORD) < 0:
# ikconfig is encapsulated in compression container => absence of magic word
inner = decompress(container)
if len(inner) == 0:
return b''
container = inner[0]

maybe_configs = decompress(container[start_offset:])
start_offset = container.find(MAGIC_WORD)
if start_offset < 0:
return b''

if len(maybe_configs) == 0:
return b''
maybe_configs = decompress(container[start_offset:])

return maybe_configs[0]
if len(maybe_configs) == 0:
return b''

@staticmethod
def object_mime_is_plaintext(file_object: FileObject) -> bool:
return file_object.processed_analysis.get('file_type', {}).get('result', {}).get('mime') == 'text/plain'
return maybe_configs[0]

@staticmethod
def object_is_kernel_image(file_object: FileObject) -> bool:
return (
'software_components' in file_object.processed_analysis
and 'summary' in file_object.processed_analysis['software_components']
and any(
'linux kernel' in component.lower()
for component in file_object.processed_analysis['software_components']['summary']
)
)

def object_is_kernel_image(software_analysis: SoftwarePlugin.Schema) -> bool:
return any('linux kernel' in component.name.lower() for component in software_analysis.software_components)


def _has_filename(file_name, vfp_dict: dict[str, list[str]]):
return any(file_name == Path(path).name for path_list in vfp_dict.values() for path in path_list)
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
from json import JSONDecodeError
from subprocess import PIPE, STDOUT
from tempfile import NamedTemporaryFile
from typing import NamedTuple

from pydantic import BaseModel

# Based on https://github.com/a13xp0p0v/kconfig-hardened-check and
# https://github.com/a13xp0p0v/linux-kernel-defence-map by Alexander Popov
Expand Down Expand Up @@ -110,7 +111,7 @@
}


class HardeningCheckResult(NamedTuple):
class HardeningCheckResult(BaseModel):
option_name: str
desired_value: str
decision: str
Expand Down Expand Up @@ -147,20 +148,33 @@ def _get_kernel_hardening_data(kernel_config: str) -> list[list[str]]:

def _add_protection_info(hardening_result: list[list[str]]) -> list[HardeningCheckResult]:
full_result = []
for single_result in hardening_result:
config_key = single_result[0]
actual_value = _detach_actual_value_from_result(single_result)
protection_info = PROTECTS_AGAINST.get(config_key, [])
full_result.append(HardeningCheckResult(*single_result, actual_value, protection_info))
for name, desired, decision, reason, check_result in hardening_result:
split_result, actual_value = _detach_actual_value_from_result(check_result)
if actual_value == '' and split_result == 'OK':
# the actual value is sometimes empty if the check result is positive
actual_value = desired
protection_info = PROTECTS_AGAINST.get(name, [])
full_result.append(
HardeningCheckResult(
option_name=name,
desired_value=desired,
decision=decision,
reason=reason,
check_result=split_result,
actual_value=actual_value,
vulnerabilities=protection_info,
)
)
return full_result


def _detach_actual_value_from_result(single_result: list[str]) -> str:
def _detach_actual_value_from_result(check_result: str) -> tuple[str, str]:
"""
the result may contain the actual value after a colon
e.g. 'FAIL: not found' or 'FAIL: "y"'
removes actual value and returns it (or empty string if missing)
"""
split_result = single_result[4].split(': ')
single_result[4] = split_result[0]
return ': '.join(split_result[1:]).replace('"', '')
split_result = check_result.split(': ')
check_result = split_result[0]
actual_value = ': '.join(split_result[1:]).replace('"', '')
return check_result, actual_value
10 changes: 5 additions & 5 deletions src/plugins/analysis/kernel_config/test/test_kernel_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,19 @@ def test_probably_kernel_config_true(self, analysis_plugin):
test_file = FileObject(file_path=str(TEST_DATA_DIR / 'configs/CONFIG'))
test_file.processed_analysis['file_type'] = {'result': {'mime': 'text/plain'}}

assert analysis_plugin.probably_kernel_config(test_file.binary)
assert analysis_plugin._is_probably_kconfig(test_file.binary)

def test_probably_kernel_config_false(self, analysis_plugin):
test_file = FileObject(file_path=str(TEST_DATA_DIR / 'configs/CONFIG_MAGIC_CORRUPT'))
test_file.processed_analysis['file_type'] = {'result': {'mime': 'text/plain'}}

assert not analysis_plugin.probably_kernel_config(test_file.binary)
assert not analysis_plugin._is_probably_kconfig(test_file.binary)

def test_probably_kernel_config_utf_error(self, analysis_plugin):
test_file = FileObject(file_path=str(TEST_DATA_DIR / 'random_invalid/a.image'))
test_file.processed_analysis['file_type'] = {'result': {'mime': 'text/plain'}}

assert not analysis_plugin.probably_kernel_config(test_file.binary)
assert not analysis_plugin._is_probably_kconfig(test_file.binary)

def test_process_configs_ko_success(self, analysis_plugin):
test_file = FileObject(file_path=str(TEST_DATA_DIR / 'synthetic/configs.ko'))
Expand Down Expand Up @@ -77,7 +77,7 @@ def test_extract_ko_success(self, analysis_plugin):
result = AnalysisPlugin.try_object_extract_ikconfig(test_file.binary)

assert len(result) > 0
assert analysis_plugin.probably_kernel_config(result)
assert analysis_plugin._is_probably_kconfig(result)

def test_process_objects_kernel_image(self, analysis_plugin):
for valid_image in (TEST_DATA_DIR / 'synthetic').glob('*.image'):
Expand Down Expand Up @@ -207,4 +207,4 @@ def test_foo1(full_type, expected_output):
test_file = FileObject()
test_file.processed_analysis['file_type'] = {'result': {'full': full_type}}

assert AnalysisPlugin.has_kconfig_type(test_file) == expected_output
assert AnalysisPlugin._has_kconfig_type(test_file) == expected_output
39 changes: 23 additions & 16 deletions src/plugins/analysis/kernel_config/view/kernel_config.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,19 @@

{% block analysis_result_details %}

{% if 'kernel_config' in analysis_result | sort %}
{% if analysis_result.is_kernel_config %}
<tr>
<td>Kernel Config</td>
<td><pre class="border rounded p-2 bg-light" style="white-space: pre-wrap; height: 450px; overflow: scroll;"><code>{{ analysis_result['kernel_config'] | safe }}</code></pre></td>
<td><pre class="border rounded p-2 bg-light" style="white-space: pre-wrap; height: 450px; overflow: scroll;"><code>{{ analysis_result.kernel_config | safe }}</code></pre></td>
</tr>

{# checksec kernel check result #}
{% if 'checksec' in analysis_result and analysis_result['checksec'] != {} %}
{% if analysis_result.checksec and analysis_result.checksec.kernel or analysis_result.checksec.selinux %}
<tr>
<td>Exploit Mitigations</td>
<td style="padding: 0">
<table style="width: 100%">
{% for category, checksec_data in analysis_result['checksec'].items() %}
{% for category, checksec_data in analysis_result.checksec.items() %}
<tr>
<td>{{ category }}</td>
<td style="padding: 0">
Expand All @@ -35,34 +35,41 @@
{% endif %}

{# kconfig-hardened-check result #}
{% if 'hardening' in analysis_result and analysis_result['hardening'] != [] %}
{% if analysis_result.hardening != [] %}
<tr>
<td>Hardening Check</td>
<td style="padding: 0">
<table style="width: 100%">
<tr>
<td class="table-head-light">Config Item</td>
<td class="table-head-light">Check Result</td>
<td class="table-head-light">Actual Value</td>
<td class="table-head-light">Desired Value</td>
<td class="table-head-light">Reasoning</td>
<td class="table-head-light">Relates to</td>
</tr>
{% for option_name, desired_value, _, reason, check_result, actual_value, vulnerabilities in analysis_result['hardening'] %}
{# option_name, desired_value, _, reason, check_result, actual_value, vulnerabilities #}
{% for hardening_result in analysis_result.hardening %}
<tr>
<td>
<a href="https://www.kernelconfig.io/search?q={{ option_name }}">
{{ option_name }}
<td style="word-break: break-all;">
<a href="https://www.kernelconfig.io/search?q={{ hardening_result.option_name }}">
{{ hardening_result.option_name }}
</a>
</td>
{% set cell_class = 'table-success' if 'OK' in check_result else 'table-danger' if 'FAIL' in check_result else '' %}
<td class="{{ cell_class }}">{{ check_result }}</td>
<td>{{ desired_value }}</td>
<td>{{ reason | replace_underscore }}</td>
{% if 'OK' in hardening_result.check_result %}
{% set cell_class = 'table-success' %}
{% elif 'FAIL' in hardening_result.check_result %}
{% set cell_class = 'table-danger' %}
{% else %}
{% set cell_class = '' %}
{% endif %}
<td class="{{ cell_class }}">{{ hardening_result.check_result }}</td>
<td>{{ hardening_result.actual_value }}</td>
<td>{{ hardening_result.desired_value }}</td>
<td>{{ hardening_result.reason | replace_underscore }}</td>
<td>
{% if vulnerabilities %}
{% if hardening_result.vulnerabilities %}
<ul style="margin-bottom: 0;">
{% for item in vulnerabilities %}
{% for item in hardening_result.vulnerabilities %}
<li>{{ item | link_cve | link_cwe | safe }}</li>
{% endfor %}
</ul>
Expand Down

0 comments on commit 25edd22

Please sign in to comment.