From 2765b3d773c45113d00bfcdd73e19e04b4665f7c Mon Sep 17 00:00:00 2001 From: cecinestpasunepipe <110607403+cecinestpasunepipe@users.noreply.github.com> Date: Wed, 9 Aug 2023 10:48:47 +0200 Subject: [PATCH 01/20] Add NamespacePlugin (DIS-1681) --- dissect/target/helpers/docs.py | 15 +- dissect/target/plugin.py | 173 +++++++++++++++++- .../plugins/apps/remoteaccess/anydesk.py | 11 +- .../plugins/apps/remoteaccess/remoteaccess.py | 52 +----- .../plugins/apps/remoteaccess/teamviewer.py | 11 +- dissect/target/plugins/browsers/browser.py | 122 +----------- dissect/target/plugins/browsers/chrome.py | 5 +- dissect/target/plugins/browsers/chromium.py | 5 +- dissect/target/plugins/browsers/edge.py | 5 +- dissect/target/plugins/browsers/firefox.py | 6 +- dissect/target/plugins/browsers/iexplore.py | 5 +- dissect/target/tools/query.py | 17 +- dissect/target/tools/utils.py | 2 +- tests/test_plugin.py | 47 +++++ .../test_plugins_apps_remoteaccess_anydesk.py | 4 +- ...st_plugins_apps_remoteaccess_teamviewer.py | 4 +- 16 files changed, 272 insertions(+), 212 deletions(-) diff --git a/dissect/target/helpers/docs.py b/dissect/target/helpers/docs.py index f312c595a..017074ddb 100644 --- a/dissect/target/helpers/docs.py +++ b/dissect/target/helpers/docs.py @@ -75,6 +75,9 @@ def get_full_func_name(plugin_class: Type, func: Callable) -> str: return func_name +FUNC_DOC_TEMPLATE = "{func_name} - {short_description} (output: {output_type})" + + def get_func_description(func: Callable, with_docstrings: bool = False) -> str: klass, func = get_real_func_obj(func) func_output, func_doc = get_func_details(func) @@ -88,7 +91,9 @@ def get_func_description(func: Callable, with_docstrings: bool = False) -> str: desc = "\n".join([func_title, "", func_doc]) else: docstring_first_line = func_doc.splitlines()[0].lstrip() - desc = f"{func_name} - {docstring_first_line} (output: {func_output})" + desc = FUNC_DOC_TEMPLATE.format( + func_name=func_name, short_description=docstring_first_line, output_type=func_output + ) return desc @@ -97,9 +102,11 @@ def get_plugin_functions_desc(plugin_class: Type, with_docstrings: bool = False) descriptions = [] for func_name in plugin_class.__exports__: func_obj = getattr(plugin_class, func_name) - _, func = get_real_func_obj(func_obj) - - func_desc = get_func_description(func, with_docstrings=with_docstrings) + if getattr(func_obj, "__documentor__", None): + func_desc = func_obj.__documentor__(FUNC_DOC_TEMPLATE) + else: + _, func = get_real_func_obj(func_obj) + func_desc = get_func_description(func, with_docstrings=with_docstrings) descriptions.append(func_desc) # sort functions in the plugin alphabetically diff --git a/dissect/target/plugin.py b/dissect/target/plugin.py index 6eec6ec31..7abc486c7 100644 --- a/dissect/target/plugin.py +++ b/dissect/target/plugin.py @@ -13,11 +13,14 @@ import os import sys import traceback +from collections import defaultdict from dataclasses import dataclass, field from pathlib import Path from typing import TYPE_CHECKING, Any, Callable, Iterator, Optional, Type -from dissect.target.exceptions import PluginError +from flow.record import Record, RecordDescriptor + +from dissect.target.exceptions import PluginError, UnsupportedPluginError from dissect.target.helpers import cache from dissect.target.helpers.record import EmptyRecord @@ -30,8 +33,6 @@ GENERATED = False if TYPE_CHECKING: - from flow.record import Record, RecordDescriptor - from dissect.target import Target from dissect.target.filesystem import Filesystem from dissect.target.helpers.record import ChildTargetRecord @@ -425,6 +426,9 @@ def register(plugincls: Type[Plugin]) -> None: if isinstance(attr, property): attr = attr.fget + if getattr(attr, "__autogen__", False) and plugincls != plugincls.__nsplugin__: + continue + if getattr(attr, "__exported__", False): exports.append(attr.__name__) functions.append(attr.__name__) @@ -794,8 +798,146 @@ def _modulepath(cls) -> str: return cls.__module__.replace(MODULE_PATH, "").lstrip(".") -# Needs to be at the bottom of the module because __init_subclass__ requires everything +# These need to be at the bottom of the module because __init_subclass__ requires everything # in the parent class Plugin to be defined and resolved. +class NamespacePlugin(Plugin): + def __init__(self, target: Target): + """A Namespace plugin provides services to access functionality + from a group of subplugins through an internal function _func. + Upon initialisation, subplugins are collected. + """ + super().__init__(target) + + # The code below only applies to the direct subclass + # indirect subclasses are finished here. + if self.__class__ != self.__nsplugin__: + return + + self._subplugins = [] + for entry in self.SUBPLUGINS: + try: + subplugin = getattr(self.target, entry) + self._subplugins.append(subplugin) + except Exception: # noqa + target.log.exception("Failed to load subplugin: %s", entry) + + def check_compatible(self) -> None: + if not len(self._subplugins): + raise UnsupportedPluginError("No compatible subplugins found") + + def __init_subclass_namespace__(cls, **kwargs): + # If this is a direct subclass of a Namespace Plugin, + # create a reference to the current class for indirect subclasses + # so that the can autogenerate aggregate methods there + cls.__nsplugin__ = cls + cls.__findable__ = False + + def __init_subclass_subplugin__(cls, **kwargs): + cls.__findable__ = True + + # Does the direct subclass already have a SUBPLUGINS attribute? + # if not, create this attribute on the direct subclass + if not getattr(cls.__nsplugin__, "SUBPLUGINS", None): + cls.__nsplugin__.SUBPLUGINS = set() + + # Register the current plugin class as a subplugin with + # the direct subclass of NamespacePlugin + cls.__nsplugin__.SUBPLUGINS.add(cls.__namespace__) + + # Collect the public attrs of the subplugin + for subplugin_func_name in cls.__exports__: + subplugin_func = inspect.getattr_static(cls, subplugin_func_name) + + # The attr need to be callable and exported + if not isinstance(subplugin_func, Callable): + continue + + # The method needs to have a single record descriptor as output + if getattr(subplugin_func, "__output__", None) != "record": + continue + + # The method needs to be part of the current subclass and not a parent + if not subplugin_func.__qualname__.startswith(cls.__name__): + continue + + # If we already have an aggregate method, skip + if existing_aggregator := getattr(cls.__nsplugin__, subplugin_func_name, None): + existing_aggregator.__subplugins__.append(cls.__namespace__) + continue + + # The generic template for the aggregator method + def generate_aggregator(method_name): + def aggregator(self) -> Iterator[Record]: + for entry in aggregator.__subplugins__: + try: + subplugin = getattr(self.target, entry) + for item in getattr(subplugin, method_name)(): + yield item + except Exception: + continue + + # Holds the subplugins that share this method + aggregator.__subplugins__ = [] + + return aggregator + + # The generic template for the documentation method + def generate_documentor(cls, method_name: str, aggregator: Callable) -> str: + def documentor(format_spec: str): + return format_spec.format_map( + defaultdict( + lambda: "???", + { + "func_name": f"{cls.__nsplugin__.__namespace__}.{method_name}", + "short_description": "".join( + [ + f"Return {method_name} for: ", + ",".join(aggregator.__subplugins__), + ] + ), + "output_type": "records", + }, + ) + ) + + return documentor + + # Manifacture a method for the namespaced class + generated_aggregator = generate_aggregator(subplugin_func_name) + generated_documentor = generate_documentor(cls, subplugin_func_name, generated_aggregator) + + # Add as a attribute to the namespace class + setattr(cls.__nsplugin__, subplugin_func_name, generated_aggregator) + + # Copy the meta descriptors of the function attribute + copy_attrs = ["__output__", "__record__", "__doc__", "__exported__"] + for copy_attr in copy_attrs: + setattr(generated_aggregator, copy_attr, getattr(subplugin_func, copy_attr, None)) + + # Add subplugin to aggregator + generated_aggregator.__subplugins__.append(cls.__namespace__) + + # Mark the function as being autogenerated + setattr(generated_aggregator, "__autogen__", True) + + # Add the documentor function to the aggregator + setattr(generated_aggregator, "__documentor__", generated_documentor) + + # Register the newly auto-created method + cls.__nsplugin__.__exports__.append(subplugin_func_name) + cls.__nsplugin__.__functions__.append(subplugin_func_name) + + def __init_subclass__(cls, **kwargs): + # Upon subclassing, decide whether this is a direct subclass of NamespacePlugin + # If this is not the case, autogenerate aggregate methods for methods with similar record types and + # signatures in the direct subclass of NamespacePlugin + super().__init_subclass__(**kwargs) + if cls.__bases__[0] != NamespacePlugin: + cls.__init_subclass_subplugin__(cls, **kwargs) + else: + cls.__init_subclass_namespace__(cls, **kwargs) + + class InternalPlugin(Plugin): """Parent class for internal plugins. @@ -864,9 +1006,7 @@ def all_plugins(): def find_plugin_functions( - target: Target, - patterns: str, - compatibility: bool = False, + target: Target, patterns: str, compatibility: bool = False, **kwargs ) -> tuple[list[PluginFunction], set[str]]: """Finds plugins that match the target and the patterns. @@ -878,6 +1018,7 @@ def find_plugin_functions( functions, rootset = plugin_function_index(target) invalid_funcs = set() + show_hidden = kwargs.get("show_hidden", False) for pattern in patterns.split(","): # backward compatibility fix for namespace-level plugins (i.e. chrome) @@ -887,8 +1028,20 @@ def find_plugin_functions( wildcard = any(char in pattern for char in ["*", "!", "?", "[", "]"]) treematch = pattern.split(".")[0] in rootset and pattern != "os" - - if treematch and not wildcard: + exact_match = pattern in functions + + # Allow for exact matches, otherwise you cannot reach documented + # namespace plugins like browsers.browser.downloads. + # You can *always* run these using the namespace/classic-style + # like: browser.downloads (but -l lists them in the tree for + # documentation purposes so it would be misleading not to allow + # tree access as well). Note that these tree items will never + # respond to wildcards though (browsers.browser.* won't work) + # to avoid duplicate results. + if exact_match: + show_hidden = True + + if treematch and not wildcard and not exact_match: # Examples: # -f browsers -> browsers* (the whole package) # -f apps.webservers.iis -> apps.webservers.iis* (logs etc) @@ -905,7 +1058,7 @@ def find_plugin_functions( loaded_plugin_object = load(func) # Skip plugins that don't want to be found by wildcards - if not loaded_plugin_object.__findable__: + if not show_hidden and not loaded_plugin_object.__findable__: continue fobject = inspect.getattr_static(loaded_plugin_object, method_name) diff --git a/dissect/target/plugins/apps/remoteaccess/anydesk.py b/dissect/target/plugins/apps/remoteaccess/anydesk.py index 3c575d071..e02ca7d59 100644 --- a/dissect/target/plugins/apps/remoteaccess/anydesk.py +++ b/dissect/target/plugins/apps/remoteaccess/anydesk.py @@ -1,11 +1,14 @@ from datetime import datetime from dissect.target.exceptions import UnsupportedPluginError -from dissect.target.plugin import Plugin, export -from dissect.target.plugins.apps.remoteaccess.remoteaccess import RemoteAccessRecord +from dissect.target.plugin import export +from dissect.target.plugins.apps.remoteaccess.remoteaccess import ( + RemoteAccessPlugin, + RemoteAccessRecord, +) -class AnydeskPlugin(Plugin): +class AnydeskPlugin(RemoteAccessPlugin): """ Anydesk plugin. """ @@ -38,7 +41,7 @@ def check_compatible(self): raise UnsupportedPluginError("No Anydesk logs found") @export(record=RemoteAccessRecord) - def remoteaccess(self): + def logs(self): """Return the content of the AnyDesk logs. AnyDesk is a remote desktop application and can be used by adversaries to get (persistent) access to a machine. diff --git a/dissect/target/plugins/apps/remoteaccess/remoteaccess.py b/dissect/target/plugins/apps/remoteaccess/remoteaccess.py index 21c92428e..08a4309de 100644 --- a/dissect/target/plugins/apps/remoteaccess/remoteaccess.py +++ b/dissect/target/plugins/apps/remoteaccess/remoteaccess.py @@ -1,7 +1,6 @@ -from dissect.target.exceptions import UnsupportedPluginError from dissect.target.helpers.descriptor_extensions import UserRecordDescriptorExtension from dissect.target.helpers.record import create_extended_descriptor -from dissect.target.plugin import Plugin, export +from dissect.target.plugin import NamespacePlugin RemoteAccessRecord = create_extended_descriptor([UserRecordDescriptorExtension])( "application/log/remoteaccess", @@ -14,7 +13,7 @@ ) -class RemoteAccessPlugin(Plugin): +class RemoteAccessPlugin(NamespacePlugin): """General Remote Access plugin. This plugin groups the functions of all remote access plugins. For example, @@ -23,50 +22,3 @@ class RemoteAccessPlugin(Plugin): """ __namespace__ = "remoteaccess" - __findable__ = False - - TOOLS = [ - "teamviewer", - "anydesk", - ] - - def __init__(self, target): - super().__init__(target) - self._plugins = [] - for entry in self.TOOLS: - try: - self._plugins.append(getattr(self.target, entry)) - except Exception: # noqa - target.log.exception("Failed to load tool plugin: %s", entry) - - def check_compatible(self): - if not len(self._plugins): - raise UnsupportedPluginError("No compatible tool plugins found") - - def _func(self, f): - for p in self._plugins: - try: - for entry in getattr(p, f)(): - yield entry - except Exception: - self.target.log.exception("Failed to execute tool plugin: {}.{}", p._name, f) - - @export(record=RemoteAccessRecord) - def remoteaccess(self): - """Return Remote Access records from all Remote Access Tools. - - This plugin groups the functions of all remote access plugins. For example, instead of having to run both - teamviewer.remoteaccess and anydesk.remoteaccess, you only have to run remoteaccess.remoteaccess to get output - from both tools. - - Yields RemoteAccessRecords with the following fields: - ('string', 'hostname'), - ('string', 'domain'), - ('datetime', 'ts'), - ('string', 'user'), - ('string', 'tool'), - ('uri', 'logfile'), - ('string', 'description') - """ - for e in self._func("remoteaccess"): - yield e diff --git a/dissect/target/plugins/apps/remoteaccess/teamviewer.py b/dissect/target/plugins/apps/remoteaccess/teamviewer.py index 431577d35..c6419fca5 100644 --- a/dissect/target/plugins/apps/remoteaccess/teamviewer.py +++ b/dissect/target/plugins/apps/remoteaccess/teamviewer.py @@ -1,11 +1,14 @@ from datetime import datetime from dissect.target.exceptions import UnsupportedPluginError -from dissect.target.plugin import Plugin, export -from dissect.target.plugins.apps.remoteaccess.remoteaccess import RemoteAccessRecord +from dissect.target.plugin import export +from dissect.target.plugins.apps.remoteaccess.remoteaccess import ( + RemoteAccessPlugin, + RemoteAccessRecord, +) -class TeamviewerPlugin(Plugin): +class TeamviewerPlugin(RemoteAccessPlugin): """ Teamviewer plugin. """ @@ -39,7 +42,7 @@ def check_compatible(self): raise UnsupportedPluginError("No Teamviewer logs found") @export(record=RemoteAccessRecord) - def remoteaccess(self): + def logs(self): """Return the content of the TeamViewer logs. TeamViewer is a commercial remote desktop application. An adversary may use it to gain persistence on a diff --git a/dissect/target/plugins/browsers/browser.py b/dissect/target/plugins/browsers/browser.py index c347f8421..4453020ff 100644 --- a/dissect/target/plugins/browsers/browser.py +++ b/dissect/target/plugins/browsers/browser.py @@ -1,7 +1,6 @@ -from dissect.target.exceptions import UnsupportedPluginError from dissect.target.helpers.descriptor_extensions import UserRecordDescriptorExtension from dissect.target.helpers.record import create_extended_descriptor -from dissect.target.plugin import Plugin, export +from dissect.target.plugin import NamespacePlugin GENERIC_DOWNLOAD_RECORD_FIELDS = [ ("datetime", "ts_start"), @@ -60,125 +59,8 @@ ) -class BrowserPlugin(Plugin): - """General browser plugin. - - This plugin groups the functions of all browser plugins. For example, - instead of having to run both firefox.history and chrome.history, - you only have to run browser.history to get output from both browsers. - """ - +class BrowserPlugin(NamespacePlugin): __namespace__ = "browser" - __findable__ = False - - BROWSERS = [ - "chrome", - "chromium", - "edge", - "firefox", - "iexplore", - ] - - def __init__(self, target): - super().__init__(target) - self._plugins = [] - for entry in self.BROWSERS: - try: - self._plugins.append(getattr(self.target, entry)) - except Exception: - target.log.exception("Failed to load browser plugin: %s", entry) - - def _func(self, func_name: str): - """Return the supported browser plugin records. - - Args: - func_name: Exported function of the browser plugin to find. - - Yields: - Record from the browser function. - """ - for plugin_name in self._plugins: - try: - for entry in getattr(plugin_name, func_name)(): - yield entry - except Exception: - self.target.log.exception("Failed to execute browser plugin: %s.%s", plugin_name._name, func_name) - - def check_compatible(self) -> bool: - if not len(self._plugins): - raise UnsupportedPluginError("No compatible browser plugins found") - - @export(record=BrowserDownloadRecord) - def downloads(self): - """Return browser download records from all browsers installed and supported. - - Yields BrowserDownloadRecord with the following fields: - hostname (string): The target hostname. - domain (string): The target domain. - ts_start (datetime): Download start timestamp. - ts_end (datetime): Download end timestamp. - browser (string): The browser from which the records are generated from. - id (string): Record ID. - path (string): Download path. - url (uri): Download URL. - size (varint): Download file size. - state (varint): Download state number. - source: (path): The source file of the download record. - """ - yield from self._func("downloads") - - @export(record=BrowserExtensionRecord) - def extensions(self): - """Return browser extensions from all browsers installed and supported. - - Browser extensions for Chrome, Chromium, and Edge (Chromium). - - Yields BrowserExtensionRecord with the following fields: - hostname (string): The target hostname. - domain (string): The target domain. - ts_install (datetime): Extension install timestamp. - ts_update (datetime): Extension update timestamp. - browser (string): The browser from which the records are generated. - id (string): Extension unique identifier. - name (string): Name of the extension. - short_name (string): Short name of the extension. - default_title (string): Default title of the extension. - description (string): Description of the extension. - version (string): Version of the extension. - ext_path (path): Relative path of the extension. - from_webstore (boolean): Extension from webstore. - permissions (string[]): Permissions of the extension. - manifest (varint): Version of the extensions' manifest. - source: (path): The source file of the download record. - """ - yield from self._func("extensions") - - @export(record=BrowserHistoryRecord) - def history(self): - """Return browser history records from all browsers installed and supported. - - Historical browser records for Chrome, Chromium, Edge (Chromium), Firefox, and Internet Explorer are returned. - - Yields BrowserHistoryRecord with the following fields: - hostname (string): The target hostname. - domain (string): The target domain. - ts (datetime): Visit timestamp. - browser (string): The browser from which the records are generated from. - id (string): Record ID. - url (uri): History URL. - title (string): Page title. - description (string): Page description. - rev_host (string): Reverse hostname. - visit_type (varint): Visit type. - visit_count (varint): Amount of visits. - hidden (string): Hidden value. - typed (string): Typed value. - session (varint): Session value. - from_visit (varint): Record ID of the "from" visit. - from_url (uri): URL of the "from" visit. - source: (path): The source file of the history record. - """ - yield from self._func("history") def try_idna(url: str) -> bytes: diff --git a/dissect/target/plugins/browsers/chrome.py b/dissect/target/plugins/browsers/chrome.py index 2a3a32e5b..7e400d82a 100644 --- a/dissect/target/plugins/browsers/chrome.py +++ b/dissect/target/plugins/browsers/chrome.py @@ -1,15 +1,16 @@ from dissect.target.helpers.descriptor_extensions import UserRecordDescriptorExtension from dissect.target.helpers.record import create_extended_descriptor -from dissect.target.plugin import Plugin, export +from dissect.target.plugin import export from dissect.target.plugins.browsers.browser import ( GENERIC_DOWNLOAD_RECORD_FIELDS, GENERIC_EXTENSION_RECORD_FIELDS, GENERIC_HISTORY_RECORD_FIELDS, + BrowserPlugin, ) from dissect.target.plugins.browsers.chromium import ChromiumMixin -class ChromePlugin(ChromiumMixin, Plugin): +class ChromePlugin(ChromiumMixin, BrowserPlugin): """Chrome browser plugin.""" __namespace__ = "chrome" diff --git a/dissect/target/plugins/browsers/chromium.py b/dissect/target/plugins/browsers/chromium.py index 707831ad1..cb1ce3403 100644 --- a/dissect/target/plugins/browsers/chromium.py +++ b/dissect/target/plugins/browsers/chromium.py @@ -12,11 +12,12 @@ from dissect.target.helpers.descriptor_extensions import UserRecordDescriptorExtension from dissect.target.helpers.fsutil import TargetPath from dissect.target.helpers.record import create_extended_descriptor -from dissect.target.plugin import Plugin, export +from dissect.target.plugin import export from dissect.target.plugins.browsers.browser import ( GENERIC_DOWNLOAD_RECORD_FIELDS, GENERIC_EXTENSION_RECORD_FIELDS, GENERIC_HISTORY_RECORD_FIELDS, + BrowserPlugin, try_idna, ) from dissect.target.plugins.general.users import UserDetails @@ -318,7 +319,7 @@ def history(self, browser_name: str = None) -> Iterator[BrowserHistoryRecord]: self.target.log.warning("Error processing history file: %s", db_file, exc_info=e) -class ChromiumPlugin(ChromiumMixin, Plugin): +class ChromiumPlugin(ChromiumMixin, BrowserPlugin): """Chromium browser plugin.""" __namespace__ = "chromium" diff --git a/dissect/target/plugins/browsers/edge.py b/dissect/target/plugins/browsers/edge.py index 1652465a0..0071074c4 100644 --- a/dissect/target/plugins/browsers/edge.py +++ b/dissect/target/plugins/browsers/edge.py @@ -1,15 +1,16 @@ from dissect.target.helpers.descriptor_extensions import UserRecordDescriptorExtension from dissect.target.helpers.record import create_extended_descriptor -from dissect.target.plugin import Plugin, export +from dissect.target.plugin import export from dissect.target.plugins.browsers.browser import ( GENERIC_DOWNLOAD_RECORD_FIELDS, GENERIC_EXTENSION_RECORD_FIELDS, GENERIC_HISTORY_RECORD_FIELDS, + BrowserPlugin, ) from dissect.target.plugins.browsers.chromium import ChromiumMixin -class EdgePlugin(ChromiumMixin, Plugin): +class EdgePlugin(ChromiumMixin, BrowserPlugin): """Edge browser plugin.""" __namespace__ = "edge" diff --git a/dissect/target/plugins/browsers/firefox.py b/dissect/target/plugins/browsers/firefox.py index b17d8f62b..e75f6a347 100644 --- a/dissect/target/plugins/browsers/firefox.py +++ b/dissect/target/plugins/browsers/firefox.py @@ -10,15 +10,16 @@ from dissect.target.exceptions import FileNotFoundError, UnsupportedPluginError from dissect.target.helpers.descriptor_extensions import UserRecordDescriptorExtension from dissect.target.helpers.record import create_extended_descriptor -from dissect.target.plugin import Plugin, export +from dissect.target.plugin import export from dissect.target.plugins.browsers.browser import ( GENERIC_DOWNLOAD_RECORD_FIELDS, GENERIC_HISTORY_RECORD_FIELDS, + BrowserPlugin, try_idna, ) -class FirefoxPlugin(Plugin): +class FirefoxPlugin(BrowserPlugin): """Firefox browser plugin.""" __namespace__ = "firefox" @@ -43,7 +44,6 @@ class FirefoxPlugin(Plugin): def __init__(self, target): super().__init__(target) - self.users_dirs = [] for user_details in self.target.user_details.all_with_home(): for directory in self.DIRS: diff --git a/dissect/target/plugins/browsers/iexplore.py b/dissect/target/plugins/browsers/iexplore.py index de2ef4fcf..a1e13b022 100644 --- a/dissect/target/plugins/browsers/iexplore.py +++ b/dissect/target/plugins/browsers/iexplore.py @@ -7,10 +7,11 @@ from dissect.target.exceptions import UnsupportedPluginError from dissect.target.helpers.descriptor_extensions import UserRecordDescriptorExtension from dissect.target.helpers.record import create_extended_descriptor -from dissect.target.plugin import Plugin, export +from dissect.target.plugin import export from dissect.target.plugins.browsers.browser import ( GENERIC_DOWNLOAD_RECORD_FIELDS, GENERIC_HISTORY_RECORD_FIELDS, + BrowserPlugin, try_idna, ) from dissect.target.plugins.general.users import UserDetails @@ -68,7 +69,7 @@ def downloads(self) -> Iterator[record.Record]: yield from self._iter_records("iedownload") -class InternetExplorerPlugin(Plugin): +class InternetExplorerPlugin(BrowserPlugin): """Internet explorer browser plugin.""" __namespace__ = "iexplore" diff --git a/dissect/target/tools/query.py b/dissect/target/tools/query.py index ba1b42cef..59025c9d5 100644 --- a/dissect/target/tools/query.py +++ b/dissect/target/tools/query.py @@ -18,7 +18,13 @@ ) from dissect.target.helpers import cache, hashutil from dissect.target.loaders.targetd import ProxyLoader -from dissect.target.plugin import PLUGINS, OSPlugin, Plugin, find_plugin_functions +from dissect.target.plugin import ( + PLUGINS, + NamespacePlugin, + OSPlugin, + Plugin, + find_plugin_functions, +) from dissect.target.report import ExecutionReport from dissect.target.tools.utils import ( catch_sigpipe, @@ -154,11 +160,11 @@ def main(): plugin_target = Target.open(target) if isinstance(plugin_target._loader, ProxyLoader): parser.error("can't list compatible plugins for remote targets.") - funcs, _ = find_plugin_functions(plugin_target, args.list, True) + funcs, _ = find_plugin_functions(plugin_target, args.list, True, show_hidden=True) for func in funcs: collected_plugins[func.name] = func.plugin_desc else: - funcs, _ = find_plugin_functions(Target(), args.list, False) + funcs, _ = find_plugin_functions(Target(), args.list, False, show_hidden=True) for func in funcs: collected_plugins[func.name] = func.plugin_desc @@ -197,6 +203,7 @@ def main(): # custom plugins with idiosyncratic output across OS-versions/branches. output_types = set() funcs, invalid_funcs = find_plugin_functions(Target(), args.function, False) + for func in funcs: output_types.add(func.output_type) @@ -286,7 +293,9 @@ def main(): if not first_seen_output_type: first_seen_output_type = output_type - executed_plugins.add(func_def.method_name) + # Plugins derived from NamespacePlugin are not meant to be unique + if not issubclass(func_def.class_object, NamespacePlugin): + executed_plugins.add(func_def.method_name) if output_type == "record": record_entries.append(result) diff --git a/dissect/target/tools/utils.py b/dissect/target/tools/utils.py index fe9e68508..e0d512199 100644 --- a/dissect/target/tools/utils.py +++ b/dissect/target/tools/utils.py @@ -75,7 +75,7 @@ def generate_argparse_for_unbound_method( if not inspect.isfunction(method): raise ValueError(f"Value `{method}` is not an unbound plugin method") - desc = docs.get_func_description(method, with_docstrings=True) + desc = method.__doc__ or docs.get_func_description(method, with_docstrings=True) help_formatter = argparse.RawDescriptionHelpFormatter parser = argparse.ArgumentParser(description=desc, formatter_class=help_formatter, conflict_handler="resolve") diff --git a/tests/test_plugin.py b/tests/test_plugin.py index f7874115e..a1e034d13 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -4,8 +4,13 @@ import pytest +from dissect.target.helpers.descriptor_extensions import UserRecordDescriptorExtension +from dissect.target.helpers.record import create_extended_descriptor from dissect.target.plugin import ( + PLUGINS, + NamespacePlugin, environment_variable_paths, + export, find_plugin_functions, get_external_module_paths, save_plugin_import_failure, @@ -121,6 +126,48 @@ def test_find_plugin_function_unix(target_unix: Target) -> None: assert found[0].name == "os.unix.services.services" +TestRecord = create_extended_descriptor([UserRecordDescriptorExtension])( + "application/test", + [ + ("string", "test"), + ], +) + + +class _TestNSPlugin(NamespacePlugin): + __namespace__ = "NS" + + +class _TestSubPlugin1(_TestNSPlugin): + __namespace__ = "t1" + + @export(record=TestRecord) + def test(self): + yield TestRecord(test="test1") + + +class _TestSubPlugin2(_TestNSPlugin): + __namespace__ = "t2" + + @export(record=TestRecord) + def test(self): + yield TestRecord(test="test2") + + +def test_namespace_plugin(target_win: Target) -> None: + assert "SUBPLUGINS" in dir(_TestNSPlugin) + # Rename the test functions to protect them from being filtered by NS + + target_win._register_plugin_functions(_TestSubPlugin1(target_win)) + target_win._register_plugin_functions(_TestSubPlugin2(target_win)) + target_win._register_plugin_functions(_TestNSPlugin(target_win)) + assert len(list(target_win.NS.test())) == 2 + assert len(target_win.NS.SUBPLUGINS) == 2 + + # Remove test plugin from list afterwards to avoid order effects + del PLUGINS["tests"] + + def test_find_plugin_function_default(target_default: Target) -> None: found, _ = find_plugin_functions(target_default, "services") diff --git a/tests/test_plugins_apps_remoteaccess_anydesk.py b/tests/test_plugins_apps_remoteaccess_anydesk.py index ebd85bfb2..083fe63f2 100644 --- a/tests/test_plugins_apps_remoteaccess_anydesk.py +++ b/tests/test_plugins_apps_remoteaccess_anydesk.py @@ -14,7 +14,7 @@ def test_anydesk_plugin_global_log(target_win_users, fs_win): adp = AnydeskPlugin(target_win_users) - records = list(adp.remoteaccess()) + records = list(adp.logs()) assert len(records) == 1 record = records[0] @@ -36,7 +36,7 @@ def test_anydesk_plugin_user_log(target_win_users, fs_win): adp = AnydeskPlugin(target_win_users) - records = list(adp.remoteaccess()) + records = list(adp.logs()) assert len(records) == 1 record = records[0] diff --git a/tests/test_plugins_apps_remoteaccess_teamviewer.py b/tests/test_plugins_apps_remoteaccess_teamviewer.py index 186e3afa6..c654301c1 100644 --- a/tests/test_plugins_apps_remoteaccess_teamviewer.py +++ b/tests/test_plugins_apps_remoteaccess_teamviewer.py @@ -14,7 +14,7 @@ def test_teamviewer_plugin_global_log(target_win_users, fs_win): tvp = TeamviewerPlugin(target_win_users) - records = list(tvp.remoteaccess()) + records = list(tvp.logs()) assert len(records) == 1 record = records[0] @@ -36,7 +36,7 @@ def test_teamviewer_plugin_user_log(target_win_users, fs_win): tvp = TeamviewerPlugin(target_win_users) - records = list(tvp.remoteaccess()) + records = list(tvp.logs()) assert len(records) == 1 record = records[0] From 0bdf30fbde728ba16fcb25380fcea9646dce3155 Mon Sep 17 00:00:00 2001 From: cecinestpasunepipe <110607403+cecinestpasunepipe@users.noreply.github.com> Date: Wed, 9 Aug 2023 10:56:10 +0200 Subject: [PATCH 02/20] Fix varying order of output plugins (#266) (DIS-2132) --- dissect/target/plugin.py | 5 ++++- tests/test_plugin.py | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/dissect/target/plugin.py b/dissect/target/plugin.py index 7abc486c7..0d2ec3750 100644 --- a/dissect/target/plugin.py +++ b/dissect/target/plugin.py @@ -987,6 +987,9 @@ def all_plugins(): if "get_all_records" in available["exports"]: available["exports"].remove("get_all_records") modulepath = available["module"] + # Always skip these + if available["class"] in ["DefaultPlugin", "ExamplePlugin"]: + continue if modulepath.endswith("._os"): if not target._os: # if no target available add a namespaceless section @@ -1119,4 +1122,4 @@ def find_plugin_functions( ) ) - return list(set(result)), invalid_funcs + return sorted(set(result), key=result.index), invalid_funcs diff --git a/tests/test_plugin.py b/tests/test_plugin.py index a1e034d13..c96a24404 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -1,4 +1,5 @@ import os +from functools import reduce from pathlib import Path from unittest.mock import MagicMock, Mock, patch @@ -175,3 +176,18 @@ def test_find_plugin_function_default(target_default: Target) -> None: names = [item.name for item in found] assert "os.unix.services.services" in names assert "os.windows.services.services" in names + + +@pytest.mark.parametrize( + "pattern", + [ + ("version,ips,hostname"), + ("ips,version,hostname"), + ("hostname,ips,version"), + ("users,osinfo"), + ("osinfo,users"), + ], +) +def test_find_plugin_function_order(target_win: Target, pattern: str) -> None: + found = ",".join(reduce(lambda rs, el: rs + [el.method_name], find_plugin_functions(target_win, pattern)[0], [])) + assert found == pattern From 19fe677fcf6805e7c0c1499a6b29ab3d6b283858 Mon Sep 17 00:00:00 2001 From: cecinestpasunepipe <110607403+cecinestpasunepipe@users.noreply.github.com> Date: Tue, 15 Aug 2023 11:47:56 +0200 Subject: [PATCH 03/20] Update dissect/target/plugin.py Co-authored-by: Erik Schamper <1254028+Schamper@users.noreply.github.com> --- dissect/target/plugin.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dissect/target/plugin.py b/dissect/target/plugin.py index 0d2ec3750..2e3a04142 100644 --- a/dissect/target/plugin.py +++ b/dissect/target/plugin.py @@ -802,9 +802,9 @@ def _modulepath(cls) -> str: # in the parent class Plugin to be defined and resolved. class NamespacePlugin(Plugin): def __init__(self, target: Target): - """A Namespace plugin provides services to access functionality - from a group of subplugins through an internal function _func. - Upon initialisation, subplugins are collected. + """A namespace plugin provides services to access functionality from a group of subplugins. + + Support is currently limited to shared exported functions that yield records. """ super().__init__(target) From 0d48d12a4c3e03f8f700a8ae4a617734ac7077b2 Mon Sep 17 00:00:00 2001 From: cecinestpasunepipe <110607403+cecinestpasunepipe@users.noreply.github.com> Date: Tue, 15 Aug 2023 11:48:19 +0200 Subject: [PATCH 04/20] Update dissect/target/plugin.py Co-authored-by: Erik Schamper <1254028+Schamper@users.noreply.github.com> --- dissect/target/plugin.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/dissect/target/plugin.py b/dissect/target/plugin.py index 2e3a04142..13e3578ff 100644 --- a/dissect/target/plugin.py +++ b/dissect/target/plugin.py @@ -808,8 +808,7 @@ def __init__(self, target: Target): """ super().__init__(target) - # The code below only applies to the direct subclass - # indirect subclasses are finished here. + # The code below only applies to the direct subclass, indirect subclasses are finished here. if self.__class__ != self.__nsplugin__: return From 29a590b8f54353708a9aee29097119ca71d8ddbe Mon Sep 17 00:00:00 2001 From: cecinestpasunepipe <110607403+cecinestpasunepipe@users.noreply.github.com> Date: Tue, 15 Aug 2023 11:48:29 +0200 Subject: [PATCH 05/20] Update dissect/target/plugin.py Co-authored-by: Erik Schamper <1254028+Schamper@users.noreply.github.com> --- dissect/target/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dissect/target/plugin.py b/dissect/target/plugin.py index 13e3578ff..2448b8e52 100644 --- a/dissect/target/plugin.py +++ b/dissect/target/plugin.py @@ -817,7 +817,7 @@ def __init__(self, target: Target): try: subplugin = getattr(self.target, entry) self._subplugins.append(subplugin) - except Exception: # noqa + except Exception: target.log.exception("Failed to load subplugin: %s", entry) def check_compatible(self) -> None: From 5d5f76aaf20e4fa92a59542c334db4a4074e1ed3 Mon Sep 17 00:00:00 2001 From: cecinestpasunepipe <110607403+cecinestpasunepipe@users.noreply.github.com> Date: Tue, 15 Aug 2023 11:48:45 +0200 Subject: [PATCH 06/20] Update dissect/target/plugin.py Co-authored-by: Erik Schamper <1254028+Schamper@users.noreply.github.com> --- dissect/target/plugin.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/dissect/target/plugin.py b/dissect/target/plugin.py index 2448b8e52..5fe71ae11 100644 --- a/dissect/target/plugin.py +++ b/dissect/target/plugin.py @@ -825,9 +825,8 @@ def check_compatible(self) -> None: raise UnsupportedPluginError("No compatible subplugins found") def __init_subclass_namespace__(cls, **kwargs): - # If this is a direct subclass of a Namespace Plugin, - # create a reference to the current class for indirect subclasses - # so that the can autogenerate aggregate methods there + # If this is a direct subclass of a Namespace plugin, create a reference to the current class for indirect subclasses + # This is necessary to autogenerate aggregate methods there cls.__nsplugin__ = cls cls.__findable__ = False From c011134f8e23e9cfefa144c05a81377261d33510 Mon Sep 17 00:00:00 2001 From: cecinestpasunepipe <110607403+cecinestpasunepipe@users.noreply.github.com> Date: Tue, 15 Aug 2023 11:49:05 +0200 Subject: [PATCH 07/20] Update dissect/target/plugin.py Co-authored-by: Erik Schamper <1254028+Schamper@users.noreply.github.com> --- dissect/target/plugin.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/dissect/target/plugin.py b/dissect/target/plugin.py index 5fe71ae11..ec7481076 100644 --- a/dissect/target/plugin.py +++ b/dissect/target/plugin.py @@ -833,8 +833,6 @@ def __init_subclass_namespace__(cls, **kwargs): def __init_subclass_subplugin__(cls, **kwargs): cls.__findable__ = True - # Does the direct subclass already have a SUBPLUGINS attribute? - # if not, create this attribute on the direct subclass if not getattr(cls.__nsplugin__, "SUBPLUGINS", None): cls.__nsplugin__.SUBPLUGINS = set() From fecbc7e8a1be76fa470e9548e126fa9c8b52f7b8 Mon Sep 17 00:00:00 2001 From: cecinestpasunepipe <110607403+cecinestpasunepipe@users.noreply.github.com> Date: Tue, 15 Aug 2023 12:47:01 +0200 Subject: [PATCH 08/20] Implement feedback --- dissect/target/plugin.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/dissect/target/plugin.py b/dissect/target/plugin.py index ec7481076..40b09ce94 100644 --- a/dissect/target/plugin.py +++ b/dissect/target/plugin.py @@ -205,6 +205,7 @@ class attribute. Namespacing results in your plugin needing to be prefixed """Defines a list of :class:`~flow.record.RecordDescriptor` of the exported plugin functions.""" __findable__: bool = True """Determines whether this plugin will be revealed when using search patterns. + Some (meta)-plugins are not very suitable for wild cards on CLI or plugin searches, because they will produce duplicate records or results. @@ -803,7 +804,7 @@ def _modulepath(cls) -> str: class NamespacePlugin(Plugin): def __init__(self, target: Target): """A namespace plugin provides services to access functionality from a group of subplugins. - + Support is currently limited to shared exported functions that yield records. """ super().__init__(target) @@ -906,8 +907,7 @@ def documentor(format_spec: str): setattr(cls.__nsplugin__, subplugin_func_name, generated_aggregator) # Copy the meta descriptors of the function attribute - copy_attrs = ["__output__", "__record__", "__doc__", "__exported__"] - for copy_attr in copy_attrs: + for copy_attr in ["__output__", "__record__", "__doc__", "__exported__"]: setattr(generated_aggregator, copy_attr, getattr(subplugin_func, copy_attr, None)) # Add subplugin to aggregator @@ -925,8 +925,7 @@ def documentor(format_spec: str): def __init_subclass__(cls, **kwargs): # Upon subclassing, decide whether this is a direct subclass of NamespacePlugin - # If this is not the case, autogenerate aggregate methods for methods with similar record types and - # signatures in the direct subclass of NamespacePlugin + # If this is not the case, autogenerate aggregate methods for methods record output. super().__init_subclass__(**kwargs) if cls.__bases__[0] != NamespacePlugin: cls.__init_subclass_subplugin__(cls, **kwargs) From f3f330217901883fbd29309bb00e2e04daadc7eb Mon Sep 17 00:00:00 2001 From: cecinestpasunepipe <110607403+cecinestpasunepipe@users.noreply.github.com> Date: Tue, 15 Aug 2023 12:48:25 +0200 Subject: [PATCH 09/20] Implement feedback --- dissect/target/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dissect/target/plugin.py b/dissect/target/plugin.py index 40b09ce94..bec672425 100644 --- a/dissect/target/plugin.py +++ b/dissect/target/plugin.py @@ -849,7 +849,7 @@ def __init_subclass_subplugin__(cls, **kwargs): if not isinstance(subplugin_func, Callable): continue - # The method needs to have a single record descriptor as output + # The method needs to output records if getattr(subplugin_func, "__output__", None) != "record": continue From e99000ddc0a812121ebb2c173454553ea4ccba7a Mon Sep 17 00:00:00 2001 From: cecinestpasunepipe <110607403+cecinestpasunepipe@users.noreply.github.com> Date: Tue, 15 Aug 2023 12:49:28 +0200 Subject: [PATCH 10/20] Implement feedback --- dissect/target/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dissect/target/plugin.py b/dissect/target/plugin.py index bec672425..00a0d6810 100644 --- a/dissect/target/plugin.py +++ b/dissect/target/plugin.py @@ -863,7 +863,7 @@ def __init_subclass_subplugin__(cls, **kwargs): continue # The generic template for the aggregator method - def generate_aggregator(method_name): + def generate_aggregator(method_name: str) -> Callable: def aggregator(self) -> Iterator[Record]: for entry in aggregator.__subplugins__: try: From 2b7fc8ab124a7e6fc7e1f5c0552c8ee2e90bcf53 Mon Sep 17 00:00:00 2001 From: cecinestpasunepipe <110607403+cecinestpasunepipe@users.noreply.github.com> Date: Tue, 15 Aug 2023 14:25:32 +0200 Subject: [PATCH 11/20] Implement feedback --- dissect/target/helpers/docs.py | 4 ++-- dissect/target/plugin.py | 35 ++++++++++++++++------------------ 2 files changed, 18 insertions(+), 21 deletions(-) diff --git a/dissect/target/helpers/docs.py b/dissect/target/helpers/docs.py index 017074ddb..f5fa71578 100644 --- a/dissect/target/helpers/docs.py +++ b/dissect/target/helpers/docs.py @@ -102,8 +102,8 @@ def get_plugin_functions_desc(plugin_class: Type, with_docstrings: bool = False) descriptions = [] for func_name in plugin_class.__exports__: func_obj = getattr(plugin_class, func_name) - if getattr(func_obj, "__documentor__", None): - func_desc = func_obj.__documentor__(FUNC_DOC_TEMPLATE) + if getattr(func_obj, "get_func_doc_spec", None): + func_desc = FUNC_DOC_TEMPLATE.format_map(func_obj.get_func_doc_spec()) else: _, func = get_real_func_obj(func_obj) func_desc = get_func_description(func, with_docstrings=with_docstrings) diff --git a/dissect/target/plugin.py b/dissect/target/plugin.py index 00a0d6810..0524be360 100644 --- a/dissect/target/plugin.py +++ b/dissect/target/plugin.py @@ -205,7 +205,6 @@ class attribute. Namespacing results in your plugin needing to be prefixed """Defines a list of :class:`~flow.record.RecordDescriptor` of the exported plugin functions.""" __findable__: bool = True """Determines whether this plugin will be revealed when using search patterns. - Some (meta)-plugins are not very suitable for wild cards on CLI or plugin searches, because they will produce duplicate records or results. @@ -826,8 +825,8 @@ def check_compatible(self) -> None: raise UnsupportedPluginError("No compatible subplugins found") def __init_subclass_namespace__(cls, **kwargs): - # If this is a direct subclass of a Namespace plugin, create a reference to the current class for indirect subclasses - # This is necessary to autogenerate aggregate methods there + # If this is a direct subclass of a Namespace plugin, create a reference to the current class for indirect + # subclasses. This is necessary to autogenerate aggregate methods there cls.__nsplugin__ = cls cls.__findable__ = False @@ -880,21 +879,19 @@ def aggregator(self) -> Iterator[Record]: # The generic template for the documentation method def generate_documentor(cls, method_name: str, aggregator: Callable) -> str: - def documentor(format_spec: str): - return format_spec.format_map( - defaultdict( - lambda: "???", - { - "func_name": f"{cls.__nsplugin__.__namespace__}.{method_name}", - "short_description": "".join( - [ - f"Return {method_name} for: ", - ",".join(aggregator.__subplugins__), - ] - ), - "output_type": "records", - }, - ) + def documentor(): + return defaultdict( + lambda: "???", + { + "func_name": f"{cls.__nsplugin__.__namespace__}.{method_name}", + "short_description": "".join( + [ + f"Return {method_name} for: ", + ",".join(aggregator.__subplugins__), + ] + ), + "output_type": "records", + }, ) return documentor @@ -917,7 +914,7 @@ def documentor(format_spec: str): setattr(generated_aggregator, "__autogen__", True) # Add the documentor function to the aggregator - setattr(generated_aggregator, "__documentor__", generated_documentor) + setattr(generated_aggregator, "get_func_doc_spec", generated_documentor) # Register the newly auto-created method cls.__nsplugin__.__exports__.append(subplugin_func_name) From dd89fe9e798ed11ccea0940876c5e363fd15721d Mon Sep 17 00:00:00 2001 From: cecinestpasunepipe <110607403+cecinestpasunepipe@users.noreply.github.com> Date: Tue, 15 Aug 2023 14:14:33 +0200 Subject: [PATCH 12/20] Update dissect/target/plugin.py Co-authored-by: Erik Schamper <1254028+Schamper@users.noreply.github.com> --- dissect/target/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dissect/target/plugin.py b/dissect/target/plugin.py index 0524be360..f16da1f94 100644 --- a/dissect/target/plugin.py +++ b/dissect/target/plugin.py @@ -900,7 +900,7 @@ def documentor(): generated_aggregator = generate_aggregator(subplugin_func_name) generated_documentor = generate_documentor(cls, subplugin_func_name, generated_aggregator) - # Add as a attribute to the namespace class + # Add as an attribute to the namespace class setattr(cls.__nsplugin__, subplugin_func_name, generated_aggregator) # Copy the meta descriptors of the function attribute From a589bfd6d2061e759f73afa62e3f4397250c3885 Mon Sep 17 00:00:00 2001 From: cecinestpasunepipe <110607403+cecinestpasunepipe@users.noreply.github.com> Date: Tue, 15 Aug 2023 14:32:09 +0200 Subject: [PATCH 13/20] Implement feedback --- dissect/target/plugin.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/dissect/target/plugin.py b/dissect/target/plugin.py index f16da1f94..950ce6ede 100644 --- a/dissect/target/plugin.py +++ b/dissect/target/plugin.py @@ -1025,14 +1025,11 @@ def find_plugin_functions( treematch = pattern.split(".")[0] in rootset and pattern != "os" exact_match = pattern in functions - # Allow for exact matches, otherwise you cannot reach documented - # namespace plugins like browsers.browser.downloads. - # You can *always* run these using the namespace/classic-style - # like: browser.downloads (but -l lists them in the tree for - # documentation purposes so it would be misleading not to allow - # tree access as well). Note that these tree items will never - # respond to wildcards though (browsers.browser.* won't work) - # to avoid duplicate results. + # Allow for exact matches, otherwise you cannot reach documented namespace plugins like + # browsers.browser.downloads. You can *always* run these using the namespace/classic-style like: + # browser.downloads (but -l lists them in the tree for documentation purposes so it would be misleading + # not to allow tree access as well). Note that these tree items will never respond to wildcards though + # (browsers.browser.* won't work) to avoid duplicate results. if exact_match: show_hidden = True From 76b3a3a7b0986f5514c102f37faf005e1d136a35 Mon Sep 17 00:00:00 2001 From: cecinestpasunepipe <110607403+cecinestpasunepipe@users.noreply.github.com> Date: Tue, 15 Aug 2023 14:40:09 +0200 Subject: [PATCH 14/20] Implement feedback --- dissect/target/plugin.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/dissect/target/plugin.py b/dissect/target/plugin.py index 950ce6ede..86c391aad 100644 --- a/dissect/target/plugin.py +++ b/dissect/target/plugin.py @@ -1010,6 +1010,11 @@ def find_plugin_functions( a list of plugin function descriptors (including output types). """ result = [] + + def add_to_result(func: PluginFunction) -> None: + if func not in result: + result.append(func) + functions, rootset = plugin_function_index(target) invalid_funcs = set() @@ -1063,7 +1068,7 @@ def find_plugin_functions( continue matches = True - result.append( + add_to_result( PluginFunction( name=index_name, class_object=loaded_plugin_object, @@ -1101,7 +1106,7 @@ def find_plugin_functions( if compatibility and not loaded_plugin_object(target).is_compatible(): continue - result.append( + add_to_result( PluginFunction( name=f"{description['module']}.{pattern}", class_object=loaded_plugin_object, @@ -1111,4 +1116,4 @@ def find_plugin_functions( ) ) - return sorted(set(result), key=result.index), invalid_funcs + return result, invalid_funcs From 5ba7aa281e9c3043ae8eef97f7d3f97cc98e278d Mon Sep 17 00:00:00 2001 From: cecinestpasunepipe <110607403+cecinestpasunepipe@users.noreply.github.com> Date: Tue, 15 Aug 2023 17:12:26 +0200 Subject: [PATCH 15/20] Implement feedback --- dissect/target/plugin.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/dissect/target/plugin.py b/dissect/target/plugin.py index 86c391aad..c2aae60b0 100644 --- a/dissect/target/plugin.py +++ b/dissect/target/plugin.py @@ -950,7 +950,7 @@ def __init_subclass__(cls, **kwargs): class PluginFunction: name: str output_type: str - class_object: str + class_object: Plugin method_name: str plugin_desc: dict = field(hash=False) @@ -979,9 +979,7 @@ def all_plugins(): if "get_all_records" in available["exports"]: available["exports"].remove("get_all_records") modulepath = available["module"] - # Always skip these - if available["class"] in ["DefaultPlugin", "ExamplePlugin"]: - continue + if modulepath.endswith("._os"): if not target._os: # if no target available add a namespaceless section @@ -1011,8 +1009,12 @@ def find_plugin_functions( """ result = [] + # Avoid cyclic import + from dissect.target.plugins.general.default import DefaultPlugin # noqa + from dissect.target.plugins.general.example import ExamplePlugin # noqa + def add_to_result(func: PluginFunction) -> None: - if func not in result: + if func not in result and func.class_object not in [DefaultPlugin, ExamplePlugin]: result.append(func) functions, rootset = plugin_function_index(target) From f5764354b17e21fbd4e335fed11ac20ba1798d3d Mon Sep 17 00:00:00 2001 From: cecinestpasunepipe <110607403+cecinestpasunepipe@users.noreply.github.com> Date: Tue, 15 Aug 2023 21:01:49 +0200 Subject: [PATCH 16/20] Update dissect/target/plugin.py Co-authored-by: Erik Schamper <1254028+Schamper@users.noreply.github.com> --- dissect/target/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dissect/target/plugin.py b/dissect/target/plugin.py index c2aae60b0..eca41e2b3 100644 --- a/dissect/target/plugin.py +++ b/dissect/target/plugin.py @@ -950,7 +950,7 @@ def __init_subclass__(cls, **kwargs): class PluginFunction: name: str output_type: str - class_object: Plugin + class_object: type[Plugin] method_name: str plugin_desc: dict = field(hash=False) From 66acb34313c341f8568e0bfe790e8b1d544e7fb8 Mon Sep 17 00:00:00 2001 From: cecinestpasunepipe <110607403+cecinestpasunepipe@users.noreply.github.com> Date: Tue, 15 Aug 2023 21:23:51 +0200 Subject: [PATCH 17/20] Implement feedback --- dissect/target/plugin.py | 8 +++----- dissect/target/plugins/general/default.py | 2 ++ dissect/target/plugins/general/example.py | 3 ++- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/dissect/target/plugin.py b/dissect/target/plugin.py index eca41e2b3..67f829750 100644 --- a/dissect/target/plugin.py +++ b/dissect/target/plugin.py @@ -212,6 +212,8 @@ class attribute. Namespacing results in your plugin needing to be prefixed produce redundant results when used with a wild card (browser.* -> browser.history + browser.*.history). """ + __skip__: bool = False + """Prevents plugin functions from indexing this plugin at all.""" def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) @@ -1009,12 +1011,8 @@ def find_plugin_functions( """ result = [] - # Avoid cyclic import - from dissect.target.plugins.general.default import DefaultPlugin # noqa - from dissect.target.plugins.general.example import ExamplePlugin # noqa - def add_to_result(func: PluginFunction) -> None: - if func not in result and func.class_object not in [DefaultPlugin, ExamplePlugin]: + if func not in result and not func.class_object.__skip__: result.append(func) functions, rootset = plugin_function_index(target) diff --git a/dissect/target/plugins/general/default.py b/dissect/target/plugins/general/default.py index f36b36e8e..6da7bf8d4 100644 --- a/dissect/target/plugins/general/default.py +++ b/dissect/target/plugins/general/default.py @@ -3,6 +3,8 @@ class DefaultPlugin(OSPlugin): + __skip__: bool = True + def __init__(self, target): super().__init__(target) if len(target.filesystems) == 1: diff --git a/dissect/target/plugins/general/example.py b/dissect/target/plugins/general/example.py index 6e6f1f4bd..3313849fa 100644 --- a/dissect/target/plugins/general/example.py +++ b/dissect/target/plugins/general/example.py @@ -53,7 +53,8 @@ def __init__(self, target): super().__init__(target) """ - __findable__ = False + __findable__: bool = False + __skip__: bool = True def check_compatible(self) -> bool: """Perform a compatibility check with the target. From 61ec836f3e5b2be99e2eb4a8bf0ef938d39342b4 Mon Sep 17 00:00:00 2001 From: cecinestpasunepipe <110607403+cecinestpasunepipe@users.noreply.github.com> Date: Tue, 15 Aug 2023 22:14:33 +0200 Subject: [PATCH 18/20] Implement feedback --- tests/test_plugin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_plugin.py b/tests/test_plugin.py index c96a24404..ea8d4b359 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -96,6 +96,7 @@ def test_find_plugin_functions(plugin_loader, target, os_plugins, plugins, searc class MockPlugin(MagicMock): __exports__ = ["f6"] # OS exports f6 __findable__ = findable + __skip__ = False def get_all_records(): return [] From 9ea97dd0e15c68fbfc70c59a2bac1600bba1bbd6 Mon Sep 17 00:00:00 2001 From: cecinestpasunepipe <110607403+cecinestpasunepipe@users.noreply.github.com> Date: Tue, 15 Aug 2023 22:27:58 +0200 Subject: [PATCH 19/20] Update dissect/target/plugins/general/example.py Co-authored-by: Erik Schamper <1254028+Schamper@users.noreply.github.com> --- dissect/target/plugins/general/example.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/dissect/target/plugins/general/example.py b/dissect/target/plugins/general/example.py index 3313849fa..510e81c76 100644 --- a/dissect/target/plugins/general/example.py +++ b/dissect/target/plugins/general/example.py @@ -53,8 +53,9 @@ def __init__(self, target): super().__init__(target) """ - __findable__: bool = False - __skip__: bool = True + # IMPORTANT: Remove these attributes when using this as boilerplate for your own plugin! + __findable__ = False + __skip__ = True def check_compatible(self) -> bool: """Perform a compatibility check with the target. From b0b3301cb33f8ecb6e6b2fbfe8f645f566dd5cd1 Mon Sep 17 00:00:00 2001 From: cecinestpasunepipe <110607403+cecinestpasunepipe@users.noreply.github.com> Date: Tue, 15 Aug 2023 22:28:14 +0200 Subject: [PATCH 20/20] Update dissect/target/plugins/general/default.py Co-authored-by: Erik Schamper <1254028+Schamper@users.noreply.github.com> --- dissect/target/plugins/general/default.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dissect/target/plugins/general/default.py b/dissect/target/plugins/general/default.py index 6da7bf8d4..b6031ab3a 100644 --- a/dissect/target/plugins/general/default.py +++ b/dissect/target/plugins/general/default.py @@ -3,7 +3,7 @@ class DefaultPlugin(OSPlugin): - __skip__: bool = True + __skip__ = True def __init__(self, target): super().__init__(target)