Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 50 additions & 43 deletions dissect/target/plugins/os/unix/_os.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import re
import uuid
from pathlib import Path
from typing import Iterator
from typing import Callable, Iterator

from flow.record.fieldtypes import posix_path

Expand Down Expand Up @@ -42,7 +42,7 @@ def __init__(self, target: Target):
super().__init__(target)
self._add_mounts()
self._add_devices()
self._hostname_dict = self._parse_hostname_string()
self._hostname, self._domain = self._parse_hostname_string()
self._hosts_dict = self._parse_hosts_string()
self._os_release = self._parse_os_release()

Expand Down Expand Up @@ -149,14 +149,13 @@ def architecture(self) -> str | None:

@export(property=True)
def hostname(self) -> str | None:
hosts_string = self._hosts_dict.get("hostname", "localhost")
return self._hostname_dict.get("hostname", hosts_string)
return self._hostname or self._hosts_dict.get("hostname", "localhost")

@export(property=True)
def domain(self) -> str | None:
domain = self._hostname_dict.get("domain", "localhost")
domain = self._domain or "localhost"
if domain == "localhost":
domain = self._hosts_dict["hostname", "localhost"]
domain = self._hosts_dict.get("hostname", "localhost")
Copy link
Copy Markdown
Member

@Schamper Schamper Apr 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hah, that looked like a fun bug.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just because we're here anyway, could you add a unit test that would cover this scenario?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you ask the author of this piece of code instead (iirc @Horofic)? Not sure what the original purpose is of setting domain to "localhost".

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oof.

I've committed some small changes in 43505c6 and added a simple test. The second parameterised test case was my original intent. Which I believe, mimicked Debian, but not 100% clear on that anymore. This case intentionally fails now, feel free to implement it or take it in the direction you believe is more correct!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the content of /etc/hosts contains 127.0.0.1 mydomain (and no /etc/hostname is found) in my opinion the parsed hostname should be mydomain. In this limited context we have no way to safely determine the difference between a hostname and domain.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very fair. I adjusts the test case in 9394c1c and added some additional test cases just to be sure. Please see if you agree with them. I also adjusted some other tests to take into account some of the localhost behaviour.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. I think leaving the domain to None makes more sense instead of assuming localhost.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ill change the behaviour back later today.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reverted in 3f6f6bb and adjusted the behaviour in 343c12a. This should do the trick now.

if domain == self.hostname:
return domain # domain likely not defined, so localhost is the domain.
return domain
Expand All @@ -165,46 +164,58 @@ def domain(self) -> str | None:
def os(self) -> str:
return OperatingSystem.UNIX.value

def _parse_rh_legacy(self, path: Path) -> str | None:
hostname = None
file_contents = path.open("rt").readlines()
for line in file_contents:
if not line.startswith("HOSTNAME"):
continue
_, _, hostname = line.rstrip().partition("=")
return hostname

def _parse_hostname_string(self, paths: list[str] | None = None) -> dict[str, str] | None:
"""Returns a dict containing the hostname and domain name portion of the path(s) specified.
def _parse_hostname_string(
self, paths: list[tuple[str, Callable[[Path], str] | None]] | None = None
) -> tuple[str | None, str | None]:
"""Returns a tuple containing respectively the hostname and domain name portion of the path(s) specified.

Args:
paths (list): list of paths
"""
redhat_legacy_path = "/etc/sysconfig/network"
paths = paths or ["/etc/hostname", "/etc/HOSTNAME", "/proc/sys/kernel/hostname", redhat_legacy_path]
hostname_dict = {"hostname": None, "domain": None}
paths (list): list of tuples with paths and callables to parse the path or None

for path in paths:
path = self.target.fs.path(path)

if not path.exists():
Returns:
Tuple with ``hostname`` and ``domain`` strings.
"""
hostname = None
domain = None

paths = paths or [
("/etc/hostname", None),
("/etc/HOSTNAME", None),
("/proc/sys/kernel/hostname", None),
("/etc/sysconfig/network", self._parse_rh_legacy),
("/etc/hosts", self._parse_etc_hosts), # fallback if no other hostnames are found
]

for path, callable in paths:
if not (path := self.target.fs.path(path)).exists():
continue

if path.as_posix() == redhat_legacy_path:
hostname_string = self._parse_rh_legacy(path)
if callable:
hostname = callable(path)
else:
hostname_string = path.open("rt").read().rstrip()
hostname = path.open("rt").read().rstrip()

if hostname and "." in hostname:
hostname, domain = hostname.split(".", maxsplit=1)

