dissect.target 3.18.dev16__py3-none-any.whl → 3.19__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
Files changed (94) hide show
  1. dissect/target/filesystem.py +44 -25
  2. dissect/target/filesystems/config.py +32 -21
  3. dissect/target/filesystems/extfs.py +4 -0
  4. dissect/target/filesystems/itunes.py +1 -1
  5. dissect/target/filesystems/tar.py +1 -1
  6. dissect/target/filesystems/zip.py +81 -46
  7. dissect/target/helpers/config.py +22 -7
  8. dissect/target/helpers/configutil.py +69 -5
  9. dissect/target/helpers/cyber.py +4 -2
  10. dissect/target/helpers/fsutil.py +32 -4
  11. dissect/target/helpers/loaderutil.py +26 -7
  12. dissect/target/helpers/network_managers.py +22 -7
  13. dissect/target/helpers/record.py +37 -0
  14. dissect/target/helpers/record_modifier.py +23 -4
  15. dissect/target/helpers/shell_application_ids.py +732 -0
  16. dissect/target/helpers/utils.py +11 -0
  17. dissect/target/loader.py +1 -0
  18. dissect/target/loaders/ab.py +285 -0
  19. dissect/target/loaders/libvirt.py +40 -0
  20. dissect/target/loaders/mqtt.py +14 -1
  21. dissect/target/loaders/tar.py +8 -4
  22. dissect/target/loaders/utm.py +3 -0
  23. dissect/target/loaders/velociraptor.py +6 -6
  24. dissect/target/plugin.py +60 -3
  25. dissect/target/plugins/apps/browser/chrome.py +1 -0
  26. dissect/target/plugins/apps/browser/chromium.py +7 -5
  27. dissect/target/plugins/apps/browser/edge.py +1 -0
  28. dissect/target/plugins/apps/browser/firefox.py +82 -36
  29. dissect/target/plugins/apps/remoteaccess/anydesk.py +70 -50
  30. dissect/target/plugins/apps/remoteaccess/remoteaccess.py +8 -8
  31. dissect/target/plugins/apps/remoteaccess/teamviewer.py +46 -31
  32. dissect/target/plugins/apps/ssh/openssh.py +1 -1
  33. dissect/target/plugins/apps/ssh/ssh.py +177 -0
  34. dissect/target/plugins/apps/texteditor/__init__.py +0 -0
  35. dissect/target/plugins/apps/texteditor/texteditor.py +13 -0
  36. dissect/target/plugins/apps/texteditor/windowsnotepad.py +340 -0
  37. dissect/target/plugins/child/qemu.py +21 -0
  38. dissect/target/plugins/filesystem/ntfs/mft.py +132 -45
  39. dissect/target/plugins/filesystem/unix/capability.py +102 -87
  40. dissect/target/plugins/filesystem/walkfs.py +32 -21
  41. dissect/target/plugins/filesystem/yara.py +144 -23
  42. dissect/target/plugins/general/network.py +82 -0
  43. dissect/target/plugins/general/users.py +14 -10
  44. dissect/target/plugins/os/unix/_os.py +19 -5
  45. dissect/target/plugins/os/unix/bsd/freebsd/_os.py +3 -5
  46. dissect/target/plugins/os/unix/esxi/_os.py +29 -23
  47. dissect/target/plugins/os/unix/etc/etc.py +5 -8
  48. dissect/target/plugins/os/unix/history.py +3 -7
  49. dissect/target/plugins/os/unix/linux/_os.py +15 -14
  50. dissect/target/plugins/os/unix/linux/android/_os.py +15 -24
  51. dissect/target/plugins/os/unix/linux/redhat/_os.py +1 -1
  52. dissect/target/plugins/os/unix/locale.py +17 -6
  53. dissect/target/plugins/os/unix/shadow.py +47 -31
  54. dissect/target/plugins/os/windows/_os.py +4 -4
  55. dissect/target/plugins/os/windows/adpolicy.py +4 -1
  56. dissect/target/plugins/os/windows/catroot.py +1 -11
  57. dissect/target/plugins/os/windows/credential/__init__.py +0 -0
  58. dissect/target/plugins/os/windows/credential/lsa.py +174 -0
  59. dissect/target/plugins/os/windows/{sam.py → credential/sam.py} +5 -2
  60. dissect/target/plugins/os/windows/defender.py +6 -3
  61. dissect/target/plugins/os/windows/dpapi/blob.py +3 -0
  62. dissect/target/plugins/os/windows/dpapi/crypto.py +61 -23
  63. dissect/target/plugins/os/windows/dpapi/dpapi.py +127 -133
  64. dissect/target/plugins/os/windows/dpapi/keyprovider/__init__.py +0 -0
  65. dissect/target/plugins/os/windows/dpapi/keyprovider/credhist.py +21 -0
  66. dissect/target/plugins/os/windows/dpapi/keyprovider/empty.py +17 -0
  67. dissect/target/plugins/os/windows/dpapi/keyprovider/keychain.py +20 -0
  68. dissect/target/plugins/os/windows/dpapi/keyprovider/keyprovider.py +8 -0
  69. dissect/target/plugins/os/windows/dpapi/keyprovider/lsa.py +38 -0
  70. dissect/target/plugins/os/windows/dpapi/master_key.py +3 -0
  71. dissect/target/plugins/os/windows/jumplist.py +292 -0
  72. dissect/target/plugins/os/windows/lnk.py +96 -93
  73. dissect/target/plugins/os/windows/regf/shimcache.py +2 -2
  74. dissect/target/plugins/os/windows/regf/usb.py +179 -114
  75. dissect/target/plugins/os/windows/task_helpers/tasks_xml.py +1 -1
  76. dissect/target/plugins/os/windows/wua_history.py +1073 -0
  77. dissect/target/target.py +4 -3
  78. dissect/target/tools/fs.py +53 -15
  79. dissect/target/tools/fsutils.py +243 -0
  80. dissect/target/tools/info.py +11 -4
  81. dissect/target/tools/query.py +2 -2
  82. dissect/target/tools/shell.py +505 -333
  83. dissect/target/tools/utils.py +23 -2
  84. dissect/target/tools/yara.py +65 -0
  85. dissect/target/volumes/md.py +2 -2
  86. {dissect.target-3.18.dev16.dist-info → dissect.target-3.19.dist-info}/METADATA +11 -7
  87. {dissect.target-3.18.dev16.dist-info → dissect.target-3.19.dist-info}/RECORD +93 -74
  88. {dissect.target-3.18.dev16.dist-info → dissect.target-3.19.dist-info}/WHEEL +1 -1
  89. {dissect.target-3.18.dev16.dist-info → dissect.target-3.19.dist-info}/entry_points.txt +1 -0
  90. dissect/target/helpers/ssh.py +0 -177
  91. /dissect/target/plugins/os/windows/{credhist.py → credential/credhist.py} +0 -0
  92. {dissect.target-3.18.dev16.dist-info → dissect.target-3.19.dist-info}/COPYRIGHT +0 -0
  93. {dissect.target-3.18.dev16.dist-info → dissect.target-3.19.dist-info}/LICENSE +0 -0
  94. {dissect.target-3.18.dev16.dist-info → dissect.target-3.19.dist-info}/top_level.txt +0 -0
@@ -1,45 +1,54 @@
1
+ from __future__ import annotations
2
+
1
3
  import argparse
2
4
  import cmd
3
5
  import contextlib
4
- import datetime
5
6
  import fnmatch
6
7
  import io
7
8
  import itertools
8
9
  import logging
9
10
  import os
10
11
  import pathlib
12
+ import platform
11
13
  import pydoc
12
14
  import random
13
15
  import re
14
16
  import shlex
15
17
  import shutil
16
- import stat
17
18
  import subprocess
18
19
  import sys
19
- import traceback
20
20
  from contextlib import contextmanager
21
- from typing import Any, BinaryIO, Callable, Iterator, Optional, TextIO, Union
21
+ from datetime import datetime, timedelta, timezone
22
+ from typing import Any, BinaryIO, Callable, Iterator, TextIO
22
23
 
23
24
  from dissect.cstruct import hexdump
24
25
  from flow.record import RecordOutput
25
26
 
26
27
  from dissect.target.exceptions import (
27
- FileNotFoundError,
28
28
  PluginError,
29
29
  RegistryError,
30
30
  RegistryKeyNotFoundError,
31
31
  RegistryValueNotFoundError,
32
32
  TargetError,
33
33
  )
34
- from dissect.target.filesystem import FilesystemEntry, LayerFilesystemEntry
34
+ from dissect.target.filesystem import FilesystemEntry
35
35
  from dissect.target.helpers import cyber, fsutil, regutil
36
- from dissect.target.plugin import arg
36
+ from dissect.target.plugin import PluginFunction, alias, arg, clone_alias
37
37
  from dissect.target.target import Target
38
+ from dissect.target.tools.fsutils import (
39
+ fmt_ls_colors,
40
+ ls_scandir,
41
+ print_ls,
42
+ print_stat,
43
+ print_xattr,
44
+ )
38
45
  from dissect.target.tools.info import print_target_info
39
46
  from dissect.target.tools.utils import (
40
47
  args_to_uri,
41
48
  catch_sigpipe,
42
49
  configure_generic_arguments,
50
+ execute_function_on_target,
51
+ find_and_filter_plugins,
43
52
  generate_argparse_for_bound_method,
44
53
  process_generic_arguments,
45
54
  )
@@ -51,46 +60,23 @@ logging.raiseExceptions = False
51
60
  try:
52
61
  import readline
53
62
 
54
- # remove `-` as an autocomplete delimeter on Linux
63
+ # remove `-`, `$` and `{` as an autocomplete delimeter on Linux
55
64
  # https://stackoverflow.com/questions/27288340/python-cmd-on-linux-does-not-autocomplete-special-characters-or-symbols
56
- readline.set_completer_delims(readline.get_completer_delims().replace("-", "").replace("$", ""))
65
+ readline.set_completer_delims(readline.get_completer_delims().replace("-", "").replace("$", "").replace("{", ""))
66
+
67
+ # Fix autocomplete on macOS
68
+ # https://stackoverflow.com/a/7116997
69
+ if "libedit" in readline.__doc__:
70
+ readline.parse_and_bind("bind ^I rl_complete")
71
+ else:
72
+ readline.parse_and_bind("tab: complete")
57
73
  except ImportError:
58
74
  # Readline is not available on Windows
59
75
  log.warning("Readline module is not available")
76
+ readline = None
60
77
 
61
- # ['mode', 'addr', 'dev', 'nlink', 'uid', 'gid', 'size', 'atime', 'mtime', 'ctime']
62
- STAT_TEMPLATE = """ File: {path} {symlink}
63
- Size: {size} {filetype}
64
- Inode: {inode} Links: {nlink}
65
- Access: ({modeord}/{modestr}) Uid: ( {uid} ) Gid: ( {gid} )
66
- Access: {atime}
67
- Modify: {mtime}
68
- Change: {ctime}"""
69
-
70
- FALLBACK_LS_COLORS = "rs=0:di=01;34:ln=01;36:mh=00:pi=40;33:so=01;35:do=01;35:bd=40;33;01:cd=40;33;01:or=40;31;01:mi=00:su=37;41:sg=30;43:ca=30;41:tw=30;42:ow=34;42:st=37;44:ex=01;32" # noqa: E501
71
-
72
-
73
- def prepare_ls_colors() -> dict[str, str]:
74
- """Parse the LS_COLORS environment variable so we can use it later."""
75
- d = {}
76
- ls_colors = os.environ.get("LS_COLORS", FALLBACK_LS_COLORS)
77
- for line in ls_colors.split(":"):
78
- if not line:
79
- continue
80
-
81
- ft, _, value = line.partition("=")
82
- if ft.startswith("*"):
83
- ft = ft[1:]
84
78
 
85
- d[ft] = f"\x1b[{value}m{{}}\x1b[0m"
86
-
87
- return d
88
-
89
-
90
- LS_COLORS = prepare_ls_colors()
91
-
92
-
93
- class TargetCmd(cmd.Cmd):
79
+ class ExtendedCmd(cmd.Cmd):
94
80
  """Subclassed cmd.Cmd to provide some additional features.
95
81
 
96
82
  Add new simple commands by implementing:
@@ -110,17 +96,25 @@ class TargetCmd(cmd.Cmd):
110
96
 
111
97
  CMD_PREFIX = "cmd_"
112
98
 
113
- def __init__(self, target: Target):
99
+ def __init__(self, cyber: bool = False):
114
100
  cmd.Cmd.__init__(self)
115
- self.target = target
116
101
  self.debug = False
102
+ self.cyber = cyber
103
+ self.identchars += "."
104
+
105
+ self.register_aliases()
117
106
 
118
107
  def __getattr__(self, attr: str) -> Any:
119
108
  if attr.startswith("help_"):
120
109
  _, _, command = attr.partition("_")
110
+
111
+ def print_help(command: str, func: Callable) -> None:
112
+ parser = generate_argparse_for_bound_method(func, usage_tmpl=f"{command} {{usage}}")
113
+ parser.print_help()
114
+
121
115
  try:
122
116
  func = getattr(self, self.CMD_PREFIX + command)
123
- return lambda: print(func.__doc__)
117
+ return lambda: print_help(command, func)
124
118
  except AttributeError:
125
119
  pass
126
120
 
@@ -130,6 +124,16 @@ class TargetCmd(cmd.Cmd):
130
124
  def check_compatible(target: Target) -> bool:
131
125
  return True
132
126
 
127
+ def register_aliases(self) -> None:
128
+ for name in self.get_names():
129
+ if name.startswith(self.CMD_PREFIX):
130
+ func = getattr(self.__class__, name)
131
+ for alias_name in getattr(func, "__aliases__", []):
132
+ if not alias_name.startswith(self.CMD_PREFIX):
133
+ alias_name = self.CMD_PREFIX + alias_name
134
+
135
+ clone_alias(self.__class__, func, alias_name)
136
+
133
137
  def get_names(self) -> list[str]:
134
138
  names = cmd.Cmd.get_names(self)
135
139
 
@@ -141,44 +145,49 @@ class TargetCmd(cmd.Cmd):
141
145
 
142
146
  return names
143
147
 
144
- def default(self, line: str) -> Optional[bool]:
148
+ def _handle_command(self, line: str) -> bool | None:
149
+ """Check whether custom handling of the cmd can be performed and if so, do it.
150
+
151
+ If a custom handling of the cmd was performed, return the result (a boolean indicating whether the shell should
152
+ exit). If not, return None. Can be overridden by subclasses to perform further / other 'custom command' checks.
153
+ """
145
154
  if line == "EOF":
146
155
  return True
147
156
 
148
- # Override default command execution to first attempt complex
149
- # command execution, and then target plugin command execution
157
+ # Override default command execution to first attempt complex command execution
150
158
  command, command_args_str, line = self.parseline(line)
151
159
 
152
- try:
160
+ if hasattr(self, self.CMD_PREFIX + command):
153
161
  return self._exec_command(command, command_args_str)
154
- except AttributeError:
155
- pass
156
162
 
157
- if self.target.has_function(command):
158
- return self._exec_target(command, command_args_str)
163
+ # Return None if no custom command was found to be run
164
+ return None
159
165
 
160
- return cmd.Cmd.default(self, line)
166
+ def default(self, line: str) -> bool:
167
+ if (should_exit := self._handle_command(line)) is not None:
168
+ return should_exit
169
+
170
+ # Fallback to default
171
+ cmd.Cmd.default(self, line)
172
+ return False
161
173
 
162
174
  def emptyline(self) -> None:
163
175
  """This function forces Python's cmd.Cmd module to behave like a regular shell.
164
176
 
165
177
  When entering an empty command, the cmd module will by default repeat the previous command.
166
178
  By defining an empty ``emptyline`` function we make sure no command is executed instead.
