Skip to content

Commit ae72d52

Browse files
JSCU-CNISchamper
andauthored
Improve RedHat OS detection (#1079)
Co-authored-by: Erik Schamper <[email protected]>
1 parent c8c8a1c commit ae72d52

File tree

11 files changed

+206
-75
lines changed

11 files changed

+206
-75
lines changed

dissect/target/plugins/os/unix/_os.py

Lines changed: 53 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import re
55
import uuid
66
from pathlib import Path
7-
from typing import Iterator
7+
from typing import Callable, Iterator
88

99
from flow.record.fieldtypes import posix_path
1010

@@ -49,7 +49,7 @@ def __init__(self, target: Target):
4949
super().__init__(target)
5050
self._add_mounts()
5151
self._add_devices()
52-
self._hostname_dict = self._parse_hostname_string()
52+
self._hostname, self._domain = self._parse_hostname_string()
5353
self._hosts_dict = self._parse_hosts_string()
5454
self._os_release = self._parse_os_release()
5555

@@ -156,78 +156,83 @@ def architecture(self) -> str | None:
156156

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

162161
@export(property=True)
163162
def domain(self) -> str | None:
164-
domain = self._hostname_dict.get("domain", "localhost")
165-
if domain == "localhost":
166-
domain = self._hosts_dict["hostname", "localhost"]
167-
if domain == self.hostname:
168-
return domain # domain likely not defined, so localhost is the domain.
169-
return domain
163+
if self._domain is None or self._domain == "localhost":
164+
# fall back to /etc/hosts file
165+
return self._hosts_dict.get("hostname")
166+
return self._domain
170167

171168
@export(property=True)
172169
def os(self) -> str:
173170
return OperatingSystem.UNIX.value
174171

175-
def _parse_rh_legacy(self, path: Path) -> str | None:
176-
hostname = None
177-
file_contents = path.open("rt").readlines()
178-
for line in file_contents:
179-
if not line.startswith("HOSTNAME"):
180-
continue
181-
_, _, hostname = line.rstrip().partition("=")
182-
return hostname
183-
184-
def _parse_hostname_string(self, paths: list[str] | None = None) -> dict[str, str] | None:
185-
"""Returns a dict containing the hostname and domain name portion of the path(s) specified.
172+
def _parse_hostname_string(
173+
self, paths: list[tuple[str, Callable[[Path], str] | None]] | None = None
174+
) -> tuple[str | None, str | None]:
175+
"""Returns a tuple containing respectively the hostname and domain name portion of the path(s) specified.
186176
187177
Args:
188-
paths (list): list of paths
189-
"""
190-
redhat_legacy_path = "/etc/sysconfig/network"
191-
paths = paths or ["/etc/hostname", "/etc/HOSTNAME", "/proc/sys/kernel/hostname", redhat_legacy_path]
192-
hostname_dict = {"hostname": None, "domain": None}
178+
paths (list): list of tuples with paths and callables to parse the path or None
193179
194-
for path in paths:
195-
path = self.target.fs.path(path)
196-
197-
if not path.exists():
180+
Returns:
181+
Tuple with hostname and domain strings.
182+
"""
183+
hostname = None
184+
domain = None
185+
186+
paths = paths or [
187+
("/etc/hostname", None),
188+
("/etc/HOSTNAME", None),
189+
("/proc/sys/kernel/hostname", None),
190+
("/etc/sysconfig/network", self._parse_rh_legacy),
191+
("/etc/hosts", self._parse_etc_hosts), # fallback if no other hostnames are found
192+
]
193+
194+
for path, callable in paths:
195+
if not (path := self.target.fs.path(path)).exists():
198196
continue
199197

200-
if path.as_posix() == redhat_legacy_path:
201-
hostname_string = self._parse_rh_legacy(path)
198+
if callable:
199+
hostname = callable(path)
202200
else:
203-
hostname_string = path.open("rt").read().rstrip()
201+
hostname = path.open("rt").read().rstrip()
202+
203+
if hostname and "." in hostname:
204+
hostname, domain = hostname.split(".", maxsplit=1)
204205

205-
if hostname_string and "." in hostname_string:
206-
hostname_string = hostname_string.split(".", maxsplit=1)
207-
hostname_dict = {"hostname": hostname_string[0], "domain": hostname_string[1]}
208-
elif hostname_string != "":
209-
hostname_dict = {"hostname": hostname_string, "domain": None}
210-
else:
211-
hostname_dict = {"hostname": None, "domain": None}
212206
break # break whenever a valid hostname is found
213207

214-
return hostname_dict
208+
# Can be an empty string due to splitting of hostname and domain
209+
return hostname or None, domain or None
210+
211+
def _parse_rh_legacy(self, path: Path) -> str | None:
212+
hostname = None
213+
file_contents = path.open("rt").readlines()
214+
for line in file_contents:
215+
if not line.startswith("HOSTNAME"):
216+
continue
217+
_, _, hostname = line.rstrip().partition("=")
218+
return hostname
219+
220+
def _parse_etc_hosts(self, path: Path) -> str | None:
221+
for line in path.open("rt"):
222+
if line.startswith(("127.0.0.1 ", "::1 ")) and "localhost" not in line:
223+
return line.split(" ")[1]
215224

216225
def _parse_hosts_string(self, paths: list[str] | None = None) -> dict[str, str]:
217226
paths = paths or ["/etc/hosts"]
218-
hosts_string = {"ip": None, "hostname": None}
227+
hosts_string = {}
219228

220229
for path in paths:
221230
for fs in self.target.filesystems:
222231
if fs.exists(path):
223232
for line in fs.path(path).open("rt").readlines():
224-
line = line.split()
225-
if not line:
233+
if not (line := line.split()):
226234
continue
227-
228-
if (line[0].startswith("127.0.") or line[0].startswith("::1")) and line[
229-
1
230-
].lower() != "localhost":
235+
if line[0].startswith(("127.0.", "::1")):
231236
hosts_string = {"ip": line[0], "hostname": line[1]}
232237
return hosts_string
233238

dissect/target/plugins/os/unix/bsd/freebsd/_os.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,16 @@ def __init__(self, target: Target):
1313

1414
@classmethod
1515
def detect(cls, target: Target) -> Filesystem | None:
16+
FREEBSD_PATHS = {
17+
"/.sujournal",
18+
"/entropy",
19+
"/bin/freebsd-version",
20+
}
21+
1622
for fs in target.filesystems:
17-
if fs.exists("/net") and (fs.exists("/.sujournal") or fs.exists("/entropy")):
23+
if fs.exists("/net") and any(fs.exists(path) for path in FREEBSD_PATHS):
1824
return fs
1925

20-
return None
21-
2226
@export(property=True)
2327
def version(self) -> str | None:
2428
return self._os_release.get("USERLAND_VERSION")
Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
from __future__ import annotations
22

3-
from typing import Optional
4-
53
from dissect.target.filesystem import Filesystem
64
from dissect.target.plugin import export
75
from dissect.target.plugins.os.unix.bsd._os import BsdPlugin
@@ -11,20 +9,28 @@
119
class OpenBsdPlugin(BsdPlugin):
1210
def __init__(self, target: Target):
1311
super().__init__(target)
14-
self._hostname_dict = self._parse_hostname_string(["/etc/myname"])
12+
self._hostname, self._domain = self._parse_hostname_string([("/etc/myname", None)])
1513

1614
@classmethod
17-
def detect(cls, target: Target) -> Optional[Filesystem]:
15+
def detect(cls, target: Target) -> Filesystem | None:
16+
OPENBSD_PATHS = {
17+
"/bsd",
18+
"/bsd.rd",
19+
"/bsd.mp",
20+
}
21+
1822
for fs in target.filesystems:
19-
if fs.exists("/bsd") or fs.exists("/bsd.rd") or fs.exists("/bsd.mp") or fs.exists("/bsd.mp"):
23+
if any(fs.exists(path) for path in OPENBSD_PATHS):
2024
return fs
2125

26+
@export(property=True)
27+
def version(self) -> str | None:
2228
return None
2329

2430
@export(property=True)
25-
def version(self) -> Optional[str]:
26-
return None
31+
def hostname(self) -> str | None:
32+
return self._hostname
2733

2834
@export(property=True)
29-
def hostname(self) -> Optional[str]:
30-
return self._hostname_dict.get("hostname", None)
35+
def domain(self) -> str | None:
36+
return self._domain
Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,26 @@
1-
from typing import Optional
1+
from __future__ import annotations
22

33
from dissect.target.filesystem import Filesystem
44
from dissect.target.plugins.os.unix.linux._os import LinuxPlugin
55
from dissect.target.target import Target
66

77

88
class RedHatPlugin(LinuxPlugin):
9+
"""RedHat, CentOS and Fedora Plugin."""
10+
911
def __init__(self, target: Target):
1012
super().__init__(target)
1113

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

19-
return None
23+
for fs in target.filesystems:
24+
for path in REDHAT_PATHS:
25+
if fs.exists(path):
26+
return fs

dissect/target/plugins/os/unix/locale.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,15 +84,18 @@ def language(self) -> list[str]:
8484
"/etc/sysconfig/i18n",
8585
]
8686