if hostname_string and "." in hostname_string:
hostname_string = hostname_string.split(".", maxsplit=1)
hostname_dict = {"hostname": hostname_string[0], "domain": hostname_string[1]}
elif hostname_string != "":
hostname_dict = {"hostname": hostname_string, "domain": None}
else:
hostname_dict = {"hostname": None, "domain": None}
break # break whenever a valid hostname is found

return hostname_dict
# can be an empty string due to splitting of hostname and domain
return hostname if hostname else None, domain if domain else None

def _parse_rh_legacy(self, path: Path) -> str | None:
hostname = None
file_contents = path.open("rt").readlines()
for line in file_contents:
if not line.startswith("HOSTNAME"):
continue
_, _, hostname = line.rstrip().partition("=")
return hostname

def _parse_etc_hosts(self, path: Path) -> str | None:
for line in path.open("rt"):
if line.startswith(("127.0.0.1 ", "::1 ")) and "localhost" not in line:
return line.split(" ")[1]

def _parse_hosts_string(self, paths: list[str] | None = None) -> dict[str, str]:
paths = paths or ["/etc/hosts"]
Expand All @@ -214,13 +225,9 @@ def _parse_hosts_string(self, paths: list[str] | None = None) -> dict[str, str]:
for fs in self.target.filesystems:
if fs.exists(path):
for line in fs.path(path).open("rt").readlines():
line = line.split()
if not line:
if not (line := line.split()):
continue

if (line[0].startswith("127.0.") or line[0].startswith("::1")) and line[
1
].lower() != "localhost":
if line[0].startswith(("127.0.", "::1")) and line[1].lower() != "localhost":
hosts_string = {"ip": line[0], "hostname": line[1]}
return hosts_string

Expand Down
14 changes: 10 additions & 4 deletions dissect/target/plugins/os/unix/bsd/freebsd/_os.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,17 @@ def __init__(self, target: Target):

@classmethod
def detect(cls, target: Target) -> Filesystem | None:
for fs in target.filesystems:
if fs.exists("/net") and (fs.exists("/.sujournal") or fs.exists("/entropy")):
return fs
FREEBSD_PATHS = {
"/net",
"/.sujournal",
"/entropy",
"/bin/freebsd-version",
}

return None
for fs in target.filesystems:
for path in FREEBSD_PATHS:
if fs.exists(path):
return fs

@export(property=True)
def version(self) -> str | None:
Expand Down
27 changes: 17 additions & 10 deletions dissect/target/plugins/os/unix/bsd/openbsd/_os.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
from __future__ import annotations

from typing import Optional

from dissect.target.filesystem import Filesystem
from dissect.target.plugin import export
from dissect.target.plugins.os.unix.bsd._os import BsdPlugin
Expand All @@ -11,20 +9,29 @@
class OpenBsdPlugin(BsdPlugin):
def __init__(self, target: Target):
super().__init__(target)
self._hostname_dict = self._parse_hostname_string(["/etc/myname"])
self._hostname, self._domain = self._parse_hostname_string([("/etc/myname", None)])

@classmethod
def detect(cls, target: Target) -> Optional[Filesystem]:
def detect(cls, target: Target) -> Filesystem | None:
BSD_PATHS = {
"/bsd",
"/bsd.rd",
"/bsd.mp",
}

for fs in target.filesystems:
if fs.exists("/bsd") or fs.exists("/bsd.rd") or fs.exists("/bsd.mp") or fs.exists("/bsd.mp"):
return fs
for path in BSD_PATHS:
if fs.exists(path):
return fs

@export(property=True)
def version(self) -> str | None:
return None

@export(property=True)
def version(self) -> Optional[str]:
return None
def hostname(self) -> str | None:
return self._hostname

@export(property=True)
def hostname(self) -> Optional[str]:
return self._hostname_dict.get("hostname", None)
def domain(self) -> str | None:
return self._domain
21 changes: 14 additions & 7 deletions dissect/target/plugins/os/unix/linux/redhat/_os.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,26 @@
from typing import Optional
from __future__ import annotations

from dissect.target.filesystem import Filesystem
from dissect.target.plugins.os.unix.linux._os import LinuxPlugin
from dissect.target.target import Target


class RedHatPlugin(LinuxPlugin):
"""RedHat, CentOS and Fedora Plugin."""

def __init__(self, target: Target):
super().__init__(target)

@classmethod
def detect(cls, target: Target) -> Optional[Filesystem]:
# also applicable to centos (which is a red hat derivative)
for fs in target.filesystems:
if fs.exists("/etc/sysconfig/network-scripts"):
return fs
def detect(cls, target: Target) -> Filesystem | None:
REDHAT_PATHS = {
"/etc/centos-release",
"/etc/fedora-release",
"/etc/redhat-release",
"/etc/sysconfig/network-scripts", # legacy detection
}