167
- See https://stackoverflow.com/a/16479030
179
+ Resources:
180
+ - https://stackoverflow.com/a/16479030
181
+ - https://github.com/python/cpython/blob/3.12/Lib/cmd.py#L10
168
182
  """
169
183
  pass
170
184
 
171
- def _exec(
172
- self, func: Callable[[list[str], TextIO], bool], command_args_str: str, no_cyber: bool = False
173
- ) -> Optional[bool]:
185
+ def _exec(self, func: Callable[[list[str], TextIO], bool], command_args_str: str, no_cyber: bool = False) -> bool:
174
186
  """Command execution helper that chains initial command and piped subprocesses (if any) together."""
175
187
 
176
188
  argparts = []
177
189
  if command_args_str is not None:
178
- lexer = shlex.shlex(command_args_str, posix=True, punctuation_chars=True)
179
- lexer.wordchars += "$"
180
- lexer.whitespace_split = True
181
- argparts = list(lexer)
190
+ argparts = arg_str_to_arg_list(command_args_str)
182
191
 
183
192
  if "|" in argparts:
184
193
  pipeidx = argparts.index("|")
@@ -189,15 +198,16 @@ class TargetCmd(cmd.Cmd):
189
198
  except OSError as e:
190
199
  # in case of a failure in a subprocess
191
200
  print(e)
201
+ return False
192
202
  else:
193
203
  ctx = contextlib.nullcontext()
194
- if self.target.props.get("cyber") and not no_cyber:
204
+ if self.cyber and not no_cyber:
195
205
  ctx = cyber.cyber(color=None, run_at_end=True)
196
206
 
197
207
  with ctx:
198
208
  return func(argparts, sys.stdout)
199
209
 
200
- def _exec_command(self, command: str, command_args_str: str) -> Optional[bool]:
210
+ def _exec_command(self, command: str, command_args_str: str) -> bool:
201
211
  """Command execution helper for ``cmd_`` commands."""
202
212
  cmdfunc = getattr(self, self.CMD_PREFIX + command)
203
213
  argparser = generate_argparse_for_bound_method(cmdfunc, usage_tmpl=f"{command} {{usage}}")
@@ -206,31 +216,123 @@ class TargetCmd(cmd.Cmd):
206
216
  try:
207
217
  args = argparser.parse_args(argparts)
208
218
  except SystemExit:
209
- return
219
+ return False
210
220
  return cmdfunc(args, stdout)
211
221
 
212
222
  # These commands enter a subshell, which doesn't work well with cyber
213
223
  no_cyber = cmdfunc.__func__ in (TargetCli.cmd_registry, TargetCli.cmd_enter)
214
224
  return self._exec(_exec_, command_args_str, no_cyber)
215
225
 
216
- def _exec_target(self, func: str, command_args_str: str) -> Optional[bool]:
226
+ def do_man(self, line: str) -> bool:
227
+ """alias for help"""
228
+ return self.do_help(line)
229
+
230
+ def complete_man(self, *args) -> list[str]:
231
+ return cmd.Cmd.complete_help(self, *args)
232
+
233
+ def do_clear(self, line: str) -> bool:
234
+ """clear the terminal screen"""
235
+ os.system("cls||clear")
236
+ return False
237
+
238
+ def do_cls(self, line: str) -> bool:
239
+ """alias for clear"""
240
+ return self.do_clear(line)
241
+
242
+ def do_exit(self, line: str) -> bool:
243
+ """exit shell"""
244
+ return True
245
+
246
+ def do_cyber(self, line: str) -> bool:
247
+ """cyber"""
248
+ self.cyber = not self.cyber
249
+ word, color = {False: ("D I S E N", cyber.Color.RED), True: ("E N", cyber.Color.YELLOW)}[self.cyber]
250
+ with cyber.cyber(color=color):
251
+ print(f"C Y B E R - M O D E - {word} G A G E D")
252
+ return False
253
+
254
+ def do_debug(self, line: str) -> bool:
255
+ """toggle debug mode"""
256
+ self.debug = not self.debug
257
+ if self.debug:
258
+ print("Debug mode on")
259
+ else:
260
+ print("Debug mode off")
261
+ return False
262
+
263
+
264
+ class TargetCmd(ExtendedCmd):
265
+ DEFAULT_HISTFILE = "~/.dissect_history"
266
+ DEFAULT_HISTFILESIZE = 10_000
267
+ DEFAULT_HISTDIR = None
268
+ DEFAULT_HISTDIRFMT = ".dissect_history_{uid}_{target}"
269
+
270
+ def __init__(self, target: Target):
271
+ self.target = target
272
+
273
+ # history file
274
+ self.histfilesize = getattr(target._config, "HISTFILESIZE", self.DEFAULT_HISTFILESIZE)
275
+ self.histdir = getattr(target._config, "HISTDIR", self.DEFAULT_HISTDIR)
276
+
277
+ if self.histdir:
278
+ self.histdirfmt = getattr(target._config, "HISTDIRFMT", self.DEFAULT_HISTDIRFMT)
279
+ self.histfile = pathlib.Path(self.histdir).resolve() / pathlib.Path(
280
+ self.histdirfmt.format(uid=os.getuid(), target=target.name)
281
+ )
282
+ else:
283
+ self.histfile = pathlib.Path(getattr(target._config, "HISTFILE", self.DEFAULT_HISTFILE)).expanduser()
284
+
285
+ # prompt format
286
+ if ps1 := getattr(target._config, "PS1", None):
287
+ if "{cwd}" in ps1 and "{base}" in ps1:
288
+ self.prompt_ps1 = ps1
289
+
290
+ elif getattr(target._config, "NO_COLOR", None) or os.getenv("NO_COLOR"):
291
+ self.prompt_ps1 = "{base}:{cwd}$ "
292
+
293
+ else:
294
+ self.prompt_ps1 = "\x1b[1;32m{base}\x1b[0m:\x1b[1;34m{cwd}\x1b[0m$ "
295
+
296
+ super().__init__(self.target.props.get("cyber"))
297
+
298
+ def preloop(self) -> None:
299
+ if readline and self.histfile.exists():
300
+ try:
301
+ readline.read_history_file(self.histfile)
302
+ except Exception as e:
303
+ log.debug("Error reading history file: %s", e)
304
+
305
+ def postloop(self) -> None:
306
+ if readline:
307
+ readline.set_history_length(self.histfilesize)
308
+ try:
309
+ readline.write_history_file(self.histfile)
310
+ except Exception as e:
311
+ log.debug("Error writing history file: %s", e)
312
+
313
+ def _handle_command(self, line: str) -> bool | None:
314
+ if (should_exit := super()._handle_command(line)) is not None:
315
+ return should_exit
316
+
317
+ # The parent class has already attempted complex command execution, we now attempt target plugin command
318
+ # execution
319
+ command, command_args_str, line = self.parseline(line)
320
+
321
+ if plugins := list(find_and_filter_plugins(self.target, command, [])):
322
+ return self._exec_target(plugins, command_args_str)
323
+
324
+ # We didn't execute a function on the target
325
+ return None
326
+
327
+ def _exec_target(self, funcs: list[PluginFunction], command_args_str: str) -> bool:
217
328
  """Command exection helper for target plugins."""
218
- attr = self.target
219
- for part in func.split("."):
220
- attr = getattr(attr, part)
221
329
 
222
- def _exec_(argparts: list[str], stdout: TextIO) -> Optional[bool]:
223
- if callable(attr):
224
- argparser = generate_argparse_for_bound_method(attr)
225
- try:
226
- args = argparser.parse_args(argparts)
227
- except SystemExit:
228
- return False
229
- value = attr(**vars(args))
230
- else:
231
- value = attr
330
+ def _exec_(argparts: list[str], stdout: TextIO) -> None:
331
+ try:
332
+ output, value, _ = execute_function_on_target(self.target, func, argparts)
333
+ except SystemExit:
334
+ return
232
335
 
233
- output = getattr(attr, "__output__", "default")
234
336
  if output == "record":
235
337
  # if the command results are piped to another process,
236
338
  # the process will receive Record objects
@@ -251,39 +353,21 @@ class TargetCmd(cmd.Cmd):
251
353
  else:
252
354
  print(value, file=stdout)
253
355
 
254
- try:
255
- return self._exec(_exec_, command_args_str)
256
- except PluginError:
257
- traceback.print_exc()
356
+ for func in funcs:
357
+ try:
358
+ self._exec(_exec_, command_args_str)
359
+ except PluginError as err:
360
+ if self.debug:
361
+ raise err
362
+ self.target.log.error(err)
258
363
 
259
- def do_python(self, line: str) -> Optional[bool]:
364
+ # Keep the shell open
365
+ return False
366
+
367
+ def do_python(self, line: str) -> bool:
260
368
  """drop into a Python shell"""
261
369
  python_shell([self.target])
262
-
263
- def do_clear(self, line: str) -> Optional[bool]:
264
- """clear the terminal screen"""
265
- os.system("cls||clear")
266
-
267
- def do_cyber(self, line: str) -> Optional[bool]:
268
- """cyber"""
269
- self.target.props["cyber"] = not bool(self.target.props.get("cyber"))
270
- word, color = {False: ("D I S E N", cyber.Color.RED), True: ("E N", cyber.Color.YELLOW)}[
271
- self.target.props["cyber"]
272
- ]
273
- with cyber.cyber(color=color):
274
- print(f"C Y B E R - M O D E - {word} G A G E D")
275
-
276
- def do_exit(self, line: str) -> Optional[bool]:
277
- """exit shell"""
278
- return True
279
-
280
- def do_debug(self, line: str) -> Optional[bool]:
281
- """toggle debug mode"""
282
- self.debug = not self.debug
283
- if self.debug:
284
- print("Debug mode on")
285
- else:
286
- print("Debug mode off")
370
+ return False
287
371
 
288
372
 
289
373
  class TargetHubCli(cmd.Cmd):
@@ -306,24 +390,26 @@ class TargetHubCli(cmd.Cmd):
306
390
 
307
391
  self._clicache = {}
308
392
 
309
- def default(self, line: str) -> Optional[bool]:
393
+ def default(self, line: str) -> bool:
310
394
  if line == "EOF":
311
395
  return True
312
396
 
313
- return cmd.Cmd.default(self, line)
397
+ cmd.Cmd.default(self, line)
398
+ return False
314
399
 
315
400
  def emptyline(self) -> None:
316
401
  pass
317
402
 
318
- def do_exit(self, line: str) -> Optional[bool]:
403
+ def do_exit(self, line: str) -> bool:
319
404
  """exit shell"""
320
405
  return True
321
406
 
322
- def do_list(self, line: str) -> Optional[bool]:
407
+ def do_list(self, line: str) -> bool:
323
408
  """list the loaded targets"""
324
409
  print("\n".join([f"{i:2d}: {e}" for i, e in enumerate(self._names)]))
410
+ return False
325
411
 
326
- def do_enter(self, line: str) -> Optional[bool]:
412
+ def do_enter(self, line: str) -> bool:
327
413
  """enter a target by number or name"""
328
414
 
329
415
  if line.isdigit():
@@ -333,24 +419,25 @@ class TargetHubCli(cmd.Cmd):
333
419
  idx = self._names_lower.index(line.lower())
334
420
  except ValueError:
335
421
  print("Unknown name")
336
- return
422
+ return False
337
423
 
338
424
  if idx >= len(self.targets):
339
425
  print("Unknown target")
340
- return
426
+ return False
341
427
 
342
428
  try:
343
429
  cli = self._clicache[idx]
344
430
  except KeyError:
345
431
  target = self.targets[idx]
346
432
  if not self._targetcli.check_compatible(target):
347
- return
433
+ return False
348
434
 
349
435
  cli = self._targetcli(self.targets[idx])
350
436
  self._clicache[idx] = cli
351
437
 
352
438
  print(f"Entering {idx}: {self._names[idx]}")
353
439
  run_cli(cli)
440
+ return False
354
441
 
355
442
  def complete_enter(self, text: str, line: str, begidx: int, endidx: int) -> list[str]:
356
443
  if not text:
@@ -364,32 +451,41 @@ class TargetHubCli(cmd.Cmd):
364
451
 
365
452
  return compl
366
453
 
367
- def do_python(self, line: str) -> Optional[bool]:
454
+ def do_python(self, line: str) -> bool:
368
455
  """drop into a Python shell"""
369
456
  python_shell(self.targets)
457
+ return False
370
458
 
371
459
 
372
460
  class TargetCli(TargetCmd):
373
461
  """CLI for interacting with a target and browsing the filesystem."""
374
462
 
375
463
  def __init__(self, target: Target):
464
+ self.prompt_base = _target_name(target)
465
+
376
466
  TargetCmd.__init__(self, target)
377
- self.prompt_base = target.name
378
- self._clicache = {}
379
467
 
468
+ self._clicache = {}
380
469
  self.cwd = None
381
470
  self.chdir("/")
382
471
 
383
472
  @property
384
473
  def prompt(self) -> str:
385
- return f"{self.prompt_base} {self.cwd}> "
474
+ return self.prompt_ps1.format(base=self.prompt_base, cwd=self.cwd)
386
475
 
387
- def completedefault(self, text: str, line: str, begidx: int, endidx: int):
388
- path = line[:begidx].rsplit(" ")[-1]
476
+ def completedefault(self, text: str, line: str, begidx: int, endidx: int) -> list[str]:
477
+ path = self.resolve_path(line[:begidx].rsplit(" ")[-1])
389
478
  textlower = text.lower()
390
479
 
391
- r = [fname for _, fname in self.scandir(path) if fname.lower().startswith(textlower)]
392
- return r
480
+ suggestions = []
481
+ for fpath, fname in ls_scandir(path):
482
+ if not fname.lower().startswith(textlower):
483
+ continue
484
+
485
+ # Add a trailing slash to directories, to allow for easier traversal of the filesystem
486
+ suggestion = f"{fname}/" if fpath.is_dir() else fname
487
+ suggestions.append(suggestion)
488
+ return suggestions
393
489
 
394
490
  def resolve_path(self, path: str) -> fsutil.TargetPath:
395
491
  if not path:
@@ -417,7 +513,7 @@ class TargetCli(TargetCmd):
417
513
  # component
418
514
  print(err)
419
515
 
420
- def check_file(self, path: str) -> Optional[fsutil.TargetPath]:
516
+ def check_file(self, path: str) -> fsutil.TargetPath | None:
421
517
  path = self.resolve_path(path)
422
518
  if not path.exists():
423
519
  print(f"{path}: No such file")
@@ -433,7 +529,7 @@ class TargetCli(TargetCmd):
433
529
 
434
530
  return path
435
531
 
436
- def check_dir(self, path: str) -> Optional[fsutil.TargetPath]:
532
+ def check_dir(self, path: str) -> fsutil.TargetPath | None:
437
533
  path = self.resolve_path(path)
438
534
  if not path.exists():
439
535
  print(f"{path}: No such directory")
@@ -449,70 +545,51 @@ class TargetCli(TargetCmd):
449
545
 
450
546
  return path
451
547
 
548
+ def check_path(self, path: str) -> fsutil.TargetPath | None:
549
+ path = self.resolve_path(path)
550
+ if not path.exists():
551
+ print(f"{path}: No such file or directory")
552
+ return
553
+
554
+ return path
555
+
452
556
  def chdir(self, path: str) -> None:
453
557
  """Change directory to the given path."""
454
558
  if path := self.check_dir(path):
455
559
  self.cwd = path
456
560
 
457
- def scandir(self, path: str, color: bool = False) -> list[tuple[fsutil.TargetPath, str]]:
458
- """List a directory for the given path."""
459
- path = self.resolve_path(path)
460
- result = []
461
-
462
- if path.exists() and path.is_dir():
463
- for file_ in path.iterdir():
464
- file_type = None
465
- if color:
466
- if file_.is_symlink():
467
- file_type = "ln"
468
- elif file_.is_dir():
469
- file_type = "di"
470
- elif file_.is_file():
471
- file_type = "fi"
472
-
473
- result.append((file_, fmt_ls_colors(file_type, file_.name) if color else file_.name))
474
-
475
- # If we happen to scan an NTFS filesystem see if any of the
476
- # entries has an alternative data stream and also list them.
477
- entry = file_.get()
478
- if isinstance(entry, LayerFilesystemEntry):
479
- if entry.entries.fs.__type__ == "ntfs":
480
- attrs = entry.lattr()
481
- for data_stream in attrs.DATA:
482
- if data_stream.name != "":
483
- name = f"{file_.name}:{data_stream.name}"
484
- result.append((file_, fmt_ls_colors(file_type, name) if color else name))
485
-
486
- result.sort(key=lambda e: e[0].name)
487
-
488
- return result
489
-
490
- def do_cd(self, line: str) -> Optional[bool]:
561
+ def do_cd(self, line: str) -> bool:
491
562
  """change directory"""
492
563
  self.chdir(line)
564
+ return False
493
565
 
494
- def do_pwd(self, line: str) -> Optional[bool]:
566
+ def do_pwd(self, line: str) -> bool:
495
567
  """print current directory"""
496
568
  print(self.cwd)
569
+ return False
497
570
 
498
- def do_disks(self, line: str) -> Optional[bool]:
571
+ def do_disks(self, line: str) -> bool:
499
572
  """print target disks"""
500
573
  for d in self.target.disks:
501
574
  print(str(d))
575
+ return False
502
576
 
503
- def do_volumes(self, line: str) -> Optional[bool]:
577
+ def do_volumes(self, line: str) -> bool:
504
578
  """print target volumes"""
505
579
  for v in self.target.volumes:
506
580
  print(str(v))
581
+ return False
507
582
 
508
- def do_filesystems(self, line: str) -> Optional[bool]:
583
+ def do_filesystems(self, line: str) -> bool:
509
584
  """print target filesystems"""
510
585
  for fs in self.target.filesystems:
511
586
  print(str(fs))
587
+ return False
512
588
 
513
- def do_info(self, line: str) -> Optional[bool]:
589
+ def do_info(self, line: str) -> bool:
514
590
  """print target information"""
515
- return print_target_info(self.target)
591
+ print_target_info(self.target)
592
+ return False
516
593
 
517
594
  @arg("path", nargs="?")
518
595
  @arg("-l", action="store_true")
@@ -521,129 +598,137 @@ class TargetCli(TargetCmd):
521
598
  @arg("-R", "--recursive", action="store_true", help="recursively list subdirectories encountered")
522
599
  @arg("-c", action="store_true", dest="use_ctime", help="show time when file status was last changed")
523
600
  @arg("-u", action="store_true", dest="use_atime", help="show time of last access")
524
- def cmd_ls(self, args: argparse.Namespace, stdout: TextIO) -> Optional[bool]:
601
+ @alias("l")
602
+ @alias("dir")
603
+ def cmd_ls(self, args: argparse.Namespace, stdout: TextIO) -> bool:
525
604
  """list directory contents"""
526
605
 
527
606
  path = self.resolve_path(args.path)
528
607
 
529
608
  if args.use_ctime and args.use_atime:
530
609
  print("can't specify -c and -u at the same time")
531
- return
610
+ return False
532
611
 
533
- if not path or not path.exists():
534
- return
612
+ if not path or not self.check_dir(path):
613
+ return False
535
614
 
536
- self._print_ls(args, path, 0, stdout)
615
+ if path.is_file():
616
+ print(args.path) # mimic ls behaviour
617
+ return False
537
618
 
538
- def _print_ls(self, args: argparse.Namespace, path: fsutil.TargetPath, depth: int, stdout: TextIO) -> None:
539
- path = self.resolve_path(path)
540
- subdirs = []
619
+ print_ls(path, 0, stdout, args.l, args.human_readable, args.recursive, args.use_ctime, args.use_atime)
620
+ return False
541
621
 
542
- if path.is_dir():
543
- contents = self.scandir(path, color=True)
544
- elif path.is_file():
545
- contents = [(path, path.name)]
546
-
547
- if depth > 0:
548
- print(f"\n{str(path)}:", file=stdout)
549
-
550
- if not args.l:
551
- for target_path, name in contents:
552
- print(name, file=stdout)
553
- if target_path.is_dir():
554
- subdirs.append(target_path)
555
- else:
556
- if len(contents) > 1:
557
- print(f"total {len(contents)}", file=stdout)
558
- for target_path, name in contents:
559
- self.print_extensive_file_stat(args=args, stdout=stdout, target_path=target_path, name=name)
560
- if target_path.is_dir():
561
- subdirs.append(target_path)
562
-
563
- if args.recursive and subdirs:
564
- for subdir in subdirs:
565
- self._print_ls(args, subdir, depth + 1, stdout)
566
-
567
- def print_extensive_file_stat(
568
- self, args: argparse.Namespace, stdout: TextIO, target_path: fsutil.TargetPath, name: str
569
- ) -> None:
570
- """Print the file status."""
571
- try:
572
- entry = target_path.get()
573
- stat = entry.lstat()
574
- symlink = f" -> {entry.readlink()}" if entry.is_symlink() else ""
575
- show_time = stat.st_mtime
576
- if args.use_ctime:
577
- show_time = stat.st_ctime
578
- elif args.use_atime:
579
- show_time = stat.st_atime
580
- utc_time = datetime.datetime.utcfromtimestamp(show_time).isoformat()
581
-
582
- print(
583
- f"{stat_modestr(stat)} {stat.st_uid:4d} {stat.st_gid:4d} {stat.st_size:6d} {utc_time} {name}{symlink}",
584
- file=stdout,
585
- )
622
+ @arg("path", nargs="?")
623
+ def cmd_ll(self, args: argparse.Namespace, stdout: TextIO) -> bool:
624
+ """alias for ls -la"""
625
+ args = extend_args(args, self.cmd_ls)
626
+ args.l = True # noqa: E741
627
+ args.a = True
628
+ return self.cmd_ls(args, stdout)
586
629
 
587
- except FileNotFoundError:
588
- print(
589
- f"?????????? ? ? ? ????-??-??T??:??:??.?????? {name}",
590
- file=stdout,
591
- )
630
+ @arg("path", nargs="?")
631
+ def cmd_tree(self, args: argparse.Namespace, stdout: TextIO) -> bool:
632
+ """alias for ls -R"""
633
+ args = extend_args(args, self.cmd_ls)
634
+ args.recursive = True
635
+ return self.cmd_ls(args, stdout)
592
636
 
593
637
  @arg("path", nargs="?")
594
- @arg("-name", default="*")
595
- @arg("-iname")
596
- def cmd_find(self, args: argparse.Namespace, stdout: TextIO) -> Optional[bool]:
638
+ @arg("-name", default="*", help="path to match with")
639
+ @arg("-iname", help="like -name, but the match is case insensitive")
640
+ @arg("-atime", type=int, help="file was last accessed n*24 hours ago")
641
+ @arg("-mtime", type=int, help="file was last modified n*24 hours ago")
642
+ @arg("-ctime", type=int, help="file (windows) or metadata (unix) was last changed n*24 hours ago")
643
+ @arg("-btime", type=int, help="file was born n*24 hours ago (ext4)")
644
+ def cmd_find(self, args: argparse.Namespace, stdout: TextIO) -> bool:
597
645
  """search for files in a directory hierarchy"""
598
646
  path = self.resolve_path(args.path)
599
- if not path:
600
- return
647
+ if not path or not self.check_dir(path):
648
+ return False
649
+
650
+ matches = []
651
+ now = datetime.now(tz=timezone.utc)
652
+ do_time_compare = any([args.mtime, args.atime])
601
653
 
602
654
  if args.iname:
603
655
  pattern = re.compile(fnmatch.translate(args.iname), re.IGNORECASE)
604
656
  for f in path.rglob("*"):
605
657
  if pattern.match(f.name):
606
- print(f, file=stdout)
607
-
608
- elif args.name:
658
+ matches.append(f) if do_time_compare else print(f, file=stdout)
659
+ else:
609
660
  for f in path.rglob(args.name):
661
+ matches.append(f) if do_time_compare else print(f, file=stdout)
662
+
663
+ def compare(now: datetime, then_ts: float, offset: int) -> bool:
664
+ then = datetime.fromtimestamp(then_ts, tz=timezone.utc)
665
+ return now - timedelta(hours=offset * 24) > then
666
+
667
+ if do_time_compare:
668
+ for f in matches:
669
+ s = f.lstat()
670
+
671
+ if args.mtime and compare(now, s.st_mtime, offset=args.mtime):
672
+ continue
673
+
674
+ if args.atime and compare(now, s.st_atime, offset=args.atime):
675
+ continue
676
+
677
+ if args.ctime and compare(now, s.st_ctime, offset=args.ctime):
678
+ continue
679
+
680
+ if args.btime and compare(now, s.st_birthtime, offset=args.btime):
681
+ continue
682
+
610
683
  print(f, file=stdout)
611
684
 
685
+ return False
686
+
612
687
  @arg("path")
613
688
  @arg("-L", "--dereference", action="store_true")
614
- def cmd_stat(self, args: argparse.Namespace, stdout: TextIO) -> Optional[bool]:
689
+ def cmd_stat(self, args: argparse.Namespace, stdout: TextIO) -> bool:
615
690
  """display file status"""
616
691
  path = self.resolve_path(args.path)
617
- if not path:
618
- return
692
+ if not path or not self.check_path(path):
693
+ return False
694
+
695
+ print_stat(path, stdout, args.dereference)
696
+ return False
697
+
698
+ @arg("path")
699
+ @arg("-d", "--dump", action="store_true")
700
+ @arg("-R", "--recursive", action="store_true")
701
+ @alias("getfattr")
702
+ def cmd_attr(self, args: argparse.Namespace, stdout: TextIO) -> bool:
703
+ """display file attributes"""
704
+ path = self.resolve_path(args.path)
705
+ if not path or not self.check_path(path):
706
+ return False
707
+
708
+ try:
709
+ if attr := path.get().attr():
710
+ print_xattr(path.name, attr, stdout)
711
+ except Exception:
712
+ pass
713
+
714
+ if args.recursive:
715
+ for child in path.rglob("*"):
716
+ try:
717
+ if child_attr := child.get().attr():
718
+ print_xattr(child, child_attr, stdout)
719
+ print()
720
+ except Exception:
721
+ pass
619
722
 
620
- symlink = f"-> {path.readlink()}" if path.is_symlink() else ""
621
-
622
- s = path.stat() if args.dereference else path.lstat()
623
-
624
- res = STAT_TEMPLATE.format(
625
- path=path,
626
- symlink=symlink,
627
- size=s.st_size,
628
- filetype="",
629
- inode=s.st_ino,
630
- nlink=s.st_nlink,
631
- modeord=oct(stat.S_IMODE(s.st_mode)),
632
- modestr=stat_modestr(s),
633
- uid=s.st_uid,
634
- gid=s.st_gid,
635
- atime=datetime.datetime.utcfromtimestamp(s.st_atime).isoformat(),
636
- mtime=datetime.datetime.utcfromtimestamp(s.st_mtime).isoformat(),
637
- ctime=datetime.datetime.utcfromtimestamp(s.st_ctime).isoformat(),
638
- )
639
- print(res, file=stdout)
723
+ return False
640
724
 
641
725
  @arg("path")
642
- def cmd_file(self, args: argparse.Namespace, stdout: TextIO) -> Optional[bool]:
726
+ def cmd_file(self, args: argparse.Namespace, stdout: TextIO) -> bool:
643
727
  """determine file type"""
728
+
644
729
  path = self.check_file(args.path)
645
730
  if not path:
646
- return
731
+ return False
647
732
 
648
733
  fh = path.open()
649
734
 
@@ -655,11 +740,12 @@ class TargetCli(TargetCmd):
655
740
  p.wait()
656
741
  filetype = p.stdout.read().decode().split(":", 1)[1].strip()
657
742
  print(f"{path}: {filetype}", file=stdout)
743
+ return False
658
744
 
659
745
  @arg("path", nargs="+")
660
746
  @arg("-o", "--out", default=".")
661
747
  @arg("-v", "--verbose", action="store_true")
662
- def cmd_save(self, args: argparse.Namespace, stdout: TextIO) -> Optional[bool]:
748
+ def cmd_save(self, args: argparse.Namespace, stdout: TextIO) -> bool:
663
749
  """save a common file or directory to the host filesystem"""
664
750
  dst_path = pathlib.Path(args.out).resolve()
665
751
 
@@ -688,7 +774,7 @@ class TargetCli(TargetCmd):
688
774
  return diverging_path
689
775
 
690
776
  def save_path(
691
- src_path: pathlib.Path, dst_path: pathlib.Path, create_dst_subdir: Optional[pathlib.Path] = None
777
+ src_path: pathlib.Path, dst_path: pathlib.Path, create_dst_subdir: pathlib.Path | None = None
692
778
  ) -> None:
693
779
  """Save a common file or directory in src_path to dst_path.