87-
found_languages = []
87+
found_languages = set()
8888

8989
for locale_path in locale_paths:
9090
if (path := self.target.fs.path(locale_path)).exists():
9191
for line in path.open("rt"):
9292
if "LANG=" in line:
93-
found_languages.append(normalize_language(line.replace("LANG=", "").strip().strip('"')))
93+
lang_str = line.partition("=")[-1].strip().strip('"')
94+
if lang_str == "C.UTF-8": # Skip if no locales are installed.
95+
continue
96+
found_languages.add(normalize_language(lang_str))
9497

95-
return found_languages
98+
return list(found_languages)
9699

97100
@export(record=UnixKeyboardRecord)
98101
def keyboard(self) -> Iterator[UnixKeyboardRecord]:

tests/plugins/os/unix/bsd/freebsd/__init__.py

Whitespace-only changes.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from dissect.target.filesystem import VirtualFilesystem
2+
from dissect.target.plugin import OperatingSystem
3+
from dissect.target.plugins.os.unix.bsd.freebsd._os import FreeBsdPlugin
4+
from dissect.target.target import Target
5+
from tests._utils import absolute_path
6+
7+
8+
def test_bsd_freebsd_os_detection(target_bare: Target) -> None:
9+
"""test if we detect FreeBSD correctly."""
10+
11+
fs = VirtualFilesystem()
12+
fs.makedirs("/net")
13+
fs.map_file("/bin/freebsd-version", absolute_path("_data/plugins/os/unix/bsd/freebsd/freebsd-freebsd-version"))
14+
15+
target_bare.filesystems.add(fs)
16+
target_bare.apply()
17+
18+
assert FreeBsdPlugin.detect(target_bare)
19+
assert isinstance(target_bare._os, FreeBsdPlugin)
20+
assert target_bare.os == OperatingSystem.BSD

