dissect.target 3.16.dev45__py3-none-any.whl → 3.17__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 (62) hide show
  1. dissect/target/container.py +1 -0
  2. dissect/target/containers/fortifw.py +190 -0
  3. dissect/target/filesystem.py +192 -67
  4. dissect/target/filesystems/dir.py +14 -1
  5. dissect/target/filesystems/overlay.py +103 -0
  6. dissect/target/helpers/compat/path_common.py +19 -5
  7. dissect/target/helpers/configutil.py +30 -7
  8. dissect/target/helpers/network_managers.py +101 -73
  9. dissect/target/helpers/record_modifier.py +4 -1
  10. dissect/target/loader.py +3 -1
  11. dissect/target/loaders/dir.py +23 -5
  12. dissect/target/loaders/itunes.py +3 -3
  13. dissect/target/loaders/mqtt.py +309 -0
  14. dissect/target/loaders/overlay.py +31 -0
  15. dissect/target/loaders/target.py +12 -9
  16. dissect/target/loaders/vb.py +2 -2
  17. dissect/target/loaders/velociraptor.py +5 -4
  18. dissect/target/plugin.py +1 -1
  19. dissect/target/plugins/apps/browser/brave.py +10 -0
  20. dissect/target/plugins/apps/browser/browser.py +43 -0
  21. dissect/target/plugins/apps/browser/chrome.py +10 -0
  22. dissect/target/plugins/apps/browser/chromium.py +234 -12
  23. dissect/target/plugins/apps/browser/edge.py +10 -0
  24. dissect/target/plugins/apps/browser/firefox.py +512 -19
  25. dissect/target/plugins/apps/browser/iexplore.py +2 -2
  26. dissect/target/plugins/apps/container/docker.py +24 -4
  27. dissect/target/plugins/apps/ssh/openssh.py +4 -0
  28. dissect/target/plugins/apps/ssh/putty.py +45 -14
  29. dissect/target/plugins/apps/ssh/ssh.py +40 -0
  30. dissect/target/plugins/apps/vpn/openvpn.py +115 -93
  31. dissect/target/plugins/child/docker.py +24 -0
  32. dissect/target/plugins/filesystem/ntfs/mft.py +1 -1
  33. dissect/target/plugins/filesystem/walkfs.py +2 -2
  34. dissect/target/plugins/os/unix/bsd/__init__.py +0 -0
  35. dissect/target/plugins/os/unix/esxi/_os.py +2 -2
  36. dissect/target/plugins/os/unix/linux/debian/vyos/_os.py +1 -1
  37. dissect/target/plugins/os/unix/linux/fortios/_os.py +9 -9
  38. dissect/target/plugins/os/unix/linux/services.py +1 -0
  39. dissect/target/plugins/os/unix/linux/sockets.py +2 -2
  40. dissect/target/plugins/os/unix/log/messages.py +53 -8
  41. dissect/target/plugins/os/windows/_os.py +10 -1
  42. dissect/target/plugins/os/windows/catroot.py +178 -63
  43. dissect/target/plugins/os/windows/credhist.py +210 -0
  44. dissect/target/plugins/os/windows/dpapi/crypto.py +12 -1
  45. dissect/target/plugins/os/windows/dpapi/dpapi.py +62 -7
  46. dissect/target/plugins/os/windows/dpapi/master_key.py +22 -2
  47. dissect/target/plugins/os/windows/regf/runkeys.py +6 -4
  48. dissect/target/plugins/os/windows/sam.py +10 -1
  49. dissect/target/target.py +1 -1
  50. dissect/target/tools/dump/run.py +23 -28
  51. dissect/target/tools/dump/state.py +11 -8
  52. dissect/target/tools/dump/utils.py +5 -4
  53. dissect/target/tools/query.py +3 -15
  54. dissect/target/tools/shell.py +48 -8
  55. dissect/target/tools/utils.py +23 -0
  56. {dissect.target-3.16.dev45.dist-info → dissect.target-3.17.dist-info}/METADATA +7 -3
  57. {dissect.target-3.16.dev45.dist-info → dissect.target-3.17.dist-info}/RECORD +62 -55
  58. {dissect.target-3.16.dev45.dist-info → dissect.target-3.17.dist-info}/WHEEL +1 -1
  59. {dissect.target-3.16.dev45.dist-info → dissect.target-3.17.dist-info}/COPYRIGHT +0 -0
  60. {dissect.target-3.16.dev45.dist-info → dissect.target-3.17.dist-info}/LICENSE +0 -0
  61. {dissect.target-3.16.dev45.dist-info → dissect.target-3.17.dist-info}/entry_points.txt +0 -0
  62. {dissect.target-3.16.dev45.dist-info → dissect.target-3.17.dist-info}/top_level.txt +0 -0