694
780
 
@@ -767,8 +853,8 @@ class TargetCli(TargetCmd):
767
853
  try:
768
854
  first_src_path = next(src_paths)
769
855
  except StopIteration:
770
- print(f"{path}: no such file or directory")
771
- return
856
+ print(f"{path}: No such file or directory")
857
+ return False
772
858
 
773
859
  try:
774
860
  second_src_path = next(src_paths)
@@ -789,10 +875,18 @@ class TargetCli(TargetCmd):
789
875
  extra_dir = get_diverging_path(src_path, reference_path).parent
790
876
  save_path(src_path, dst_path, create_dst_subdir=extra_dir)
791
877
 
878
+ return False
879
+
792
880
  @arg("path")
793
- def cmd_cat(self, args: argparse.Namespace, stdout: TextIO) -> Optional[bool]:
881
+ @alias("type")
882
+ def cmd_cat(self, args: argparse.Namespace, stdout: TextIO) -> bool:
794
883
  """print file content"""
795
- paths = self.resolve_glob_path(args.path)
884
+ paths = list(self.resolve_glob_path(args.path))
885
+
886
+ if not paths:
887
+ print(f"{args.path}: No such file or directory")
888
+ return False
889
+
796
890
  stdout = stdout.buffer
797
891
  for path in paths:
