Skip to content
Open
70 changes: 59 additions & 11 deletions dissect/target/plugins/os/unix/_os.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import annotations

import re
import shlex
import uuid
from typing import TYPE_CHECKING

Expand Down Expand Up @@ -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::

<file system> <mount point> <type> <options> <dump> <pass>
"""
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)

# <file system> <mount point> <type> <options> <dump> <pass>
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,
Expand All @@ -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:
Copy link
Member

Choose a reason for hiding this comment

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

I meant more something like this:

Suggested change
for line in fstab_data:
with fstab.open("rt") as fh:
for line in fh:

Copy link
Author

Choose a reason for hiding this comment

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

I wanted to do it but below it will sum up into 5 tabs Don't you think it will be too much tabs?

Copy link
Member

Choose a reason for hiding this comment

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

It's fine. We have a 120 width line limit. Plenty of space :)

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)
Expand All @@ -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(":")
Expand Down
68 changes: 68 additions & 0 deletions dissect/target/plugins/os/unix/etc/fstab.py
Original file line number Diff line number Diff line change
@@ -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,
)
56 changes: 56 additions & 0 deletions dissect/target/plugins/os/unix/linux/mounts.py
Original file line number Diff line number Diff line change
@@ -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,
)
37 changes: 34 additions & 3 deletions dissect/target/plugins/os/unix/linux/proc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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]*")

Expand Down
3 changes: 3 additions & 0 deletions tests/_data/plugins/os/unix/etc/fstab
Git LFS file not shown
21 changes: 20 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand All @@ -247,14 +264,16 @@ 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)

fs.map_file_fh(dir + "/stat", BytesIO(stat_files_data[idx].encode()))
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")
Expand Down
Empty file.
Loading