dissect.target 3.14.dev29__py3-none-any.whl → 3.15__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
Files changed (86) hide show
  1. dissect/target/containers/ewf.py +1 -1
  2. dissect/target/containers/vhd.py +5 -2
  3. dissect/target/filesystem.py +36 -18
  4. dissect/target/filesystems/dir.py +10 -4
  5. dissect/target/filesystems/jffs.py +122 -0
  6. dissect/target/helpers/compat/path_310.py +506 -0
  7. dissect/target/helpers/compat/path_311.py +539 -0
  8. dissect/target/helpers/compat/path_312.py +443 -0
  9. dissect/target/helpers/compat/path_39.py +545 -0
  10. dissect/target/helpers/compat/path_common.py +223 -0
  11. dissect/target/helpers/cyber.py +512 -0
  12. dissect/target/helpers/fsutil.py +128 -666
  13. dissect/target/helpers/hashutil.py +17 -57
  14. dissect/target/helpers/keychain.py +9 -3
  15. dissect/target/helpers/loaderutil.py +1 -1
  16. dissect/target/helpers/mount.py +47 -4
  17. dissect/target/helpers/polypath.py +73 -0
  18. dissect/target/helpers/record_modifier.py +100 -0
  19. dissect/target/loader.py +2 -1
  20. dissect/target/loaders/asdf.py +2 -0
  21. dissect/target/loaders/cyber.py +37 -0
  22. dissect/target/loaders/log.py +14 -3
  23. dissect/target/loaders/raw.py +2 -0
  24. dissect/target/loaders/remote.py +12 -0
  25. dissect/target/loaders/tar.py +13 -0
  26. dissect/target/loaders/targetd.py +2 -0
  27. dissect/target/loaders/velociraptor.py +12 -3
  28. dissect/target/loaders/vmwarevm.py +2 -0
  29. dissect/target/plugin.py +272 -143
  30. dissect/target/plugins/apps/ssh/openssh.py +11 -54
  31. dissect/target/plugins/apps/ssh/opensshd.py +4 -3
  32. dissect/target/plugins/apps/ssh/putty.py +236 -0
  33. dissect/target/plugins/apps/ssh/ssh.py +58 -0
  34. dissect/target/plugins/apps/vpn/openvpn.py +6 -0
  35. dissect/target/plugins/apps/webserver/apache.py +309 -95
  36. dissect/target/plugins/apps/webserver/caddy.py +5 -2
  37. dissect/target/plugins/apps/webserver/citrix.py +82 -0
  38. dissect/target/plugins/apps/webserver/iis.py +9 -12
  39. dissect/target/plugins/apps/webserver/nginx.py +5 -2
  40. dissect/target/plugins/apps/webserver/webserver.py +25 -41
  41. dissect/target/plugins/child/wsl.py +1 -1
  42. dissect/target/plugins/filesystem/ntfs/mft.py +10 -0
  43. dissect/target/plugins/filesystem/ntfs/mft_timeline.py +10 -0
  44. dissect/target/plugins/filesystem/ntfs/usnjrnl.py +10 -0
  45. dissect/target/plugins/filesystem/ntfs/utils.py +28 -5
  46. dissect/target/plugins/filesystem/resolver.py +6 -4
  47. dissect/target/plugins/general/default.py +0 -2
  48. dissect/target/plugins/general/example.py +0 -1
  49. dissect/target/plugins/general/loaders.py +3 -5
  50. dissect/target/plugins/os/unix/_os.py +3 -3
  51. dissect/target/plugins/os/unix/bsd/citrix/_os.py +68 -28
  52. dissect/target/plugins/os/unix/bsd/citrix/history.py +130 -0
  53. dissect/target/plugins/os/unix/generic.py +17 -12
  54. dissect/target/plugins/os/unix/linux/fortios/__init__.py +0 -0
  55. dissect/target/plugins/os/unix/linux/fortios/_os.py +534 -0
  56. dissect/target/plugins/os/unix/linux/fortios/generic.py +30 -0
  57. dissect/target/plugins/os/unix/linux/fortios/locale.py +109 -0
  58. dissect/target/plugins/os/windows/log/evt.py +1 -1
  59. dissect/target/plugins/os/windows/log/schedlgu.py +155 -0
  60. dissect/target/plugins/os/windows/regf/firewall.py +1 -1
  61. dissect/target/plugins/os/windows/regf/shimcache.py +1 -1
  62. dissect/target/plugins/os/windows/regf/trusteddocs.py +1 -1
  63. dissect/target/plugins/os/windows/registry.py +1 -1
  64. dissect/target/plugins/os/windows/sam.py +3 -0
  65. dissect/target/plugins/os/windows/sru.py +41 -28
  66. dissect/target/plugins/os/windows/tasks.py +5 -2
  67. dissect/target/target.py +7 -3
  68. dissect/target/tools/dd.py +7 -1
  69. dissect/target/tools/fs.py +8 -1
  70. dissect/target/tools/info.py +22 -16
  71. dissect/target/tools/mount.py +28 -3
  72. dissect/target/tools/query.py +146 -117
  73. dissect/target/tools/reg.py +21 -16
  74. dissect/target/tools/shell.py +30 -6
  75. dissect/target/tools/utils.py +28 -0
  76. dissect/target/volumes/bde.py +14 -10
  77. dissect/target/volumes/luks.py +18 -10
  78. {dissect.target-3.14.dev29.dist-info → dissect.target-3.15.dist-info}/METADATA +4 -3
  79. {dissect.target-3.14.dev29.dist-info → dissect.target-3.15.dist-info}/RECORD +85 -67
  80. dissect/target/plugins/os/unix/linux/fortigate/_os.py +0 -175
  81. /dissect/target/{plugins/os/unix/linux/fortigate → helpers/compat}/__init__.py +0 -0
  82. {dissect.target-3.14.dev29.dist-info → dissect.target-3.15.dist-info}/COPYRIGHT +0 -0
  83. {dissect.target-3.14.dev29.dist-info → dissect.target-3.15.dist-info}/LICENSE +0 -0
  84. {dissect.target-3.14.dev29.dist-info → dissect.target-3.15.dist-info}/WHEEL +0 -0
  85. {dissect.target-3.14.dev29.dist-info → dissect.target-3.15.dist-info}/entry_points.txt +0 -0
  86. {dissect.target-3.14.dev29.dist-info → dissect.target-3.15.dist-info}/top_level.txt +0 -0