@@ -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
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import io
4
4
  import json
5
5
  import re
6
+ import sys
6
7
  from collections import deque
7
8
  from configparser import ConfigParser, MissingSectionHeaderError
8
9
  from dataclasses import dataclass
@@ -28,11 +29,22 @@ from dissect.target.filesystem import FilesystemEntry
28
29
  from dissect.target.helpers.fsutil import TargetPath
29
30
 
30
31
  try:
31
- import yaml
32
+ from ruamel.yaml import YAML
32
33
 
33
- PY_YAML = True
34
- except (AttributeError, ImportError):
35
- PY_YAML = False
34
+ HAS_YAML = True
35
+ except ImportError:
36
+ HAS_YAML = False
37
+
38
+ try:
39
+ if sys.version_info < (3, 11):
40
+ import tomli as toml
41
+ else:
42
+ # tomllib is included since python 3.11
43
+ import tomllib as toml # novermin
44
+
45
+ HAS_TOML = True
46
+ except ImportError:
47
+ HAS_TOML = False
36
48
 
37
49
 
38
50
  def _update_dictionary(current: dict[str, Any], key: str, value: Any) -> None:
@@ -401,11 +413,21 @@ class Yaml(ConfigurationParser):
401
413
  """Parses a Yaml file."""
402
414
 
403
415
  def parse_file(self, fh: TextIO) -> None:
404
- if PY_YAML:
405
- parsed_data = yaml.load(fh, yaml.BaseLoader)
416
+ if HAS_YAML:
417
+ parsed_data = YAML(typ="safe").load(fh)
406
418
  self.parsed_data = ListUnwrapper.unwrap(parsed_data)
407
419
  else:
408
- raise ConfigurationParsingError("Failed to parse file, please install PyYAML.")
420
+ raise ConfigurationParsingError("Failed to parse file, please install ruamel.yaml.")
421
+
422
+
423
+ class Toml(ConfigurationParser):
424
+ """Parses a Toml file."""
425
+
426
+ def parse_file(self, fh: TextIO) -> None:
427
+ if HAS_TOML:
428
+ self.parsed_data = toml.loads(fh.read())
429
+ else:
430
+ raise ConfigurationParsingError("Failed to parse file, please install tomli.")
409
431
 
410
432
 
411
433
  class ScopeManager:
@@ -696,6 +718,7 @@ CONFIG_MAP: dict[tuple[str, ...], ParserConfig] = {
696
718
  "sample": ParserConfig(Txt),
697
719
  "systemd": ParserConfig(SystemD),
698
720
  "template": ParserConfig(Txt),
721
+ "toml": ParserConfig(Toml),
699
722
  }
700
723
 
