dissect.target 3.17.dev31__py3-none-any.whl → 3.17.dev33__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.
- dissect/target/filesystems/dir.py +14 -1
- dissect/target/filesystems/overlay.py +103 -0
- dissect/target/helpers/compat/path_common.py +19 -5
- dissect/target/loader.py +1 -0
- dissect/target/loaders/itunes.py +3 -3
- dissect/target/loaders/overlay.py +31 -0
- dissect/target/plugins/apps/browser/brave.py +10 -0
- dissect/target/plugins/apps/browser/browser.py +43 -0
- dissect/target/plugins/apps/browser/chrome.py +10 -0
- dissect/target/plugins/apps/browser/chromium.py +234 -12
- dissect/target/plugins/apps/browser/edge.py +10 -0
- dissect/target/plugins/apps/browser/firefox.py +440 -19
- dissect/target/plugins/apps/browser/iexplore.py +1 -1
- dissect/target/plugins/apps/container/docker.py +24 -4
- dissect/target/plugins/apps/ssh/putty.py +10 -1
- dissect/target/plugins/child/docker.py +24 -0
- dissect/target/plugins/os/unix/linux/fortios/_os.py +6 -6
- dissect/target/plugins/os/windows/catroot.py +11 -2
- dissect/target/plugins/os/windows/dpapi/crypto.py +12 -1
- dissect/target/plugins/os/windows/dpapi/dpapi.py +62 -7
- dissect/target/plugins/os/windows/dpapi/master_key.py +22 -2
- dissect/target/plugins/os/windows/sam.py +10 -1
- {dissect.target-3.17.dev31.dist-info → dissect.target-3.17.dev33.dist-info}/METADATA +1 -1
- {dissect.target-3.17.dev31.dist-info → dissect.target-3.17.dev33.dist-info}/RECORD +29 -26
- {dissect.target-3.17.dev31.dist-info → dissect.target-3.17.dev33.dist-info}/COPYRIGHT +0 -0
- {dissect.target-3.17.dev31.dist-info → dissect.target-3.17.dev33.dist-info}/LICENSE +0 -0
- {dissect.target-3.17.dev31.dist-info → dissect.target-3.17.dev33.dist-info}/WHEEL +0 -0
- {dissect.target-3.17.dev31.dist-info → dissect.target-3.17.dev33.dist-info}/entry_points.txt +0 -0
- {dissect.target-3.17.dev31.dist-info → dissect.target-3.17.dev33.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
|
-
|
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
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
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")
|
dissect/target/loaders/itunes.py
CHANGED
@@ -28,9 +28,9 @@ except ImportError:
|
|
28
28
|
try:
|
29
29
|
from Crypto.Cipher import AES
|
30
30
|
|
31
|
-
|
31
|
+
HAS_CRYPTO = True
|
32
32
|
except ImportError:
|
33
|
-
|
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
|
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))
|
@@ -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")
|