dissect.target 3.21.dev11__py3-none-any.whl → 3.21.dev12__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.
@@ -0,0 +1,990 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import dataclasses
7
+ import logging
8
+ import os
9
+ import re
10
+ import shutil
11
+ import sys
12
+ from difflib import diff_bytes, unified_diff
13
+ from fnmatch import fnmatch, translate
14
+ from io import BytesIO
15
+ from typing import Iterable, Iterator, TextIO
16
+
17
+ from dissect.cstruct import hexdump
18
+ from flow.record import Record, RecordOutput, ignore_fields_for_comparison
19
+
20
+ from dissect.target import Target
21
+ from dissect.target.exceptions import FileNotFoundError
22
+ from dissect.target.filesystem import FilesystemEntry
23
+ from dissect.target.helpers import fsutil
24
+ from dissect.target.helpers.record import TargetRecordDescriptor
25
+ from dissect.target.plugin import alias, arg
26
+ from dissect.target.tools.fsutils import print_extensive_file_stat_listing
27
+ from dissect.target.tools.query import record_output
28
+ from dissect.target.tools.shell import (
29
+ ExtendedCmd,
30
+ TargetCli,
31
+ arg_str_to_arg_list,
32
+ build_pipe_stdout,
33
+ fmt_ls_colors,
34
+ python_shell,
35
+ run_cli,
36
+ )
37
+ from dissect.target.tools.utils import (
38
+ catch_sigpipe,
39
+ configure_generic_arguments,
40
+ generate_argparse_for_bound_method,
41
+ process_generic_arguments,
42
+ )
43
+
44
+ log = logging.getLogger(__name__)
45
+ logging.lastResort = None
46
+ logging.raiseExceptions = False
47
+
48
+ BLOCK_SIZE = 2048
49
+ FILE_LIMIT = BLOCK_SIZE * 16
50
+
51
+ FILE_DIFF_RECORD_FIELDS = [
52
+ ("string", "src_target"),
53
+ ("string", "dst_target"),
54
+ ("string", "path"),
55
+ ]
56
+ RECORD_DIFF_RECORD_FIELDS = [
57
+ ("string", "src_target"),
58
+ ("string", "dst_target"),
59
+ ("record", "record"),
60
+ ]
61
+
62
+ FileDeletedRecord = TargetRecordDescriptor("differential/file/deleted", FILE_DIFF_RECORD_FIELDS)
63
+ FileCreatedRecord = TargetRecordDescriptor("differential/file/created", FILE_DIFF_RECORD_FIELDS)
64
+ FileModifiedRecord = TargetRecordDescriptor(
65
+ "differential/file/modified",
66
+ FILE_DIFF_RECORD_FIELDS
67
+ + [
68
+ ("bytes[]", "diff"),
69
+ ],
70
+ )
71
+
72
+ RecordCreatedRecord = TargetRecordDescriptor("differential/record/created", RECORD_DIFF_RECORD_FIELDS)
73
+ RecordDeletedRecord = TargetRecordDescriptor("differential/record/deleted", RECORD_DIFF_RECORD_FIELDS)
74
+ RecordUnchangedRecord = TargetRecordDescriptor("differential/record/unchanged", RECORD_DIFF_RECORD_FIELDS)
75
+
76
+
77
+ @dataclasses.dataclass
78
+ class DifferentialEntry:
79
+ """Signifies a change for a FilesystemEntry between two versions of a target."""
80
+
81
+ path: str
82
+ name: str
83
+ src_target_entry: FilesystemEntry
84
+ dst_target_entry: FilesystemEntry
85
+ diff: list[bytes]
86
+
87
+
88
+ @dataclasses.dataclass
89
+ class DirectoryDifferential:
90
+ """For a given directory, contains the unchanged, created, modified and deleted entries, as well as a list of
91
+ subdirectories."""
92
+
93
+ directory: str
94
+ unchanged: list[FilesystemEntry] = dataclasses.field(default_factory=list)
95
+ created: list[FilesystemEntry] = dataclasses.field(default_factory=list)
96
+ modified: list[DifferentialEntry] = dataclasses.field(default_factory=list)
97
+ deleted: list[FilesystemEntry] = dataclasses.field(default_factory=list)
98
+
99
+
100
+ def likely_unchanged(src: fsutil.stat_result, dst: fsutil.stat_result) -> bool:
101
+ """Determine whether or not, based on the file stats, we can assume a file hasn't been changed."""
102
+ if src.st_size != dst.st_size or src.st_mtime != dst.st_mtime or src.st_ctime != dst.st_ctime:
103
+ return False
104
+ return True
105
+
106
+
107
+ def get_plugin_output_records(plugin_name: str, plugin_arg_parts: list[str], target: Target) -> Iterable[Record]:
108
+ """Command exection helper for target plugins. Highly similar to target-shell's _exec_target, however this function
109
+ only accepts plugins that outputs records, and returns an iterable of records rather than a function that outputs
110
+ to stdout."""
111
+ attr = target
112
+ for part in plugin_name.split("."):
113
+ attr = getattr(attr, part)
114
+
115
+ if getattr(attr, "__output__", "default") != "record":
116
+ raise ValueError("Comparing plugin output is only supported for plugins outputting records.")
117
+
118
+ if callable(attr):
119
+ argparser = generate_argparse_for_bound_method(attr)
120
+ try:
121
+ args = argparser.parse_args(plugin_arg_parts)
122
+ except SystemExit:
123
+ return False
124
+
125
+ return attr(**vars(args))
126
+ else:
127
+ return attr
128
+
129
+
130
+ class TargetComparison:
131
+ """This class wraps functionality that for two given targets can identify similarities and differences between them.
132
+ Currently supports differentiating between the target filesystems, and between plugin outputs."""
133
+
134
+ def __init__(
135
+ self,
136
+ src_target: Target,
137
+ dst_target: Target,
138
+ deep: bool = False,
139
+ file_limit: int = FILE_LIMIT,
140
+ ):
141
+ self.src_target = src_target
142
+ self.dst_target = dst_target
143
+ self.deep = deep
144
+ self.file_limit = file_limit
145
+
146
+ def scandir(self, path: str) -> DirectoryDifferential:
147
+ """Scan a given directory for files that have been unchanged, modified, created or deleted from one target to
148
+ the next. Add these results (as well as subdirectories) to a DirectoryDifferential object."""
149
+ unchanged = []
150
+ modified = []
151
+ exists_as_directory_src = self.src_target.fs.exists(path) and self.src_target.fs.get(path).is_dir()
152
+ exists_as_directory_dst = self.dst_target.fs.exists(path) and self.dst_target.fs.get(path).is_dir()
153
+
154
+ if not (exists_as_directory_src and exists_as_directory_dst):
155
+ if exists_as_directory_src:
156
+ # Path only exists on src target, hence all entries can be considered 'deleted'
157
+ entries = list(self.src_target.fs.scandir(path))
158
+ return DirectoryDifferential(path, deleted=entries)
159
+ elif exists_as_directory_dst:
160
+ # Path only exists on dst target, hence all entries can be considered 'created'
161
+ entries = list(self.dst_target.fs.scandir(path))
162
+ return DirectoryDifferential(path, created=entries)
163
+ raise ValueError(f"{path} is not a directory on either the source or destination target!")
164
+
165
+ src_target_entries = list(self.src_target.fs.scandir(path))
166
+ src_target_children_paths = set(entry.path for entry in src_target_entries)
167
+
168
+ dst_target_entries = list(self.dst_target.fs.scandir(path))
169
+ dst_target_children_paths = set(entry.path for entry in dst_target_entries)
170
+
171
+ paths_only_on_src_target = src_target_children_paths - dst_target_children_paths
172
+ paths_only_on_dst_target = dst_target_children_paths - src_target_children_paths
173
+
174
+ deleted = [entry for entry in src_target_entries if entry.path in paths_only_on_src_target]
175
+ created = [entry for entry in dst_target_entries if entry.path in paths_only_on_dst_target]
176
+
177
+ paths_on_both = src_target_children_paths.intersection(dst_target_children_paths)
178
+ entry_pairs = []
179
+
180
+ for dst_entry in dst_target_entries:
181
+ if dst_entry.path not in paths_on_both:
182
+ continue
183
+ src_entry = next((entry for entry in src_target_entries if entry.path == dst_entry.path), None)
184
+ entry_pairs.append((src_entry, dst_entry))
185
+
186
+ for entry_pair in entry_pairs:
187
+ src_entry, dst_entry = entry_pair
188
+ entry_path = src_entry.path
189
+
190
+ # It's possible that there is an entry, but upon trying to retrieve its stats / content, we get a
191
+ # FileNotFoundError. We account for this by wrapping both stat retrievals in a try except
192
+ src_target_notfound = False
193
+ dst_target_notfound = False
194
+ src_target_isdir = None
195
+ dst_target_isdir = None
196
+
197
+ try:
198
+ src_target_stat = src_entry.stat()
199
+ src_target_isdir = src_entry.is_dir()
200
+ except FileNotFoundError:
201
+ src_target_notfound = True
202
+
203
+ try:
204
+ dst_target_stat = dst_entry.stat()
205
+ dst_target_isdir = dst_entry.is_dir()
206
+ except FileNotFoundError:
207
+ dst_target_notfound = True
208
+
209
+ if src_target_notfound or dst_target_notfound:
210
+ if src_target_notfound and not dst_target_notfound:
211
+ created.append(dst_entry)
212
+ elif dst_target_notfound and not src_target_notfound:
213
+ deleted.append(src_entry)
214
+ else:
215
+ # Not found on both
216
+ unchanged.append(src_entry)
217
+ # We can't continue as we cannot access the stats (or buffer)
218
+ continue
219
+
220
+ if src_target_isdir or dst_target_isdir:
221
+ if src_target_isdir == dst_target_isdir:
222
+ unchanged.append(src_entry)
223
+ else:
224
+ # Went from a file to a dir, or from a dir to a file. Either way, we consider the source entry
225
+ # 'deleted' and the dst entry 'Created'
226
+ deleted.append(src_entry)
227
+ created.append(dst_entry)
228
+ continue
229
+
230
+ if self.deep is False and likely_unchanged(src_target_stat, dst_target_stat):
231
+ unchanged.append(src_entry)
232
+ continue
233
+
234
+ # If we get here, we have two files that we need to compare contents of
235
+ src_fh = src_entry.open()
236
+ dst_fh = dst_entry.open()
237
+
238
+ while True:
239
+ chunk_a = src_fh.read(BLOCK_SIZE)
240
+ chunk_b = dst_fh.read(BLOCK_SIZE)
241
+ if chunk_a != chunk_b:
242
+ # We immediately break after discovering a difference in file contents
243
+ # This means that we won't return a full diff of the file, merely the first block where a difference
244
+ # is observed
245
+ content_difference = list(diff_bytes(unified_diff, [chunk_a], [chunk_b]))
246
+ differential_entry = DifferentialEntry(
247
+ entry_path,
248
+ src_entry.name,
249
+ src_entry,
250
+ dst_entry,
251
+ content_difference,
252
+ )
253
+ modified.append(differential_entry)
254
+ break
255
+
256
+ if src_fh.tell() > self.file_limit:
257
+ unchanged.append(src_entry)
258
+ break
259
+
260
+ if len(chunk_a) == 0:
261
+ # End of file
262
+ unchanged.append(src_entry)
263
+ break
264
+
265
+ return DirectoryDifferential(path, unchanged, created, modified, deleted)
266
+
267
+ def walkdir(
268
+ self,
269
+ path: str,
270
+ exclude: list[str] | str | None = None,
271
+ already_iterated: list[str] = None,
272
+ ) -> Iterator[DirectoryDifferential]:
273
+ """Recursively iterate directories and yield DirectoryDifferentials."""
274
+ if already_iterated is None:
275
+ already_iterated = []
276
+
277
+ if path in already_iterated:
278
+ return
279
+
280
+ if exclude is not None and not isinstance(exclude, list):
281
+ exclude = [exclude]
282
+
283
+ already_iterated.append(path)
284
+
285
+ diff = self.scandir(path)
286
+ yield diff
287
+
288
+ subentries = diff.created + diff.unchanged + diff.deleted
289
+ subdirectories = [entry for entry in subentries if entry.is_dir()]
290
+ # Check if the scandir lead to the discovery of new directories that we have to scan for differentials
291
+ # Directories are always in 'unchanged'
292
+ for subdirectory in subdirectories:
293
+ if subdirectory in already_iterated:
294
+ continue
295
+
296
+ # Right-pad with a '/'
297
+ subdirectory_path = subdirectory.path if subdirectory.path.endswith("/") else subdirectory.path + "/"
298
+ if exclude:
299
+ match = next((pattern for pattern in exclude if fnmatch(subdirectory_path, pattern)), None)
300
+ if match:
301
+ continue
302
+ yield from self.walkdir(subdirectory.path, exclude, already_iterated)
303
+
304
+ def differentiate_plugin_outputs(
305
+ self, plugin_name: str, plugin_arg_parts: list[str], only_changed: bool = False
306
+ ) -> Iterator[Record]:
307
+ """Run a plugin on the source and destination targets and yield RecordUnchanged, RecordCreated and RecordDeleted
308
+ records. There is no equivalent for the FileModifiedRecord. For files and directories, we can use the path to
309
+ reliably track changes from one target to the next. There is no equivalent for plugin outputs, so we just assume
310
+ that all records are either deleted (only on src), created (only on dst) or unchanged (on both)."""
311
+ with ignore_fields_for_comparison(["_generated", "_source", "hostname", "domain"]):
312
+ src_records = set(get_plugin_output_records(plugin_name, plugin_arg_parts, self.src_target))
313
+ src_records_seen = set()
314
+
315
+ for dst_record in get_plugin_output_records(plugin_name, plugin_arg_parts, self.dst_target):
316
+ if dst_record in src_records:
317
+ src_records_seen.add(dst_record)
318
+ if not only_changed:
319
+ yield RecordUnchangedRecord(
320
+ src_target=self.src_target.path, dst_target=self.dst_target.path, record=dst_record
321
+ )
322
+ else:
323
+ yield RecordCreatedRecord(
324
+ src_target=self.src_target.path, dst_target=self.dst_target.path, record=dst_record
325
+ )
326
+ for record in src_records - src_records_seen:
327
+ yield RecordDeletedRecord(
328
+ src_target=self.src_target.path, dst_target=self.dst_target.path, record=record
329
+ )
330
+
331
+
332
+ class DifferentialCli(ExtendedCmd):
333
+ """CLI for browsing the differential between two or more targets."""
334
+
335
+ doc_header_prefix = "target-diff\n" "==========\n"
336
+ doc_header_suffix = "\n\nDocumented commands (type help <topic>):"
337
+ doc_header_multiple_targets = "Use 'list', 'prev' and 'next' to list and select targets to differentiate between."
338
+
339
+ def __init__(self, *targets: tuple[Target], deep: bool = False, limit: int = FILE_LIMIT):
340
+ self.targets = targets
341
+ self.deep = deep
342
+ self.limit = limit
343
+
344
+ self.src_index = 0
345
+ self.dst_index = 0
346
+ self.comparison: TargetComparison = None
347
+
348
+ self.cwd = "/"
349
+ self.alt_separator = "/"
350
+
351
+ doc_header_middle = self.doc_header_multiple_targets if len(targets) > 2 else ""
352
+ self.doc_header = self.doc_header_prefix + doc_header_middle + self.doc_header_suffix
353
+
354
+ self._select_source_and_dest(0, 1)
355
+
356
+ start_in_cyber = any(target.props.get("cyber") for target in self.targets)
357
+ super().__init__(start_in_cyber)
358
+
359
+ if len(self.targets) > 2:
360
+ # Some help may be nice if you are diffing more than 2 targets at once
361
+ self.do_help(arg=None)
362
+
363
+ @property
364
+ def src_target(self) -> Target:
365
+ return self.targets[self.src_index]
366
+
367
+ @property
368
+ def dst_target(self) -> Target:
369
+ return self.targets[self.dst_index]
370
+
371
+ @property
372
+ def prompt(self) -> str:
373
+ if self.comparison.src_target.name != self.comparison.dst_target.name:
374
+ prompt_base = f"{self.comparison.src_target.name}/{self.comparison.dst_target.name}"
375
+ else:
376
+ prompt_base = self.comparison.src_target.name
377
+
378
+ if os.getenv("NO_COLOR"):
379
+ suffix = f"{prompt_base}:{self.cwd}$ "
380
+ else:
381
+ suffix = f"\x1b[1;32m{prompt_base}\x1b[0m:\x1b[1;34m{self.cwd}\x1b[0m$ "
382
+
383
+ if len(self.targets) <= 2:
384
+ return f"(diff) {suffix}"
385
+
386
+ chain_prefix = "[ "
387
+ for i in range(len(self.targets)):
388
+ char = "O " if i == self.src_index or i == self.dst_index else ". "
389
+ chain_prefix += char
390
+ chain_prefix += "] "
391
+
392
+ return f"(diff) {chain_prefix}{suffix}"
393
+
394
+ def _select_source_and_dest(self, src_index: int, dst_index: int) -> None:
395
+ """Set local variables according to newly selected source and destination index, and re-instatiate
396
+ TargetComparison."""
397
+ self.src_index = src_index
398
+ self.dst_index = dst_index
399
+ if not self.src_target.fs.exists(self.cwd) and not self.dst_target.fs.exists(self.cwd):
400
+ logging.warning("The current directory exists on neither of the selected targets.")
401
+ if self.src_target.fs.alt_separator != self.dst_target.fs.alt_separator:
402
+ raise NotImplementedError("No support for handling targets with different path separators")
403
+
404
+ self.alt_separator = self.src_target.fs.alt_separator
405
+ self.comparison = TargetComparison(self.src_target, self.dst_target, self.deep, self.limit)
406
+
407
+ def _annotate_differential(
408
+ self,
409
+ diff: DirectoryDifferential,
410
+ unchanged: bool = True,
411
+ created: bool = True,
412
+ modified: bool = True,
413
+ deleted: bool = True,
414
+ absolute: bool = False,
415
+ ) -> list[tuple[fsutil.TargetPath | DifferentialEntry], str]:
416
+ """Given a DirectoryDifferential instance, construct a list of tuples where the first element is a Filesystem /
417
+ DifferentialEntry and the second a color-formatted string."""
418
+ r = []
419
+
420
+ attr = "path" if absolute else "name"
421
+ if unchanged:
422
+ for entry in diff.unchanged:
423
+ color = "di" if entry.is_dir() else "fi"
424
+ r.append((entry, fmt_ls_colors(color, getattr(entry, attr))))
425
+
426
+ if created:
427
+ for entry in diff.created:
428
+ color = "tw" if entry.is_dir() else "ex"
429
+ r.append((entry, fmt_ls_colors(color, f"{getattr(entry, attr)} (created)")))
430
+
431
+ if modified:
432
+ for entry in diff.modified:
433
+ # Modified entries are always files
434
+ r.append((entry, fmt_ls_colors("ln", f"{getattr(entry, attr)} (modified)")))
435
+ if deleted:
436
+ for entry in diff.deleted:
437
+ color = "su" if entry.is_dir() else "or"
438
+ r.append((entry, fmt_ls_colors(color, f"{getattr(entry, attr)} (deleted)")))
439
+
440
+ r.sort(key=lambda e: e[0].name)
441
+ return r
442
+
443
+ def _targets_with_directory(self, path: str, warn_when_incomplete: bool = False) -> int:
444
+ """Return whether a given path is an existing directory for neither, one of, or both of the targets being
445
+ compared. Optionally log a warning if the directory only exists on one of the two targets."""
446
+ src_has_dir = False
447
+ dst_has_dir = False
448
+ try:
449
+ entry = self.comparison.src_target.fs.get(path)
450
+ src_has_dir = entry.is_dir()
451
+ except FileNotFoundError:
452
+ pass
453
+ try:
454
+ entry = self.comparison.dst_target.fs.get(path)
455
+ dst_has_dir = entry.is_dir()
456
+ except FileNotFoundError:
457
+ pass
458
+
459
+ if (src_has_dir is False or dst_has_dir is False) and warn_when_incomplete:
460
+ if src_has_dir != dst_has_dir:
461
+ target_with_dir = self.comparison.src_target if src_has_dir else self.comparison.dst_target
462
+ log.warning("'%s' is only a valid path on '%s'.", path, target_with_dir)
463
+ else:
464
+ log.warning("'%s' is not a valid path on either target.", path)
465
+ return int(src_has_dir) + int(dst_has_dir)
466
+
467
+ def _write_entry_contents_to_stdout(self, entry: FilesystemEntry, stdout: TextIO) -> bool:
468
+ """Copy the contents of a Filesystementry to stdout."""
469
+ stdout = stdout.buffer
470
+ fh = entry.open()
471
+ shutil.copyfileobj(fh, stdout)
472
+ stdout.flush()
473
+ print("")
474
+ return False
475
+
476
+ def completedefault(self, text: str, line: str, begidx: int, endidx: int) -> list[str]:
477
+ """Autocomplete based on files / directories found in the current path."""
478
+ path = line[:begidx].rsplit(" ")[-1]
479
+ textlower = text.lower()
480
+
481
+ path = fsutil.abspath(path, cwd=str(self.cwd), alt_separator=self.alt_separator)
482
+
483
+ diff = self.comparison.scandir(path)
484
+ items = [
485
+ (item.entry.is_dir(), item.name) for group in [diff.created, diff.unchanged, diff.deleted] for item in group
486
+ ]
487
+ items += [
488
+ (item.src_target_entry.is_dir() and item.dst_target_entry.is_dir(), item.name) for item in diff.modified
489
+ ]
490
+ suggestions = []
491
+ for is_dir, fname in items:
492
+ if not fname.lower().startswith(textlower):
493
+ continue
494
+
495
+ # Add a trailing slash to directories, to allow for easier traversal of the filesystem
496
+ suggestion = f"{fname}/" if is_dir else fname
497
+ suggestions.append(suggestion)
498
+ return suggestions
499
+
500
+ def do_list(self, line: str) -> bool:
501
+ """Prints a list of targets to differentiate between. Useful when differentiating between three or more
502
+ targets. Looks quite bad on small terminal screens."""
503
+ columns = ["#", "Name", "Path", "From", "To"]
504
+
505
+ rows = []
506
+
507
+ for i, target in enumerate(self.targets):
508
+ rows.append(
509
+ [
510
+ f"{i:2d}",
511
+ target.name,
512
+ str(target.path),
513
+ "**" if i == self.src_index else "",
514
+ "**" if i == self.dst_index else "",
515
+ ]
516
+ )
517
+
518
+ longest_name = max(len(row[1]) + 4 for row in rows)
519
+ longest_path = max(len(row[2]) + 4 for row in rows)
520
+ name_len = max(10, longest_name)
521
+ path_len = max(15, longest_path)
522
+
523
+ fmt = "{:^5} | {:<" + str(name_len) + "} | {:<" + str(path_len) + "} | {:^6} | {:^6} |"
524
+ print(fmt.format(*columns))
525
+ print("")
526
+ for row in rows:
527
+ print(fmt.format(*row))
528
+ print("")
529
+ return False
530
+
531
+ @alias("prev")
532
+ @arg("-a", "--absolute", action="store_true", help="Only move the destination target one position back.")
533
+ def cmd_previous(self, args: argparse.Namespace, line: str) -> bool:
534
+ """When three or more targets are available, move the 'comparison window' one position back."""
535
+ src_index = self.src_index - 1 if not args.absolute else 0
536
+ if src_index < 0:
537
+ src_index = len(self.targets) - 1
538
+ dst_index = self.dst_index - 1
539
+ if dst_index < 0:
540
+ dst_index = len(self.targets) - 1
541
+ if dst_index <= src_index:
542
+ src_index, dst_index = dst_index, src_index
543
+ self._select_source_and_dest(src_index, dst_index)
544
+ return False
545
+
546
+ @arg("-a", "--absolute", action="store_true", help="Only move the destination target one position forward.")
547
+ def cmd_next(self, args: argparse.Namespace, line: str) -> bool:
548
+ """When three or more targets are available, move the 'comparison window' one position forward."""
549
+ dst_index = (self.dst_index + 1) % len(self.targets)
550
+ src_index = self.src_index + 1 % len(self.targets) if not args.absolute else 0
551
+
552
+ if dst_index <= src_index:
553
+ src_index, dst_index = dst_index, src_index
554
+ self._select_source_and_dest(src_index, dst_index)
555
+ return False
556
+
557
+ def do_cd(self, path: str) -> bool:
558
+ """Change directory to the given path."""
559
+ path = fsutil.abspath(path, cwd=str(self.cwd), alt_separator=self.alt_separator)
560
+ if self._targets_with_directory(path, warn_when_incomplete=True) != 0:
561
+ self.cwd = path
562
+ return False
563
+
564
+ @arg("path", nargs="?")
565
+ @arg("-l", action="store_true")
566
+ @arg("-a", "--all", action="store_true") # ignored but included for proper argument parsing
567
+ @arg("-h", "--human-readable", action="store_true")
568
+ def cmd_ls(self, args: argparse.Namespace, stdout: TextIO) -> bool:
569
+ """List contents of a directory for two targets."""
570
+ path = args.path if args.path is not None else self.cwd
571
+ diff = self.comparison.scandir(path)
572
+ results = self._annotate_differential(diff)
573
+ if not args.l:
574
+ print("\n".join([name for _, name in results]), file=stdout)
575
+ else:
576
+ for entry, name in results:
577
+ if not isinstance(entry, DifferentialEntry):
578
+ print_extensive_file_stat_listing(stdout, name, entry, human_readable=args.human_readable)
579
+ else:
580
+ # We have to choose for which version of this file we are going to print detailed info. The
581
+ # destination target seems to make the most sense: it is likely newer
582
+ print_extensive_file_stat_listing(
583
+ stdout, name, entry.dst_target_entry, human_readable=args.human_readable
584
+ )
585
+ return False
586
+
587
+ @arg("path", nargs="?")
588
+ def cmd_cat(self, args: argparse.Namespace, stdout: TextIO) -> bool:
589
+ """Output the contents of a file."""
590
+ base_dir, _, name = args.path.rpartition("/")
591
+ if not base_dir:
592
+ base_dir = self.cwd
593
+
594
+ directory_differential = self.comparison.scandir(base_dir)
595
+ entry = None
596
+ for entry in directory_differential.unchanged:
597
+ if entry.name == name:
598
+ return self._write_entry_contents_to_stdout(entry, stdout)
599
+ for entry in directory_differential.created:
600
+ if entry.name == name:
601
+ log.warning("'%s' is only present on '%s'.", entry.name, self.comparison.dst_target.path)
602
+ return self._write_entry_contents_to_stdout(entry, stdout)
603
+ for entry in directory_differential.deleted:
604
+ if entry.name == name:
605
+ log.warning("'%s' is only present on '%s'.", entry.name, self.comparison.src_target.path)
606
+ return self._write_entry_contents_to_stdout(entry, stdout)
607
+ for entry in directory_differential.modified:
608
+ if entry.name == name:
609
+ log.warning(
610
+ "Concatinating latest version of '%s'. Use 'diff' to differentiate between target versions.",
611
+ entry.name,
612
+ )
613
+ return self._write_entry_contents_to_stdout(entry.dst_target_entry, stdout)
614
+ print(f"File {name} not found.")
615
+ return False
616
+
617
+ @arg("path", nargs="?")
618
+ @arg("--hex", action="store_true", default=False)
619
+ def cmd_diff(self, args: argparse.Namespace, stdout: TextIO) -> bool:
620
+ """Output the difference in file contents between two targets."""
621
+ stdout = stdout.buffer
622
+ base_dir, _, name = args.path.rpartition("/")
623
+ if not base_dir:
624
+ base_dir = self.cwd
625
+ directory_differential = self.comparison.scandir(base_dir)
626
+ for entry in directory_differential.modified:
627
+ if entry.name == name:
628
+ if args.hex:
629
+ primary_fh_lines = [
630
+ line.encode()
631
+ for line in hexdump(entry.src_target_entry.open().read(), output="string").split("\n")
632
+ ]
633
+ secondary_fh_lines = [
634
+ line.encode()
635
+ for line in hexdump(entry.dst_target_entry.open().read(), output="string").split("\n")
636
+ ]
637
+ else:
638
+ primary_fh_lines = entry.src_target_entry.open().readlines()
639
+ secondary_fh_lines = entry.dst_target_entry.open().readlines()
640
+
641
+ for chunk in diff_bytes(unified_diff, primary_fh_lines, secondary_fh_lines):
642
+ if chunk.startswith(b"@@"):
643
+ chunk = fmt_ls_colors("ln", chunk.decode()).encode()
644
+ elif chunk.startswith(b"+"):
645
+ chunk = fmt_ls_colors("ex", chunk.decode()).encode()
646
+ elif chunk.startswith(b"-"):
647
+ chunk = fmt_ls_colors("or", chunk.decode()).encode()
648
+
649
+ shutil.copyfileobj(BytesIO(chunk), stdout)
650
+
651
+ if args.hex:
652
+ stdout.write(b"\n")
653
+
654
+ stdout.flush()
655
+
656
+ print("")
657
+ return False
658
+
659
+ # Check if this file is even present on one of the targets
660
+ files = directory_differential.unchanged + directory_differential.created + directory_differential.deleted
661
+ match = next((entry for entry in files if entry.name == name), None)
662
+ if match is None:
663
+ print(f"File {name} not found.")
664
+ else:
665
+ print(f"No two versions available for {name} to differentiate between.")
666
+ return False
667
+
668
+ @arg("path", nargs="?")
669
+ @alias("xxd")
670
+ def cmd_hexdump(self, args: argparse.Namespace, stdout: TextIO) -> bool:
671
+ """Output difference of the given file between targets in hexdump."""
672
+ setattr(args, "hex", True)
673
+ return self.cmd_diff(args, stdout)
674
+
675
+ @arg("index", type=str)
676
+ @arg("type", choices=["src", "dst"])
677
+ def cmd_set(self, args: argparse.Namespace, stdout: TextIO) -> bool:
678
+ """Change either the source or destination target for differentiation. Index can be given relative (when
679
+ prefixed with '+' or '-', e.g. "set dst +1") or absolute (e.g. set src 0)."""
680
+ index = args.index.strip()
681
+ pos = self.src_index if args.type == "src" else self.dst_index
682
+
683
+ if index.startswith(("+", "-")):
684
+ multiplier = 1 if index[0] == "+" else -1
685
+ index = index[1:].strip()
686
+ if not index.isdigit():
687
+ return False
688
+ pos += int(index) * multiplier
689
+ elif index.isdigit():
690
+ pos = int(index)
691
+ else:
692
+ raise ValueError(f"Could not set {args.type} to {index}.")
693
+ if args.type == "src":
694
+ self._select_source_and_dest(pos, self.dst_index)
695
+ else:
696
+ self._select_source_and_dest(self.src_index, pos)
697
+ return False
698
+
699
+ @arg("target", choices=["src", "dst"])
700
+ def cmd_enter(self, args: argparse.Namespace, stdout: TextIO) -> bool:
701
+ """Open a subshell for the source or destination target."""
702
+ target = self.src_target if args.target == "src" else self.dst_target
703
+ cli = TargetCli(target)
704
+ if target.fs.exists(self.cwd):
705
+ cli.chdir(self.cwd)
706
+
707
+ # Cyber doesn't work well with subshells
708
+ cli.cyber = False
709
+ run_cli(cli)
710
+ return False
711
+
712
+ @arg("path", nargs="?")
713
+ @arg("-name", default="*")
714
+ @arg("-iname")
715
+ @arg("-c", "--created", action="store_true")
716
+ @arg("-m", "--modified", action="store_true")
717
+ @arg("-d", "--deleted", action="store_true")
718
+ @arg("-u", "--unchanged", action="store_true")
719
+ def cmd_find(self, args: argparse.Namespace, stdout: TextIO) -> bool:
720
+ """Search for files in a directory hierarchy."""
721
+ path = fsutil.abspath(args.path, cwd=str(self.cwd), alt_separator=self.comparison.src_target.fs.alt_separator)
722
+ if not path:
723
+ return False
724
+
725
+ if self._targets_with_directory(path, warn_when_incomplete=True) == 0:
726
+ return False
727
+
728
+ if args.iname:
729
+ pattern = re.compile(translate(args.iname), re.IGNORECASE)
730
+ else:
731
+ pattern = re.compile(translate(args.name))
732
+
733
+ include_all_changes = not (args.created or args.modified or args.deleted or args.unchanged)
734
+
735
+ include_unchanged = args.unchanged
736
+ include_modified = include_all_changes or args.modified
737
+ include_created = include_all_changes or args.created
738
+ include_deleted = include_all_changes or args.deleted
739
+
740
+ for differential in self.comparison.walkdir(path):
741
+ for entry, line in self._annotate_differential(
742
+ differential, include_unchanged, include_created, include_modified, include_deleted, absolute=True
743
+ ):
744
+ if not pattern.match(entry.name):
745
+ continue
746
+
747
+ print(line, file=stdout)
748
+
749
+ return False
750
+
751
+ def do_plugin(self, line: str) -> bool:
752
+ """Yield RecordCreated, RecordUnchanged and RecordDeleted Records by comparing plugin outputs for two
753
+ targets."""
754
+ argparts = arg_str_to_arg_list(line)
755
+ pipeparts = []
756
+ if "|" in argparts:
757
+ pipeidx = argparts.index("|")
758
+ argparts, pipeparts = argparts[:pipeidx], argparts[pipeidx + 1 :]
759
+
760
+ if len(argparts) < 1:
761
+ raise ValueError("Provide a plugin name, and optionally parameters to pass to the plugin.")
762
+
763
+ plugin = argparts.pop(0)
764
+
765
+ iterator = self.comparison.differentiate_plugin_outputs(plugin, argparts)
766
+ if pipeparts:
767
+ try:
768
+ with build_pipe_stdout(pipeparts) as pipe_stdin:
769
+ rs = RecordOutput(pipe_stdin.buffer)
770
+ for record in iterator:
771
+ rs.write(record)
772
+ except OSError as e:
773
+ # in case of a failure in a subprocess
774
+ print(e)
775
+ else:
776
+ for record in iterator:
777
+ print(record, file=sys.stdout)
778
+
779
+ return False
780
+
781
+ def do_python(self, line: str) -> bool:
782
+ """drop into a Python shell."""
783
+ python_shell(list(self.targets))
784
+ return False
785
+
786
+
787
+ def make_target_pairs(targets: tuple[Target], absolute: bool = False) -> list[tuple[Target, Target]]:
788
+ """Make 'pairs' of targets that we are going to compare against one another. A list of targets can be treated in two
789
+ ways: compare every target with the one that came before it, or compare all targets against a 'base' target (which
790
+ has to be supplied as initial target in the list)."""
791
+ target_pairs = []
792
+
793
+ previous_target = targets[0]
794
+ for target in targets[1:]:
795
+ target_pairs.append((previous_target, target))
796
+ if not absolute:
797
+ # The next target should be compared against the one we just opened
798
+ previous_target = target
799
+ return target_pairs
800
+
801
+
802
+ def differentiate_target_filesystems(
803
+ *targets: tuple[Target],
804
+ deep: bool = False,
805
+ limit: int = FILE_LIMIT,
806
+ absolute: bool = False,
807
+ include: list[str] = None,
808
+ exclude: list[str] = None,
809
+ ) -> Iterator[Record]:
810
+ """Given a list of targets, compare targets against one another and yield File[Created|Modified|Deleted]Records
811
+ indicating the differences between them."""
812
+
813
+ for target_pair in make_target_pairs(targets, absolute):
814
+ # Unpack the tuple and initialize the comparison class
815
+ src_target, dst_target = target_pair
816
+ comparison = TargetComparison(src_target, dst_target, deep, limit)
817
+
818
+ paths = ["/"] if include is None else include
819
+
820
+ for path in paths:
821
+ for directory_diff in comparison.walkdir(path, exclude=exclude):
822
+ for creation_entry in directory_diff.created:
823
+ yield FileCreatedRecord(
824
+ path=creation_entry.path,
825
+ src_target=src_target.path,
826
+ dst_target=dst_target.path,
827
+ )
828
+
829
+ for deletion_entry in directory_diff.deleted:
830
+ yield FileDeletedRecord(
831
+ path=deletion_entry.path,
832
+ src_target=src_target.path,
833
+ dst_target=dst_target.path,
834
+ )
835
+
836
+ for entry_difference in directory_diff.modified:
837
+ yield FileModifiedRecord(
838
+ path=entry_difference.path,
839
+ diff=entry_difference.diff,
840
+ src_target=src_target.path,
841
+ dst_target=dst_target.path,
842
+ )
843
+
844
+
845
+ def differentiate_target_plugin_outputs(
846
+ *targets: tuple[Target], absolute: bool = False, only_changed: bool = False, plugin: str, plugin_args: str = ""
847
+ ) -> Iterator[Record]:
848
+ """Given a list of targets, yielding records indicating which records from this plugin are new, unmodified or
849
+ deleted."""
850
+ for target_pair in make_target_pairs(targets, absolute):
851
+ src_target, dst_target = target_pair
852
+ comparison = TargetComparison(src_target, dst_target)
853
+ yield from comparison.differentiate_plugin_outputs(plugin, plugin_args, only_changed)
854
+
855
+
856
+ @catch_sigpipe
857
+ def main() -> None:
858
+ help_formatter = argparse.ArgumentDefaultsHelpFormatter
859
+ parser = argparse.ArgumentParser(
860
+ description="target-diff",
861
+ fromfile_prefix_chars="@",
862
+ formatter_class=help_formatter,
863
+ )
864
+
865
+ parser.add_argument(
866
+ "-d",
867
+ "--deep",
868
+ action="store_true",
869
+ help="Compare file contents even if metadata suggests they have been left unchanged",
870
+ )
871
+ parser.add_argument(
872
+ "-l",
873
+ "--limit",
874
+ default=FILE_LIMIT,
875
+ type=int,
876
+ help="How many bytes to compare before assuming a file is left unchanged (0 for no limit)",
877
+ )
878
+ subparsers = parser.add_subparsers(help="Mode for differentiating targets", dest="mode", required=True)
879
+
880
+ shell_mode = subparsers.add_parser("shell", help="Open an interactive shell to compare two or more targets.")
881
+ shell_mode.add_argument("targets", metavar="TARGETS", nargs="+", help="Targets to differentiate between")
882
+
883
+ fs_mode = subparsers.add_parser("fs", help="Yield records about differences between target filesystems.")
884
+ fs_mode.add_argument("targets", metavar="TARGETS", nargs="+", help="Targets to differentiate between")
885
+ fs_mode.add_argument("-s", "--strings", action="store_true", help="print records as strings")
886
+ fs_mode.add_argument("-e", "--exclude", action="append", help="Path(s) on targets not to check for differences")
887
+ fs_mode.add_argument(
888
+ "-i",
889
+ "--include",
890
+ action="append",
891
+ help="Path(s) on targets to check for differences (all will be checked if left omitted)",
892
+ )
893
+ fs_mode.add_argument(
894
+ "-a",
895
+ "--absolute",
896
+ action="store_true",
897
+ help=(
898
+ "Treat every target as an absolute. The first given target is treated as the 'base' target to compare "
899
+ "subsequent targets against. If omitted, every target is treated as a 'delta' and compared against the "
900
+ "target that came before it."
901
+ ),
902
+ )
903
+
904
+ query_mode = subparsers.add_parser("query", help="Differentiate plugin outputs between two or more targets.")
905
+ query_mode.add_argument("targets", metavar="TARGETS", nargs="+", help="Targets to differentiate between")
906
+ query_mode.add_argument("-s", "--strings", action="store_true", help="print records as strings")
907
+ query_mode.add_argument(
908
+ "-p",
909
+ "--parameters",
910
+ type=str,
911
+ required=False,
912
+ default="",
913
+ help="Parameters for the plugin",
914
+ )
915
+ query_mode.add_argument(
916
+ "-f",
917
+ "--plugin",
918
+ type=str,
919
+ required=True,
920
+ help="Function to execute",
921
+ )
922
+ query_mode.add_argument(
923
+ "-a",
924
+ "--absolute",
925
+ action="store_true",
926
+ help=(
927
+ "Treat every target as an absolute. The first given target is treated as the 'base' target to compare "
928
+ "subsequent targets against. If omitted, every target is treated as a 'delta' and compared against the "
929
+ "target that came before it."
930
+ ),
931
+ )
932
+ query_mode.add_argument(
933
+ "--only-changed",
934
+ action="store_true",
935
+ help="Do not output unchanged records",
936
+ default=False,
937
+ )
938
+
939
+ configure_generic_arguments(parser)
940
+
941
+ args = parser.parse_args()
942
+ process_generic_arguments(args)
943
+
944
+ if len(args.targets) < 2:
945
+ print("At least two targets are required for target-diff.")
946
+ parser.exit(1)
947
+
948
+ target_list = [Target.open(path) for path in args.targets]
949
+ if args.mode == "shell":
950
+ cli = DifferentialCli(*target_list, deep=args.deep, limit=args.limit)
951
+ run_cli(cli)
952
+ else:
953
+ writer = record_output(args.strings)
954
+ if args.mode == "fs":
955
+ iterator = differentiate_target_filesystems(
956
+ *target_list,
957
+ deep=args.deep,
958
+ limit=args.limit,
959
+ absolute=args.absolute,
960
+ include=args.include,
961
+ exclude=args.exclude,
962
+ )
963
+ elif args.mode == "query":
964
+ if args.deep:
965
+ log.error("argument --deep is not available in target-diff query mode")
966
+ parser.exit(1)
967
+
968
+ if args.limit != FILE_LIMIT:
969
+ log.error("argument --limit is not available in target-diff query mode")
970
+ parser.exit(1)
971
+
972
+ iterator = differentiate_target_plugin_outputs(
973
+ *target_list,
974
+ absolute=args.absolute,
975
+ only_changed=args.only_changed,
976
+ plugin=args.plugin,
977
+ plugin_args=arg_str_to_arg_list(args.parameters),
978
+ )
979
+
980
+ try:
981
+ for record in iterator:
982
+ writer.write(record)
983
+
984
+ except Exception as e:
985
+ log.error(e)
986
+ parser.exit(1)
987
+
988
+
989
+ if __name__ == "__main__":
990
+ main()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: dissect.target
3
- Version: 3.21.dev11
3
+ Version: 3.21.dev12
4
4
  Summary: This module ties all other Dissect modules together, it provides a programming API and command line tools which allow easy access to various data sources inside disk images or file collections (a.k.a. targets)
