dissect.target 3.17.dev31__py3-none-any.whl → 3.17.dev34__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. dissect/target/filesystems/dir.py +14 -1
  2. dissect/target/filesystems/overlay.py +103 -0
  3. dissect/target/helpers/compat/path_common.py +19 -5
  4. dissect/target/loader.py +1 -0
  5. dissect/target/loaders/dir.py +23 -5
  6. dissect/target/loaders/itunes.py +3 -3
  7. dissect/target/loaders/overlay.py +31 -0
  8. dissect/target/loaders/velociraptor.py +5 -4
  9. dissect/target/plugins/apps/browser/brave.py +10 -0
  10. dissect/target/plugins/apps/browser/browser.py +43 -0
  11. dissect/target/plugins/apps/browser/chrome.py +10 -0
  12. dissect/target/plugins/apps/browser/chromium.py +234 -12
  13. dissect/target/plugins/apps/browser/edge.py +10 -0
  14. dissect/target/plugins/apps/browser/firefox.py +440 -19
  15. dissect/target/plugins/apps/browser/iexplore.py +1 -1
  16. dissect/target/plugins/apps/container/docker.py +24 -4
  17. dissect/target/plugins/apps/ssh/putty.py +10 -1
  18. dissect/target/plugins/child/docker.py +24 -0
  19. dissect/target/plugins/os/unix/linux/fortios/_os.py +6 -6
  20. dissect/target/plugins/os/windows/catroot.py +11 -2
  21. dissect/target/plugins/os/windows/dpapi/crypto.py +12 -1
  22. dissect/target/plugins/os/windows/dpapi/dpapi.py +62 -7
  23. dissect/target/plugins/os/windows/dpapi/master_key.py +22 -2
  24. dissect/target/plugins/os/windows/sam.py +10 -1
  25. {dissect.target-3.17.dev31.dist-info → dissect.target-3.17.dev34.dist-info}/METADATA +1 -1
  26. {dissect.target-3.17.dev31.dist-info → dissect.target-3.17.dev34.dist-info}/RECORD +31 -28
  27. {dissect.target-3.17.dev31.dist-info → dissect.target-3.17.dev34.dist-info}/COPYRIGHT +0 -0
  28. {dissect.target-3.17.dev31.dist-info → dissect.target-3.17.dev34.dist-info}/LICENSE +0 -0
  29. {dissect.target-3.17.dev31.dist-info → dissect.target-3.17.dev34.dist-info}/WHEEL +0 -0
  30. {dissect.target-3.17.dev31.dist-info → dissect.target-3.17.dev34.dist-info}/entry_points.txt +0 -0
  31. {dissect.target-3.17.dev31.dist-info → dissect.target-3.17.dev34.dist-info}/top_level.txt +0 -0
@@ -7,6 +7,7 @@ from dissect.target.exceptions import (
7
7
  FilesystemError,
8
8
  IsADirectoryError,
9
9
  NotADirectoryError,
10
+ NotASymlinkError,
10
11
  )
11
12
  from dissect.target.filesystem import Filesystem, FilesystemEntry
12
13
  from dissect.target.helpers import fsutil
@@ -55,6 +56,8 @@ class DirectoryFilesystem(Filesystem):
55
56
 
56
57
 
57
58
  class DirectoryFilesystemEntry(FilesystemEntry):
59
+ entry: Path
60
+
58
61
  def get(self, path: str) -> FilesystemEntry:
59
62
  path = fsutil.join(self.path, path, alt_separator=self.fs.alt_separator)
60
63
  return self.fs.get(path)
@@ -113,7 +116,17 @@ class DirectoryFilesystemEntry(FilesystemEntry):
113
116
  return False
114
117
 
115
118
  def readlink(self) -> str:
