diff --git a/dissect/target/plugins/os/unix/_os.py b/dissect/target/plugins/os/unix/_os.py index c12ccb7039..f61c7163df 100644 --- a/dissect/target/plugins/os/unix/_os.py +++ b/dissect/target/plugins/os/unix/_os.py @@ -1,6 +1,6 @@ from __future__ import annotations -import re +import shlex import uuid from typing import TYPE_CHECKING @@ -446,6 +446,54 @@ def _get_architecture(self, os: str = "unix", path: Path | str = "/bin/ls") -> s return f"{arch}_32-{os}" if bits == 1 and arch[-2:] != "32" else f"{arch}-{os}" +def parse_fstab_entry(entry: str) -> tuple[str, str, str, str, bool, int]: + """Parse a single fstab entry according to the man page fstab(5). + + According to the man page, the structure of a fstab entry is:: + + + """ + entry = entry.strip() + if not entry or entry.startswith("#"): + raise ValueError("Empty or commented line") + + # Fields are separated by tabs or spaces. + parts = shlex.split(entry) + + # + if len(parts) < 2: + raise ValueError(f"Invalid fstab entry, not enough fields: {entry}") + + if len(parts) > 6: + raise ValueError(f"Invalid fstab entry, too many fields: {entry}") + + # Pad with defaults + parts.extend([""] * (6 - len(parts))) + fs_spec, mount_point, fs_type, options, dump, pass_num = parts + + if not fs_type: + fs_type = "auto" + + if not options: + options = "defaults" + + if dump == "1": + is_dump = True + elif not dump or dump == "0": + is_dump = False + else: + raise ValueError(f"Invalid dump: {dump}") + + if not pass_num: + pass_num = 0 + elif pass_num.isnumeric(): + pass_num = int(pass_num) + else: + raise ValueError(f"Invalid pass num: {pass_num}") + + return fs_spec, mount_point, fs_type, options, is_dump, pass_num + + def parse_fstab( fstab: TargetPath, log: logging.Logger = log, @@ -465,17 +513,17 @@ def parse_fstab( if not fstab.exists(): return - for entry in fstab.open("rt"): - entry = entry.strip() - if entry.startswith("#"): - continue - - entry_parts = re.split(r"\s+", entry) + with fstab.open("rt") as fstab_file: + fstab_data = fstab_file.readlines() - if len(entry_parts) != 6: + for line in fstab_data: + try: + entry = parse_fstab_entry(line) + except ValueError as e: + log.warning("Failed to parse fstab entry: %s", e) continue - dev, mount_point, fs_type, options, _, _ = entry_parts + dev, mount_point, fs_type, options, _, _ = entry if fs_type in SKIP_FS_TYPES: log.warning("Skipped FS type: %s, %s, %s", fs_type, dev, mount_point) @@ -492,9 +540,9 @@ def parse_fstab( # Emulate that here when combining the vg and lv names volume_name = "-".join(part.replace("-", "--") for part in dev.rsplit("/")[-2:]) elif dev.startswith("UUID="): - dev_id = dev.split("=")[1] + dev_id = dev.split("=")[1].strip('"') elif dev.startswith("LABEL="): - volume_name = dev.split("=")[1] + volume_name = dev.split("=")[1].strip('"') elif fs_type == "nfs": # Put the nfs server address in dev_id and the root path in volume_name dev_id, sep, volume_name = dev.partition(":") diff --git a/dissect/target/plugins/os/unix/etc/fstab.py b/dissect/target/plugins/os/unix/etc/fstab.py new file mode 100644 index 0000000000..fd27ab105d --- /dev/null +++ b/dissect/target/plugins/os/unix/etc/fstab.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from dissect.target.exceptions import UnsupportedPluginError +from dissect.target.helpers.record import TargetRecordDescriptor +from dissect.target.plugin import Plugin, export +from dissect.target.plugins.os.unix._os import parse_fstab_entry + +if TYPE_CHECKING: + from collections.abc import Iterator + +FstabRecord = TargetRecordDescriptor( + "linux/etc/fstab", + [ + ("string", "device_path"), + ("string", "mount_path"), + ("string", "fs_type"), + ("string[]", "options"), + ("boolean", "is_dump"), + ("varint", "pass_num"), + ], +) + + +class FstabPlugin(Plugin): + """Linux fstab file plugin.""" + + __namespace__ = "etc" + + def check_compatible(self) -> None: + if not self.target.fs.exists("/etc/fstab"): + raise UnsupportedPluginError("No fstab file found on target") + + @export(record=FstabRecord) + def fstab(self) -> Iterator[FstabRecord]: + """Return the mount entries from ``/etc/fstab``. + + Yields ``FstabRecord`` with the following fields: + + . code-block:: text + + device_path (string): The device path. + mount_path (string): The mount path. + fs_type (string): The filesystem type. + options (string[]): The mount options. + is_dump (boolean): The dump frequency flag. + pass_num (varint): The pass number. + """ + fstab_path = self.target.fs.path("/etc/fstab") + with fstab_path.open("rt") as fstab_file: + for line in fstab_file: + try: + entry = parse_fstab_entry(line) + except ValueError as e: + self.target.log.warning("Failed to parse fstab entry: %s", e) + continue + + fs_spec, mount_point, fs_type, options, is_dump, pass_num = entry + yield FstabRecord( + device_path=fs_spec, + mount_path=mount_point, + fs_type=fs_type, + options=options.split(","), + is_dump=is_dump, + pass_num=pass_num, + _target=self.target, + ) diff --git a/dissect/target/plugins/os/unix/linux/mounts.py b/dissect/target/plugins/os/unix/linux/mounts.py new file mode 100644 index 0000000000..334dfd7e49 --- /dev/null +++ b/dissect/target/plugins/os/unix/linux/mounts.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from dissect.target.exceptions import UnsupportedPluginError +from dissect.target.helpers.record import TargetRecordDescriptor +from dissect.target.plugin import Plugin, export +from dissect.target.plugins.os.unix.etc.fstab import FstabRecord + +if TYPE_CHECKING: + from collections.abc import Iterator + +MountRecord = TargetRecordDescriptor( + "linux/proc/mounts", + [ + ("varint", "pid"), + *FstabRecord.target_fields, + ], +) + + +class MountsPlugin(Plugin): + """Linux volatile proc mounts plugin.""" + + def check_compatible(self) -> None: + if not self.target.has_function("proc"): + raise UnsupportedPluginError("proc filesystem not available") + + @export(record=MountRecord) + def mounts(self) -> Iterator[MountRecord]: + """Return the mount points for all processes. + + Yields ``MountRecord`` with the following fields: + + . code-block:: text + + pid (varint): The process id (pid) of the process. + device_path (string): The device path. + mount_path (string): The mount path. + fs_type (string): The filesystem type. + options (string[]): The mount options. + is_dump (boolean): The dump frequency flag. + pass_num (varint): The pass number. + """ + for process in self.target.proc.processes(): + for mount_entry in process.mounts(): + yield MountRecord( + pid=process.pid, + device_path=mount_entry.fs_spec, + mount_path=mount_entry.mount_path, + fs_type=mount_entry.fs_type, + options=mount_entry.options, + is_dump=mount_entry.is_dump, + pass_num=mount_entry.pass_num, + _target=self.target, + ) diff --git a/dissect/target/plugins/os/unix/linux/proc.py b/dissect/target/plugins/os/unix/linux/proc.py index 89d9a39129..3c0bad24f8 100644 --- a/dissect/target/plugins/os/unix/linux/proc.py +++ b/dissect/target/plugins/os/unix/linux/proc.py @@ -16,14 +16,15 @@ from dissect.target.filesystem import fsutil from dissect.target.helpers.utils import StrEnum from dissect.target.plugin import Plugin, internal +from dissect.target.plugins.os.unix._os import parse_fstab_entry if TYPE_CHECKING: from collections.abc import Iterator from datetime import datetime - from pathlib import Path from typing_extensions import Self + from dissect.target.helpers.fsutil import TargetPath from dissect.target.target import Target @@ -155,6 +156,16 @@ class Environ: contents: str +@dataclass +class FstabEntry: + fs_spec: str + mount_path: str + fs_type: str + options: list[str] + is_dump: bool + pass_num: int + + class ProcessStateEnum(StrEnum): R = "Running" # Running I = "Idle" # Idle # noqa: E741 @@ -501,7 +512,7 @@ def _boottime(self) -> int | None: return int(line.split()[1]) return None - def get(self, path: str) -> Path: + def get(self, path: str) -> TargetPath: """Returns a TargetPath relative to this process.""" return self.entry.joinpath(path) @@ -608,6 +619,26 @@ def cmdline(self) -> str: return line + def mounts(self) -> Iterator[FstabEntry]: + """Yields the content of the mount file associated with the process.""" + mount_path = self.get("mounts") + + if not (mount_path.exists() and mount_path.is_file()): + return + + with mount_path.open("rt") as mount_file: + for line in mount_file: + try: + entry = parse_fstab_entry(line) + except ValueError as e: + self.target.log.warning("Failed to parse fstab entry: %s", e) + continue + + fs_spec, mount_point, fs_type, options, is_dump, pass_num = entry + options = options.split(",") + + yield FstabEntry(fs_spec, mount_point, fs_type, options, is_dump, pass_num) + def stat(self) -> fsutil.stat_result: """Return a stat entry of the process.""" return self.entry.stat() @@ -646,7 +677,7 @@ def inode_map(self) -> dict[int, list[ProcProcess]]: return map @internal - def iter_proc(self) -> Iterator[Path]: + def iter_proc(self) -> Iterator[TargetPath]: """Yields ``/proc/[pid]`` filesystems entries for every process id (pid) found in procfs.""" yield from self.target.fs.path("/proc").glob("[0-9]*") diff --git a/tests/_data/plugins/os/unix/etc/fstab b/tests/_data/plugins/os/unix/etc/fstab new file mode 100644 index 0000000000..afc1382f8a --- /dev/null +++ b/tests/_data/plugins/os/unix/etc/fstab @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ebac5687fc1e1d718f4dcd94d9498dded7aa9a2835d67898b7ebb945c505fda0 +size 1601 diff --git a/tests/conftest.py b/tests/conftest.py index faf53376bd..54eff2100d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -219,24 +219,41 @@ def fs_linux_proc(fs_linux: VirtualFilesystem) -> VirtualFilesystem: VirtualSymlink(fs, "/proc/1/fd/4", "socket:[1337]"), "test\x00cmdline\x00", "VAR=1", + """ + UUID=1349-vbay-as78-efeh /home ext4 defaults 0 2 + /dev/sdc1 /mnt/windows ntfs-3g ro,uid=1000 0 0 + """, ), ( "proc/2", VirtualSymlink(fs, "/proc/2/fd/4", "socket:[1338]"), "\x00", "VAR=1\x00", + """ + 192.168.1.50:/exports/share /mnt/nfs nfs _netdev,auto 0 0 + UUID=1234-abcd /data ext4 defaults + /dev/sdc2 /mnt/usb ext3 sw 2 0 + """, ), ( "proc/3", VirtualSymlink(fs, "/proc/3/fd/4", "socket:[1339]"), "sshd", "VAR=1", + """ + /dev/sdb2 none swap sw 0 0 + /dev/sdc2 /mnt/usb ext3 sw 1 0 + /dev/sdd1 / ext4 sw 0 a + """, ), ( "proc/1337", VirtualSymlink(fs, "/proc/1337/fd/4", "socket:[1337]"), "acquire\x00-p\x00full\x00--proc\x00", "", + """ + /dev/sdb1 /mnt/my backup ext4 defaults 0 0 + """, ), ) stat_files_data = ( @@ -247,7 +264,7 @@ def fs_linux_proc(fs_linux: VirtualFilesystem) -> VirtualFilesystem: ) for idx, proc in enumerate(procs): - dir, fd, cmdline, environ = proc + dir, fd, cmdline, environ, mounts = proc fs.makedirs(dir) fs.map_file_entry(fd.path, fd) @@ -255,6 +272,8 @@ def fs_linux_proc(fs_linux: VirtualFilesystem) -> VirtualFilesystem: fs.map_file_fh(dir + "/cmdline", BytesIO(cmdline.encode())) if environ: fs.map_file_fh(dir + "/environ", BytesIO(environ.encode())) + if mounts: + fs.map_file_fh(dir + "/mounts", BytesIO(mounts.encode())) # symlink acquire process to self fs.link("/proc/1337", "/proc/self") diff --git a/tests/plugins/os/unix/etc/__init__.py b/tests/plugins/os/unix/etc/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/plugins/os/unix/etc/test_fstab.py b/tests/plugins/os/unix/etc/test_fstab.py new file mode 100644 index 0000000000..2463843ed0 --- /dev/null +++ b/tests/plugins/os/unix/etc/test_fstab.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from io import BytesIO +from textwrap import dedent +from typing import TYPE_CHECKING + +from dissect.target.plugins.os.unix.etc.fstab import FstabPlugin +from tests._utils import absolute_path + +if TYPE_CHECKING: + import pytest + + from dissect.target.filesystem import VirtualFilesystem + from dissect.target.target import Target + + +def test_etc_fstab_plugin(target_unix_users: Target, fs_unix: VirtualFilesystem) -> None: + fstab_file = absolute_path("_data/plugins/os/unix/etc/fstab") + fs_unix.map_file("/etc/fstab", fstab_file) + target_unix_users.add_plugin(FstabPlugin) + + results = list(target_unix_users.etc.fstab()) + assert len(results) == 13 + + +def test_etc_fstab_plugin_invalid( + caplog: pytest.LogCaptureFixture, target_unix_users: Target, fs_unix: VirtualFilesystem +) -> None: + """Test if we can parse invalid fstab entries.""" + fstab_invalid = """ + UUID=1349-vbay-as78-efeh /home ext4 defaults 0 2 + /dev/sdc1 /mnt/windows ntfs-3g ro,uid=1000 0 0 + 192.168.1.50:/exports/share /mnt/nfs nfs _netdev,auto 0 0 + UUID=1234-abcd /data ext4 defaults + /dev/sdc2 /mnt/usb ext3 sw 2 0 + /dev/sdb2 none swap sw 0 0 + /dev/sdd1 / ext4 sw 0 a + /dev/sdb1 /mnt/my backup ext4 defaults 0 0 + """ + fs_unix.map_file_fh("/etc/fstab", BytesIO(dedent(fstab_invalid).encode())) + target_unix_users.add_plugin(FstabPlugin) + + results = list(target_unix_users.etc.fstab()) + assert len(results) == 5 + assert results[0].device_path == "UUID=1349-vbay-as78-efeh" + assert results[0].mount_path == "/home" + assert results[1].device_path == "/dev/sdc1" + assert results[2].fs_type == "nfs" + assert results[3].device_path == "UUID=1234-abcd" + assert results[4].mount_path == "none" diff --git a/tests/plugins/os/unix/linux/test_mounts.py b/tests/plugins/os/unix/linux/test_mounts.py new file mode 100644 index 0000000000..178ef17b09 --- /dev/null +++ b/tests/plugins/os/unix/linux/test_mounts.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from collections import defaultdict +from typing import TYPE_CHECKING + +from dissect.target.plugins.os.unix.linux.proc import ProcPlugin + +if TYPE_CHECKING: + from dissect.target.filesystem import VirtualFilesystem + from dissect.target.target import Target + + +def test_mounts(target_linux_users: Target, fs_linux_proc: VirtualFilesystem) -> None: + target_linux_users.add_plugin(ProcPlugin) + results = list(target_linux_users.mounts()) + + assert len(results) == 6 + + sum_pid_results = defaultdict(int) + for result in results: + sum_pid_results[result.pid] += 1 + + assert sum_pid_results[1] == 2 + + assert sum_pid_results[2] == 2 + + assert sum_pid_results[3] == 2 + + assert sum_pid_results[4] == 0 diff --git a/tests/plugins/os/unix/test__os.py b/tests/plugins/os/unix/test__os.py index 18e970683d..c9261323e7 100644 --- a/tests/plugins/os/unix/test__os.py +++ b/tests/plugins/os/unix/test__os.py @@ -1,6 +1,5 @@ from __future__ import annotations -import tempfile from io import BytesIO from typing import TYPE_CHECKING from unittest.mock import Mock, patch @@ -12,56 +11,18 @@ from dissect.target.filesystem import VirtualFilesystem from dissect.target.plugins.os.unix._os import UnixPlugin, parse_fstab from dissect.target.target import Target +from tests._utils import absolute_path if TYPE_CHECKING: from pathlib import Path -FSTAB_CONTENT = """ -# /etc/fstab: static file system information. -# -# Use 'blkid' to print the universally unique identifier for a -# device; this may be used with UUID= as a more robust way to name devices -# that works even if disks are added and removed. See fstab(5). -# -# - -proc /proc proc nodev,noexec,nosuid 0 0 - -UUID=563f929e-ab4b-4741-b0f4-e3843c9a7a6a / ext4 defaults,discard 0 0 - -UUID=5d1f1508-069b-4274-9bfa-ae2bf7ffb5e0 /home ext4 defaults 0 2 - -UUID=be0afdc6-10bb-4744-a71c-02e0e2812160 none swap sw 0 0 - -/dev/mapper/vgubuntu-swap_1 none swap sw 0 0 - -UUID=28a25297-9825-4f87-ac41-f9c20cd5db4f /boot ext4 defaults 0 2 - -UUID=F631-BECA /boot/efi vfat defaults,discard,umask=0077 0 0 - -/dev/disk/cloud/azure_resource-part1 /mnt auto defaults,nofail,x-systemd.requires=cloud-init.service,comment=cloudconfig 0 2 - -/dev/mapper/vg--main-lv--var /var auto default 0 2 - -/dev/vg-main/lv-data /data auto default 0 2 - -/dev/disk/by-uuid/af0b9707-0945-499a-a37d-4da23d8dd245 /moredata auto default 0 2 - -LABEL=foo /foo auto default 0 2 - -localhost:/home/user/nfstest /mnt/nfs nfs ro 0 0 -""" # noqa - def test_parse_fstab(tmp_path: Path) -> None: - with tempfile.NamedTemporaryFile(dir=tmp_path, delete=False) as tf: - tf.write(FSTAB_CONTENT.encode("ascii")) - tf.close() - - fs = VirtualFilesystem() - fs.map_file("/etc/fstab", tf.name) + fs = VirtualFilesystem() + fstab_file = absolute_path("_data/plugins/os/unix/etc/fstab") + fs.map_file("/etc/fstab", fstab_file) - records = list(parse_fstab(fs.path("/etc/fstab"))) + records = list(parse_fstab(fs.path("/etc/fstab"))) # 11 input records minus # 2 unsupported mount devices (proc, /dev/disk/cloud/azure_resource-part1)