5
5
  Author-email: Dissect Team <dissect@fox-it.com>
6
6
  License: Affero General Public License v3
@@ -360,6 +360,7 @@ dissect/target/plugins/os/windows/task_helpers/tasks_xml.py,sha256=fKwh9jtOP_gzW
360
360
  dissect/target/tools/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
361
361
  dissect/target/tools/build_pluginlist.py,sha256=5fomcuMwsVzcnYx5Htf5f9lSwsLeUUvomLUXNA4t7m4,849
362
362
  dissect/target/tools/dd.py,sha256=rTM-lgXxrYBpVAtJqFqAatDz45bLoD8-mFt_59Q3Lio,1928
363
+ dissect/target/tools/diff.py,sha256=0y6TgIYjZfQNhKXYB-LTen9EeMDh3AxvlzVLtKO5uIc,40976
363
364
  dissect/target/tools/fs.py,sha256=3Ny8zoooVeeF7OUkQ0nxZVdEaQeU7vPRjDOYhz6XfRA,5385
364
365
  dissect/target/tools/fsutils.py,sha256=q0t9gFwKHaPr2Ya-MN2o4LsYledde7kp2DZZTd8roIc,8314
365
366
  dissect/target/tools/info.py,sha256=t2bWENeyaEh87ayE_brdKvz9kHAWOLqkKJcGixl6hGo,5725