tests/plugins/os/unix/bsd/openbsd/__init__.py

Whitespace-only changes.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from io import BytesIO
2+
3+
from dissect.target.filesystem import VirtualFilesystem
4+
from dissect.target.plugin import OperatingSystem
5+
from dissect.target.plugins.os.unix.bsd.openbsd._os import OpenBsdPlugin
6+
from dissect.target.target import Target
7+
8+
9+
def test_bsd_openbsd_os_detection(target_bare: Target) -> None:
10+
"""test if we detect OpenBSD correctly."""
11+
12+
fs = VirtualFilesystem()
13+
fs.map_file_fh("/etc/myname", BytesIO(b"hostname"))
14+
fs.makedirs("/bsd")
15+
16+
target_bare.filesystems.add(fs)
17+
target_bare.apply()
18+
19+
assert OpenBsdPlugin.detect(target_bare)
20+
assert isinstance(target_bare._os, OpenBsdPlugin)
21+
assert target_bare.os == OperatingSystem.BSD
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from io import BytesIO
2+
3+
import pytest
4+
5+
from dissect.target.filesystem import VirtualFilesystem
6+
from dissect.target.plugin import OperatingSystem
7+
from dissect.target.plugins.os.unix.linux.redhat._os import RedHatPlugin
8+
from dissect.target.target import Target
9+
10+
11+
@pytest.mark.parametrize(
12+
"file_name",
13+
[
14+
("/etc/redhat-release"),
15+
("/etc/centos-release"),
16+
("/etc/fedora-release"),
17+
("/etc/sysconfig/network-scripts"),
18+
],
19+
)
20+
def test_unix_linux_redhat_os_detection(target_bare: Target, file_name: str) -> None:
21+
"""test if we detect RedHat OS correctly."""
22+
23+
fs = VirtualFilesystem()
24+
fs.map_file_fh(file_name, BytesIO(b""))
25+
26+
target_bare.filesystems.add(fs)
27+
target_bare.apply()
28+
29+
assert RedHatPlugin.detect(target_bare)
30+
assert isinstance(target_bare._os, RedHatPlugin)
31+
assert target_bare.os == OperatingSystem.LINUX

0 commit comments

Comments
 (0)