798
892
  path = self.check_file(path)
@@ -803,11 +897,17 @@ class TargetCli(TargetCmd):
803
897
  shutil.copyfileobj(fh, stdout)
804
898
  stdout.flush()
805
899
  print("")
900
+ return False
806
901
 
807
902
  @arg("path")
808
- def cmd_zcat(self, args: argparse.Namespace, stdout: TextIO) -> Optional[bool]:
903
+ def cmd_zcat(self, args: argparse.Namespace, stdout: TextIO) -> bool:
809
904
  """print file content from compressed files"""
810
- paths = self.resolve_glob_path(args.path)
905
+ paths = list(self.resolve_glob_path(args.path))
906
+
907
+ if not paths:
908
+ print(f"{args.path}: No such file or directory")
909
+ return False
910
+
811
911
  stdout = stdout.buffer
812
912
  for path in paths:
813
913
  path = self.check_file(path)
@@ -818,45 +918,70 @@ class TargetCli(TargetCmd):
818
918
  shutil.copyfileobj(fh, stdout)
819
919
  stdout.flush()
820
920
 
921
+ return False
922
+
821
923
  @arg("path")
822
- def cmd_hexdump(self, args: argparse.Namespace, stdout: TextIO) -> Optional[bool]:
823
- """print a hexdump of the first X bytes"""
924
+ @arg("-n", "--length", type=int, default=16 * 20, help="amount of bytes to read")
925
+ @arg("-s", "--skip", type=int, default=0, help="skip offset bytes from the beginning")
926
+ @arg("-p", "--hex", action="store_true", default=False, help="output in plain hexdump style")
927
+ @arg("-C", "--canonical", action="store_true")
928
+ @alias("xxd")
929
+ def cmd_hexdump(self, args: argparse.Namespace, stdout: TextIO) -> bool:
930
+ """print a hexdump of a file"""
824
931
  path = self.check_file(args.path)