701
724
  KNOWN_FILES: dict[str, type[ConfigurationParser]] = {
@@ -1,9 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
1
4
  import re
2
5
  from collections import defaultdict
3
6
  from configparser import ConfigParser, MissingSectionHeaderError
4
7
  from io import StringIO
8
+ from itertools import chain
5
9
  from re import compile, sub
6
- from typing import Any, Callable, Iterable, Match, Optional, Union
10
+ from typing import Any, Callable, Iterable, Match, Optional
7
11
 
8
12
  from defusedxml import ElementTree
9
13
 
@@ -11,12 +15,14 @@ from dissect.target.exceptions import PluginError
11
15
  from dissect.target.helpers.fsutil import TargetPath
12
16
  from dissect.target.target import Target
13
17
 
18
+ log = logging.getLogger(__name__)
19
+
14
20
  try:
15
- import yaml
21
+ from ruamel.yaml import YAML
16
22
 
17
- PY_YAML = True
23
+ HAS_YAML = True
18
24
  except ImportError:
19
- PY_YAML = False
25
+ HAS_YAML = False
20
26
 
21
27
  IGNORED_IPS = [
22
28
  "0.0.0.0",
@@ -49,7 +55,7 @@ class Template:
49
55
  """Sets the name of the the used parsing template to the name of the discovered network manager."""
50
56
  self.name = name
51
57
 
52
- def create_config(self, path: TargetPath) -> Union[dict, None]:
58
+ def create_config(self, path: TargetPath) -> Optional[dict]:
53
59
  """Create a network config dictionary based on the configured template and supplied path.
54
60
 
55
61
  Args:
@@ -60,7 +66,7 @@ class Template:
60
66
  """
61
67
 
62
68
  if not path.exists() or path.is_dir():
63
- self.target.log.debug("Failed to get config file %s", path)
69
+ log.debug("Failed to get config file %s", path)
64
70
  config = None
65
71
 
66
72
  if self.name == "netplan":
@@ -73,26 +79,26 @@ class Template:
73
79
  config = self._parse_configparser_config(path)
74
80
  return config
75
81
 
76
- def _parse_netplan_config(self, fh: TargetPath) -> Union[dict, None]:
82
+ def _parse_netplan_config(self, path: TargetPath) -> Optional[dict]:
77
83
  """Internal function to parse a netplan YAML based configuration file into a dict.
78
84
 
79
85
  Args:
80
- fh: A file-like object to the configuration file to be parsed.
86
+ fh: A path to the configuration file to be parsed.
81
87
 
82
88
  Returns:
83
89
  Dictionary containing the parsed YAML based configuration file.
84
90
  """
85
- if PY_YAML:
86
- return self.parser(stream=fh.open(), Loader=yaml.FullLoader)
91
+ if HAS_YAML:
92
+ return self.parser(path.open("rb"))
87
93
  else:
88
- self.target.log.error("Failed to parse %s. Cannot import PyYAML", self.name)
94
+ log.error("Failed to parse %s. Cannot import ruamel.yaml", self.name)
89
95
  return None
90
96
 
91
- def _parse_wicked_config(self, fh: TargetPath) -> dict:
97
+ def _parse_wicked_config(self, path: TargetPath) -> dict:
92
98
  """Internal function to parse a wicked XML based configuration file into a dict.
93
99
 
94
100
  Args:
95
- fh: A file-like object to the configuration file to be parsed.
101
+ fh: A path to the configuration file to be parsed.
96
102
 
97
103
  Returns:
98
104
  Dictionary containing the parsed xml based Linux network manager based configuration file.
@@ -101,44 +107,43 @@ class Template:
101
107
  # we have to replace the ":" for this with "___" (three underscores) to make the xml config non-namespaced.
102
108
  pattern = compile(r"(?<=\n)\s+(<.+?>)")
103
109
  replace_match: Callable[[Match]] = lambda match: match.group(1).replace(":", "___")
104
- text = sub(pattern, replace_match, fh.open("rt").read())
110
+ text = sub(pattern, replace_match, path.open("rt").read())
105
111
 
106
112
  xml = self.parser.parse(StringIO(text))
107
113
  return self._parse_xml_config(xml, self.sections, self.options)
108
114
 
109
- def _parse_configparser_config(self, fh: TargetPath) -> dict:
115
+ def _parse_configparser_config(self, path: TargetPath) -> dict:
110
116
  """Internal function to parse ConfigParser compatible configuration files into a dict.
111
117
 
112
118
  Args:
113
- fh: A file-like object to the configuration file to be parsed.
119
+ path: A path to the configuration file to be parsed.
114
120
 
115
121
  Returns:
116
122
  Dictionary containing the parsed ConfigParser compatible configuration file.
117
123
  """
118
124
  try:
119
- self.parser.read_string(fh.open("rt").read(), fh.name)
125
+ self.parser.read_string(path.open("rt").read(), path.name)
120
126
  return self.parser._sections
121
127
  except MissingSectionHeaderError:
122
128
  # configparser does like config files without headers, so we inject a header to make it work.
123
- self.parser.read_string(f"[{self.name}]\n" + fh.open("rt").read(), fh.name)
129
+ self.parser.read_string(f"[{self.name}]\n" + path.open("rt").read(), path.name)
124
130
  return self.parser._sections
125
131
 
126
- def _parse_text_config(self, comments: str, delim: str, fh: TargetPath) -> dict:
132
+ def _parse_text_config(self, comments: str, delim: str, path: TargetPath) -> dict:
127
133
  """Internal function to parse a basic plain text based configuration file into a dict.
128
134
 
129
135
  Args:
130
136
  comments: A string value defining the comment style of the configuration file.
131
137
  delim: A string value defining the delimiters used in the configuration file.
132
- fh: A file-like object to the configuration file to be parsed.
138
+ path: A path to the configuration file to be parsed.
133
139
 
134
140
  Returns:
135
141
  Dictionary with a parsed plain text based Linux network manager configuration file.
136
142
  """
137
143
  config = defaultdict(dict)
138
144
  option_dict = {}
139
- fh = fh.open("rt")
140
145
 
141
- for line in fh.readlines():
146
+ for line in path.open("rt"):
142
147
  line = line.strip()
143
148
 
144
149
  if not line or line.startswith(comments):
@@ -279,7 +284,7 @@ class Parser:
279
284
  if option in translation_values and value:
280
285
  return translation_key
281
286
 
282
- def _get_option(self, config: dict, option: str, section: Optional[str] = None) -> Union[str, Callable]:
287
+ def _get_option(self, config: dict, option: str, section: Optional[str] = None) -> Optional[str | Callable]:
283
288
  """Internal function to get arbitrary options values from a parsed (non-translated) dictionary.
284
289
 
285
290
  Args:
@@ -290,8 +295,14 @@ class Parser:
290
295
  Returns:
291
296
  Value(s) corrensponding to that network configuration option.
292
297
  """
298
+ if not config:
299
+ log.error("Cannot get option %s: No config to parse", option)
300
+ return
301
+
293
302
  if section:
294
- config = config[section]
303
+ # account for values of sections which are None
304
+ config = config.get(section, {}) or {}
305
+
295
306
  for key, value in config.items():
296
307
  if key == option:
297
308
  return value
@@ -365,7 +376,7 @@ class NetworkManager:
365
376
  if self.registered:
366
377
  self.config = self.parser.parse()
367
378
  else:
368
- self.target.log.error("Network manager %s is not registered. Cannot parse config.", self.name)
379
+ log.error("Network manager %s is not registered. Cannot parse config.", self.name)
369
380
 
370
381
  @property
371
382
  def interface(self) -> set:
@@ -499,7 +510,7 @@ class LinuxNetworkManager:
499
510
 
500
511
 
501
512
  def parse_unix_dhcp_log_messages(target) -> list[str]:
502
- """Parse local syslog and cloud init log files for DHCP lease IPs.
513
+ """Parse local syslog, journal and cloud init-log files for DHCP lease IPs.
503
514
 
504
515
  Args:
505
516
  target: Target to discover and obtain network information from.
@@ -507,53 +518,68 @@ def parse_unix_dhcp_log_messages(target) -> list[str]:
507
518
  Returns:
508
519
  List of DHCP ip addresses.
509
520
  """
510
- ips = []
511
-
512
- # Search through parsed syslogs for DHCP leases.
513
- try:
514
- messages = target.messages()
515
- for record in messages:
516
- line = record.message
517
-
518
- # Ubuntu DHCP
519
- if ("DHCPv4" in line or "DHCPv6" in line) and " address " in line and " via " in line:
520
- ip = line.split(" address ")[1].split(" via ")[0].strip().split("/")[0]
521
- if ip not in ips:
522
- ips.append(ip)
523
-
524
- # Ubuntu DHCP NetworkManager
525
- elif "option ip_address" in line and ("dhcp4" in line or "dhcp6" in line) and "=> '" in line:
526
- ip = line.split("=> '")[1].replace("'", "").strip()
527
- if ip not in ips:
528
- ips.append(ip)
529
-
530
- # Debian and CentOS dhclient
531
- elif record.daemon == "dhclient" and "bound to" in line:
532
- ip = line.split("bound to")[1].split(" ")[1].strip()
533
- if ip not in ips:
534
- ips.append(ip)
535
-
536
- # CentOS DHCP and general NetworkManager
537
- elif " address " in line and ("dhcp4" in line or "dhcp6" in line):
538
- ip = line.split(" address ")[1].strip()
539
- if ip not in ips:
540
- ips.append(ip)
541
-
542
- except PluginError:
543
- target.log.debug("Can not search for DHCP leases in syslog files as they does not exist.")
544
-
545
- # A unix system might be provisioned using Ubuntu's cloud-init (https://cloud-init.io/).
546
- if (path := target.fs.path("/var/log/cloud-init.log")).exists():
547
- for line in path.open("rt"):
548
- # We are interested in the following log line:
549
- # YYYY-MM-DD HH:MM:SS,000 - dhcp.py[DEBUG]: Received dhcp lease on IFACE for IP/MASK
550
- if "Received dhcp lease on" in line:
551
- interface, ip, netmask = re.search(r"Received dhcp lease on (\w{0,}) for (\S+)\/(\S+)", line).groups()
552
- if ip not in ips:
553
- ips.append(ip)
521
+ ips = set()
522
+ messages = set()
554
523
 
555
- if not path and not messages:
556
- target.log.warning("Can not search for DHCP leases in syslog or cloud-init.log files as they does not exist.")
524
+ for log_func in ["messages", "journal"]:
525
+ try:
526
+ messages = chain(messages, getattr(target, log_func)())
527
+ except PluginError:
528
+ target.log.debug(f"Could not search for DHCP leases in {log_func} files.")
529
+
530
+ if not messages:
531
+ target.log.warning(f"Could not search for DHCP leases using {log_func}: No log entries found.")
532
+
533
+ for record in messages:
534
+ line = record.message
535
+
536
+ # Ubuntu cloud-init
537
+ if "Received dhcp lease on" in line:
538
+ interface, ip, netmask = re.search(r"Received dhcp lease on (\w{0,}) for (\S+)\/(\S+)", line).groups()
539
+ ips.add(ip)
540
+ continue
541
+
542
+ # Ubuntu DHCP
543
+ if ("DHCPv4" in line or "DHCPv6" in line) and " address " in line and " via " in line:
544
+ ip = line.split(" address ")[1].split(" via ")[0].strip().split("/")[0]
545
+ ips.add(ip)
546
+ continue
547
+
548
+ # Ubuntu DHCP NetworkManager
549
+ if "option ip_address" in line and ("dhcp4" in line or "dhcp6" in line) and "=> '" in line:
550
+ ip = line.split("=> '")[1].replace("'", "").strip()
551
+ ips.add(ip)
552
+ continue
553
+
554
+ # Debian and CentOS dhclient
555
+ if hasattr(record, "daemon") and record.daemon == "dhclient" and "bound to" in line:
556
+ ip = line.split("bound to")[1].split(" ")[1].strip()
557
+ ips.add(ip)
558
+ continue
559
+
560
+ # CentOS DHCP and general NetworkManager
561
+ if " address " in line and ("dhcp4" in line or "dhcp6" in line):
562
+ ip = line.split(" address ")[1].strip()
563
+ ips.add(ip)
564
+ continue
565
+
566
+ # Ubuntu/Debian DHCP networkd (Journal)
567
+ if (
568
+ hasattr(record, "code_func")
569
+ and record.code_func == "dhcp_lease_acquired"
570
+ and " address " in line
571
+ and " via " in line
572
+ ):
573
+ interface, ip, netmask, gateway = re.search(
574
+ r"^(\S+): DHCPv[4|6] address (\S+)\/(\S+) via (\S+)", line
575
+ ).groups()
576
+ ips.add(ip)
577
+ continue
578
+
579
+ # Journals and syslogs can be large and slow to iterate,
580
+ # so we stop if we have some results and have reached the journal plugin.
581
+ if len(ips) >= 2 and record._desc.name == "linux/log/journal":
582
+ break
557
583
 
558
584
  return ips
559
585
 
@@ -631,7 +657,9 @@ TEMPLATES = {
631
657
  ["netctl"],
632
658
  ["address", "gateway", "dns", "ip"],
633
659
  ),
634
- "netplan": Template("netplan", yaml.load if PY_YAML else None, ["network"], ["addresses", "dhcp4", "gateway4"]),
660
+ "netplan": Template(
661
+ "netplan", YAML(typ="safe").load if HAS_YAML else None, ["network"], ["addresses", "dhcp4", "gateway4"]
662
+ ),
635
663
  "NetworkManager": Template(
636
664
  "NetworkManager",
637
665
  ConfigParser(delimiters=("="), comment_prefixes="#", dict_type=dict),
@@ -62,13 +62,16 @@ MODIFIER_MAPPING = {
62
62
 
63
63
  def _resolve_path_types(target: Target, record: Record) -> Iterator[tuple[str, TargetPath]]:
64
64
  for field_name, field_type in record._field_types.items():
65
- if not issubclass(field_type, fieldtypes.path):
65
+ if not issubclass(field_type, (fieldtypes.path, fieldtypes.command)):
66
66
  continue
67
67
 
68
68
  path = getattr(record, field_name, None)
69
69
  if path is None:
70
70
  continue
71
71
 
72
+ if isinstance(path, fieldtypes.command):
73
+ path = path.executable
74
+
72
75
  yield field_name, target.resolve(str(path))
73
76
 
74
77
 
dissect/target/loader.py CHANGED
@@ -77,7 +77,7 @@ class Loader:
77
77
  raise NotImplementedError()
78
78
 
79
79
  @staticmethod
80
- def find_all(path: Path) -> Iterator[Path]:
80
+ def find_all(path: Path, **kwargs) -> Iterator[Path]:
81
81
  """Finds all targets to load from ``path``.
82
82
 
83
83
  This can be used to open multiple targets from a target path that doesn't necessarily map to files on a disk.
@@ -176,6 +176,7 @@ def open(item: Union[str, Path], *args, **kwargs) -> Loader:
176
176
 
177
177
  register("local", "LocalLoader")
178
178
  register("remote", "RemoteLoader")
179
+ register("mqtt", "MQTTLoader")
179
180
  register("targetd", "TargetdLoader")
180
181
  register("asdf", "AsdfLoader")
181
182
  register("tar", "TarLoader")
@@ -198,6 +199,7 @@ register("target", "TargetLoader")
198
199
  register("log", "LogLoader")
199
200
  # Disabling ResLoader because of DIS-536
200
201
  # register("res", "ResLoader")
202
+ register("overlay", "Overlay2Loader")
201
203
  register("phobos", "PhobosLoader")
202
204
  register("velociraptor", "VelociraptorLoader")
203
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),