116
- return os.readlink(self.entry) # Python 3.7 compatibility
119
+ if not self.is_symlink():
120
+ raise NotASymlinkError()
121
+
122
+ # We want to get the "truest" form of the symlink
123
+ # If we use the readlink() of pathlib.Path directly, it gets thrown into the path parsing of pathlib
124
+ # Because DirectoryFilesystem may also be used with TargetPath, we specifically handle that case here
125
+ # and use os.readlink for host paths
126
+ if isinstance(self.entry, fsutil.TargetPath):
127
+ return self.entry.get().readlink()
128
+ else:
129
+ return os.readlink(self.entry)
117
130
 
118
131
  def stat(self, follow_symlinks: bool = True) -> fsutil.stat_result:
119
132
  return self._resolve(follow_symlinks=follow_symlinks).entry.lstat()
@@ -0,0 +1,103 @@
1
+ import json
2
+ import logging
3
+ from pathlib import Path
4
+
5
+ from dissect.target.filesystem import LayerFilesystem, VirtualFilesystem
6
+ from dissect.target.filesystems.dir import DirectoryFilesystem
7
+
8
+ log = logging.getLogger(__name__)
9
+
10
+
11
+ class Overlay2Filesystem(LayerFilesystem):
12
+ """Overlay 2 filesystem implementation.
13
+
14
+ Deleted files will be present on the reconstructed filesystem.
15
+ Volumes and bind mounts will be added to their respective mount locations.
16
+ Does not support tmpfs mounts.
17
+
18
+ References:
19
+ - https://docs.docker.com/storage/storagedriver/
20
+ - https://docs.docker.com/storage/volumes/
21
+ - https://www.didactic-security.com/resources/docker-forensics.pdf
22
+ - https://www.didactic-security.com/resources/docker-forensics-cheatsheet.pdf
23
+ - https://github.com/google/docker-explorer
24
+ """
25
+
26
+ __type__ = "overlay2"
27
+
28
+ def __init__(self, path: Path, *args, **kwargs):
29
+ super().__init__(*args, **kwargs)
30
+ self.base_path = path
31
+
32
+ # base_path is /foo/bar/image/overlay2/layerdb/mounts/<id> so we traverse up to /foo/bar to get to the root.
33
+ root = path.parents[4]
34
+
35
+ layers = []
36
+ parent_layer = path.joinpath("parent").read_text()
37
+
38
+ # iterate over all image layers
39
+ while parent_layer:
40
+ hash_type, layer_hash = parent_layer.split(":")
41
+ layer_ref = root.joinpath("image", "overlay2", "layerdb", hash_type, layer_hash)
42
+ cache_id = layer_ref.joinpath("cache-id").read_text()
43
+ layers.append(("/", root.joinpath("overlay2", cache_id, "diff")))
44
+
45
+ if (parent_file := layer_ref.joinpath("parent")).exists():
46
+ parent_layer = parent_file.read_text()
47
+ else:
48
+ parent_layer = None
49
+
50
+ # add the container layers
51
+ for container_layer_name in ["init-id", "mount-id"]:
52
+ layer = path.joinpath(container_layer_name).read_text()
53
+ layers.append(("/", root.joinpath("overlay2", layer, "diff")))
54
+
55
+ # add anonymous volumes, named volumes and bind mounts
56
+ if (config_path := root.joinpath("containers", path.name, "config.v2.json")).exists():
57
+ try:
58
+ config = json.loads(config_path.read_text())
59
+ except json.JSONDecodeError as e:
60
+ log.warning("Unable to parse overlay mounts for container %s", path.name)
61
+ log.debug("", exc_info=e)
62
+ return
63
+
64
+ for mount in config.get("MountPoints").values():
65
+ if not mount["Type"] in ["volume", "bind"]:
66
+ log.warning("Encountered unsupported mount type %s in container %s", mount["Type"], path.name)
67
+ continue
68
+
69
+ if not mount["Source"] and mount["Name"]:
70
+ # anonymous volumes do not have a Source but a volume id
71
+ layer = root.joinpath("volumes", mount["Name"], "_data")
72
+ elif mount["Source"]:
73
+ # named volumes and bind mounts have a Source set
74
+ layer = root.parents[-1].joinpath(mount["Source"])
75
+ else:
76
+ log.warning("Could not determine layer source for mount in container %s", path.name)
77
+ log.debug(json.dumps(mount))
78
+ continue
79
+
80
+ layers.append((mount["Destination"], layer))
81
+
82
+ # add hosts, hostname and resolv.conf files
83
+ for file in ["HostnamePath", "HostsPath", "ResolvConfPath"]:
84
+ if not config.get(file) or not (fp := path.parents[-1].joinpath(config.get(file))).exists():
85
+ log.warning("Container %s has no %s mount", path.name, file)
86
+ continue
87
+
88
+ layers.append(("/etc/" + fp.name, fp))
89
+
90
+ # append and mount every layer
91
+ for dest, layer in layers:
92
+ if layer.is_file() and dest in ["/etc/hosts", "/etc/hostname", "/etc/resolv.conf"]:
93
+ layer_fs = VirtualFilesystem()
94
+ layer_fs.map_file_fh("/etc/" + layer.name, layer.open("rb"))
95
+ dest = dest.split("/")[0]
96
+
97
+ else:
98
+ layer_fs = DirectoryFilesystem(layer)
99
+
100
+ self.append_layer().mount(dest, layer_fs)
101
+
102
+ def __repr__(self) -> str:
103
+ return f"<{self.__class__.__name__} {self.base_path}>"
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import io
4
4
  import posixpath