@@ -1,16 +1,16 @@
1
- from typing import Iterator
1
+ from typing import Iterator, Union
2
2
 
3
- from dissect.target.exceptions import UnsupportedPluginError
4
3
  from dissect.target.helpers.record import TargetRecordDescriptor
5
- from dissect.target.plugin import Plugin, export
6
- from dissect.target.target import Target
4
+ from dissect.target.plugin import NamespacePlugin, export
7
5
 
8
6
  WebserverAccessLogRecord = TargetRecordDescriptor(
9
- "application/log/webserver",
7
+ "application/log/webserver/access",
10
8
  [
11
9
  ("datetime", "ts"),
12
10
  ("string", "remote_user"),
13
11
  ("net.ipaddress", "remote_ip"),
12
+ ("net.ipaddress", "local_ip"),
13
+ ("varint", "pid"),
14
14
  ("string", "method"),
15
15
  ("uri", "uri"),
16
16
  ("string", "protocol"),
@@ -18,49 +18,33 @@ WebserverAccessLogRecord = TargetRecordDescriptor(
18
18
  ("varint", "bytes_sent"),
19
19
  ("uri", "referer"),
20
20
  ("string", "useragent"),
21
+ ("varint", "response_time_ms"),
21
22
  ("path", "source"),
22
23
  ],
23
24
  )
24
25
 
26
+ WebserverErrorLogRecord = TargetRecordDescriptor(
27
+ "application/log/webserver/error",
28
+ [
29
+ ("datetime", "ts"),
30
+ ("net.ipaddress", "remote_ip"),
31
+ ("varint", "pid"),
32
+ ("string", "module"),
33
+ ("string", "level"),
34
+ ("string", "error_source"),
35
+ ("string", "error_code"),
36
+ ("string", "message"),
37
+ ("path", "source"),
38
+ ],
39
+ )
25
40
 
26
- class WebserverPlugin(Plugin):
41
+
42
+ class WebserverPlugin(NamespacePlugin):
27
43
  __namespace__ = "webserver"
28
44
  __findable__ = False
29
45
 
30
- WEBSERVERS = [
31
- "apache",
32
- "nginx",
33
- "iis",
34
- "caddy",
35
- ]
36
-
37
- def __init__(self, target: Target):
38
- super().__init__(target)
39
- self._plugins = []
40
- for entry in self.WEBSERVERS:
41
- try:
42
- self._plugins.append(getattr(self.target, entry))
43
- except Exception: # noqa
44
- target.log.exception("Failed to load webserver plugin: %s", entry)
45
-
46
- def check_compatible(self) -> None:
47
- if not len(self._plugins):
48
- raise UnsupportedPluginError("No compatible webserver plugins found")
49
-
50
- def _func(self, f: str) -> Iterator[WebserverAccessLogRecord]:
51
- for p in self._plugins:
52
- try:
53
- yield from getattr(p, f)()
54
- except Exception:
55
- self.target.log.exception("Failed to execute webserver plugin: %s.%s", p._name, f)
56
-
57
- @export(record=WebserverAccessLogRecord)
58
- def logs(self) -> Iterator[WebserverAccessLogRecord]:
46
+ @export(record=[WebserverAccessLogRecord, WebserverErrorLogRecord])
47
+ def logs(self) -> Iterator[Union[WebserverAccessLogRecord, WebserverErrorLogRecord]]:
59
48
  """Returns log file records from installed webservers."""
60
49
  yield from self.access()
61
- # TODO: In the future we should add error logs too.
62
-
63
- @export(record=WebserverAccessLogRecord)
64
- def access(self) -> Iterator[WebserverAccessLogRecord]:
65
- """Returns WebserverAccessLogRecord records from installed webservers."""
66
- yield from self._func("access")
50
+ yield from self.error()
@@ -26,7 +26,7 @@ def find_wsl_installs(target: Target) -> Iterator[Path]:
26
26
  continue
27
27
  base_path = target.resolve(distribution_key.value("BasePath").value)
28
28
  # WSL needs diskname to be ext4.vhdx, but they can be renamed when WSL is not active
29
- yield from target.fs.path(base_path).glob("*.vhdx")
29
+ yield from base_path.glob("*.vhdx")
30
30
  except PluginError:
31
31
  pass
32
32
 
@@ -123,6 +123,12 @@ class MftPlugin(Plugin):
123
123
 
124
124
  The Master File Table (MFT) contains primarily metadata about every file and folder on a NFTS filesystem.
125
125
 
126
+ If the filesystem is part of a virtual NTFS filesystem (a ``VirtualFilesystem`` with the MFT properties
127
+ added to it through a "fake" ``NtfsFilesystem``), the paths returned in the MFT records are based on the
128
+ mount point of the ``VirtualFilesystem``. This ensures that the proper original drive letter is used when
129
+ available.
130
+ When no drive letter can be determined, the path will show as e.g. ``\\$fs$\\fs0``.
131
+
126
132
  References:
127
133
  - https://docs.microsoft.com/en-us/windows/win32/fileio/master-file-table
128
134
  """
@@ -136,6 +142,10 @@ class MftPlugin(Plugin):
136
142
  if fs.__type__ != "ntfs":
137
143
  continue
138
144
 
145
+ # If this filesystem is a "fake" NTFS filesystem, used to enhance a
146
+ # VirtualFilesystem, The driveletter (more accurate mount point)
147
+ # returned will be that of the VirtualFilesystem. This makes sure
148
+ # the paths returned in the records are actually reachable.
139
149
  drive_letter = get_drive_letter(self.target, fs)
140
150
  volume_uuid = get_volume_identifier(fs)
141
151
 
@@ -105,6 +105,12 @@ class MftTimelinePlugin(Plugin):
105
105
 
106
106
  The Master File Table (MFT) contains metadata about every file and folder on a NFTS filesystem.
107
107
 
108
+ If the filesystem is part of a virtual NTFS filesystem (a ``VirtualFilesystem`` with the MFT properties
109
+ added to it through a "fake" ``NtfsFilesystem``), the paths returned in the MFT records are based on the
110
+ mount point of the ``VirtualFilesystem``. This ensures that the proper original drive letter is used when
111
+ available.
112
+ When no drive letter can be determined, the path will show as e.g. ``\\$fs$\\fs0``.
113
+
108
114
  References:
109
115
  - https://docs.microsoft.com/en-us/windows/win32/fileio/master-file-table
110
116
  """
@@ -112,6 +118,10 @@ class MftTimelinePlugin(Plugin):
112
118
  if fs.__type__ != "ntfs":
113
119
  continue
114
120
 
121
+ # If this filesystem is a "fake" NTFS filesystem, used to enhance a
122
+ # VirtualFilesystem, The driveletter (more accurate mount point)
123
+ # returned will be that of the VirtualFilesystem. This makes sure
124
+ # the paths returned in the records are actually reachable.
115
125
  drive_letter = get_drive_letter(self.target, fs)
116
126
  extras = Extras(
117
127
  serial=fs.ntfs.serial,
@@ -34,6 +34,12 @@ class UsnjrnlPlugin(Plugin):
34
34
  The Update Sequence Number Journal (UsnJrnl) is a feature of an NTFS file system and contains information about
35
35
  filesystem activities. Each volume has its own UsnJrnl.
36
36
 
37
+ If the filesystem is part of a virtual NTFS filesystem (a ``VirtualFilesystem`` with the UsnJrnl
38
+ properties added to it through a "fake" ``NtfsFilesystem``), the paths returned in the UsnJrnl records
39
+ are based on the mount point of the ``VirtualFilesystem``. This ensures that the proper original drive
40
+ letter is used when available.
41
+ When no drive letter can be determined, the path will show as e.g. ``\\$fs$\\fs0``.
42
+
37
43
  References:
38
44
  - https://en.wikipedia.org/wiki/USN_Journal
39
45
  - https://velociraptor.velocidex.com/the-windows-usn-journal-f0c55c9010e
@@ -47,6 +53,10 @@ class UsnjrnlPlugin(Plugin):
47
53
  if not usnjrnl:
48
54
  continue
49
55
 
56
+ # If this filesystem is a "fake" NTFS filesystem, used to enhance a
57
+ # VirtualFilesystem, The driveletter (more accurate mount point)
58
+ # returned will be that of the VirtualFilesystem. This makes sure
59
+ # the paths returned in the records are actually reachable.
50
60
  drive_letter = get_drive_letter(self.target, fs)
51
61
  for record in usnjrnl.records():
52
62
  try:
@@ -1,3 +1,4 @@
1
+ import re
1
2
  from enum import Enum, auto
2
3
  from typing import Optional, Tuple
3
4
  from uuid import UUID
@@ -8,6 +9,8 @@ from dissect.ntfs.mft import MftRecord
8
9
  from dissect.target import Target
9
10
  from dissect.target.filesystems.ntfs import NtfsFilesystem
10
11
 
12
+ DRIVE_LETTER_RE = re.compile(r"[a-zA-Z]:")
13
+
11
14
 
12
15
  class InformationType(Enum):
13
16
  STANDARD_INFORMATION = auto()
@@ -20,13 +23,33 @@ def get_drive_letter(target: Target, filesystem: NtfsFilesystem):
20
23
 
21
24
  When the drive letter is not available for that filesystem it returns empty.
22
25
  """
26
+ # A filesystem can be known under multiple drives (mount points). If it is
27
+ # a windows system volume, there are the default sysvol and c: drives.
28
+ # If the target has a virtual ntfs filesystem, e.g. as constructed by the
29
+ # tar and dir loaders, there is also the /$fs$/fs<n> drive, under which the
30
+ # "fake" ntfs filesystem is mounted.
31
+ # The precedence for drives is first the drive letter drives (e.g. c:),
32
+ # second the "normally" named drives (e.g. sysvol) and finally the anonymous
33
+ # drives (e.g. /$fs/fs0).
23
34
  mount_items = (item for item in target.fs.mounts.items() if hasattr(item[1], "ntfs"))
24
- driveletters = [key for key, fs in mount_items if fs.ntfs is filesystem.ntfs]
35
+ drives = [key for key, fs in mount_items if fs.ntfs is filesystem.ntfs]
36
+
37
+ single_letter_drives = []
38
+ other_drives = []
39
+ anon_drives = []
40
+
41
+ for drive in drives:
42
+ if DRIVE_LETTER_RE.match(drive):
43
+ single_letter_drives.append(drive)
44
+ elif "$fs$" in drive:
45
+ anon_drives.append(drive)
46
+ else:
47
+ other_drives.append(drive)
48
+
49
+ drives = sorted(single_letter_drives) + sorted(other_drives) + sorted(anon_drives)
25
50
 
26
- if driveletters:
27
- # Currently, mount_dict contain 2 instances of the same filesystem: 'sysvol' and 'c:'
28
- # This is to choose the latter which will be 'c:'
29
- return f"{driveletters[-1]}\\"
51
+ if drives:
52
+ return f"{drives[0]}\\"
30
53
  else:
31
54
  return ""
32
55
 
@@ -2,7 +2,7 @@ import re
2
2
  from typing import Optional
3
3
 
4
4
  from dissect.target.helpers import fsutil
5
- from dissect.target.plugin import Plugin, internal
5
+ from dissect.target.plugin import OperatingSystem, Plugin, internal
6
6
 
7
7
  re_quoted = re.compile(r"\"(.+?)\"")
8
8
 
@@ -34,10 +34,12 @@ class ResolverPlugin(Plugin):
34
34
  if not path:
35
35
  return path
36
36
 
37
- if self.target.os == "windows":
38
- return self.resolve_windows(path, user_sid=user)
37
+ if self.target.os == OperatingSystem.WINDOWS:
38
+ resolved_path = self.resolve_windows(path, user_sid=user)
39
39
  else:
40
- return self.resolve_default(path, user_id=user)
40
+ resolved_path = self.resolve_default(path, user_id=user)
41
+
42
+ return self.target.fs.path(resolved_path)
41
43
 
42
44
  def resolve_windows(self, path: str, user_sid: Optional[str] = None) -> str:
43
45
  # Normalize first so the replacements are easier
@@ -13,8 +13,6 @@ from dissect.target.plugin import OSPlugin, export
13
13
 
14
14
 
15
15
  class DefaultPlugin(OSPlugin):
16
- __skip__ = True
17
-
18
16
  def __init__(self, target: Target):
19
17
  super().__init__(target)
20
18
  if len(target.filesystems) == 1:
@@ -55,7 +55,6 @@ class ExamplePlugin(Plugin):
55
55
 
56
56
  # IMPORTANT: Remove these attributes when using this as boilerplate for your own plugin!
57
57
  __findable__ = False
58
- __skip__ = True
59
58
 
60
59
  def check_compatible(self) -> None:
61
60
  """Perform a compatibility check with the target.
@@ -1,7 +1,5 @@
1
- import itertools
2
-
3
1
  from dissect.target.helpers.docs import INDENT_STEP, get_docstring
4
- from dissect.target.loader import LOADERS, DirLoader
2
+ from dissect.target.loader import LOADERS_BY_SCHEME
5
3
  from dissect.target.plugin import Plugin, export
6
4
 
7
5
 
@@ -16,10 +14,10 @@ class LoaderListPlugin(Plugin):
16
14
  """List the available loaders."""
17
15
 
18
16
  loaders_info = {}
19
- for loader in itertools.chain(LOADERS, [DirLoader]):
17
+ for key, loader in LOADERS_BY_SCHEME.items():
20
18
  try:
21
19
  docstring = get_docstring(loader, "No documentation.").splitlines()[0].strip()
22
- loaders_info[loader.__name__] = docstring
20
+ loaders_info[key] = docstring
23
21
  except ImportError:
24
22
  continue
25
23
 
@@ -254,7 +254,7 @@ class UnixPlugin(OSPlugin):
254
254
  continue
255
255
  return os_release
256
256
 
257
- def _get_architecture(self, os: str = "unix") -> Optional[str]:
257
+ def _get_architecture(self, os: str = "unix", path: str = "/bin/ls") -> Optional[str]:
258
258
  arch_strings = {
259
259
  0x00: "Unknown",
260
260
  0x02: "SPARC",
@@ -271,8 +271,8 @@ class UnixPlugin(OSPlugin):
271
271
  }
272
272
 
273
273
  for fs in self.target.filesystems:
274
- if fs.exists("/bin/ls"):
275
- fh = fs.open("/bin/ls")
274
+ if fs.exists(path):
275
+ fh = fs.open(path)
276
276
  fh.seek(4)
277
277
  # ELF - e_ident[EI_CLASS]
278
278
  bits = unpack("B", fh.read(1))[0]
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  import re
4
4
  from typing import Iterator, Optional
5
5
 
6
- from dissect.target.filesystem import Filesystem, VirtualFilesystem
6
+ from dissect.target.filesystem import Filesystem
7
7
  from dissect.target.helpers.record import UnixUserRecord
8
8
  from dissect.target.plugin import OperatingSystem, export
9
9
  from dissect.target.plugins.os.unix.bsd._os import BsdPlugin
@@ -18,12 +18,12 @@ RE_CONFIG_USER = re.compile(r"bind system user (?P<user>[^ ]+) ")
18
18
  RE_LOADER_CONFIG_KERNEL_VERSION = re.compile(r'kernel="/(?P<version>.*)"')
19
19
 
20
20
 
21
- class CitrixBsdPlugin(BsdPlugin):
21
+ class CitrixPlugin(BsdPlugin):
22
22
  def __init__(self, target: Target):
23
23
  super().__init__(target)
24
24
  self._ips = []
25
25
  self._hostname = None
26
- self.config_usernames = []
26
+ self._config_usernames = []
27
27
  self._parse_netscaler_configs()
28
28
 
29
29
  def _parse_netscaler_configs(self) -> None:
@@ -49,26 +49,46 @@ class CitrixBsdPlugin(BsdPlugin):
49
49
 
50
50
  @classmethod
51
51
  def detect(cls, target: Target) -> Optional[Filesystem]:
52
- newfilesystem = VirtualFilesystem()
53
- is_citrix = False
52
+ ramdisk = None
53
+ for fs in target.filesystems:
54
+ # /netscaler can be present on both the ramdisk and the harddisk. Therefore we also check for the /log
55
+ # folder, which is not present on the ramdisk. We regard the harddisk as the system volume, as it is
56
+ # possible to only have a disk image of a Netscaler. However, in the case where we only have the ramdisk,
57
+ # we want to fall back on that as the system volume. Thus we store that filesystem in a fallback variable.
58
+ if fs.exists("/netscaler"):
59
+ if fs.exists("/log"):
60
+ return fs
61
+ ramdisk = fs
62
+
63
+ # At this point, we could not find the filesystem for '/var'. Thus, we fall back to the ramdisk variable, which
64
+ # is either 'None' (in which case this isn't a Citrix netscaler), or points to the filesystem of the ramdisk.
65
+ return ramdisk
66
+
67
+ @classmethod
68
+ def create(cls, target: Target, sysvol: Filesystem) -> CitrixPlugin:
69
+ # A disk image of a Citrix Netscaler contains two partitions, that after boot are mounted to /var and /flash.
70
+ # The rest of the filesystem is recreated at runtime into a 'ramdisk'. Currently, this plugin does not
71
+ # yet support recreating the ramdisk from a 'clean' state. This might be possible in a future iteration but
72
+ # requires further research.
73
+
74
+ # When the ramdisk is present within the target's filesystems, mount it accordingly,
54
75
  for fs in target.filesystems:
55
76
  if fs.exists("/bin/freebsd-version"):
56
- newfilesystem.map_fs("/", fs)
57
- break
77
+ # If available, mount the ramdisk first.
78
+ target.fs.mount("/", fs)
79
+ # The 'disk' filesystem is mounted at '/var'.
80
+ target.fs.mount("/var", sysvol)
81
+
82
+ # Enumerate filesystems for flash partition
58
83
  for fs in target.filesystems:
59
84
  if fs.exists("/nsconfig") and fs.exists("/boot"):
60
- newfilesystem.map_fs("/flash", fs)
61
- is_citrix = True
62
- elif fs.exists("/netscaler"):
63
- newfilesystem.map_fs("/var", fs)
64
- is_citrix = True
65
- if is_citrix:
66
- return newfilesystem
67
- return None
85
+ target.fs.mount("/flash", fs)
86
+
87
+ return cls(target)
68
88
 
69
89
  @export(property=True)
70
90
  def hostname(self) -> Optional[str]:
71
- return self._hostname
91
+ return self._hostname or super().hostname
72
92
 
73
93
  @export(property=True)
74
94
  def version(self) -> Optional[str]:
@@ -96,16 +116,21 @@ class CitrixBsdPlugin(BsdPlugin):
96
116
  @export(record=UnixUserRecord)
97
117
  def users(self) -> Iterator[UnixUserRecord]:
98
118
  nstmp_users = set()
99
- nstmp_path = "/var/nstmp/"
100
-
101
- nstmp_user_path = nstmp_path + "{username}"
102
-
103
- for entry in self.target.fs.scandir(nstmp_path):
104
- if entry.is_dir() and entry.name != "#nsinternal#":
105
- nstmp_users.add(entry.name)
119
+ seen = set()
120
+ nstmp_path = self.target.fs.path("/var/nstmp/")
121
+
122
+ # Build a set of nstmp users
123
+ if nstmp_path.exists():
124
+ for entry in nstmp_path.iterdir():
125
+ if entry.is_dir() and entry.name != "#nsinternal#":
126
+ # The nsmonitor user has a home directory of /var/nstmp/monitors rather than /var/nstmp/nsmonitor
127
+ username = "nsmonitor" if entry.name == "monitors" else entry.name
128
+ nstmp_users.add(username)
129
+
130
+ # Yield users from the config, matching them to their 'home' in /var/nstmp if it exists.
106
131
  for username in self._config_usernames:
107
- nstmp_home = nstmp_user_path.format(username=username)
108
- user_home = nstmp_home if self.target.fs.exists(nstmp_home) else None
132
+ nstmp_home = nstmp_path.joinpath(username)
133
+ user_home = nstmp_home if nstmp_home.exists() else None
109
134
 
110
135
  if user_home:
111
136
  # After this loop we will yield all users who are not in the config, but are listed in /var/nstmp/
@@ -114,13 +139,28 @@ class CitrixBsdPlugin(BsdPlugin):
114
139
 
115
140
  if username == "root" and self.target.fs.exists("/root"):
116
141
  # If we got here, 'root' is present both in /var/nstmp and in /root. In such cases, we yield
117
- # the 'root' user as having '/root' as a home, not in /var/nstmp.
118
- user_home = "/root"
142
+ # the 'root' user as having '/root' as a home, not in /var/nstmp, as there is no 'nscli_history'
143
+ # for the root user in /var/nstmp.
144
+ user_home = self.target.fs.path("/root")
119
145
 
146
+ seen.add((username, user_home.as_posix() if user_home else None, None))
120
147
  yield UnixUserRecord(name=username, home=user_home)
121
148
 
149
+ # Yield all users in nstmp that were not observed in the config
122
150
  for username in nstmp_users:
123
- yield UnixUserRecord(name=username, home=nstmp_user_path.format(username=username))
151
+ # The nsmonitor user has a home directory of /var/nstmp/monitors rather than /var/nstmp/nsmonitor
152
+ home = nstmp_path.joinpath(username) if username != "nsmonitor" else nstmp_path.joinpath("monitors")
153
+ seen.add((username, home.as_posix(), None))
154
+ yield UnixUserRecord(name=username, home=home)
155
+
156
+ # Yield users from /etc/passwd if we have not seem them in previous loops
157
+ for user in super().users():
158
+ if (user.name, user.home.as_posix(), user.shell) in seen:
159
+ continue
160
+ # To prevent bogus command history for all users without a home whenever a history is located at the root
161
+ # of the filesystem, we set the user home to None if their home is equivalent to '/'
162
+ user.home = user.home if user.home != "/" else None
163
+ yield user
124
164
 
125
165
  @export(property=True)
126
166
  def os(self) -> str:
@@ -0,0 +1,130 @@
1
+ import re
2
+ from typing import Iterator, Optional
3
+
4
+ from dissect.target.helpers.fsutil import TargetPath
5
+ from dissect.target.helpers.record import UnixUserRecord
6
+ from dissect.target.helpers.utils import year_rollover_helper
7
+ from dissect.target.plugin import export
8
+ from dissect.target.plugins.os.unix.history import (
9
+ CommandHistoryPlugin,
10
+ CommandHistoryRecord,
11
+ )
12
+
13
+ RE_CITRIX_NETSCALER_BASH_HISTORY_DATE = re.compile(r"(?P<date>[^<]+)\s")
14
+
15
+ CITRIX_NETSCALER_BASH_HISTORY_RE = re.compile(
16
+ r"""
17
+ (?P<date>[^<]+)
18
+ \s
19
+ <
20
+ (?P<syslog_facility>[^\.]+)
21
+ \.
22
+ (?P<syslog_loglevel>[^>]+)
23
+ >
24
+ \s
25
+ (?P<hostname>[^\s]+)
26
+ \s
27
+ (?P<process_name>[^\[]+)
28
+ \[
29
+ (?P<process_id>\d+)
30
+ \]
31
+ :
32
+ \s
33
+ (?P<username>.*)\s
34
+ on\s
35
+ (?P<destination>[^\s]+)\s
36
+ shell_command=
37
+ \"
38
+ (?P<command>.*)
39
+ \"
40
+ $
41
+ """,
42
+ re.VERBOSE,
43
+ )
44
+
45
+
46
+ class CitrixCommandHistoryPlugin(CommandHistoryPlugin):
47
+ COMMAND_HISTORY_ABSOLUTE_PATHS = (("citrix-netscaler-bash", "/var/log/bash.log*"),)
48
+ COMMAND_HISTORY_RELATIVE_PATHS = CommandHistoryPlugin.COMMAND_HISTORY_RELATIVE_PATHS + (
49
+ ("citrix-netscaler-cli", ".nscli_history"),
50
+ )
51
+
52
+ def _find_history_files(self) -> list[tuple[str, TargetPath, Optional[UnixUserRecord]]]:
53
+ """Find history files on the target that this plugin can parse."""
54
+ history_files = []
55
+ for shell, history_absolute_path_glob in self.COMMAND_HISTORY_ABSOLUTE_PATHS:
56
+ for path in self.target.fs.path("/").glob(history_absolute_path_glob.lstrip("/")):
57
+ history_files.append((shell, path, None))
58
+
59
+ # Also utilize the _find_history_files function of the parent class
60
+ history_files.extend(super()._find_history_files())
61
+ return history_files
62
+
63
+ def _find_user_by_name(self, username: str) -> Optional[UnixUserRecord]:
64
+ """Cached function to return the matching UnixUserRecord for a given username."""
65
+ if username is None:
66
+ return None
67
+
68
+ user_details = self.target.user_details.find(username=username)
69
+ return user_details.user if user_details else None
70
+
71
+ @export(record=CommandHistoryRecord)
72
+ def commandhistory(self) -> Iterator[CommandHistoryRecord]:
73
+ """Return shell history for all users.
74
+
75
+ When using a shell, history of the used commands is kept on the system.
76
+ """
77
+
78
+ for shell, history_path, user in self._history_files:
79
+ if shell == "citrix-netscaler-cli":
80
+ yield from self.parse_netscaler_cli_history(history_path, user)
81
+ elif shell == "citrix-netscaler-bash":
82
+ yield from self.parse_netscaler_bash_history(history_path)
83
+
84
+ def parse_netscaler_bash_history(self, path: TargetPath) -> Iterator[CommandHistoryRecord]:
85
+ """Parse bash.log* contents."""
86
+ for ts, line in year_rollover_helper(path, RE_CITRIX_NETSCALER_BASH_HISTORY_DATE, "%b %d %H:%M:%S "):
87
+ line = line.strip()
88
+ if not line:
89
+ continue
90
+
91
+ match = CITRIX_NETSCALER_BASH_HISTORY_RE.match(line)
92
+ if not match:
93
+ continue
94
+
95
+ group = match.groupdict()
96
+ command = group.get("command")
97
+ user = self._find_user_by_name(group.get("username"))
98
+
99
+ yield CommandHistoryRecord(
100
+ ts=ts,
101
+ command=command,
102
+ shell="citrix-netscaler-bash",
103
+ source=path,
104
+ _target=self.target,
105
+ _user=user,
106
+ )
107
+
108
+ def parse_netscaler_cli_history(
109
+ self, history_file: TargetPath, user: UnixUserRecord
110
+ ) -> Iterator[CommandHistoryRecord]:
111
+ """Parses the history file of the Citrix Netscaler CLI.
112
+
113
+ The only difference compared to generic bash history files is that the first line will start with
114
+ ``_HiStOrY_V2_``, which we will skip.
115
+ """
116
+ for idx, line in enumerate(history_file.open("rt")):
117
+ if not (line := line.strip()):
118
+ continue
119
+
120
+ if idx == 0 and line == "_HiStOrY_V2_":
121
+ continue
122
+
123
+ yield CommandHistoryRecord(
124
+ ts=None,
125
+ command=line,
126
+ shell="citrix-netscaler-cli",
127
+ source=history_file,
128
+ _target=self.target,
129
+ _user=user,
130
+ )
@@ -1,4 +1,5 @@
1
1
  from datetime import datetime
2
+ from pathlib import Path
2
3
  from statistics import median
3
4
  from typing import Optional
4
5
 
@@ -15,18 +16,7 @@ class GenericPlugin(Plugin):
15
16
  def activity(self) -> Optional[datetime]:
16
17
  """Return last seen activity based on filesystem timestamps."""
17
18
  var_log = self.target.fs.path("/var/log")
18
- if not var_log.exists():
19
- return
20
-
21
- last_seen = 0
22
- for f in var_log.iterdir():
23
- if not f.exists():
24
- continue
25
- if f.stat().st_mtime > last_seen:
26
- last_seen = f.stat().st_mtime
27
-
28
- if last_seen != 0:
29
- return ts.from_unix(last_seen)
19
+ return calculate_last_activity(var_log)
30
20
 
31
21
  @export(property=True)
32
22
  def install_date(self) -> Optional[datetime]:
@@ -63,3 +53,18 @@ class GenericPlugin(Plugin):
63
53
  root_stat = self.target.fs.stat("/")
64
54
  if root_stat.st_ctime == root_stat.st_mtime:
65
55
  return ts.from_unix(root_stat.st_ctime)
56
+
57
+
58
+ def calculate_last_activity(folder: Path) -> Optional[datetime]:
59
+ if not folder.exists():
60
+ return
61
+
62
+ last_seen = 0
63
+ for file in folder.iterdir():
64
+ if not file.exists():
65
+ continue
66
+ if file.stat().st_mtime > last_seen:
67
+ last_seen = file.stat().st_mtime
68
+
69
+ if last_seen != 0:
70
+ return ts.from_unix(last_seen)