dissect.target 3.21.dev11__py3-none-any.whl → 3.21.dev13__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
@@ -470,9 +470,9 @@ class Toml(ConfigurationParser):
470
470
  class Env(ConfigurationParser):
471
471
  """Parses ``.env`` file contents according to Docker and bash specification.
472
472
 
473
- Does not apply interpolation of substituted values, eg. ``foo=${bar}`` and does not attempt
474
- to parse list or dict strings. Does not support dynamic env files, eg. `` foo=`bar` ``. Also
475
- does not support multi-line key/value assignments (yet).
473
+ Does not apply interpolation of substituted values, e.g. ``foo=${bar}`` and does not attempt to parse list or dict
474
+ strings. Does not support dynamic env files, e.g. ``foo=`bar```. Also does not support multi-line key/value
475
+ assignments (yet).
476
476
 
477
477
  Resources:
478
478
  - https://docs.docker.com/compose/environment-variables/variable-interpolation/#env-file-syntax
@@ -169,7 +169,7 @@ class PluginListPlugin(Plugin):
169
169
 
170
170
 
171
171
  def generate_plugins_json(plugins: list[Plugin]) -> Iterator[dict]:
172
- """Generates JSON output of a list of :class:`Plugin`s."""
172
+ """Generates JSON output of a list of :class:`Plugin`."""
173
173
 
174
174
  for p in plugins:
175
175
  func = getattr(p.class_object, p.method_name)
@@ -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.dev13
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
@@ -46,7 +46,7 @@ dissect/target/filesystems/zip.py,sha256=BeNj23DOYfWuTm5V1V419ViJiMfBrO1VA5gP6rl
46
46
  dissect/target/helpers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
47
47
  dissect/target/helpers/cache.py,sha256=TXlJBdFRz6V9zKs903am4Yawr0maYw5kZY0RqklDQJM,8568
48
48
  dissect/target/helpers/config.py,sha256=RMHnIuKJHINHiLrvKN3EyA0jFA1o6-pbeaycG8Pgrp8,2596
49
- dissect/target/helpers/configutil.py,sha256=xXCmQA939iacd6cHr1SwNv6dyUo2bKMWsAI0TxwL24I,31428
49
+ dissect/target/helpers/configutil.py,sha256=OBzRZSf_N8MtVcGy9sbNP3s_mpslPPN7rwsifYOLEXY,31428
50
50
  dissect/target/helpers/cyber.py,sha256=2kAA2YjWnPfKn_aTmRSse4uB64lq6AnNX19TD8Alylc,16830
51
51
  dissect/target/helpers/descriptor_extensions.py,sha256=uT8GwznfDAiIgMM7JKKOY0PXKMv2c0GCqJTCkWFgops,2605
52
52
  dissect/target/helpers/docs.py,sha256=G5Ll1cX2kYUfNcFFI-qjeUQ5Di60RAVpVnlB72F4Vqk,5153
@@ -192,7 +192,7 @@ dissect/target/plugins/general/example.py,sha256=mYAbhtfQmUBj2L2C1DFt9bWpI7rQLJw
192
192
  dissect/target/plugins/general/loaders.py,sha256=z_t55Q1XNjmTOxq0E4tCwpZ-utFyxiLKyAJIFgJMlJs,1508
193
193
  dissect/target/plugins/general/network.py,sha256=I9wdFbBkDik1S9zvTi7sN20MdjJ2c_5tT8X8bgxWx5U,3257
194
194
  dissect/target/plugins/general/osinfo.py,sha256=oU-vmMiA-oaSEQWTSyn6-yQiH2sLQT6aTQHRd0677wo,1415
195
- dissect/target/plugins/general/plugins.py,sha256=9KJ70YvYwBfxt19C9yISv8YE4mOdHNvP16fTCTHC68U,6033
195
+ dissect/target/plugins/general/plugins.py,sha256=zFoqaXwHTIVIlxTYOeVbFF4y6erBxH6R1dVhI9JJHJY,6032
196
196
  dissect/target/plugins/general/scrape.py,sha256=Fz7BNXflvuxlnVulyyDhLpyU8D_hJdH6vWVtER9vjTg,6651
197
197
  dissect/target/plugins/general/users.py,sha256=yy9gvRXfN9BT71v4Xqo5hpwfgN9he9Otu8TBPZ_Tegs,3009
198
198
  dissect/target/plugins/os/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -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.dev13.dist-info/COPYRIGHT,sha256=m-9ih2RVhMiXHI2bf_oNSSgHgkeIvaYRVfKTwFbnJPA,301
387
+ dissect.target-3.21.dev13.dist-info/LICENSE,sha256=DZak_2itbUtvHzD3E7GNUYSRK6jdOJ-GqncQ2weavLA,34523
388
+ dissect.target-3.21.dev13.dist-info/METADATA,sha256=rW2YozuxYIEypckNXA-n-2s1LQ6CKJ-8SwSkFrsVagU,13187
389
+ dissect.target-3.21.dev13.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
390
+ dissect.target-3.21.dev13.dist-info/entry_points.txt,sha256=yQwLCWUuzHgS6-sfCcRk66gAfoCfqXdCjqKjvhnQW8o,537
391
+ dissect.target-3.21.dev13.dist-info/top_level.txt,sha256=Mn-CQzEYsAbkxrUI0TnplHuXnGVKzxpDw_po_sXpvv4,8
392
+ dissect.target-3.21.dev13.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