dissect.target 3.16.dev44__py3-none-any.whl → 3.17__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
Files changed (63) 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/general/users.py +6 -0
  35. dissect/target/plugins/os/unix/bsd/__init__.py +0 -0
  36. dissect/target/plugins/os/unix/esxi/_os.py +2 -2
  37. dissect/target/plugins/os/unix/linux/debian/vyos/_os.py +1 -1
  38. dissect/target/plugins/os/unix/linux/fortios/_os.py +9 -9
  39. dissect/target/plugins/os/unix/linux/services.py +1 -0
  40. dissect/target/plugins/os/unix/linux/sockets.py +2 -2
  41. dissect/target/plugins/os/unix/log/messages.py +53 -8
  42. dissect/target/plugins/os/windows/_os.py +10 -1
  43. dissect/target/plugins/os/windows/catroot.py +178 -63
  44. dissect/target/plugins/os/windows/credhist.py +210 -0
  45. dissect/target/plugins/os/windows/dpapi/crypto.py +12 -1
  46. dissect/target/plugins/os/windows/dpapi/dpapi.py +62 -7
  47. dissect/target/plugins/os/windows/dpapi/master_key.py +22 -2
  48. dissect/target/plugins/os/windows/regf/runkeys.py +6 -4
  49. dissect/target/plugins/os/windows/sam.py +10 -1
  50. dissect/target/target.py +1 -1
  51. dissect/target/tools/dump/run.py +23 -28
  52. dissect/target/tools/dump/state.py +11 -8
  53. dissect/target/tools/dump/utils.py +5 -4
  54. dissect/target/tools/query.py +3 -15
  55. dissect/target/tools/shell.py +48 -8
  56. dissect/target/tools/utils.py +23 -0
  57. {dissect.target-3.16.dev44.dist-info → dissect.target-3.17.dist-info}/METADATA +7 -3
  58. {dissect.target-3.16.dev44.dist-info → dissect.target-3.17.dist-info}/RECORD +63 -56
  59. {dissect.target-3.16.dev44.dist-info → dissect.target-3.17.dist-info}/WHEEL +1 -1
  60. {dissect.target-3.16.dev44.dist-info → dissect.target-3.17.dist-info}/COPYRIGHT +0 -0
  61. {dissect.target-3.16.dev44.dist-info → dissect.target-3.17.dist-info}/LICENSE +0 -0
  62. {dissect.target-3.16.dev44.dist-info → dissect.target-3.17.dist-info}/entry_points.txt +0 -0
  63. {dissect.target-3.16.dev44.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),