@@ -382,10 +383,10 @@ dissect/target/volumes/luks.py,sha256=OmCMsw6rCUXG1_plnLVLTpsvE1n_6WtoRUGQbpmu1z
382
383
  dissect/target/volumes/lvm.py,sha256=wwQVR9I3G9YzmY6UxFsH2Y4MXGBcKL9aayWGCDTiWMU,2269
383
384
  dissect/target/volumes/md.py,sha256=7ShPtusuLGaIv27SvEETtgsuoQyAa4iAAeOR1NEaajI,1689
384
385
  dissect/target/volumes/vmfs.py,sha256=-LoUbn9WNwTtLi_4K34uV_-wDw2W5hgaqxZNj4UmqAQ,1730
385
- dissect.target-3.21.dev11.dist-info/COPYRIGHT,sha256=m-9ih2RVhMiXHI2bf_oNSSgHgkeIvaYRVfKTwFbnJPA,301
386
- dissect.target-3.21.dev11.dist-info/LICENSE,sha256=DZak_2itbUtvHzD3E7GNUYSRK6jdOJ-GqncQ2weavLA,34523
387
- dissect.target-3.21.dev11.dist-info/METADATA,sha256=uQAwCX2CCiEQB6V8zn1a0qC1Z76apQll52CwncR80ek,13187
388
- dissect.target-3.21.dev11.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
389
- dissect.target-3.21.dev11.dist-info/entry_points.txt,sha256=BWuxAb_6AvUAQpIQOQU0IMTlaF6TDht2AIZK8bHd-zE,492
390
- dissect.target-3.21.dev11.dist-info/top_level.txt,sha256=Mn-CQzEYsAbkxrUI0TnplHuXnGVKzxpDw_po_sXpvv4,8
391
- dissect.target-3.21.dev11.dist-info/RECORD,,
386
+ dissect.target-3.21.dev12.dist-info/COPYRIGHT,sha256=m-9ih2RVhMiXHI2bf_oNSSgHgkeIvaYRVfKTwFbnJPA,301
387
+ dissect.target-3.21.dev12.dist-info/LICENSE,sha256=DZak_2itbUtvHzD3E7GNUYSRK6jdOJ-GqncQ2weavLA,34523
388
+ dissect.target-3.21.dev12.dist-info/METADATA,sha256=WsHf4dOaCQDZy208YPuFfTDBPnDozqhis8woCOZFRzw,13187
389
+ dissect.target-3.21.dev12.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
390
+ dissect.target-3.21.dev12.dist-info/entry_points.txt,sha256=yQwLCWUuzHgS6-sfCcRk66gAfoCfqXdCjqKjvhnQW8o,537
391
+ dissect.target-3.21.dev12.dist-info/top_level.txt,sha256=Mn-CQzEYsAbkxrUI0TnplHuXnGVKzxpDw_po_sXpvv4,8
392
+ dissect.target-3.21.dev12.dist-info/RECORD,,
@@ -1,6 +1,7 @@
1
1
  [console_scripts]
2
2
  target-build-pluginlist = dissect.target.tools.build_pluginlist:main
3
3
  target-dd = dissect.target.tools.dd:main
4
+ target-diff = dissect.target.tools.diff:main
4
5
  target-dump = dissect.target.tools.dump.run:main
5
6
  target-fs = dissect.target.tools.fs:main
6
7
  target-info = dissect.target.tools.info:main