825
932
  if not path:
826
- return
933
+ return False
827
934
 
828
- print(hexdump(path.open().read(16 * 20), output="string"), file=stdout)
935
+ fh = path.open("rb")
936
+ if args.skip > 0:
937
+ fh.seek(args.skip + 1)
938
+
939
+ if args.hex:
940
+ print(fh.read(args.length).hex(), file=stdout)
941
+ else:
942
+ print(hexdump(fh.read(args.length), output="string"), file=stdout)
943
+
944
+ return False
829
945
 
830
946
  @arg("path")
831
- def cmd_hash(self, args: argparse.Namespace, stdout: TextIO) -> Optional[bool]:
947
+ @alias("digest")
948
+ @alias("shasum")
949
+ def cmd_hash(self, args: argparse.Namespace, stdout: TextIO) -> bool:
832
950
  """print the MD5, SHA1 and SHA256 hashes of a file"""
833
951
  path = self.check_file(args.path)
834
952
  if not path:
835
- return
953
+ return False
836
954
 
837
955
  md5, sha1, sha256 = path.get().hash()
838
956
  print(f"MD5:\t{md5}\nSHA1:\t{sha1}\nSHA256:\t{sha256}", file=stdout)
957
+ return False
839
958
 
840
959
  @arg("path")
841
- def cmd_less(self, args: argparse.Namespace, stdout: TextIO) -> Optional[bool]:
960
+ @alias("head")
961
+ @alias("more")
962
+ def cmd_less(self, args: argparse.Namespace, stdout: TextIO) -> bool:
842
963
  """open the first 10 MB of a file with less"""