5
5
  import stat
6
+ import sys
6
7
  from typing import IO, TYPE_CHECKING, Iterator, Literal, Optional
7
8
 
8
9
  if TYPE_CHECKING:
@@ -27,11 +28,24 @@ try:
27
28
  self._fs = path._fs
28
29
  self._flavour = path._flavour
29
30
 
30
- def __getitem__(self, idx: int) -> TargetPath:
31
- result = super().__getitem__(idx)
32
- result._fs = self._fs
33
- result._flavour = self._flavour
34
- return result
31
+ if sys.version_info >= (3, 10):
32
+
33
+ def __getitem__(self, idx: int) -> TargetPath:
34
+ result = super().__getitem__(idx)
35
+ result._fs = self._fs
36
+ result._flavour = self._flavour
37
+ return result
38
+
39
+ else:
40
+
41
+ def __getitem__(self, idx: int) -> TargetPath:
42
+ if idx < 0:
43
+ idx = len(self) + idx
44
+
45
+ result = super().__getitem__(idx)
46
+ result._fs = self._fs
47
+ result._flavour = self._flavour
48
+ return result
35
49
 
36
50
  except ImportError:
37
51
  pass
dissect/target/loader.py CHANGED
@@ -199,6 +199,7 @@ register("target", "TargetLoader")
199
199
  register("log", "LogLoader")
200
200
  # Disabling ResLoader because of DIS-536
201
201
  # register("res", "ResLoader")
202
+ register("overlay", "Overlay2Loader")
202
203
  register("phobos", "PhobosLoader")
203
204
  register("velociraptor", "VelociraptorLoader")
204
205
  register("smb", "SmbLoader")
@@ -1,9 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import zipfile
4
+ from collections import defaultdict
4
5
  from pathlib import Path
5
6
  from typing import TYPE_CHECKING
6
7
 
8
+ from dissect.target.filesystem import LayerFilesystem
7
9
  from dissect.target.filesystems.dir import DirectoryFilesystem
8
10
  from dissect.target.filesystems.zip import ZipFilesystem
9
11
  from dissect.target.helpers import loaderutil
@@ -48,6 +50,7 @@ def map_dirs(target: Target, dirs: list[Path | tuple[str, Path]], os_type: str,
48
50
  alt_separator = "\\"
49
51
  case_sensitive = False
50
52
 
53
+ drive_letter_map = defaultdict(list)
51
54
  for path in dirs:
52
55
  drive_letter = None
53
56
  if isinstance(path, tuple):
@@ -59,13 +62,28 @@ def map_dirs(target: Target, dirs: list[Path | tuple[str, Path]], os_type: str,
59
62
  dfs = ZipFilesystem(path.root.fp, path.at, alt_separator=alt_separator, case_sensitive=case_sensitive)
60
63
  else:
61
64
  dfs = DirectoryFilesystem(path, alt_separator=alt_separator, case_sensitive=case_sensitive)
62
- target.filesystems.add(dfs)
63
65
 
64
- if os_type == OperatingSystem.WINDOWS:
65
- loaderutil.add_virtual_ntfs_filesystem(target, dfs, **kwargs)
66
+ drive_letter_map[drive_letter].append(dfs)
67
+
68
+ fs_to_add = []
69
+ for drive_letter, dfs in drive_letter_map.items():
70
+ if drive_letter is not None:
71
+ if len(dfs) > 1:
72
+ vfs = LayerFilesystem()
73
+ for fs in dfs:
74
+ vfs.append_fs_layer(fs)
75
+ else:
76
+ vfs = dfs[0]
66
77
 
67
- if drive_letter is not None:
68
- target.fs.mount(drive_letter.lower() + ":", dfs)
78
+ fs_to_add.append(vfs)
79
+ target.fs.mount(drive_letter.lower() + ":", vfs)
80
+ else:
81
+ fs_to_add.extend(dfs)
82
+
83
+ for fs in fs_to_add:
84
+ target.filesystems.add(fs)
85
+ if os_type == OperatingSystem.WINDOWS:
86
+ loaderutil.add_virtual_ntfs_filesystem(target, fs, **kwargs)
69
87
 
70
88
 
71
89
  def find_and_map_dirs(target: Target, path: Path, **kwargs) -> None:
@@ -28,9 +28,9 @@ except ImportError:
28
28
  try:
29
29
  from Crypto.Cipher import AES
30
30
 
31
- HAS_PYCRYPTODOME = True
31
+ HAS_CRYPTO = True
32
32
  except ImportError:
33
- HAS_PYCRYPTODOME = False
33
+ HAS_CRYPTO = False
34
34
 
35
35
 
36
36
  DOMAIN_TRANSLATION = {
@@ -383,7 +383,7 @@ def _create_cipher(key: bytes, iv: bytes = b"\x00" * 16, mode: str = "cbc") -> A
383
383
  raise ValueError(f"Invalid key size: {key_size}")
384
384
 
385
385
  return _pystandalone.cipher(f"aes-{key_size * 8}-{mode}", key, iv)
386
- elif HAS_PYCRYPTODOME:
386
+ elif HAS_CRYPTO:
387
387
  mode_map = {
388
388
  "cbc": (AES.MODE_CBC, True),
389
389
  "ecb": (AES.MODE_ECB, False),
@@ -0,0 +1,31 @@
1
+ from dissect.target.filesystems.overlay import Overlay2Filesystem
2
+ from dissect.target.helpers.fsutil import TargetPath
3
+ from dissect.target.loader import Loader
4
+ from dissect.target.target import Target
5
+
6
+
7
+ class Overlay2Loader(Loader):
8
+ """Load overlay2 filesystems"""
9
+
10
+ def __init__(self, path: TargetPath, **kwargs):
11
+ super().__init__(path.resolve(), **kwargs)
12
+
13
+ @staticmethod
14
+ def detect(path: TargetPath) -> bool:
15
+ # path should be a folder
16
+ if not path.is_dir():
17
+ return False
18
+
19
+ # with the following three files
20
+ for required_file in ["init-id", "parent", "mount-id"]:
21
+ if not path.joinpath(required_file).exists():
22
+ return False
23
+
24
+ # and should have the following parent folders
25
+ if "image/overlay2/layerdb/mounts/" not in path.as_posix():
26
+ return False
27
+
28
+ return True
29
+
30
+ def map(self, target: Target) -> None:
31
+ target.filesystems.add(Overlay2Filesystem(self.path))
@@ -61,6 +61,10 @@ def extract_drive_letter(name: str) -> Optional[str]:
61
61
  if len(name) == 14 and name.startswith("%5C%5C.%5C") and name.endswith("%3A"):
62
62
  return name[10].lower()
63
63
 
64
+ # X: in URL encoding
65
+ if len(name) == 4 and name.endswith("%3A"):
66
+ return name[0].lower()
67
+
64
68
 
65
69
  class VelociraptorLoader(DirLoader):
66
70
  """Load Rapid7 Velociraptor forensic image files.
@@ -71,10 +75,7 @@ class VelociraptorLoader(DirLoader):
71
75
  {"Generic.Collectors.File":{"Root":"/","collectionSpec":"Glob\\netc/**\\nvar/log/**"}}
72
76
 
73
77
  Generic.Collectors.File (Windows) and Windows.KapeFiles.Targets (Windows) uses the accessors mft, ntfs, lazy_ntfs,
74
- ntfs_vss and auto. The loader only supports a collection where a single accessor is used, which can be forced by
75
- using the following configuration::
76
-
77
- {"Windows.KapeFiles.Targets":{"VSSAnalysisAge":"1000","_SANS_Triage":"Y"}}
78
+ ntfs_vss and auto. The loader supports a collection where multiple accessors were used.
78
79
 
79
80
  References:
80
81
  - https://www.rapid7.com/products/velociraptor/
@@ -8,6 +8,7 @@ from dissect.target.plugins.apps.browser.browser import (
8
8
  GENERIC_DOWNLOAD_RECORD_FIELDS,
9
9
  GENERIC_EXTENSION_RECORD_FIELDS,
10
10
  GENERIC_HISTORY_RECORD_FIELDS,
11
+ GENERIC_PASSWORD_RECORD_FIELDS,
11
12
  BrowserPlugin,
12
13
  )
13
14
  from dissect.target.plugins.apps.browser.chromium import (
@@ -47,6 +48,10 @@ class BravePlugin(ChromiumMixin, BrowserPlugin):
47
48
  "browser/brave/extension", GENERIC_EXTENSION_RECORD_FIELDS
48
49
  )
49
50
 
51
+ BrowserPasswordRecord = create_extended_descriptor([UserRecordDescriptorExtension])(
52
+ "browser/brave/password", GENERIC_PASSWORD_RECORD_FIELDS
53
+ )
54
+
50
55
  @export(record=BrowserHistoryRecord)
51
56
  def history(self) -> Iterator[BrowserHistoryRecord]:
52
57
  """Return browser history records for Brave."""
@@ -66,3 +71,8 @@ class BravePlugin(ChromiumMixin, BrowserPlugin):
66
71
  def extensions(self) -> Iterator[BrowserExtensionRecord]:
67
72
  """Return browser extension records for Brave."""
68
73
  yield from super().extensions("brave")
74
+
75
+ @export(record=BrowserPasswordRecord)
76
+ def passwords(self) -> Iterator[BrowserPasswordRecord]:
77
+ """Return browser password records for Brave."""
78
+ yield from super().passwords("brave")
@@ -1,6 +1,10 @@
1
+ from functools import cache
2
+
3
+ from dissect.target.helpers import keychain
1
4
  from dissect.target.helpers.descriptor_extensions import UserRecordDescriptorExtension
2
5
  from dissect.target.helpers.record import create_extended_descriptor
3
6
  from dissect.target.plugin import NamespacePlugin
7
+ from dissect.target.target import Target
4
8
 
5
9
  GENERIC_DOWNLOAD_RECORD_FIELDS = [
6
10
  ("datetime", "ts_start"),
@@ -63,6 +67,21 @@ GENERIC_HISTORY_RECORD_FIELDS = [
63
67
  ("uri", "from_url"),
64
68
  ("path", "source"),
65
69
  ]
70
+
71
+ GENERIC_PASSWORD_RECORD_FIELDS = [
72
+ ("datetime", "ts_created"),
73
+ ("datetime", "ts_last_used"),
74
+ ("datetime", "ts_last_changed"),
75
+ ("string", "browser"),
76
+ ("varint", "id"),
77
+ ("uri", "url"),
78
+ ("string", "encrypted_username"),
79
+ ("string", "encrypted_password"),
80
+ ("string", "decrypted_username"),
81
+ ("string", "decrypted_password"),
82
+ ("path", "source"),
83
+ ]
84
+
66
85
  BrowserDownloadRecord = create_extended_descriptor([UserRecordDescriptorExtension])(
67
86
  "browser/download", GENERIC_DOWNLOAD_RECORD_FIELDS
68
87
  )
@@ -75,11 +94,35 @@ BrowserHistoryRecord = create_extended_descriptor([UserRecordDescriptorExtension
75
94
  BrowserCookieRecord = create_extended_descriptor([UserRecordDescriptorExtension])(
76
95
  "browser/cookie", GENERIC_COOKIE_FIELDS
77
96
  )
97
+ BrowserPasswordRecord = create_extended_descriptor([UserRecordDescriptorExtension])(
98
+ "browser/password", GENERIC_PASSWORD_RECORD_FIELDS
99
+ )
78
100
 
79
101
 
80
102
  class BrowserPlugin(NamespacePlugin):
81
103
  __namespace__ = "browser"
82
104
 
105
+ def __init__(self, target: Target):
106
+ super().__init__(target)
107
+ self.keychain = cache(self.keychain)
108
+
109
+ def keychain(self) -> set:
110
+ """Retrieve a set of passphrases to use for decrypting saved browser credentials.
111
+
112
+ Always adds an empty passphrase as some browsers encrypt values using empty passphrases.
113
+
114
+ Returns:
115
+ Set of passphrase strings.
116
+ """
117
+ passphrases = set()
118
+ for provider in [self.__namespace__, "browser", "user", None]:
119
+ for key in keychain.get_keys_for_provider(provider) if provider else keychain.get_keys_without_provider():
120
+ if key.key_type == keychain.KeyType.PASSPHRASE:
121
+ passphrases.add(key.value)
122
+
123
+ passphrases.add("")
124
+ return passphrases
125
+
83
126
 
84
127
  def try_idna(url: str) -> bytes:
85
128
  """Attempts to convert a possible Unicode url to ASCII using the IDNA standard.
@@ -8,6 +8,7 @@ from dissect.target.plugins.apps.browser.browser import (
8
8
  GENERIC_DOWNLOAD_RECORD_FIELDS,
9
9
  GENERIC_EXTENSION_RECORD_FIELDS,
10
10
  GENERIC_HISTORY_RECORD_FIELDS,
11
+ GENERIC_PASSWORD_RECORD_FIELDS,
11
12
  BrowserPlugin,
12
13
  )
13
14
  from dissect.target.plugins.apps.browser.chromium import (
@@ -49,6 +50,10 @@ class ChromePlugin(ChromiumMixin, BrowserPlugin):
49
50
  "browser/chrome/extension", GENERIC_EXTENSION_RECORD_FIELDS
50
51
  )
51
52
 
53
+ BrowserPasswordRecord = create_extended_descriptor([UserRecordDescriptorExtension])(
54
+ "browser/chrome/password", GENERIC_PASSWORD_RECORD_FIELDS
55
+ )
56
+
52
57
  @export(record=BrowserHistoryRecord)
53
58
  def history(self) -> Iterator[BrowserHistoryRecord]:
54
59
  """Return browser history records for Google Chrome."""
@@ -68,3 +73,8 @@ class ChromePlugin(ChromiumMixin, BrowserPlugin):
68
73
  def extensions(self) -> Iterator[BrowserExtensionRecord]:
69
74
  """Return browser extension records for Google Chrome."""
70
75
  yield from super().extensions("chrome")
76
+
77
+ @export(record=BrowserPasswordRecord)
78
+ def passwords(self) -> Iterator[BrowserPasswordRecord]:
79
+ """Return browser password records for Google Chrome."""
80
+ yield from super().passwords("chrome")