return None
for fs in target.filesystems:
for path in REDHAT_PATHS:
if fs.exists(path):
return fs
9 changes: 6 additions & 3 deletions dissect/target/plugins/os/unix/locale.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,15 +84,18 @@ def language(self) -> list[str]:
"/etc/sysconfig/i18n",
]

found_languages = []
found_languages = set()

for locale_path in locale_paths:
if (path := self.target.fs.path(locale_path)).exists():
for line in path.open("rt"):
if "LANG=" in line:
found_languages.append(normalize_language(line.replace("LANG=", "").strip().strip('"')))
lang_str = line.partition("=")[-1].strip().strip('"')
if lang_str == "C.UTF-8":
continue
found_languages.add(normalize_language(lang_str))

return found_languages
return list(found_languages)

@export(record=UnixKeyboardRecord)
def keyboard(self) -> Iterator[UnixKeyboardRecord]:
Expand Down
Empty file.
19 changes: 19 additions & 0 deletions tests/plugins/os/unix/bsd/freebsd/test__os.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from dissect.target.filesystem import VirtualFilesystem
from dissect.target.plugin import OperatingSystem
from dissect.target.plugins.os.unix.bsd.freebsd._os import FreeBsdPlugin
from dissect.target.target import Target
from tests._utils import absolute_path


def test_bsd_freebsd_os_detection(target_bare: Target) -> None:
"""test if we detect FreeBSD correctly."""

fs = VirtualFilesystem()
fs.map_file("/bin/freebsd-version", absolute_path("_data/plugins/os/unix/bsd/freebsd/freebsd-freebsd-version"))

target_bare.filesystems.add(fs)
target_bare.apply()

assert FreeBsdPlugin.detect(target_bare)
assert isinstance(target_bare._os, FreeBsdPlugin)
assert target_bare.os == OperatingSystem.BSD
Empty file.
21 changes: 21 additions & 0 deletions tests/plugins/os/unix/bsd/openbsd/test__os.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from io import BytesIO

from dissect.target.filesystem import VirtualFilesystem
from dissect.target.plugin import OperatingSystem
from dissect.target.plugins.os.unix.bsd.openbsd._os import OpenBsdPlugin
from dissect.target.target import Target


def test_bsd_openbsd_os_detection(target_bare: Target) -> None:
"""test if we detect OpenBSD correctly."""

fs = VirtualFilesystem()
fs.map_file_fh("/etc/myname", BytesIO(b"hostname"))
fs.makedirs("/bsd")

target_bare.filesystems.add(fs)
target_bare.apply()

assert OpenBsdPlugin.detect(target_bare)
assert isinstance(target_bare._os, OpenBsdPlugin)
assert target_bare.os == OperatingSystem.BSD
31 changes: 31 additions & 0 deletions tests/plugins/os/unix/linux/redhat/test__os.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from io import BytesIO

import pytest

from dissect.target.filesystem import VirtualFilesystem
from dissect.target.plugin import OperatingSystem
from dissect.target.plugins.os.unix.linux.redhat._os import RedHatPlugin
from dissect.target.target import Target


@pytest.mark.parametrize(
"file_name",
[
("/etc/redhat-release"),
("/etc/centos-release"),
("/etc/fedora-release"),
("/etc/sysconfig/network-scripts"),
],
)
def test_unix_linux_redhat_os_detection(target_bare: Target, file_name: str) -> None:
"""test if we detect RedHat OS correctly."""

fs = VirtualFilesystem()
fs.map_file_fh(file_name, BytesIO(b""))

target_bare.filesystems.add(fs)
target_bare.apply()

assert RedHatPlugin.detect(target_bare)
assert isinstance(target_bare._os, RedHatPlugin)
assert target_bare.os == OperatingSystem.LINUX
10 changes: 5 additions & 5 deletions tests/plugins/os/unix/test__os.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,16 +119,16 @@ def test_parse_hostname_string(
target_unix: Target,
fs_unix: VirtualFilesystem,
path: Path,
expected_hostname: str,
expected_domain: str,
expected_hostname: str | None,
expected_domain: str | None,
file_content: str,
) -> None:
fs_unix.map_file_fh(path, BytesIO(file_content))

hostname_dict = target_unix._os._parse_hostname_string()
hostname, domain = target_unix._os._parse_hostname_string()

assert hostname_dict["hostname"] == expected_hostname
assert hostname_dict["domain"] == expected_domain
assert hostname == expected_hostname
assert domain == expected_domain


def test_users(target_unix_users: Target) -> None:
Expand Down