843
964
  path = self.check_file(args.path)
844
965
  if not path:
845
- return
966
+ return False
846
967
 
847
968
  pydoc.pager(path.open("rt", errors="ignore").read(10 * 1024 * 1024))
969
+ return False
848
970
 
849
971
  @arg("path")
850
- def cmd_zless(self, args: argparse.Namespace, stdout: TextIO) -> Optional[bool]:
972
+ @alias("zhead")
973
+ @alias("zmore")
974
+ def cmd_zless(self, args: argparse.Namespace, stdout: TextIO) -> bool:
851
975
  """open the first 10 MB of a compressed file with zless"""
852
976
  path = self.check_file(args.path)
853
977
  if not path:
854
- return
978
+ return False
855
979
 
856
980
  pydoc.pager(fsutil.open_decompress(path, "rt").read(10 * 1024 * 1024))
981
+ return False
857
982
 
858
983
  @arg("path", nargs="+")
859
- def cmd_readlink(self, args: argparse.Namespace, stdout: TextIO) -> Optional[bool]:
984
+ def cmd_readlink(self, args: argparse.Namespace, stdout: TextIO) -> bool:
860
985
  """print resolved symbolic links or canonical file names"""
861
986
  for path in args.path:
862
987
  path = self.resolve_path(path)
@@ -865,12 +990,14 @@ class TargetCli(TargetCmd):
865
990
 
866
991
  print(path.get().readlink(), file=stdout)
867
992
 
993
+ return False
994
+
868
995
  @arg("path", nargs="?", help="load a hive from the given path")
869
- def cmd_registry(self, args: argparse.Namespace, stdout: TextIO) -> Optional[bool]:
996
+ def cmd_registry(self, args: argparse.Namespace, stdout: TextIO) -> bool:
870
997
  """drop into a registry shell"""
871
998
  if self.target.os == "linux":
872
999
  run_cli(UnixConfigTreeCli(self.target))
873
- return
1000
+ return False
874
1001
 
875
1002
  hive = None
876
1003
 
@@ -878,7 +1005,7 @@ class TargetCli(TargetCmd):
878
1005
  if args.path:
879
1006
  path = self.check_file(args.path)
880
1007
  if not path:
881
- return
1008
+ return False
882
1009
 
883
1010
  hive = regutil.RegfHive(path)
884
1011
  clikey = f"registry_{path}"
@@ -887,26 +1014,26 @@ class TargetCli(TargetCmd):
887
1014
  cli = self._clicache[clikey]
888
1015
  except KeyError:
889
1016
  if not hive and not RegistryCli.check_compatible(self.target):
890
- return
1017
+ return False
891
1018
 
892
1019
  cli = RegistryCli(self.target, hive)
893
1020
  self._clicache[clikey] = cli
894
1021
 
895
1022
  run_cli(cli)
896
-
897
- # Print an additional empty newline after exit
898
1023
  print()
1024
+ return False
899
1025
 
900
1026
  @arg("targets", metavar="TARGETS", nargs="*", help="targets to load")
901
1027
  @arg("-p", "--python", action="store_true", help="(I)Python shell")
902
1028
  @arg("-r", "--registry", action="store_true", help="registry shell")
903
- def cmd_enter(self, args: argparse.Namespace, stdout: TextIO) -> None:
1029
+ def cmd_enter(self, args: argparse.Namespace, stdout: TextIO) -> bool:
904
1030
  """load one or more files as sub-targets and drop into a sub-shell"""
905
1031
  paths = [self.resolve_path(path) for path in args.targets]
906
1032
 
907
1033
  if args.python:
908
1034
  # Quick path that doesn't require CLI caching
909
- return open_shell(paths, args.python, args.registry)
1035
+ open_shell(paths, args.python, args.registry)
1036
+ return False
910
1037
 
911
1038
  clikey = tuple(str(path) for path in paths)
912
1039
  try:
@@ -915,33 +1042,32 @@ class TargetCli(TargetCmd):
915
1042
  targets = list(Target.open_all(paths))
916
1043
  cli = create_cli(targets, RegistryCli if args.registry else TargetCli)
917
1044
  if not cli:
918
- return
1045
+ return False
919
1046
 
920
1047
  self._clicache[clikey] = cli
921
1048
 
922
1049
  run_cli(cli)
923
-
924
- # Print an additional empty newline after exit
925
1050
  print()
1051
+ return False
926
1052
 
927
1053
 
928
1054
  class UnixConfigTreeCli(TargetCli):
929
1055
  def __init__(self, target: Target):
930
1056
  TargetCmd.__init__(self, target)
931
1057
  self.config_tree = target.etc()
932
- self.prompt_base = target.name
1058
+ self.prompt_base = _target_name(target)
933
1059
 
934
1060
  self.cwd = None
935
1061
  self.chdir("/")
936
1062
 
937
1063
  @property
938
1064
  def prompt(self) -> str:
939
- return f"{self.prompt_base}/config_tree {self.cwd}> "
1065
+ return f"(config tree) {self.prompt_base}:{self.cwd}$ "
940
1066
 
941
1067
  def check_compatible(target: Target) -> bool:
942
1068
  return target.has_function("etc")
943
1069
 
944
- def resolve_path(self, path: Optional[Union[str, fsutil.TargetPath]]) -> fsutil.TargetPath:
1070
+ def resolve_path(self, path: str | fsutil.TargetPath | None) -> fsutil.TargetPath:
945
1071
  if not path:
946
1072
  return self.cwd
947
1073
 
@@ -975,12 +1101,12 @@ class UnixConfigTreeCli(TargetCli):
975
1101
  class RegistryCli(TargetCmd):
976
1102
  """CLI for browsing the registry."""
977
1103
 
978
- def __init__(self, target: Target, registry: Optional[regutil.RegfHive] = None):
979
- TargetCmd.__init__(self, target)
980
- self.registry = registry or target.registry
1104
+ def __init__(self, target: Target, registry: regutil.RegfHive | None = None):
1105
+ self.prompt_base = _target_name(target)
981
1106
 
982
- self.prompt_base = target.name
1107
+ TargetCmd.__init__(self, target)
983
1108
 
1109
+ self.registry = registry or target.registry
984
1110
  self.cwd = None
985
1111
  self.chdir("\\")
986
1112
 
@@ -993,8 +1119,7 @@ class RegistryCli(TargetCmd):
993
1119
 
994
1120
  @property
995
1121
  def prompt(self) -> str:
996
- prompt_end = self.cwd.strip("\\")
997
- return f"{self.prompt_base}/registry {prompt_end}> "
1122
+ return "(registry) " + self.prompt_ps1.format(base=self.prompt_base, cwd=self.cwd)
998
1123
 
999
1124
  def completedefault(self, text: str, line: str, begidx: int, endidx: int) -> list[str]:
1000
1125
  path = line[:begidx].rsplit(" ")[-1]
@@ -1034,9 +1159,7 @@ class RegistryCli(TargetCmd):
1034
1159
  if self.check_key(path):
1035
1160
  self.cwd = "\\" + path.strip("\\")
1036
1161
 
1037
- def scandir(
1038
- self, path: str, color: bool = False
1039
- ) -> list[tuple[Union[regutil.RegistryKey, regutil.RegistryValue], str]]:
1162
+ def scandir(self, path: str, color: bool = False) -> list[tuple[regutil.RegistryKey | regutil.RegistryValue, str]]:
1040
1163
  try:
1041
1164
  key = self.resolve_key(path)
1042
1165
  except RegistryError:
@@ -1052,56 +1175,95 @@ class RegistryCli(TargetCmd):
1052
1175
  r.sort(key=lambda e: e[0].name)
1053
1176
  return r
1054
1177
 
1055
- def do_cd(self, line: str) -> Optional[bool]:
1178
+ def do_cd(self, line: str) -> bool:
1056
1179
  """change subkey"""
1180
+ if line == "..":
1181
+ try:
1182
+ self.resolve_key(self.cwd + "\\..")
1183
+ except RegistryError:
1184
+ self.do_up(line)
1185
+ return False
1186
+
1057
1187
  self.chdir(line)
