From 64c139095fcd3bf28e7daeacc9b8c440b90a2e32 Mon Sep 17 00:00:00 2001 From: Mike Auty Date: Sun, 1 Nov 2020 22:51:39 +0000 Subject: [PATCH] Renderers: Add in very rudimentary renderer options This allows the CLI to expose options that the renderers can offer. These options are limited (string, int, bool and bytes), so no lists or choices, etc. Each renderer's options are also global options, meaning they're always present (although grouped). There's two ways of handling this: a) Prefix each option with the renderer's name, duplicate options are unique b) Deduplicate options with duplicate names and prefix 'renderer' The current choice is a) because b) might confuse people that an option which only applied to one renderer would be available for all, but comes with the inconvenience that users must adapt the option name for those that are duplicated with the same meaning. This is at least functional and extendable in the future but still a bit clunky. We could have brought the entire configuration mechanism into play, but each renderer would require a context and a config_path and so on, meaning massive overkill and a non-addition-only API change. --- volatility/cli/__init__.py | 28 ++++++++- volatility/cli/text_renderer.py | 39 +++++++----- volatility/framework/constants/__init__.py | 2 +- volatility/framework/interfaces/__init__.py | 2 +- volatility/framework/interfaces/renderers.py | 63 ++++++++++++++++++-- 5 files changed, 109 insertions(+), 25 deletions(-) diff --git a/volatility/cli/__init__.py b/volatility/cli/__init__.py index ce685808d..953cbc16c 100644 --- a/volatility/cli/__init__.py +++ b/volatility/cli/__init__.py @@ -167,7 +167,8 @@ class CommandLine: partial_args, _ = parser.parse_known_args(known_args) banner_output = sys.stdout - if renderers[partial_args.renderer].structured_output: + renderer = renderers[partial_args.renderer] + if renderer.structured_output: banner_output = sys.stderr banner_output.write("Volatility 3 Framework {}\n".format(constants.PACKAGE_VERSION)) @@ -227,6 +228,8 @@ class CommandLine: if isinstance(amagic, interfaces.configuration.ConfigurableInterface): self.populate_requirements_argparse(parser, amagic.__class__) + self.populate_requirements_renderer_options(parser, renderers) + subparser = parser.add_subparsers(title = "Plugins", dest = "plugin", description = "For plugin specific options, run '{} --help'".format( @@ -311,8 +314,9 @@ class CommandLine: try: # Construct and run the plugin + options = [] if constructed: - renderers[args.renderer]().render(constructed.run()) + renderer(options).render(constructed.run()) except (exceptions.VolatilityException) as excp: self.process_exceptions(excp) @@ -363,7 +367,7 @@ class CommandLine: detail = "{}".format(excp) caused_by = [ "An invalid symbol table", "A plugin requesting a bad symbol", - "A plugin requesting a symbol from the wrong table" + "A plugin requesting a symbol from the wrong table" ] elif isinstance(excp, exceptions.LayerException): general = "Volatility experienced a layer-related issue: {}".format(excp.layer_name) @@ -575,6 +579,24 @@ class CommandLine: required = not requirement.optional, **additional) + def populate_requirements_renderer_options(self, parser: argparse.ArgumentParser, + renderers: Dict[str, Type[text_renderer.CLIRenderer]]): + renderer_parser = parser.add_argument_group('renderer', 'Renderer options') + for renderer_name in renderers: + renderer = renderers[renderer_name] + for option in renderer.get_render_options(): + config_name = '-'.join([renderer.name, option.name]) + extra_options = { + 'help': option.description, + 'type': option.option_type, + 'dest': config_name.replace('-', '_') + } + if option.option_type == bool: + del extra_options['type'] + extra_options['action'] = 'store_true' + renderer_parser.add_argument("--" + config_name, + **extra_options) + def main(): """A convenience function for constructing and running the diff --git a/volatility/cli/text_renderer.py b/volatility/cli/text_renderer.py index 098e21d59..88e3e954f 100644 --- a/volatility/cli/text_renderer.py +++ b/volatility/cli/text_renderer.py @@ -128,6 +128,10 @@ class CLIRenderer(interfaces.renderers.Renderer): name = "unnamed" structured_output = False + @classmethod + def get_render_options(cls) -> List[RenderOption]: + return [] + class QuickTextRenderer(CLIRenderer): _type_renderers = { @@ -143,8 +147,12 @@ class QuickTextRenderer(CLIRenderer): name = "quick" - def get_render_options(self): - pass + @classmethod + def get_render_options(cls) -> List[RenderOption]: + return [ + RenderOption(name = 'skip-errors', description = 'Skips rows that would otherwise error', + option_type = bool, + default = False)] def render(self, grid: interfaces.renderers.TreeGrid) -> None: """Renders each column immediately to stdout. @@ -178,7 +186,7 @@ class QuickTextRenderer(CLIRenderer): return accumulator if not grid.populated: - grid.populate(visitor, outfd) + grid.populate(visitor, outfd, fail_on_errors = not self.options['skip-errors'].value) else: grid.visit(node = None, function = visitor, initial_accumulator = outfd) @@ -200,9 +208,6 @@ class CSVRenderer(CLIRenderer): name = "csv" structured_output = True - def get_render_options(self): - pass - def render(self, grid: interfaces.renderers.TreeGrid) -> None: """Renders each row immediately to stdout. @@ -242,8 +247,14 @@ class PrettyTextRenderer(CLIRenderer): name = "pretty" - def get_render_options(self): - pass + @classmethod + def get_render_options(cls) -> List[RenderOption]: + return [ + RenderOption(name = 'skip-errors', description = 'Skips rows that would otherwise error', + option_type = bool, + default = False), + RenderOption(name = 'separator', description = 'Dividing characters to separate columns', option_type = str, + default = ' | ')] def render(self, grid: interfaces.renderers.TreeGrid) -> None: """Renders each column immediately to stdout. @@ -260,13 +271,14 @@ class PrettyTextRenderer(CLIRenderer): sys.stderr.write("Formatting...\n") display_alignment = ">" - column_separator = " | " + column_separator = self.options['separator'].value tree_indent_column = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(20)) max_column_widths = dict([(column.name, len(column.name)) for column in grid.columns]) def visitor( - node: interfaces.renderers.TreeNode, accumulator: List[Tuple[int, Dict[interfaces.renderers.Column, bytes]]] + node: interfaces.renderers.TreeNode, + accumulator: List[Tuple[int, Dict[interfaces.renderers.Column, bytes]]] ) -> List[Tuple[int, Dict[interfaces.renderers.Column, bytes]]]: # Nodes always have a path value, giving them a path_depth of at least 1, we use max just in case max_column_widths[tree_indent_column] = max(max_column_widths.get(tree_indent_column, 0), node.path_depth) @@ -283,7 +295,7 @@ class PrettyTextRenderer(CLIRenderer): final_output = [] # type: List[Tuple[int, Dict[interfaces.renderers.Column, bytes]]] if not grid.populated: - grid.populate(visitor, final_output) + grid.populate(visitor, final_output, fail_on_errors = not self.options['skip-errors'].value) else: grid.visit(node = None, function = visitor, initial_accumulator = final_output) @@ -313,9 +325,6 @@ class JsonRenderer(CLIRenderer): name = 'JSON' structured_output = True - def get_render_options(self) -> List[RenderOption]: - pass - def output_result(self, outfd, result): """Outputs the JSON data to a file in a particular format""" outfd.write(json.dumps(result, indent = 2, sort_keys = True)) @@ -328,7 +337,7 @@ class JsonRenderer(CLIRenderer): {}, []) # type: Tuple[Dict[str, List[interfaces.renderers.TreeNode]], List[interfaces.renderers.TreeNode]] def visitor( - node: interfaces.renderers.TreeNode, accumulator: Tuple[Dict[str, Dict[str, Any]], List[Dict[str, Any]]] + node: interfaces.renderers.TreeNode, accumulator: Tuple[Dict[str, Dict[str, Any]], List[Dict[str, Any]]] ) -> Tuple[Dict[str, Dict[str, Any]], List[Dict[str, Any]]]: # Nodes always have a path value, giving them a path_depth of at least 1, we use max just in case acc_map, final_tree = accumulator diff --git a/volatility/framework/constants/__init__.py b/volatility/framework/constants/__init__.py index b39fc2b7a..36c861be7 100644 --- a/volatility/framework/constants/__init__.py +++ b/volatility/framework/constants/__init__.py @@ -39,7 +39,7 @@ BANG = "!" # We use the SemVer 2.0.0 versioning scheme VERSION_MAJOR = 2 # Number of releases of the library with a breaking change -VERSION_MINOR = 0 # Number of changes that only add to the interface +VERSION_MINOR = 1 # Number of changes that only add to the interface VERSION_PATCH = 0 # Number of changes that do not change the interface VERSION_SUFFIX = "-beta.1" diff --git a/volatility/framework/interfaces/__init__.py b/volatility/framework/interfaces/__init__.py index 17fd6f011..3636dc609 100644 --- a/volatility/framework/interfaces/__init__.py +++ b/volatility/framework/interfaces/__init__.py @@ -12,5 +12,5 @@ components of volatility to write plugins. # Import the submodules we want people to be able to use without importing them themselves # This will also avoid namespace issues, because people can use interfaces.layers to # avoid clashing with the layers package -from volatility.framework.interfaces import renderers, configuration, context, layers, objects, plugins, symbols, \ +from volatility.framework.interfaces import configuration, renderers, context, layers, objects, plugins, symbols, \ automagic diff --git a/volatility/framework/interfaces/renderers.py b/volatility/framework/interfaces/renderers.py index 54b9f922f..e01bd09b6 100644 --- a/volatility/framework/interfaces/renderers.py +++ b/volatility/framework/interfaces/renderers.py @@ -12,11 +12,45 @@ suitable output. import datetime from abc import abstractmethod, ABCMeta from collections import abc -from typing import Any, Callable, ClassVar, Generator, List, NamedTuple, Optional, TypeVar, Type, Tuple, Union +from typing import Any, Callable, ClassVar, Generator, List, NamedTuple, Optional, TypeVar, Type, Tuple, Union, Dict + +from volatility.framework import interfaces Column = NamedTuple('Column', [('name', str), ('type', Any)]) -RenderOption = Any + +class RenderOption: + """Class to support simple type options for renderers""" + + def __init__(self, name: str, description: str, option_type: Type[interfaces.configuration.SimpleTypes], + default: interfaces.configuration.SimpleTypes): + self._value = default + self._name = name + self._description = description + self._option_type = option_type + + @property + def name(self) -> str: + return self._name + + @property + def description(self) -> str: + return self._description + + @property + def option_type(self) -> Type[interfaces.configuration.SimpleTypes]: + return self._option_type + + @property + def value(self): + return self._value + + @value.setter + def value(self, val): + if isinstance(val, self.option_type): + self._value = val + else: + raise TypeError("Expected type {} cannot be filled by {}".format(repr(self.option_type), repr(type(val)))) class Renderer(metaclass = ABCMeta): @@ -25,11 +59,30 @@ class Renderer(metaclass = ABCMeta): def __init__(self, options: Optional[List[RenderOption]] = None) -> None: """Accepts an options object to configure the renderers.""" - # FIXME: Once the config option objects are in place, put the _type_check in place + self._options = self._create_named_dict(self.get_render_options()) + if options: + for option in options: + if option.name not in self._options: + raise ValueError("Unknown option provided to renderer") + self._options[option.name].value = option.value - @abstractmethod - def get_render_options(self) -> List[RenderOption]: + def _create_named_dict(self, option_list: List[RenderOption]) -> Dict[str, RenderOption]: + result = {} + for option in option_list: + if option.name in result: + raise KeyError("Option defined twice with the same renderer") + result[option.name] = option + return result + + @classmethod + def get_render_options(cls) -> List[RenderOption]: """Returns a list of rendering options.""" + return [] + + @property + def options(self) -> Dict[str, RenderOption]: + """Returns the dictionary of the available options""" + return self._options @abstractmethod def render(self, grid: 'TreeGrid') -> None: