From 9008bd004107e3895a531c126b99f8b84d6bdc96 Mon Sep 17 00:00:00 2001 From: Torres Date: Thu, 5 Mar 2026 15:19:39 +0200 Subject: [PATCH 1/4] Added file descriptors iterator to ProcProcess --- dissect/target/plugins/os/unix/linux/proc.py | 57 ++++++++++++++++++++ tests/conftest.py | 9 +++- tests/plugins/os/unix/linux/test_proc.py | 15 ++++++ 3 files changed, 80 insertions(+), 1 deletion(-) diff --git a/dissect/target/plugins/os/unix/linux/proc.py b/dissect/target/plugins/os/unix/linux/proc.py index 9b1b401b0b..22b5c83e9d 100644 --- a/dissect/target/plugins/os/unix/linux/proc.py +++ b/dissect/target/plugins/os/unix/linux/proc.py @@ -154,6 +154,50 @@ class Environ: variable: str contents: str +@dataclass +class FileDescriptor: + def __init__(self, proc: Path, fd_num: str): + self.number = int(fd_num) + self.path = proc.joinpath(fd_num) + self.info_path = proc.parent.joinpath("fdinfo", fd_num) + self.target = None + self.fd_info = None + + @property + def link(self) -> str: + """Returns the resolved symlink""" + if not self.target is None: + return self.target + try: + self.target = self.path.readlink() + except Exception: + self.target = "unknown" + return self.target + + @property + def info(self) -> dict: + """Parsed key-value pairs from fdinfo""" + if self.fd_info is None: + self.fd_info = self._parse_fdinfo() + return self.fd_info + + def _parse_fdinfo(self): + data = {} + if not self.info_path.exists(): + return data + + for line in self.info_path.read_text().split('\n'): + line = line.strip() + if not line: + continue + + parts = line.split(None, 1) + if not len(parts) == 2: + continue + key, value = parts + data[key] = value + + return data class ProcessStateEnum(StrEnum): R = "Running" # Running @@ -490,6 +534,15 @@ def _parse_environ(self) -> Iterator[Environ]: yield Environ(variable, contents) + def _parse_fd(self) -> Iterator[FileDescriptor]: + """Internal function to parse entries in ``/proc/[pid]/fd/[fd_num]`` and ``/proc/[pid]/fdinfo/[fd_num]``.""" + if not (fd_path := self.get("fd")).exists(): + return + + for fd_link in fd_path.iterdir(): + yield FileDescriptor(fd_path, fd_link.name) + + @property def _boottime(self) -> int | None: """Returns the boot time of the system. @@ -585,6 +638,10 @@ def environ(self) -> Iterator[Environ]: """Yields the content of the environ file associated with the process.""" yield from self._parse_environ() + def fd(self) -> Iterator[FileDescriptor]: + """Yields the file descriptors associated with the process.""" + yield from self._parse_fd() + @property def uptime(self) -> timedelta: """Returns the uptime of the system from the moment it was acquired.""" diff --git a/tests/conftest.py b/tests/conftest.py index fe34e55b57..1947892107 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -239,6 +239,12 @@ def fs_linux_proc(fs_linux: VirtualFilesystem) -> VirtualFilesystem: "", ), ) + fdinfo_files_data = ( + "pos\t0\nflags\t00000002\nsocket\t1337\n", + "pos\t0\nflags\t00000002\nsocket\t1338\n", + "pos\t0\nflags\t00000002\nsocket\t1339\n", + "pos\t0\nflags\t00000002\nsocket\t1337\n" + ) stat_files_data = ( "1 (systemd) S 0 1 1 0 -1 4194560 53787 457726 166 4255 112 260 761 548 20 0 1 0 30 184877056 2658 18446744073709551615 93937510957056 93937511789581 140726499200496 0 0 0 671173123 4096 1260 0 0 0 17 0 0 0 11 0 0 93937512175824 93937512476912 93937519890432 140726499204941 140726499204952 140726499204952 140726499205101 0\n", # noqa "2 (kthread) K 1 1 1 0 -1 4194560 53787 457726 166 4255 112 260 761 548 20 0 1 0 30 184877056 2658 18446744073709551615 93937510957056 93937511789581 140726499200496 0 0 0 671173123 4096 1260 0 0 0 17 0 0 0 11 0 0 93937512175824 93937512476912 93937519890432 140726499204941 140726499204952 140726499204952 140726499205101 0\n", # noqa @@ -251,6 +257,7 @@ def fs_linux_proc(fs_linux: VirtualFilesystem) -> VirtualFilesystem: fs.makedirs(dir) fs.map_file_entry(fd.path, fd) + fs.map_file_fh(dir + "/fdinfo/" + fd.name, BytesIO(fdinfo_files_data[idx].encode())) fs.map_file_fh(dir + "/stat", BytesIO(stat_files_data[idx].encode())) fs.map_file_fh(dir + "/cmdline", BytesIO(cmdline.encode())) if environ: @@ -259,7 +266,7 @@ def fs_linux_proc(fs_linux: VirtualFilesystem) -> VirtualFilesystem: # symlink acquire process to self fs.link("/proc/1337", "/proc/self") - # boottime and uptime are needed for for time tests + # boottime and uptime are needed for time tests fs.map_file_fh("/proc/uptime", BytesIO(b"134368.27 132695.52\n")) fs.map_file_fh("/proc/stat", BytesIO(b"btime 1680559854")) diff --git a/tests/plugins/os/unix/linux/test_proc.py b/tests/plugins/os/unix/linux/test_proc.py index 475aa232a0..ea759b3064 100644 --- a/tests/plugins/os/unix/linux/test_proc.py +++ b/tests/plugins/os/unix/linux/test_proc.py @@ -30,6 +30,11 @@ def test_process(target_linux_users: Target, fs_linux_proc: VirtualFilesystem) - assert environ[0].variable == "VAR" assert environ[0].contents == "1" + fd = list(process.fd()) + assert "socket" in fd[0].link.name + assert fd[0].info["pos"] == "0" + assert fd[0].info["flags"] == "00000002" + def test_process_not_found(target_linux_users: Target, fs_linux_proc: VirtualFilesystem) -> None: target_linux_users.add_plugin(ProcPlugin) @@ -52,6 +57,11 @@ def test_processes(target_linux_users: Target, fs_linux_proc: VirtualFilesystem) assert env.variable == "VAR" assert env.contents == "1" + for fd in process.fd(): + assert "socket" in fd.link.name + assert fd.info["pos"] == "0" + assert fd.info["flags"] == "00000002" + def test_processes_without_boottime(target_linux_users: Target, fs_linux_proc: VirtualFilesystem) -> None: target_linux_users.add_plugin(ProcPlugin) @@ -70,6 +80,11 @@ def test_processes_without_boottime(target_linux_users: Target, fs_linux_proc: V assert env.variable == "VAR" assert env.contents == "1" + for fd in process.fd(): + assert "socket" in fd.link.name + assert fd.info["pos"] == "0" + assert fd.info["flags"] == "00000002" + def test_proc_plugin_incompatible(target_linux_users: Target, fs_linux: VirtualFilesystem) -> None: with pytest.raises(UnsupportedPluginError, match="No /proc directory found"): From c91a33da04bd7a3084c96c40c2434ece1af3331a Mon Sep 17 00:00:00 2001 From: Torres Date: Sun, 15 Mar 2026 11:00:23 +0200 Subject: [PATCH 2/4] Added fd plugin and test --- dissect/target/plugins/os/unix/linux/fd.py | 67 ++++++++++++++++++++++ tests/plugins/os/unix/linux/test_fd.py | 15 +++++ 2 files changed, 82 insertions(+) create mode 100644 dissect/target/plugins/os/unix/linux/fd.py create mode 100644 tests/plugins/os/unix/linux/test_fd.py diff --git a/dissect/target/plugins/os/unix/linux/fd.py b/dissect/target/plugins/os/unix/linux/fd.py new file mode 100644 index 0000000000..c7bd4dce71 --- /dev/null +++ b/dissect/target/plugins/os/unix/linux/fd.py @@ -0,0 +1,67 @@ +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 + +if TYPE_CHECKING: + from collections.abc import Iterator + + +FileDescriptorRecord = TargetRecordDescriptor( + "linux/proc/fd", + [ + ("datetime", "ts"), + ("varint", "pid"), + ("string", "name"), + ("varint", "fd"), + ("string", "path"), + ("uint64", "pos"), + ("string", "flags"), + ], +) + +class ProcFdPlugin(Plugin): + """Linux process file descriptor plugin.""" + + def check_compatible(self) -> None: + if not self.target.has_function("proc"): + raise UnsupportedPluginError("proc filesystem not available") + + @export(record=FileDescriptorRecord) + def fd(self) -> Iterator[FileDescriptorRecord]: + """Return information about open file descriptors for all processes. + + This plugin identifies files, sockets, pipes, and other artifacts + currently in use by processes by parsing /proc/[pid]/fd and fdinfo. + + Yields FileDescriptorRecord with the following fields: + + .. code-block:: text + + ts (datetime): The modification timestamp of the fd directory. + pid (varint): The process id (pid) of the process. + name (string): The name associated to the pid. + fd (varint): The file descriptor number. + path (string): The resolved path or resource link. + pos (uint64): The current file offset from fdinfo. + flags (string): The access flags from fdinfo. + """ + for process in self.target.proc.processes(): + for fd_obj in process.fds: + try: + ts = fd_obj.path.stat().st_mtime + except Exception: + ts = None + yield FileDescriptorRecord( + ts=ts, + pid=process.pid, + name=process.name, + fd=fd_obj.number, + path=fd_obj.target, + pos=int(fd_obj.info.get("pos", 0)), + flags=fd_obj.info.get("flags", "0"), + _target=self.target, + ) diff --git a/tests/plugins/os/unix/linux/test_fd.py b/tests/plugins/os/unix/linux/test_fd.py new file mode 100644 index 0000000000..cee43eb805 --- /dev/null +++ b/tests/plugins/os/unix/linux/test_fd.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +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_fd(target_linux_users: Target, fs_linux_proc: VirtualFilesystem) -> None: + target_linux_users.add_plugin(ProcPlugin) + results = list(target_linux_users.fd()) + assert len(results) == 3 \ No newline at end of file From 8920bc8fd7495311802beab9df32e94808329ceb Mon Sep 17 00:00:00 2001 From: Torres Date: Sun, 15 Mar 2026 13:02:41 +0200 Subject: [PATCH 3/4] Refactor FileDescriptor to use cached properties and fix linting --- dissect/target/plugins/os/unix/linux/proc.py | 42 ++++++++++---------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/dissect/target/plugins/os/unix/linux/proc.py b/dissect/target/plugins/os/unix/linux/proc.py index 604ace265a..818f7b5999 100644 --- a/dissect/target/plugins/os/unix/linux/proc.py +++ b/dissect/target/plugins/os/unix/linux/proc.py @@ -154,45 +154,43 @@ class Environ: variable: str contents: str -@dataclass class FileDescriptor: def __init__(self, proc: Path, fd_num: str): - self.number = int(fd_num) - self.path = proc.joinpath(fd_num) - self.info_path = proc.parent.joinpath("fdinfo", fd_num) - self.target = None - self.fd_info = None + self.proc = proc + self.number = fd_num + + @property + def path(self) -> Path: + """The path to the file descriptor symlink.""" + return self.proc.joinpath(self.number) @property + def info_path(self) -> Path: + """The path to the fdinfo entry.""" + return self.proc.parent.joinpath("fdinfo", self.number) + + @cached_property def link(self) -> str: - """Returns the resolved symlink""" - if not self.target is None: - return self.target + """Returns the resolved symlink.""" try: - self.target = self.path.readlink() + return str(self.path.readlink()) except Exception: - self.target = "unknown" - return self.target - - @property - def info(self) -> dict: - """Parsed key-value pairs from fdinfo""" - if self.fd_info is None: - self.fd_info = self._parse_fdinfo() - return self.fd_info + return "unknown" - def _parse_fdinfo(self): + @cached_property + def info(self)-> dict[str, str]: + """Parsed key-value pairs from fdinfo.""" data = {} if not self.info_path.exists(): return data - for line in self.info_path.read_text().split('\n'): + for line in self.info_path.read_text().split("\n"): line = line.strip() if not line: continue parts = line.split(None, 1) - if not len(parts) == 2: + if len(parts) != 2: continue key, value = parts data[key] = value From ad4026495c542257859aa9ed79aafc2ca8c0fdb4 Mon Sep 17 00:00:00 2001 From: Torres Date: Tue, 17 Mar 2026 13:28:33 +0200 Subject: [PATCH 4/4] Add ProcFdPlugin for Linux process file descriptor analysis --- dissect/target/plugins/os/unix/linux/fd.py | 8 ++++---- tests/plugins/os/unix/linux/test_fd.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/dissect/target/plugins/os/unix/linux/fd.py b/dissect/target/plugins/os/unix/linux/fd.py index c7bd4dce71..a1d6ada826 100644 --- a/dissect/target/plugins/os/unix/linux/fd.py +++ b/dissect/target/plugins/os/unix/linux/fd.py @@ -17,8 +17,8 @@ ("varint", "pid"), ("string", "name"), ("varint", "fd"), - ("string", "path"), - ("uint64", "pos"), + ("string", "link"), + ("varint", "pos"), ("string", "flags"), ], ) @@ -50,7 +50,7 @@ def fd(self) -> Iterator[FileDescriptorRecord]: flags (string): The access flags from fdinfo. """ for process in self.target.proc.processes(): - for fd_obj in process.fds: + for fd_obj in process.fd(): try: ts = fd_obj.path.stat().st_mtime except Exception: @@ -60,7 +60,7 @@ def fd(self) -> Iterator[FileDescriptorRecord]: pid=process.pid, name=process.name, fd=fd_obj.number, - path=fd_obj.target, + link=fd_obj.link, pos=int(fd_obj.info.get("pos", 0)), flags=fd_obj.info.get("flags", "0"), _target=self.target, diff --git a/tests/plugins/os/unix/linux/test_fd.py b/tests/plugins/os/unix/linux/test_fd.py index cee43eb805..af1f673e92 100644 --- a/tests/plugins/os/unix/linux/test_fd.py +++ b/tests/plugins/os/unix/linux/test_fd.py @@ -12,4 +12,4 @@ def test_fd(target_linux_users: Target, fs_linux_proc: VirtualFilesystem) -> None: target_linux_users.add_plugin(ProcPlugin) results = list(target_linux_users.fd()) - assert len(results) == 3 \ No newline at end of file + assert len(results) == 4 \ No newline at end of file