1188
+ return False
1058
1189
 
1059
- def do_up(self, line: str) -> Optional[bool]:
1190
+ def do_up(self, line: str) -> bool:
1060
1191
  """go up a subkey"""
1061
1192
  parent = self.cwd.rpartition("\\")[0]
1062
1193
  if not parent:
1063
1194
  parent = "\\"
1064
1195
  self.chdir(parent)
1196
+ return False
1065
1197
 
1066
- def do_pwd(self, line: str) -> Optional[bool]:
1198
+ def do_pwd(self, line: str) -> bool:
1067
1199
  """print current path"""
1068
- print(self.cwd)
1200
+ print(self.cwd.lstrip("\\"))
1201
+ return False
1069
1202
 
1070
- def do_recommend(self, line: str) -> Optional[bool]:
1203
+ def do_recommend(self, line: str) -> bool:
1071
1204
  """recommend a key"""
1072
1205
  print(random.choice([name for _, name in self.scandir(None)]))
1206
+ return False
1073
1207
 
1074
1208
  @arg("path", nargs="?")
1075
- def cmd_ls(self, args: argparse.Namespace, stdout: TextIO) -> Optional[bool]:
1209
+ def cmd_ls(self, args: argparse.Namespace, stdout: TextIO) -> bool:
1076
1210
  key = self.check_key(args.path)
1077
1211
  if not key:
1078
- return
1212
+ return False
1079
1213
 
1080
1214
  r = self.scandir(key, color=True)
1081
1215
  print("\n".join([name for _, name in r]), file=stdout)
1216
+ return False
1082
1217
 
1083
1218
  @arg("value")
1084
- def cmd_cat(self, args: argparse.Namespace, stdout: TextIO) -> Optional[bool]:
1219
+ def cmd_cat(self, args: argparse.Namespace, stdout: TextIO) -> bool:
1085
1220
  value = self.check_value(args.value)
1086
1221
  if not value:
1087
- return
1222
+ return False
1088
1223
 
1089
1224
  print(repr(value.value), file=stdout)
1225
+ return False
1090
1226
 
1227
+ @arg("value")
1228
+ @arg("-p", "--hex", action="store_true")
1229
+ @alias("xxd")
1230
+ def cmd_hexdump(self, args: argparse.Namespace, stdout: TextIO) -> bool:
1231
+ value = self.check_value(args.value)
1232
+ if not value:
1233
+ return False
1091
1234
 
1092
- def fmt_ls_colors(ft: str, name: str) -> str:
1093
- """Helper method to colorize strings according to LS_COLORS."""
1094
- try:
1095
- return LS_COLORS[ft].format(name)
1096
- except KeyError:
1097
- pass
1235
+ if args.hex:
1236
+ print(value.value.hex(), file=stdout)
1237
+ else:
1238
+ print(hexdump(value.value, output="string"), file=stdout)
1098
1239
 
1099
- try:
1100
- return LS_COLORS[fsutil.splitext(name)[1]].format(name)
1101
- except KeyError:
1102
- pass
1240
+ return False
1241
+
1242
+
1243
+ def arg_str_to_arg_list(args: str) -> list[str]:
1244
+ """Convert a commandline string to a list of command line arguments."""
1245
+ lexer = shlex.shlex(args, posix=True, punctuation_chars=True)
1246
+ lexer.wordchars += "$"
1247
+ lexer.whitespace_split = True
1248
+ return list(lexer)
1249
+
1250
+
1251
+ def extend_args(args: argparse.Namespace, func: Callable) -> argparse.Namespace:
1252
+ """Extend the arguments of the given ``func`` with the provided ``argparse.Namespace``."""
1253
+ for short, kwargs in func.__args__:
1254
+ name = kwargs.get("dest", short[-1]).lstrip("-").replace("-", "_")
1255
+ if not hasattr(args, name):
1256
+ setattr(args, name, None)
1257
+ return args
1103
1258
 
1104
- return name
1259
+
1260
+ def _target_name(target: Target) -> str:
1261
+ """Return a target name for cmd.Cmd base prompts."""
1262
+
1263
+ if target.has_function("domain") and target.domain:
1264
+ return f"{target.name}.{target.domain}"
1265
+
1266
+ return target.name
1105
1267
 
1106
1268
 
1107
1269
  @contextmanager
@@ -1169,15 +1331,7 @@ def build_pipe_stdout(pipe_parts: list[str]) -> Iterator[TextIO]:
1169
1331
  yield pipe_stdin
1170
1332
 
1171
1333
 
1172
- def stat_modestr(st: fsutil.stat_result) -> str:
1173
- """Helper method for generating a mode string from a numerical mode value."""
1174
- is_dir = "d" if stat.S_ISDIR(st.st_mode) else "-"
1175
- dic = {"7": "rwx", "6": "rw-", "5": "r-x", "4": "r--", "0": "---"}
1176
- perm = str(oct(st.st_mode)[-3:])
1177
- return is_dir + "".join(dic.get(x, x) for x in perm)
1178
-
1179
-
1180
- def open_shell(targets: list[Union[str, pathlib.Path]], python: bool, registry: bool) -> None:
1334
+ def open_shell(targets: list[str | pathlib.Path], python: bool, registry: bool) -> None:
1181
1335
  """Helper method for starting a regular, Python or registry shell for one or multiple targets."""
1182
1336
  targets = list(Target.open_all(targets))
1183
1337
 
@@ -1215,7 +1369,7 @@ def python_shell(targets: list[Target]) -> None:
1215
1369
  print()
1216
1370
 
1217
1371
 
1218
- def create_cli(targets: list[Target], cli_cls: type[TargetCmd]) -> Optional[cmd.Cmd]:
1372
+ def create_cli(targets: list[Target], cli_cls: type[TargetCmd]) -> cmd.Cmd | None:
1219
1373
  """Helper method for instatiating the appropriate CLI."""
1220
1374
  if len(targets) == 1:
1221
1375
  target = targets[0]
@@ -1242,10 +1396,14 @@ def run_cli(cli: cmd.Cmd) -> None:
1242
1396
  # Print an empty newline on exit
1243
1397
  print()
1244
1398
  return
1399
+
1245
1400
  except KeyboardInterrupt:
1401
+ # Run postloop so the interrupted command is added to the history file
1402
+ cli.postloop()
1403
+
1246
1404
  # Add a line when pressing ctrl+c, so the next one starts at a new line
1247
1405
  print()
1248
- pass
1406
+
1249
1407
  except Exception as e:
1250
1408
  if cli.debug:
1251
1409
  log.exception(e)
@@ -1253,7 +1411,8 @@ def run_cli(cli: cmd.Cmd) -> None:
1253
1411
  log.info(e)
1254
1412
  print(f"*** Unhandled error: {e}")
1255
1413
  print("If you wish to see the full debug trace, enable debug mode.")
1256
- pass
1414
+
1415
+ cli.postloop()
1257
1416
 
1258
1417
 
1259
1418
  @catch_sigpipe
@@ -1280,11 +1439,24 @@ def main() -> None:
1280
1439
  args.targets = args_to_uri(args.targets, args.loader, rest) if args.loader else args.targets
1281
1440
  process_generic_arguments(args)
1282
1441
 
1283
- # For the shell tool we want -q to log slightly more then just CRITICAL
1284
- # messages.
1442
+ # For the shell tool we want -q to log slightly more then just CRITICAL messages.
1285
1443
  if args.quiet:
1286
1444
  logging.getLogger("dissect").setLevel(level=logging.ERROR)
1287
1445
 
1446
+ # PyPy < 3.10.14 readline is stuck in Python 2.7
1447
+ if platform.python_implementation() == "PyPy":
1448
+ major, minor, patch = tuple(map(int, platform.python_version_tuple()))
1449
+ if major <= 3 and minor <= 10 and patch < 14:
1450
+ print(
1451
+ "\n".join(
1452
+ [
1453
+ "Note for users of PyPy < 3.10.14:",
1454
+ "Autocomplete might not work due to an outdated version of pyrepl/readline.py",
1455
+ "To fix this, please update your version of PyPy.",
1456
+ ]
1457
+ )
1458
+ )
1459
+
1288
1460
  try:
1289
1461
  open_shell(args.targets, args.python, args.registry)
1290
1462
  except TargetError as e: