Repository: google/turbinia
Pull request #395 (2019-05-23 09:53:39)

Adding turbinia config command
#306 


Patch:
diff --git a/CONTRIBUTING b/CONTRIBUTING
deleted file mode 100644
index 21e4c71..0000000
--- a/CONTRIBUTING
+++ /dev/null
@@ -1,25 +0,0 @@
-Want to contribute? Great! First, read this page (including the small print at the end).
-
-### Before you contribute
-Before we can use your code, you must sign the
-[Google Individual Contributor License Agreement](https://cla.developers.google.com/about/google-individual)
-(CLA), which you can do online. The CLA is necessary mainly because you own the
-copyright to your changes, even after your contribution becomes part of our
-codebase, so we need your permission to use and distribute your code. We also
-need to be sure of various other things—for instance that you'll tell us if you
-know that your code infringes on other people's patents. You don't have to sign
-the CLA until after you've submitted your code for review and a member has
-approved it, but you must do it before we can put your code into our codebase.
-Before you start working on a larger contribution, you should get in touch with
-us first through the issue tracker with your idea so that we can help out and
-possibly guide you. Coordinating up front makes it much easier to avoid
-frustration later on.
-
-### Code reviews
-All submissions, including submissions by project members, require review. We
-use Github pull requests for this purpose.
-
-### The small print
-Contributions made by corporations are covered by a different agreement than
-the one above, the
-[Software Grant and Corporate Contributor License Agreement](https://cla.developers.google.com/about/google-corporate).
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..bd3f271
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,2 @@
+# Want to contribute? 
+Great! First read this [page](https://github.com/google/turbinia/blob/master/docs/contributing.md).
diff --git a/README.md b/README.md
index 06de463..58178ed 100644
--- a/README.md
+++ b/README.md
@@ -108,7 +108,7 @@ optional arguments:
 * [Installation](docs/install.md)
 * [How it works](docs/how-it-works.md)
 * [Contributing to Turbinia](docs/contributing.md)
-* [Developing new Tasks](docs/how-it-works.md)
+* [Developing new Tasks](docs/developing-new-tasks.md)
 * [FAQ](docs/faq.md)
 * [Debugging and Common Errors](docs/debugging.md)
 
diff --git a/requirements.txt b/requirements.txt
index 51eef82..4441358 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,10 +1,10 @@
 filelock
-google-api-core>=1.2.1,<=1.4.0
-google-cloud-datastore==1.7.0
-google-api-python-client>=1.6.2
-google-cloud-pubsub==0.35.4
-google-cloud-storage==1.13.0
-google-cloud-core==0.28.1
+google-api-core
+google-cloud-datastore
+google-api-python-client
+google-cloud-pubsub
+google-cloud-storage
+google-cloud-core
 oauth2client
 psq
 six
diff --git a/setup.py b/setup.py
index b63dca7..eb0181e 100644
--- a/setup.py
+++ b/setup.py
@@ -69,6 +69,6 @@ setup(
     extras_require={
         'dev': ['mock', 'nose', 'yapf'],
         'local': ['celery~=4.1', 'kombu~=4.1', 'redis~=3.0'],
-        'worker': ['plaso>=20171118']
+        'worker': ['plaso>=20171118', 'pyhindsight>=2.2.0']
     }
 )
diff --git a/tools/gcf_init/index.js b/tools/gcf_init/index.js
index 2919aca..2c07363 100644
--- a/tools/gcf_init/index.js
+++ b/tools/gcf_init/index.js
@@ -67,7 +67,7 @@ exports.gettasks = function gettasks(req, res) {
   }
   if (req.body.user) {
     console.log('Getting Turbinia Tasks by user: ' + req.body.user);
-    query = query.filter('user', '=', req.body.user);
+    query = query.filter('requester', '=', req.body.user);
   }
   if (req.body.start_time) {
     try {
@@ -150,8 +150,8 @@ exports.closetasks = function closetasks(req, res) {
         '__key__', '=', datastore.key([turbiniaKind, req.body.task_id]))
   }
   if (req.body.user) {
-    console.log('Adding filter - user: ' + req.body.user);
-    query = query.filter('user', '=', req.body.user)
+    console.log('Adding filter - requester: ' + req.body.user);
+    query = query.filter('requester', '=', req.body.user)
   }
 
   console.log(query);
diff --git a/tools/gcf_init/index.yaml b/tools/gcf_init/index.yaml
index c609e9e..6d617f8 100644
--- a/tools/gcf_init/index.yaml
+++ b/tools/gcf_init/index.yaml
@@ -45,7 +45,7 @@ indexes:
 - kind: TurbiniaTask
   ancestor: no
   properties:
-  - name: user
+  - name: requester
   - name: last_update
     direction: desc
 
@@ -54,7 +54,7 @@ indexes:
   properties:
   - name: instance
   - name: id
-  - name: user
+  - name: requester
   - name: last_update
     direction: desc
 
@@ -63,7 +63,7 @@ indexes:
   properties:
   - name: instance
   - name: request_id
-  - name: user
+  - name: requester
   - name: last_update
     direction: desc
 
diff --git a/turbinia/client.py b/turbinia/client.py
index b7c8aff..473bd18 100644
--- a/turbinia/client.py
+++ b/turbinia/client.py
@@ -22,12 +22,14 @@ from datetime import timedelta
 import json
 import logging
 from operator import itemgetter
+from operator import attrgetter
 import os
 import stat
 import time
 
 from turbinia import config
 from turbinia.config import logger
+from turbinia.config import DATETIME_FORMAT
 from turbinia import task_manager
 from turbinia import TurbiniaException
 from turbinia.lib import text_formatter as fmt
@@ -37,12 +39,14 @@ from turbinia.workers.analysis.wordpress import WordpressAccessLogAnalysisTask
 from turbinia.workers.analysis.jenkins import JenkinsAnalysisTask
 from turbinia.workers.grep import GrepTask
 from turbinia.workers.hadoop import HadoopAnalysisTask
+from turbinia.workers.hindsight import HindsightTask
 from turbinia.workers.plaso import PlasoTask
 from turbinia.workers.psort import PsortTask
 from turbinia.workers.sshd import SSHDAnalysisTask
 from turbinia.workers.strings import StringsAsciiTask
 from turbinia.workers.strings import StringsUnicodeTask
 from turbinia.workers.tomcat import TomcatAnalysisTask
+from turbinia.workers.volatility import VolatilityTask
 from turbinia.workers.worker_stat import StatTask
 
 # TODO(aarontp): Remove this map after
@@ -53,12 +57,14 @@ TASK_MAP = {
     'jenkinsanalysistask': JenkinsAnalysisTask,
     'greptask': GrepTask,
     'hadoopanalysistask': HadoopAnalysisTask,
+    'hindsighttask': HindsightTask,
     'plasotask': PlasoTask,
     'psorttask': PsortTask,
     'sshdanalysistask': SSHDAnalysisTask,
     'stringsasciitask': StringsAsciiTask,
     'stringsunicodetask': StringsUnicodeTask,
     'tomcatanalysistask': TomcatAnalysisTask,
+    'volatilitytask': VolatilityTask,
     'stattask': StatTask,
 }
 
@@ -107,6 +113,78 @@ def check_directory(directory):
           'Can not add write permissions to {0:s}'.format(directory))
 
 
+class TurbiniaStats(object):
+  """Statistics for Turbinia task execution.
+
+  Attributes:
+    count(int): The number of tasks
+    min(datetime.timedelta): The minimum run time of all tasks
+    max(datetime.timedelta): The maximum run time of all tasks
+    mean(datetime.timedelta): The mean run time of all tasks
+    tasks(list): A list of tasks to calculate stats for
+  """
+
+  def __init__(self, description=None):
+    self.description = description
+    self.min = None
+    self.mean = None
+    self.max = None
+    self.tasks = []
+
+  def __str__(self):
+    return self.format_stats()
+
+  @property
+  def count(self):
+    """Gets a count of the tasks in this stats object.
+
+    Returns:
+      Int of task count.
+    """
+    return len(self.tasks)
+
+  def add_task(self, task):
+    """Add a task result dict.
+
+    Args:
+      task(dict): The task results we want to count stats for.
+    """
+    self.tasks.append(task)
+
+  def calculate_stats(self):
+    """Calculates statistics of the current tasks."""
+    if not self.tasks:
+      return
+
+    sorted_tasks = sorted(self.tasks, key=itemgetter('run_time'))
+    self.min = sorted_tasks[0]['run_time']
+    self.max = sorted_tasks[len(sorted_tasks) - 1]['run_time']
+    self.mean = sorted_tasks[len(sorted_tasks) // 2]['run_time']
+
+    # Remove the microseconds to keep things cleaner
+    self.min = self.min - timedelta(microseconds=self.min.microseconds)
+    self.max = self.max - timedelta(microseconds=self.max.microseconds)
+    self.mean = self.mean - timedelta(microseconds=self.mean.microseconds)
+
+  def format_stats(self):
+    """Formats statistics data.
+
+    Returns:
+      String of statistics data
+    """
+    return '{0:s}: Count: {1:d}, Min: {2!s}, Mean: {3!s}, Max: {4!s}'.format(
+        self.description, self.count, self.min, self.mean, self.max)
+
+  def format_stats_csv(self):
+    """Formats statistics data into CSV output.
+
+    Returns:
+      String of statistics data in CSV format
+    """
+    return '{0:s}, {1:d}, {2!s}, {3!s}, {4!s}'.format(
+        self.description, self.count, self.min, self.mean, self.max)
+
+
 class TurbiniaClient(object):
   """Client class for Turbinia.
 
@@ -162,7 +240,8 @@ class TurbiniaClient(object):
       user (string): The user of the request we want tasks for.
       poll_interval (int): Interval of seconds between polling cycles.
     """
-    last_count = 0
+    last_completed_count = -1
+    last_uncompleted_count = -1
     while True:
       task_results = self.get_task_data(
           instance, project, region, request_id=request_id, user=user)
@@ -186,12 +265,14 @@ class TurbiniaClient(object):
           'Tasks completed ({0:d}/{1:d}): [{2:s}], waiting for [{3:s}].'.format(
               len(completed_tasks), total_count, completed_names,
               uncompleted_names))
-      if len(completed_tasks) > last_count:
+      if (len(completed_tasks) > last_completed_count or
+          len(uncompleted_tasks) > last_uncompleted_count):
         log.info(msg)
       else:
         log.debug(msg)
 
-      last_count = len(completed_tasks)
+      last_completed_count = len(completed_tasks)
+      last_uncompleted_count = len(uncompleted_tasks)
       time.sleep(poll_interval)
 
     log.info('All {0:d} Tasks completed'.format(len(task_results)))
@@ -222,7 +303,7 @@ class TurbiniaClient(object):
       start_time = datetime.now() - timedelta(days=days)
       # Format this like '1990-01-01T00:00:00z' so we can cast it directly to a
       # javascript Date() object in the cloud function.
-      start_string = start_time.strftime('%Y-%m-%dT%H:%M:%S')
+      start_string = start_time.strftime(DATETIME_FORMAT)
       func_args.update({'start_time': start_string})
     elif task_id:
       func_args.update({'task_id': task_id})
@@ -247,9 +328,19 @@ class TurbiniaClient(object):
       results = json.loads(response['result'])
     except (TypeError, ValueError) as e:
       raise TurbiniaException(
-          'Could not deserialize result from GCF: [{0!s}]'.format(e))
+          'Could not deserialize result [{0!s}] from GCF: [{1!s}]'.format(
+              response.get('result'), e))
+
+    # Convert run_time/last_update back into datetime objects
+    task_data = results[0]
+    for task in task_data:
+      if task.get('run_time'):
+        task['run_time'] = timedelta(seconds=task['run_time'])
+      if task.get('last_update'):
+        task['last_update'] = datetime.strptime(
+            task['last_update'], DATETIME_FORMAT)
 
-    return results[0]
+    return task_data
 
   def format_task_detail(self, task, show_files=False):
     """Formats a single task in detail.
@@ -303,6 +394,174 @@ class TurbiniaClient(object):
       report.append('')
     return report
 
+  def get_task_statistics(
+      self, instance, project, region, days=0, task_id=None, request_id=None,
+      user=None):
+    """Gathers statistics for Turbinia execution data.
+
+    Args:
+      instance (string): The Turbinia instance name (by default the same as the
+          INSTANCE_ID in the config).
+      project (string): The name of the project.
+      region (string): The name of the zone to execute in.
+      days (int): The number of days we want history for.
+      task_id (string): The Id of the task.
+      request_id (string): The Id of the request we want tasks for.
+      user (string): The user of the request we want tasks for.
+
+    Returns:
+      task_stats(dict): Mapping of statistic names to values
+    """
+    task_results = self.get_task_data(
+        instance, project, region, days, task_id, request_id, user)
+    if not task_results:
+      return {}
+
+    task_stats = {
+        'all_tasks': TurbiniaStats('All Tasks'),
+        'successful_tasks': TurbiniaStats('Successful Tasks'),
+        'failed_tasks': TurbiniaStats('Failed Tasks'),
+        'requests': TurbiniaStats('Total Request Time'),
+        # The following are dicts mapping the user/worker/type names to their
+        # respective TurbiniaStats() objects.
+        # Total wall-time for all tasks of a given type
+        'tasks_per_type': {},
+        # Total wall-time for all tasks per Worker
+        'tasks_per_worker': {},
+        # Total wall-time for all tasks per User
+        'tasks_per_user': {},
+    }
+
+    # map of request ids to [min time, max time]
+    requests = {}
+
+    for task in task_results:
+      request_id = task.get('request_id')
+      task_type = task.get('name')
+      worker = task.get('worker_name')
+      user = task.get('requester')
+      if not task.get('run_time'):
+        log.debug(
+            'Ignoring task {0:s} in statistics because the run_time is not '
+            'set, and it is required to calculate stats'.format(
+                task.get('name')))
+        continue
+
+      # Stats for all/successful/failed tasks
+      task_stats['all_tasks'].add_task(task)
+      if task.get('successful') is True:
+        task_stats['successful_tasks'].add_task(task)
+      elif task.get('successful') is False:
+        task_stats['failed_tasks'].add_task(task)
+
+      # Stats for Tasks per Task type.
+      if task_type in task_stats['tasks_per_type']:
+        task_type_stats = task_stats['tasks_per_type'].get(task_type)
+      else:
+        task_type_stats = TurbiniaStats('Task type {0:s}'.format(task_type))
+        task_stats['tasks_per_type'][task_type] = task_type_stats
+      task_type_stats.add_task(task)
+
+      # Stats per worker.
+      if worker in task_stats['tasks_per_worker']:
+        worker_stats = task_stats['tasks_per_worker'].get(worker)
+      else:
+        worker_stats = TurbiniaStats('Worker {0:s}'.format(worker))
+        task_stats['tasks_per_worker'][worker] = worker_stats
+      worker_stats.add_task(task)
+
+      # Stats per submitting User.
+      if user in task_stats['tasks_per_user']:
+        user_stats = task_stats['tasks_per_user'].get(user)
+      else:
+        user_stats = TurbiniaStats('User {0:s}'.format(user))
+        task_stats['tasks_per_user'][user] = user_stats
+      user_stats.add_task(task)
+
+      # Stats for the total request.  This will, for each request, calculate the
+      # start time of the earliest task and the stop time of the latest task.
+      # This will give the overall run time covering all tasks in the request.
+      task_start_time = task['last_update'] - task['run_time']
+      task_stop_time = task['last_update']
+      if request_id in requests:
+        start_time, stop_time = requests[request_id]
+        if task_start_time < start_time:
+          requests[request_id][0] = task_start_time
+        if task_stop_time > stop_time:
+          requests[request_id][1] = task_stop_time
+      else:
+        requests[request_id] = [task_start_time, task_stop_time]
+
+    # Add a fake task result for each request with our calculated times to the
+    # stats module
+    for min_time, max_time in requests.values():
+      task = {}
+      task['run_time'] = max_time - min_time
+      task_stats['requests'].add_task(task)
+
+    # Go over all stat objects and calculate them
+    for stat_obj in task_stats.values():
+      if isinstance(stat_obj, dict):
+        for inner_stat_obj in stat_obj.values():
+          inner_stat_obj.calculate_stats()
+      else:
+        stat_obj.calculate_stats()
+
+    return task_stats
+
+  def format_task_statistics(
+      self, instance, project, region, days=0, task_id=None, request_id=None,
+      user=None, csv=False):
+    """Formats statistics for Turbinia execution data.
+
+    Args:
+      instance (string): The Turbinia instance name (by default the same as the
+          INSTANCE_ID in the config).
+      project (string): The name of the project.
+      region (string): The name of the zone to execute in.
+      days (int): The number of days we want history for.
+      task_id (string): The Id of the task.
+      request_id (string): The Id of the request we want tasks for.
+      user (string): The user of the request we want tasks for.
+      csv (bool): Whether we want the output in CSV format.
+
+    Returns:
+      String of task statistics report
+    """
+    task_stats = self.get_task_statistics(
+        instance, project, region, days, task_id, request_id, user)
+    if not task_stats:
+      return 'No tasks found'
+
+    stats_order = [
+        'all_tasks', 'successful_tasks', 'failed_tasks', 'requests',
+        'tasks_per_type', 'tasks_per_worker', 'tasks_per_user'
+    ]
+
+    if csv:
+      report = ['stat_type, count, min, mean, max']
+    else:
+      report = ['Execution time statistics for Turbinia:', '']
+    for stat_name in stats_order:
+      stat_obj = task_stats[stat_name]
+      if isinstance(stat_obj, dict):
+        # Sort by description so that we get consistent report output
+        inner_stat_objs = sorted(
+            stat_obj.values(), key=attrgetter('description'))
+        for inner_stat_obj in inner_stat_objs:
+          if csv:
+            report.append(inner_stat_obj.format_stats_csv())
+          else:
+            report.append(inner_stat_obj.format_stats())
+      else:
+        if csv:
+          report.append(stat_obj.format_stats_csv())
+        else:
+          report.append(stat_obj.format_stats())
+
+    report.append('')
+    return '\n'.join(report)
+
   def format_task_status(
       self, instance, project, region, days=0, task_id=None, request_id=None,
       user=None, all_fields=False, full_report=False,
@@ -347,7 +606,7 @@ class TurbiniaClient(object):
 
     # Build up data
     report = []
-    user = task_results[0].get('user')
+    requester = task_results[0].get('requester')
     request_id = task_results[0].get('request_id')
     success_types = ['Successful', 'Failed', 'Scheduled or Running']
     success_values = [True, False, None]
@@ -366,7 +625,8 @@ class TurbiniaClient(object):
     report.append(fmt.heading1('Turbinia report {0:s}'.format(request_id)))
     report.append(
         fmt.bullet(
-            'Processed {0:d} Tasks for user {1:s}'.format(num_results, user)))
+            'Processed {0:d} Tasks for user {1:s}'.format(
+                num_results, requester)))
 
     # Print report data for tasks
     for success_type in success_types:
diff --git a/turbinia/config/__init__.py b/turbinia/config/__init__.py
index d9f8817..d04bd79 100644
--- a/turbinia/config/__init__.py
+++ b/turbinia/config/__init__.py
@@ -22,7 +22,7 @@ import logging
 import os
 import sys
 
-log = logging.getLogger('turbinia')
+DATETIME_FORMAT = '%Y-%m-%dT%H:%M:%S.%fZ'
 
 # Look for config files with these names
 CONFIGFILES = ['.turbiniarc', 'turbinia.conf', 'turbinia_config.py']
@@ -80,6 +80,8 @@ ENVCONFIGVAR = 'TURBINIA_CONFIG_PATH'
 
 CONFIG = None
 
+log = logging.getLogger('turbinia')
+
 
 class TurbiniaConfigException(Exception):
   """Exception for Turbinia configuration."""
@@ -115,6 +117,12 @@ def LoadConfig():
   _config = imp.load_source('config', config_file)
   _config.configSource = config_file
   ValidateAndSetConfig(_config)
+
+  # Set the environment var for this so that we don't see the "No project ID
+  # could be determined." warning later.
+  if hasattr(_config, 'TURBINIA_PROJECT') and _config.TURBINIA_PROJECT:
+    os.environ['GOOGLE_CLOUD_PROJECT'] = _config.TURBINIA_PROJECT
+
   CONFIG = _config
   return _config
 
diff --git a/turbinia/config/logger.py b/turbinia/config/logger.py
index 7b26745..e9e1ce9 100644
--- a/turbinia/config/logger.py
+++ b/turbinia/config/logger.py
@@ -16,6 +16,7 @@
 
 from __future__ import unicode_literals
 import logging
+import warnings
 
 from turbinia import config
 
@@ -28,6 +29,10 @@ def setup():
   however some external modules that are called by Turbinia can use the root
   logger, so we want to be able to optionally configure that as well.
   """
+  # Remove known warning about credentials
+  warnings.filterwarnings(
+      'ignore', 'Your application has authenticated using end user credentials')
+
   # TODO(aarontp): Add a config option to set the log level
   config.LoadConfig()
   logger = logging.getLogger('turbinia')
diff --git a/turbinia/evidence.py b/turbinia/evidence.py
index e5326a4..61a5bfc 100644
--- a/turbinia/evidence.py
+++ b/turbinia/evidence.py
@@ -100,6 +100,9 @@ class Evidence(object):
         relevant to us.
   """
 
+  # The list of attributes a given piece of Evidence requires to be set
+  REQUIRED_ATTRIBUTES = []
+
   def __init__(
       self, name=None, description=None, source=None, local_path=None,
       tags=None, request_id=None):
@@ -193,12 +196,51 @@ class Evidence(object):
     if self.parent_evidence:
       self.parent_evidence.postprocess()
 
+  def validate(self):
+    """Runs validation to verify evidence meets minimum requirements.
+
+    This default implementation will just check that the attributes listed in
+    REQUIRED_ATTRIBUTES are set, but other evidence types can override this
+    method to implement their own more stringent checks as needed.  This is
+    called by the worker, prior to the pre/post-processors running.
+
+    Raises:
+      TurbiniaException: If validation fails
+    """
+    for attribute in self.REQUIRED_ATTRIBUTES:
+      attribute_value = getattr(self, attribute, None)
+      if not attribute_value:
+        message = (
+            'Evidence validation failed: Required attribute {0:s} for class '
+            '{1:s} is not set. Please check original request.'.format(
+                attribute, self.name))
+        raise TurbiniaException(message)
+
 
 class Directory(Evidence):
   """Filesystem directory evidence."""
   pass
 
 
+class ChromiumProfile(Evidence):
+  """Chromium based browser profile evidence.
+
+  Attributes:
+    browser_type: The type of browser.
+      Supported options are Chrome (default) and Brave.
+    format: Output format (default is sqlite, other options are xlsx and jsonl)
+  """
+
+  REQUIRED_ATTRIBUTES = ['browser_type', 'output_format']
+
+  def __init__(self, browser_type=None, output_format=None, *args, **kwargs):
+    """Initialization for chromium profile evidence object."""
+    super(ChromiumProfile, self).__init__(*args, **kwargs)
+    self.browser_type = browser_type
+    self.output_format = output_format
+    self.copyable = True
+
+
 class RawDisk(Evidence):
   """Evidence object for Disk based evidence.
 
@@ -257,6 +299,8 @@ class BitlockerDisk(EncryptedDisk):
     unencrypted_path: A string to the unencrypted local path
   """
 
+  REQUIRED_ATTRIBUTES = ['recovery_key', 'password']
+
   def __init__(self, recovery_key=None, password=None, *args, **kwargs):
     """Initialization for Bitlocker disk evidence object"""
     self.recovery_key = recovery_key
@@ -265,6 +309,26 @@ class BitlockerDisk(EncryptedDisk):
     self.encryption_type = self.__class__.__name__
 
 
+class APFSEncryptedDisk(EncryptedDisk):
+  """APFS encrypted disk file evidence.
+
+  Attributes:
+    recovery_key: A string of the recovery key for this disk
+    password: A string of the password used for this disk. If recovery key
+        is used, this argument is ignored
+    unencrypted_path: A string to the unencrypted local path
+  """
+
+  REQUIRED_ATTRIBUTES = ['recovery_key', 'password']
+
+  def __init__(self, recovery_key=None, password=None, *args, **kwargs):
+    """Initialization for Bitlocker disk evidence object"""
+    self.recovery_key = recovery_key
+    self.password = password
+    super(APFSEncryptedDisk, self).__init__(*args, **kwargs)
+    self.encryption_type = self.__class__.__name__
+
+
 class GoogleCloudDisk(RawDisk):
   """Evidence object for Google Cloud Disks.
 
@@ -274,6 +338,8 @@ class GoogleCloudDisk(RawDisk):
     disk_name: The cloud disk name.
   """
 
+  REQUIRED_ATTRIBUTES = ['disk_name', 'project', 'zone']
+
   def __init__(self, project=None, zone=None, disk_name=None, *args, **kwargs):
     """Initialization for Google Cloud Disk."""
     self.project = project
@@ -302,6 +368,8 @@ class GoogleCloudDiskRawEmbedded(GoogleCloudDisk):
     embedded_path: The path of the raw disk image inside the Persistent Disk
   """
 
+  REQUIRED_ATTRIBUTES = ['disk_name', 'project', 'zone', 'embedded_path']
+
   def __init__(self, embedded_path=None, *args, **kwargs):
     """Initialization for Google Cloud Disk."""
     self.embedded_path = embedded_path
@@ -369,8 +437,32 @@ class FilteredTextFile(TextFile):
 class ExportedFileArtifact(Evidence):
   """Exported file artifact."""
 
+  REQUIRED_ATTRIBUTES = ['artifact_name']
+
   def __init__(self, artifact_name=None):
     """Initializes an exported file artifact."""
     super(ExportedFileArtifact, self).__init__()
     self.artifact_name = artifact_name
     self.copyable = True
+
+
+class VolatilityReport(TextFile):
+  """Volatility output file data."""
+  pass
+
+
+class RawMemory(Evidence):
+  """Evidence object for Memory based evidence.
+
+  Attributes:
+    profile (string): Volatility profile used for the analysis
+    module_list (list): Module used for the analysis
+    """
+
+  REQUIRED_ATTRIBUTES = ['module_list', 'profile']
+
+  def __init__(self, module_list=None, profile=None, *args, **kwargs):
+    """Initialization for raw memory evidence object."""
+    super(RawMemory, self).__init__(*args, **kwargs)
+    self.profile = profile
+    self.module_list = module_list
diff --git a/turbinia/jobs/__init__.py b/turbinia/jobs/__init__.py
index 93f08b4..7df9221 100644
--- a/turbinia/jobs/__init__.py
+++ b/turbinia/jobs/__init__.py
@@ -17,9 +17,11 @@ from turbinia.jobs import grep
 from turbinia.jobs import hadoop
 from turbinia.jobs import http_access_logs
 from turbinia.jobs import jenkins
+from turbinia.jobs import hindsight
 from turbinia.jobs import plaso
 from turbinia.jobs import psort
 from turbinia.jobs import sshd
 from turbinia.jobs import strings
 from turbinia.jobs import tomcat
+from turbinia.jobs import volatility
 from turbinia.jobs import worker_stat
diff --git a/turbinia/jobs/hindsight.py b/turbinia/jobs/hindsight.py
new file mode 100644
index 0000000..dce33e7
--- /dev/null
+++ b/turbinia/jobs/hindsight.py
@@ -0,0 +1,48 @@
+# -*- coding: utf-8 -*-
+# Copyright 2018 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Job to execute Hindsight tasks."""
+
+from __future__ import unicode_literals
+
+from turbinia.evidence import ChromiumProfile
+from turbinia.evidence import TextFile
+from turbinia.jobs import interface
+from turbinia.jobs import manager
+from turbinia.workers.hindsight import HindsightTask
+
+
+class HindsightJob(interface.TurbiniaJob):
+  """Hindsight analysis job.
+    This will execute hindsight against a piece of evidence.
+    """
+
+  evidence_input = [ChromiumProfile]
+  evidence_output = [TextFile]
+
+  NAME = 'HindsightJob'
+
+  def create_tasks(self, evidence):
+    """Create task.
+        Args:
+            evidence: List of evidence objects to process
+        Returns:
+            A list of tasks to schedule.
+        """
+
+    tasks = [HindsightTask() for _ in evidence]
+    return tasks
+
+
+manager.JobsManager.RegisterJob(HindsightJob)
diff --git a/turbinia/jobs/jenkins.py b/turbinia/jobs/jenkins.py
index 514a85f..c3a961b 100644
--- a/turbinia/jobs/jenkins.py
+++ b/turbinia/jobs/jenkins.py
@@ -22,6 +22,7 @@ from turbinia.evidence import GoogleCloudDisk
 from turbinia.evidence import GoogleCloudDiskRawEmbedded
 from turbinia.evidence import ReportText
 from turbinia.jobs import interface
+from turbinia.jobs import manager
 from turbinia.workers.analysis.jenkins import JenkinsAnalysisTask
 
 
@@ -46,3 +47,6 @@ class JenkinsAnalysisJob(interface.TurbiniaJob):
     """
     tasks = [JenkinsAnalysisTask() for _ in evidence]
     return tasks
+
+
+manager.JobsManager.RegisterJob(JenkinsAnalysisJob)
diff --git a/turbinia/jobs/plaso.py b/turbinia/jobs/plaso.py
index c5aa4ca..7f1dd56 100644
--- a/turbinia/jobs/plaso.py
+++ b/turbinia/jobs/plaso.py
@@ -16,6 +16,7 @@
 
 from __future__ import unicode_literals
 
+from turbinia.evidence import APFSEncryptedDisk
 from turbinia.evidence import BitlockerDisk
 from turbinia.evidence import Directory
 from turbinia.evidence import GoogleCloudDisk
@@ -32,7 +33,7 @@ class PlasoJob(interface.TurbiniaJob):
   # The types of evidence that this Job will process
   evidence_input = [
       Directory, RawDisk, GoogleCloudDisk, GoogleCloudDiskRawEmbedded,
-      BitlockerDisk
+      BitlockerDisk, APFSEncryptedDisk
   ]
   evidence_output = [PlasoFile]
 
diff --git a/turbinia/jobs/volatility.py b/turbinia/jobs/volatility.py
new file mode 100644
index 0000000..8dd1d0e
--- /dev/null
+++ b/turbinia/jobs/volatility.py
@@ -0,0 +1,55 @@
+# -*- coding: utf-8 -*-
+# Copyright 2018 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Job to execute volatility tasks."""
+
+from __future__ import unicode_literals
+
+from turbinia.evidence import RawMemory
+from turbinia.evidence import VolatilityReport
+from turbinia.jobs import interface
+from turbinia.jobs import manager
+from turbinia.workers.volatility import VolatilityTask
+
+
+class VolatilityJob(interface.TurbiniaJob):
+  """Volatility analysis job.
+
+  This will generate a Volatility task for every module per each peice of
+  evidence.
+  """
+
+  evidence_input = [RawMemory]
+  evidence_output = [VolatilityReport]
+
+  NAME = 'VolatilityJob'
+
+  def create_tasks(self, evidence):
+    """Create task for Volatility.
+
+    Args:
+      evidence: List of evidence objects to process
+
+    Returns:
+      A list of tasks to schedule.
+    """
+
+    tasks = []
+    for evidence_item in evidence:
+      for mod in evidence_item.module_list:
+        tasks.append(VolatilityTask(mod))
+    return tasks
+
+
+manager.JobsManager.RegisterJob(VolatilityJob)
diff --git a/turbinia/lib/libcloudforensics.py b/turbinia/lib/libcloudforensics.py
index bd5a7b4..bbab76f 100644
--- a/turbinia/lib/libcloudforensics.py
+++ b/turbinia/lib/libcloudforensics.py
@@ -32,6 +32,7 @@ import time
 from apiclient.discovery import build
 from googleapiclient.errors import HttpError
 from oauth2client.client import GoogleCredentials
+from oauth2client.client import ApplicationDefaultCredentialsError
 
 log = logging.getLogger('turbinia')
 
@@ -45,8 +46,16 @@ def create_service(service_name, api_version):
 
   Returns:
     API service resource (apiclient.discovery.Resource)
+
+  Raises:
+    RuntimeError: If Application Default Credentials could not be obtained.
   """
-  credentials = GoogleCredentials.get_application_default()
+  try:
+    credentials = GoogleCredentials.get_application_default()
+  except ApplicationDefaultCredentialsError as error:
+    raise RuntimeError(
+        'Could not get application default credentials: {0!s}\n'
+        'Have you run $ gcloud auth application-default login?'.format(error))
   return build(
       service_name, api_version, credentials=credentials, cache_discovery=False)
 
@@ -270,7 +279,8 @@ class GoogleCloudProject(object):
     # Max length of disk names in GCP is 63 characters
     project_id = snapshot.project.project_id
     disk_id = project_id + snapshot.disk.name
-    disk_id_crc32 = '{0:08x}'.format(binascii.crc32(disk_id) & 0xffffffff)
+    disk_id_crc32 = '{0:08x}'.format(
+        binascii.crc32(disk_id.encode()) & 0xffffffff)
     truncate_at = 62 - len(disk_id_crc32) - len('-copy') - len(project_id)
     if disk_name_prefix:
       disk_name_prefix += '-'
@@ -285,13 +295,16 @@ class GoogleCloudProject(object):
     except HttpError as exception:
       if exception.resp.status == 409:
         raise RuntimeError('Disk {0:s} already exists'.format(disk_name))
+      raise RuntimeError(
+          'Unknown error (status: {0:d}) occurred when creating '
+          'disk from snapshot:\n{1!s}'.format(exception.resp.status, exception))
     self.gce_operation(operation, zone=self.default_zone, block=True)
     return GoogleComputeDisk(
         project=self, zone=self.default_zone, name=disk_name)
 
   def get_or_create_analysis_vm(
       self, vm_name, boot_disk_size, cpu_cores=4,
-      image_project='ubuntu-os-cloud', image_family='ubuntu-1604-lts'):
+      image_project='ubuntu-os-cloud', image_family='ubuntu-1804-lts'):
     """Get or create a new virtual machine for analysis purposes.
 
     Args:
@@ -329,10 +342,22 @@ class GoogleCloudProject(object):
     source_disk_image = ubuntu_image['selfLink']
 
     # Analysis software to install.
+    # yapf: disable
     packages_to_install = [
-        'python-plaso', 'xmount', 'libfvde-tools', 'libbde-tools',
-        'plaso-tools', 'jq', 'ncdu', 'htop', 'binutils', 'upx-ucl'
-    ]
+        'binutils',
+        'docker-explorer-tools',
+        'htop',
+        'jq',
+        'libbde-tools',
+        'libfsapfs-tools',
+        'libfvde-tools',
+        'ncdu',
+        'plaso-tools',
+        'sleuthkit',
+        'upx-ucl',
+        'xmount']
+
+    # yapf: enable
 
     startup_script = """
         #!/bin/bash
@@ -715,7 +740,7 @@ def create_disk_copy(src_proj, dst_proj, instance_name, zone, disk_name=None):
 
 def start_analysis_vm(
     project, vm_name, zone, boot_disk_size, cpu_cores, attach_disk=None,
-    image_project='ubuntu-os-cloud', image_family='ubuntu-1604-lts'):
+    image_project='ubuntu-os-cloud', image_family='ubuntu-1804-lts'):
   """Start a virtual machine for analysis purposes.
 
   Args:
diff --git a/turbinia/lib/utils.py b/turbinia/lib/utils.py
index f943145..a1930cf 100644
--- a/turbinia/lib/utils.py
+++ b/turbinia/lib/utils.py
@@ -27,6 +27,37 @@ from turbinia import TurbiniaException
 log = logging.getLogger('turbinia')
 
 
+def _image_export(command, output_dir):
+  """Runs image_export command.
+
+  Args:
+    file_name: Name of file (without path) to be extracted.
+    output_dir: Path to directory to store the the extracted files.
+
+  Returns:
+    dict: file names and paths to extracted files.
+
+  Raises:
+    TurbiniaException: If an error occurs when running image_export.
+  """
+  # TODO: Consider using the exec helper to gather stdin/err.
+  log.debug('Running image_export as [{0:s}]'.format(' '.join(command)))
+  try:
+    subprocess.check_call(command)
+  except subprocess.CalledProcessError:
+    raise TurbiniaException('image_export.py failed.')
+
+  collected_file_paths = []
+  file_count = 0
+  for dirpath, _, filenames in os.walk(output_dir):
+    for filename in filenames:
+      collected_file_paths.append(os.path.join(dirpath, filename))
+      file_count += 1
+
+  log.debug('Collected {0:d} files with image_export'.format(file_count))
+  return collected_file_paths
+
+
 def extract_artifacts(artifact_names, disk_path, output_dir):
   """Extract artifacts using image_export from Plaso.
 
@@ -37,29 +68,39 @@ def extract_artifacts(artifact_names, disk_path, output_dir):
 
   Returns:
     dict: file names and paths to extracted files.
+
+  Raises:
+    TurbiniaException: If an error occurs when running image_export.
   """
   # Plaso image_export expects artifact names as a comma separated string.
   artifacts = ','.join(artifact_names)
-
   image_export_cmd = [
       'image_export.py', '--artifact_filters', artifacts, '--write', output_dir,
       '--partitions', 'all', disk_path
   ]
 
-  # TODO: Consider using the exec helper to gather stdin/err.
-  log.debug(
-      'Running image_export as [{0:s}]'.format(' '.join(image_export_cmd)))
-  try:
-    subprocess.check_call(image_export_cmd)
-  except subprocess.CalledProcessError:
-    raise TurbiniaException('image_export.py failed.')
+  return _image_export(image_export_cmd, output_dir)
 
-  collected_file_paths = []
-  for dirpath, _, filenames in os.walk(output_dir):
-    for filename in filenames:
-      collected_file_paths.append(os.path.join(dirpath, filename))
 
-  return collected_file_paths
+def extract_files(file_name, disk_path, output_dir):
+  """Extract files using image_export from Plaso.
+
+  Args:
+    file_name: Name of file (without path) to be extracted.
+    disk_path: Path to either a raw disk image or a block device.
+    output_dir: Path to directory to store the the extracted files.
+
+  Returns:
+    dict: file names and paths to extracted files.
+
+  Raises:
+    TurbiniaException: If an error occurs when running image_export.
+  """
+  image_export_cmd = [
+      'image_export.py', '--name', file_name, '--write', output_dir,
+      '--partitions', 'all', disk_path
+  ]
+  return _image_export(image_export_cmd, output_dir)
 
 
 def bruteforce_password_hashes(password_hashes, timeout=300):
diff --git a/turbinia/message.py b/turbinia/message.py
index 3ee736e..f9b3708 100644
--- a/turbinia/message.py
+++ b/turbinia/message.py
@@ -35,16 +35,19 @@ class TurbiniaRequest(object):
   """An object to request evidence to be processed.
 
   Attributes:
-    request_id: A client specified ID for this request.
-    recipe: Recipe to use when processing this request.
-    context: A Dict of context data to be passed around with this request.
-    evidence: A list of Evidence objects.
+    request_id(str): A client specified ID for this request.
+    requestor(str): The username of who made the request.
+    recipe(dict): Recipe to use when processing this request.
+    context(dict): A Dict of context data to be passed around with this request.
+    evidence(list): A list of Evidence objects.
   """
 
   def __init__(
-      self, request_id=None, recipe=None, context=None, evidence_=None):
+      self, request_id=None, requester=None, recipe=None, context=None,
+      evidence_=None):
     """Initialization for TurbiniaRequest."""
     self.request_id = request_id if request_id else uuid.uuid4().hex
+    self.requester = requester if requester else 'user_unspecified'
     self.recipe = recipe if recipe else {}
     self.context = context if context else {}
     self.evidence = evidence_ if evidence_ else []
diff --git a/turbinia/state_manager.py b/turbinia/state_manager.py
index c4d4889..d21801a 100644
--- a/turbinia/state_manager.py
+++ b/turbinia/state_manager.py
@@ -29,6 +29,7 @@ from datetime import timedelta
 import six
 
 from turbinia import config
+from turbinia.config import DATETIME_FORMAT
 from turbinia import TurbiniaException
 from turbinia.workers import TurbiniaTask
 from turbinia.workers import TurbiniaTaskResult
@@ -44,7 +45,6 @@ else:
       config.STATE_MANAGER)
   raise TurbiniaException(msg)
 
-DATETIME_FORMAT = '%Y-%m-%dT%H:%M:%S'
 MAX_DATASTORE_STRLEN = 1500
 log = logging.getLogger('turbinia')
 
@@ -101,6 +101,10 @@ class BaseStateManager(object):
         if isinstance(task_dict[attr], six.binary_type):
           task_dict[attr] = six.u(task_dict[attr])
 
+    # We'll store the run_time as seconds instead of a timedelta()
+    if task_dict.get('run_time'):
+      task_dict['run_time'] = task_dict['run_time'].total_seconds()
+
     # Set all non-existent keys to None
     all_attrs = set(
         TurbiniaTask.STORED_ATTRIBUTES + TurbiniaTaskResult.STORED_ATTRIBUTES)
@@ -154,7 +158,13 @@ class DatastoreStateManager(BaseStateManager):
 
   def __init__(self):
     config.LoadConfig()
-    self.client = datastore.Client(project=config.TURBINIA_PROJECT)
+    try:
+      self.client = datastore.Client(project=config.TURBINIA_PROJECT)
+    except EnvironmentError as e:
+      message = (
+          'Could not create Datastore client: {0!s}\n'
+          'Have you run $ gcloud auth application-default login?'.format(e))
+      raise TurbiniaException(message)
 
   def _validate_data(self, data):
     for key, value in iter(data.items()):
@@ -233,14 +243,20 @@ class RedisStateManager(BaseStateManager):
         if json.loads(self.client.get(task)).get('instance') == instance or
         not instance
     ]
+
+    # Convert relevant date attributes back into dates/timedeltas
+    for task in tasks:
+      if task.get('last_update'):
+        task['last_update'] = datetime.strptime(
+            task.get('last_update'), DATETIME_FORMAT)
+      if task.get('run_time'):
+        task['run_time'] = datetime.timedelta(seconds=task['run_time'])
+
     # pylint: disable=no-else-return
     if days:
       start_time = datetime.now() - timedelta(days=days)
       # Redis only supports strings; we convert to/from datetime here and below
-      return [
-          task for task in tasks if datetime.strptime(
-              task.get('last_update'), DATETIME_FORMAT) > start_time
-      ]
+      return [task for task in tasks if task.get('last_update') > start_time]
     elif task_id:
       return [task for task in tasks if task.get('task_id') == task_id]
     elif request_id:
@@ -256,6 +272,8 @@ class RedisStateManager(BaseStateManager):
     task_data = self.get_task_dict(task)
     task_data['last_update'] = task_data['last_update'].strftime(
         DATETIME_FORMAT)
+    if task_data['run_time']:
+      task_data['run_time'] = task_data['run_time'].total_seconds()
     # Need to use json.dumps, else redis returns single quoted string which
     # is invalid json
     if not self.client.set(key, json.dumps(task_data)):
@@ -268,6 +286,8 @@ class RedisStateManager(BaseStateManager):
     task_data = self.get_task_dict(task)
     task_data['last_update'] = task_data['last_update'].strftime(
         DATETIME_FORMAT)
+    if task_data['run_time']:
+      task_data['run_time'] = task_data['run_time'].total_seconds()
     # nx=True prevents overwriting (i.e. no unintentional task clobbering)
     if not self.client.set(key, json.dumps(task_data), nx=True):
       log.error(
diff --git a/turbinia/task_manager.py b/turbinia/task_manager.py
index af6ecd1..df22a5c 100644
--- a/turbinia/task_manager.py
+++ b/turbinia/task_manager.py
@@ -168,6 +168,7 @@ class BaseTaskManager(object):
         job_count += 1
         for task in job.create_tasks([evidence_]):
           task.base_output_dir = config.OUTPUT_DIR
+          task.requester = evidence_.config.get('requester')
           self.add_task(task, evidence_)
 
     if not job_count:
@@ -352,6 +353,7 @@ class CeleryTaskManager(BaseTaskManager):
         if not evidence_.request_id:
           evidence_.request_id = request.request_id
         evidence_.config = request.recipe
+        evidence_.config['requester'] = request.requester
         log.info(
             'Received evidence [{0:s}] from Kombu message.'.format(
                 str(evidence_)))
@@ -436,6 +438,7 @@ class PSQTaskManager(BaseTaskManager):
         if not evidence_.request_id:
           evidence_.request_id = request.request_id
         evidence_.config = request.recipe
+        evidence_.config['requester'] = request.requester
         log.info(
             'Received evidence [{0:s}] from PubSub message.'.format(
                 str(evidence_)))
diff --git a/turbinia/turbiniactl.py b/turbinia/turbiniactl.py
index 65777d9..cf3aa6b 100644
--- a/turbinia/turbiniactl.py
+++ b/turbinia/turbiniactl.py
@@ -59,6 +59,8 @@ def main():
   # by specifying evidence which will also terminate the task manager after
   # evidence has been processed.
   parser = argparse.ArgumentParser()
+  parser.add_argument(
+      '-c', '--config', action='store_true', help='Print config file')
   parser.add_argument(
       '-q', '--quiet', action='store_true', help='Show minimal output')
   parser.add_argument(
@@ -142,6 +144,25 @@ def main():
   parser_rawdisk.add_argument(
       '-n', '--name', help='Descriptive name of the evidence', required=False)
 
+  # Parser options for APFS Disk Evidence type
+  parser_apfs = subparsers.add_parser(
+      'apfs', help='Process APFSEncryptedDisk as Evidence')
+  parser_apfs.add_argument(
+      '-l', '--local_path', help='Local path to the encrypted APFS evidence',
+      required=True)
+  parser_apfs.add_argument(
+      '-r', '--recovery_key', help='Recovery key for the APFS evidence.  '
+      'Either recovery key or password must be specified.', required=False)
+  parser_apfs.add_argument(
+      '-p', '--password', help='Password for the APFS evidence.  '
+      'If a recovery key is specified concurrently, password will be ignored.',
+      required=False)
+  parser_apfs.add_argument(
+      '-s', '--source', help='Description of the source of the evidence',
+      required=False)
+  parser_apfs.add_argument(
+      '-n', '--name', help='Descriptive name of the evidence', required=False)
+
   # Parser options for Bitlocker Disk Evidence type
   parser_bitlocker = subparsers.add_parser(
       'bitlocker', help='Process Bitlocker Disk as Evidence')
@@ -210,6 +231,19 @@ def main():
   parser_googleclouddiskembedded.add_argument(
       '-n', '--name', help='Descriptive name of the evidence', required=False)
 
+  # RawMemory
+  parser_rawmemory = subparsers.add_parser(
+      'rawmemory', help='Process RawMemory as Evidence')
+  parser_rawmemory.add_argument(
+      '-l', '--local_path', help='Local path to the evidence', required=True)
+  parser_rawmemory.add_argument(
+      '-P', '--profile', help='Profile to use with Volatility', required=True)
+  parser_rawmemory.add_argument(
+      '-n', '--name', help='Descriptive name of the evidence', required=False)
+  parser_rawmemory.add_argument(
+      '-m', '--module_list', type=csv_list,
+      help='Volatility module(s) to execute', required=True)
+
   # Parser options for Directory evidence type
   parser_directory = subparsers.add_parser(
       'directory', help='Process a directory as Evidence')
@@ -221,6 +255,20 @@ def main():
   parser_directory.add_argument(
       '-n', '--name', help='Descriptive name of the evidence', required=False)
 
+  # Parser options for ChromiumProfile evidence type
+  parser_hindsight = subparsers.add_parser(
+      'hindsight', help='Process ChromiumProfile as Evidence')
+  parser_hindsight.add_argument(
+      '-l', '--local_path', help='Local path to the evidence', required=True)
+  parser_hindsight.add_argument(
+      '-f', '--format', help='Output format (supported types are '
+      'xlsx, sqlite, jsonl)', default='sqlite')
+  parser_hindsight.add_argument(
+      '-b', '--browser_type', help='The type of browser the input files belong'
+      'to (supported types are Chrome, Brave)', default='Chrome')
+  parser_hindsight.add_argument(
+      '-n', '--name', help='Descriptive name of the evidence', required=False)
+
   # List Jobs
   subparsers.add_parser(
       'listjobs',
@@ -242,6 +290,10 @@ def main():
   parser_status.add_argument(
       '-c', '--close_tasks', action='store_true',
       help='Close tasks based on Request ID or Task ID', required=False)
+  parser_status.add_argument(
+      '-C', '--csv', action='store_true',
+      help='When used with --statistics, the output will be in CSV format',
+      required=False)
   parser_status.add_argument(
       '-d', '--days_history', default=0, type=int,
       help='Number of days of history to show', required=False)
@@ -263,6 +315,9 @@ def main():
       '-R', '--full_report',
       help='Generate full markdown report instead of just a summary',
       action='store_true', required=False)
+  parser_status.add_argument(
+      '-s', '--statistics', help='Generate statistics only',
+      action='store_true', required=False)
   parser_status.add_argument(
       '-t', '--task_id', help='Show task for given Task ID', required=False)
   parser_status.add_argument(
@@ -272,6 +327,13 @@ def main():
   subparsers.add_parser('server', help='Run Turbinia Server')
 
   args = parser.parse_args()
+  if args.config:
+    try:
+      with open(config.configSource, "r") as f:
+        conf = f.read()
+        print(conf)
+    except:
+      log.info("failed to print config, Check if config exists")
   if args.quiet:
     log.setLevel(logging.ERROR)
   elif args.debug:
@@ -334,6 +396,15 @@ def main():
     evidence_ = evidence.RawDisk(
         name=args.name, local_path=local_path,
         mount_partition=args.mount_partition, source=args.source)
+  elif args.command == 'apfs':
+    if not args.password and not args.recovery_key:
+      log.error('Neither recovery key nor password is specified.')
+      sys.exit(1)
+    args.name = args.name if args.name else args.local_path
+    local_path = os.path.abspath(args.local_path)
+    evidence_ = evidence.APFSEncryptedDisk(
+        name=args.name, local_path=local_path, recovery_key=args.recovery_key,
+        password=args.password, source=args.source)
   elif args.command == 'bitlocker':
     if not args.password and not args.recovery_key:
       log.error('Neither recovery key nor password is specified.')
@@ -362,6 +433,24 @@ def main():
         name=args.name, disk_name=args.disk_name,
         embedded_path=args.embedded_path, mount_partition=args.mount_partition,
         project=args.project, zone=args.zone, source=args.source)
+  elif args.command == 'hindsight':
+    if args.format not in ['xlsx', 'sqlite', 'jsonl']:
+      log.error('Invalid output format.')
+      sys.exit(1)
+    if args.browser_type not in ['Chrome', 'Brave']:
+      log.error('Browser type not supported.')
+      sys.exit(1)
+    args.name = args.name if args.name else args.local_path
+    local_path = os.path.abspath(args.local_path)
+    evidence_ = evidence.ChromiumProfile(
+        name=args.name, local_path=local_path, output_format=args.format,
+        browser_type=args.browser_type)
+  elif args.command == 'rawmemory':
+    args.name = args.name if args.name else args.local_path
+    local_path = os.path.abspath(args.local_path)
+    evidence_ = evidence.RawMemory(
+        name=args.name, local_path=local_path, profile=args.profile,
+        module_list=args.module_list)
   elif args.command == 'psqworker':
     # Set up root logger level which is normally set by the psqworker command
     # which we are bypassing.
@@ -392,6 +481,14 @@ def main():
         )
         sys.exit(1)
 
+    if args.statistics:
+      print(
+          client.format_task_statistics(
+              instance=config.INSTANCE_ID, project=config.TURBINIA_PROJECT,
+              region=region, days=args.days_history, task_id=args.task_id,
+              request_id=args.request_id, user=args.user, csv=args.csv))
+      sys.exit(0)
+
     if args.wait and args.request_id:
       client.wait_for_request(
           instance=config.INSTANCE_ID, project=config.TURBINIA_PROJECT,
@@ -452,7 +549,8 @@ def main():
     server.add_evidence(evidence_)
     server.start()
   elif evidence_:
-    request = TurbiniaRequest(request_id=args.request_id)
+    request = TurbiniaRequest(
+        request_id=args.request_id, requester=getpass.getuser())
     request.evidence.append(evidence_)
     if filter_patterns:
       request.recipe['filter_patterns'] = filter_patterns
@@ -462,6 +560,7 @@ def main():
       request.recipe['jobs_whitelist'] = args.jobs_whitelist
     if args.dump_json:
       print(request.to_json().encode('utf-8'))
+      sys.exit(0)
     else:
       log.info(
           'Creating request {0:s} with evidence {1:s}'.format(
diff --git a/turbinia/workers/__init__.py b/turbinia/workers/__init__.py
index b86a0e2..c8ef47e 100644
--- a/turbinia/workers/__init__.py
+++ b/turbinia/workers/__init__.py
@@ -34,6 +34,7 @@ import turbinia
 import filelock
 
 from turbinia import config
+from turbinia.config import DATETIME_FORMAT
 from turbinia.evidence import evidence_decode
 from turbinia import output_manager
 from turbinia import TurbiniaException
@@ -75,15 +76,15 @@ class TurbiniaTaskResult(object):
       successful: Bool indicating success status.
       task_id: Task ID of the parent task.
       task_name: Name of parent task.
-      user: The user who requested the task.
+      requester: The user who requested the task.
       worker_name: Name of worker task executed on.
       _log: A list of log messages
   """
 
   # The list of attributes that we will persist into storage
   STORED_ATTRIBUTES = [
-      'worker_name', 'report_data', 'report_priority', 'status', 'saved_paths',
-      'successful'
+      'worker_name', 'report_data', 'report_priority', 'run_time', 'status',
+      'saved_paths', 'successful'
   ]
 
   def __init__(
@@ -100,7 +101,7 @@ class TurbiniaTaskResult(object):
 
     self.task_id = None
     self.task_name = None
-    self.user = None
+    self.requester = None
     self.output_dir = None
 
     self.report_data = None
@@ -130,7 +131,7 @@ class TurbiniaTaskResult(object):
 
     self.task_id = task.id
     self.task_name = task.name
-    self.user = task.user
+    self.requester = task.requester
     if task.output_manager.is_setup:
       _, self.output_dir = task.output_manager.get_local_output_dirs()
     else:
@@ -266,7 +267,7 @@ class TurbiniaTaskResult(object):
       dict: Object dictionary that is JSON serializable.
     """
     self.run_time = self.run_time.total_seconds() if self.run_time else None
-    self.start_time = str(self.start_time)
+    self.start_time = self.start_time.strftime(DATETIME_FORMAT)
     if self.input_evidence:
       self.input_evidence = self.input_evidence.serialize()
     self.evidence = [x.serialize() for x in self.evidence]
@@ -286,8 +287,7 @@ class TurbiniaTaskResult(object):
     result.__dict__.update(input_dict)
     if result.run_time:
       result.run_time = timedelta(seconds=result.run_time)
-    result.start_time = datetime.strptime(
-        result.start_time, '%Y-%m-%d %H:%M:%S.%f')
+    result.start_time = datetime.strptime(result.start_time, DATETIME_FORMAT)
     if result.input_evidence:
       result.input_evidence = evidence_decode(result.input_evidence)
     result.evidence = [evidence_decode(x) for x in result.evidence]
@@ -315,21 +315,22 @@ class TurbiniaTask(object):
           this is a task result object, but other implementations have their
           own stub objects.
       tmp_dir: Temporary directory for Task to write to.
-      user: The user who requested the task.
+      requester: The user who requested the task.
       _evidence_config (dict): The config that we want to pass to all new
             evidence created from this task.
   """
 
   # The list of attributes that we will persist into storage
-  STORED_ATTRIBUTES = ['id', 'last_update', 'name', 'request_id', 'user']
+  STORED_ATTRIBUTES = ['id', 'last_update', 'name', 'request_id', 'requester']
 
   def __init__(
-      self, name=None, base_output_dir=None, request_id=None, user=None):
+      self, name=None, base_output_dir=None, request_id=None, requester=None):
     """Initialization for TurbiniaTask."""
     if base_output_dir:
       self.base_output_dir = base_output_dir
     else:
       self.base_output_dir = config.OUTPUT_DIR
+
     self.id = uuid.uuid4().hex
     self.last_update = datetime.now()
     self.name = name if name else self.__class__.__name__
@@ -342,7 +343,7 @@ class TurbiniaTask(object):
     self.stub = None
     self.tmp_dir = None
     self.turbinia_version = turbinia.__version__
-    self.user = user if user else getpass.getuser()
+    self.requester = requester if requester else 'user_unspecified'
     self._evidence_config = {}
 
   def serialize(self):
@@ -353,7 +354,7 @@ class TurbiniaTask(object):
     """
     task_copy = deepcopy(self.__dict__)
     task_copy['output_manager'] = self.output_manager.__dict__
-    task_copy['last_update'] = str(self.last_update)
+    task_copy['last_update'] = self.last_update.strftime(DATETIME_FORMAT)
     return task_copy
 
   @classmethod
@@ -381,7 +382,7 @@ class TurbiniaTask(object):
     task.output_manager = output_manager.OutputManager()
     task.output_manager.__dict__.update(input_dict['output_manager'])
     task.last_update = datetime.strptime(
-        input_dict['last_update'], '%Y-%m-%d %H:%M:%S.%f')
+        input_dict['last_update'], DATETIME_FORMAT)
     return task
 
   def execute(
@@ -592,6 +593,7 @@ class TurbiniaTask(object):
       try:
         self.result = self.setup(evidence)
         original_result_id = self.result.id
+        evidence.validate()
 
         if self.turbinia_version != turbinia.__version__:
           message = (
diff --git a/turbinia/workers/analysis/jenkins.py b/turbinia/workers/analysis/jenkins.py
index e9a2be3..2eaad94 100644
--- a/turbinia/workers/analysis/jenkins.py
+++ b/turbinia/workers/analysis/jenkins.py
@@ -24,7 +24,7 @@ from turbinia.evidence import ReportText
 from turbinia.lib import text_formatter as fmt
 from turbinia.workers import TurbiniaTask
 from turbinia.workers import Priority
-from turbinia.lib.utils import extract_artifacts
+from turbinia.lib.utils import extract_files
 from turbinia.lib.utils import bruteforce_password_hashes
 
 
@@ -51,17 +51,26 @@ class JenkinsAnalysisTask(TurbiniaTask):
     # Set the output file as the data source for the output evidence.
     output_evidence.local_path = output_file_path
 
+    # TODO(aarontp): We should find a more optimal solution for this because
+    # this requires traversing the entire filesystem and extracting more files
+    # than we need.  Tracked in https://github.com/google/turbinia/issues/402
     try:
-      collected_artifacts = extract_artifacts(
-          artifact_names=['JenkinsConfigFile'], disk_path=evidence.local_path,
+      collected_artifacts = extract_files(
+          file_name='config.xml', disk_path=evidence.local_path,
           output_dir=os.path.join(self.output_dir, 'artifacts'))
     except TurbiniaException as e:
       result.close(self, success=False, status=str(e))
       return result
 
+    jenkins_artifacts = []
+    jenkins_re = re.compile(r'^.*jenkins[^\/]*(\/users\/[^\/]+)*\/config\.xml$')
+    for collected_artifact in collected_artifacts:
+      if re.match(jenkins_re, collected_artifact):
+        jenkins_artifacts.append(collected_artifact)
+
     version = None
     credentials = []
-    for filepath in collected_artifacts:
+    for filepath in jenkins_artifacts:
       with open(filepath, 'r') as input_file:
         config = input_file.read()
 
@@ -169,9 +178,14 @@ class JenkinsAnalysisTask(TurbiniaTask):
         line = 'User "{0:s}" with password "{1:s}"'.format(
             credentials_registry.get(password_hash), plaintext)
         report.append(fmt.bullet(line, level=2))
+    elif credentials_registry or version != 'Unknown':
+      summary = (
+          'Jenkins version {0:s} found with {1:d} credentials, but no issues '
+          'detected'.format(version, len(credentials_registry)))
+      report.insert(0, fmt.heading4(summary))
+      priority = Priority.MEDIUM
     else:
-      summary = 'Jenkins analysis found no issues'
-      priority = Priority.LOW
+      summary = 'No Jenkins instance found'
       report.insert(0, fmt.heading4(summary))
 
     report = '\n'.join(report)
diff --git a/turbinia/workers/hindsight.py b/turbinia/workers/hindsight.py
new file mode 100644
index 0000000..a00da10
--- /dev/null
+++ b/turbinia/workers/hindsight.py
@@ -0,0 +1,55 @@
+# -*- coding: utf-8 -*-
+# Copyright 2015 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Task for executing hindsight."""
+
+from __future__ import unicode_literals
+
+import os
+
+from turbinia.evidence import TextFile
+from turbinia.workers import TurbiniaTask
+
+
+class HindsightTask(TurbiniaTask):
+  """Task to execute hindsight."""
+
+  def __init__(self):
+    super(HindsightTask, self).__init__()
+
+  def run(self, evidence, result):
+    """Task to execute hindsight."""
+
+    # Create the new Evidence object that will be generated by this Task.
+    output_evidence = TextFile()
+
+    # Create a path that we can write the new file to.
+    output_file_path = os.path.join(self.output_dir, 'hindsight_report')
+    output_evidence.local_path = output_file_path
+
+    # Create a path for the log file
+    hindsight_log = os.path.join(self.output_dir, 'hindsight.log')
+
+    cmd = [
+        'hindsight.py', '-i', evidence.local_path, '--format',
+        evidence.output_format, '--browser_type', evidence.browser_type,
+        '--output', output_file_path, '-l', hindsight_log
+    ]
+
+    result.log('Running hindsight as [{0:s}]'.format(' '.join(cmd)))
+    self.execute(
+        cmd, result, log_files=[hindsight_log], new_evidence=[output_evidence],
+        close=True)
+
+    return result
diff --git a/turbinia/workers/plaso.py b/turbinia/workers/plaso.py
index 5e42f05..0215f09 100644
--- a/turbinia/workers/plaso.py
+++ b/turbinia/workers/plaso.py
@@ -19,6 +19,7 @@ from __future__ import unicode_literals
 import os
 
 from turbinia import config
+from turbinia.evidence import APFSEncryptedDisk
 from turbinia.evidence import BitlockerDisk
 from turbinia.evidence import PlasoFile
 from turbinia.workers import TurbiniaTask
@@ -53,7 +54,7 @@ class PlasoTask(TurbiniaTask):
     if config.DEBUG_TASKS:
       cmd.append('-d')
 
-    if isinstance(evidence, BitlockerDisk):
+    if isinstance(evidence, (APFSEncryptedDisk, BitlockerDisk)):
       if evidence.recovery_key:
         cmd.extend([
             '--credential', 'recovery_password:{0:s}'.format(
diff --git a/turbinia/workers/volatility.py b/turbinia/workers/volatility.py
new file mode 100644
index 0000000..6810d21
--- /dev/null
+++ b/turbinia/workers/volatility.py
@@ -0,0 +1,107 @@
+# -*- coding: utf-8 -*-
+# Copyright 2015 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Task for executing volatility."""
+
+from __future__ import unicode_literals
+
+import os
+
+from turbinia import config
+from turbinia.evidence import VolatilityReport
+from turbinia.workers import TurbiniaTask
+
+MAX_REPORT_SIZE = 2**30  # 1 GiB
+
+
+class VolatilityTask(TurbiniaTask):
+  """Task to execute volatility.
+
+  Attributes:
+    module(str): The name of the volatility module to run.
+  """
+
+  def __init__(self, module='test', *args, **kwargs):
+    super(VolatilityTask, self).__init__(*args, **kwargs)
+    self.module = module
+
+  def run(self, evidence, result):
+    """Run volatility against evidence.
+
+    Args:
+        evidence (Evidence object): The evidence we will process
+        result (TurbiniaTaskResult): The object to place task results into.
+
+    Returns:
+        TurbiniaTaskResult object.
+    """
+    config.LoadConfig()
+
+    # Create the new Evidence object that will be generated by this Task.
+    output_evidence = VolatilityReport()
+
+    # Create a path that we can write the new file to.
+    output_file_path = os.path.join(
+        self.output_dir, '{0:s}.txt'.format(self.id))
+    output_evidence.local_path = output_file_path
+
+    # TODO: Add in config options for Turbinia
+    cmd = (
+        'vol.py -f {0:s} --profile={1:s} {2:s} --output=text '
+        '--output-file={3:s}').format(
+            evidence.local_path, evidence.profile, self.module,
+            output_file_path).split()
+
+    result.log('Running volatility as [{0:s}]'.format(' '.join(cmd)))
+    res = self.execute(cmd, result, new_evidence=[output_evidence], close=True)
+
+    if res == 0:
+      success = True
+      # Get report data from the output file.
+      try:
+        file_size = os.stat(output_file_path).st_size
+      except (IOError, OSError) as exception:
+        msg = 'Unable to determine size of output file {0:s}: {1!s}'.format(
+            output_file_path, exception)
+        summary = 'Volatility ran successfully, but no output file was created'
+        result.log(msg)
+        result.close(self, success=False, status=summary)
+        return result
+
+      if file_size > MAX_REPORT_SIZE:
+        result.log(
+            'Volatility report output size ({0:d}) is greater than max report '
+            'size ({1:d}). Truncating report to max size'.format(
+                file_size, MAX_REPORT_SIZE))
+        summary = (
+            'Volatility module {0:s} successfuly ran (report truncated)'.format(
+                self.module))
+      else:
+        summary = 'Volatility module {0:s} successfully ran'.format(self.module)
+
+      with open(output_file_path, 'rb') as fh:
+        report_data = fh.read(MAX_REPORT_SIZE)
+        try:
+          output_evidence.text_data = report_data.decode('utf-8')
+        except UnicodeDecodeError as e:
+          success = False
+          summary = 'Volatility report could not be read: {0!s}'.format(e)
+
+      result.report_data = output_evidence.text_data
+      result.close(self, success=success, status=summary)
+    else:
+      summary = 'Volatility module {0:s} failed to run'.format(self.module)
+      result.close(self, success=False, status=summary)
+
+    return result


Test patch:
diff --git a/turbinia/client_test.py b/turbinia/client_test.py
index 3ed2666..24c9a64 100644
--- a/turbinia/client_test.py
+++ b/turbinia/client_test.py
@@ -17,6 +17,8 @@
 from __future__ import unicode_literals
 
 from datetime import datetime
+from datetime import timedelta
+import json
 import unittest
 import os
 import shutil
@@ -28,6 +30,7 @@ import mock
 from turbinia import config
 from turbinia.client import TurbiniaClient
 from turbinia.client import TurbiniaServer
+from turbinia.client import TurbiniaStats
 from turbinia.client import TurbiniaPsqWorker
 from turbinia import TurbiniaException
 
@@ -58,7 +61,7 @@ LONG_REPORT = textwrap.dedent(
     ## TaskName2
     * **Status:** This second fake task executed
     * Task Id: 0xfakeTaskId2
-    * Executed on worker fake_worker
+    * Executed on worker fake_worker2
 
     ### Task Reported Data
     #### Fake High priority Report
@@ -83,7 +86,7 @@ LONG_REPORT_FILES = textwrap.dedent(
     ## TaskName2
     * **Status:** This second fake task executed
     * Task Id: 0xfakeTaskId2
-    * Executed on worker fake_worker
+    * Executed on worker fake_worker2
 
     ### Task Reported Data
     #### Fake High priority Report
@@ -110,50 +113,87 @@ LONG_REPORT_FILES = textwrap.dedent(
     * None
 """)
 
+STATISTICS_REPORT = textwrap.dedent(
+    """\
+    Execution time statistics for Turbinia:
+
+    All Tasks: Count: 3, Min: 0:01:00, Mean: 0:03:00, Max: 0:05:00
+    Successful Tasks: Count: 2, Min: 0:01:00, Mean: 0:05:00, Max: 0:05:00
+    Failed Tasks: Count: 1, Min: 0:03:00, Mean: 0:03:00, Max: 0:03:00
+    Total Request Time: Count: 2, Min: 0:03:00, Mean: 0:21:00, Max: 0:21:00
+    Task type TaskName: Count: 1, Min: 0:01:00, Mean: 0:01:00, Max: 0:01:00
+    Task type TaskName2: Count: 1, Min: 0:05:00, Mean: 0:05:00, Max: 0:05:00
+    Task type TaskName3: Count: 1, Min: 0:03:00, Mean: 0:03:00, Max: 0:03:00
+    Worker fake_worker: Count: 2, Min: 0:01:00, Mean: 0:03:00, Max: 0:03:00
+    Worker fake_worker2: Count: 1, Min: 0:05:00, Mean: 0:05:00, Max: 0:05:00
+    User myuser: Count: 2, Min: 0:01:00, Mean: 0:05:00, Max: 0:05:00
+    User myuser2: Count: 1, Min: 0:03:00, Mean: 0:03:00, Max: 0:03:00
+""")
+
+STATISTICS_REPORT_CSV = textwrap.dedent(
+    """\
+    stat_type, count, min, mean, max
+    All Tasks, 3, 0:01:00, 0:03:00, 0:05:00
+    Successful Tasks, 2, 0:01:00, 0:05:00, 0:05:00
+    Failed Tasks, 1, 0:03:00, 0:03:00, 0:03:00
+    Total Request Time, 2, 0:03:00, 0:21:00, 0:21:00
+    Task type TaskName, 1, 0:01:00, 0:01:00, 0:01:00
+    Task type TaskName2, 1, 0:05:00, 0:05:00, 0:05:00
+    Task type TaskName3, 1, 0:03:00, 0:03:00, 0:03:00
+    Worker fake_worker, 2, 0:01:00, 0:03:00, 0:03:00
+    Worker fake_worker2, 1, 0:05:00, 0:05:00, 0:05:00
+    User myuser, 2, 0:01:00, 0:05:00, 0:05:00
+    User myuser2, 1, 0:03:00, 0:03:00, 0:03:00
+""")
+
 
 class TestTurbiniaClient(unittest.TestCase):
   """Test Turbinia client class."""
 
   def setUp(self):
+    last_update = datetime.now()
     self.task_data = [
         {
             'id': '0xfakeTaskId',
             'instance': 'MyTurbiniaInstance',
-            'last_update': datetime.now(),
+            'last_update': last_update,
             'name': 'TaskName',
             'report_data': '#### Fake Low priority Report\n* Fake Bullet',
             'report_priority': 80,
             'request_id': '0xFakeRequestId',
+            'run_time': timedelta(minutes=1),
             'saved_paths': ['/no/path/', '/fake/path'],
             'status': 'This fake task executed',
             'successful': True,
-            'user': 'myuser',
+            'requester': 'myuser',
             'worker_name': 'fake_worker'
         }, {
             'id': '0xfakeTaskId2',
             'instance': 'MyTurbiniaInstance',
-            'last_update': datetime.now(),
+            'last_update': last_update + timedelta(minutes=20),
             'name': 'TaskName2',
             'report_data': '#### Fake High priority Report\n* Fake Bullet',
             'report_priority': 10,
             'request_id': '0xFakeRequestId',
+            'run_time': timedelta(minutes=5),
             'saved_paths': ['/no/path/2', '/fake/path/2'],
             'status': 'This second fake task executed',
             'successful': True,
-            'user': 'myuser',
-            'worker_name': 'fake_worker'
+            'requester': 'myuser',
+            'worker_name': 'fake_worker2'
         }, {
             'id': '0xfakeTaskId3',
             'instance': 'MyTurbiniaInstance',
-            'last_update': datetime.now(),
+            'last_update': last_update,
             'name': 'TaskName3',
             'report_data': '',
             'report_priority': 80,
-            'request_id': '0xFakeRequestId',
+            'request_id': '0xFakeRequestId2',
+            'run_time': timedelta(minutes=3),
             'saved_paths': ['/no/path/3', '/fake/path/3'],
             'status': 'Third Task Failed...',
             'successful': False,
-            'user': 'myuser',
+            'requester': 'myuser2',
             'worker_name': 'fake_worker'
         }
     ] # yapf: disable
@@ -173,11 +213,20 @@ class TestTurbiniaClient(unittest.TestCase):
   def testTurbiniaClientGetTaskData(self, _, __, mock_cloud_function):
     """Basic test for client.get_task_data"""
     # ExecuteFunction returns a dict with a 'result' key that has a json-encoded
-    # list.
-    function_return = {'result': '["bar", "baz"]'}
+    # list.  This contains our task data, which is a list of dicts.
+    run_time = timedelta(seconds=3)
+    test_task_data = [{'bar': 'bar2', 'run_time': run_time.total_seconds()}]
+    gcf_result = [test_task_data, 'Unused GCF data']
+    gcf_result = json.dumps(gcf_result)
+    function_return = {'result': gcf_result}
     mock_cloud_function.return_value = function_return
     client = TurbiniaClient()
-    self.assertEqual(client.get_task_data("inst", "proj", "reg"), "bar")
+    task_data = client.get_task_data('inst', 'proj', 'reg')
+    # get_task_data() converts this back into a timedelta(). We returned it
+    # seconds from the GCF function call because that is what it is stored in
+    # Datastore as.
+    test_task_data[0]['run_time'] = run_time
+    self.assertEqual(task_data, test_task_data)
 
   @mock.patch('turbinia.client.GoogleCloudFunction.ExecuteFunction')
   @mock.patch('turbinia.client.task_manager.PSQTaskManager._backend_setup')
@@ -200,6 +249,64 @@ class TestTurbiniaClient(unittest.TestCase):
     self.assertRaises(
         TurbiniaException, client.get_task_data, "inst", "proj", "reg")
 
+  @mock.patch('turbinia.client.GoogleCloudFunction.ExecuteFunction')
+  @mock.patch('turbinia.client.task_manager.PSQTaskManager._backend_setup')
+  @mock.patch('turbinia.state_manager.get_state_manager')
+  def testClientFormatTaskStatistics(self, _, __, ___):
+    """Tests format_task_statistics() report output."""
+    client = TurbiniaClient()
+    client.get_task_data = mock.MagicMock()
+    client.get_task_data.return_value = self.task_data
+    stats_report = client.format_task_statistics('inst', 'proj', 'reg')
+    self.maxDiff = None
+    self.assertEqual(stats_report, STATISTICS_REPORT)
+
+  @mock.patch('turbinia.client.GoogleCloudFunction.ExecuteFunction')
+  @mock.patch('turbinia.client.task_manager.PSQTaskManager._backend_setup')
+  @mock.patch('turbinia.state_manager.get_state_manager')
+  def testClientFormatTaskStatisticsCsv(self, _, __, ___):
+    """Tests format_task_statistics() CSV report output."""
+    client = TurbiniaClient()
+    client.get_task_data = mock.MagicMock()
+    client.get_task_data.return_value = self.task_data
+    stats_report = client.format_task_statistics(
+        'inst', 'proj', 'reg', csv=True)
+    self.maxDiff = None
+    self.assertEqual(stats_report, STATISTICS_REPORT_CSV)
+
+  @mock.patch('turbinia.client.GoogleCloudFunction.ExecuteFunction')
+  @mock.patch('turbinia.client.task_manager.PSQTaskManager._backend_setup')
+  @mock.patch('turbinia.state_manager.get_state_manager')
+  def testClientGetTaskStatistics(self, _, __, ___):
+    """Tests get_task_statistics() basic functionality."""
+    client = TurbiniaClient()
+    client.get_task_data = mock.MagicMock()
+    client.get_task_data.return_value = self.task_data
+    task_stats = client.get_task_statistics('inst', 'proj', 'reg')
+
+    # Make sure we have the right number of tasks for all sections
+    self.assertEqual(task_stats['all_tasks'].count, 3)
+    self.assertEqual(task_stats['successful_tasks'].count, 2)
+    self.assertEqual(task_stats['failed_tasks'].count, 1)
+    self.assertEqual(task_stats['requests'].count, 2)
+    self.assertEqual(len(task_stats['tasks_per_user']), 2)
+    self.assertEqual(len(task_stats['tasks_per_worker']), 2)
+    self.assertEqual(len(task_stats['tasks_per_type']), 3)
+
+    # Checking min/mean/max
+    self.assertEqual(task_stats['all_tasks'].min, timedelta(minutes=1))
+    self.assertEqual(task_stats['all_tasks'].mean, timedelta(minutes=3))
+    self.assertEqual(task_stats['all_tasks'].max, timedelta(minutes=5))
+    # Delta for this is 21 minutes because the last_update for 0xfakeTaskId2 is
+    # 20 minutes later than the first task, and the first task ran for 1 minute.
+    self.assertEqual(task_stats['requests'].max, timedelta(minutes=21))
+    self.assertEqual(
+        task_stats['tasks_per_user']['myuser'].max, timedelta(minutes=5))
+    self.assertEqual(
+        task_stats['tasks_per_worker']['fake_worker'].max, timedelta(minutes=3))
+    self.assertEqual(
+        task_stats['tasks_per_type']['TaskName2'].mean, timedelta(minutes=5))
+
   @mock.patch('turbinia.client.GoogleCloudFunction.ExecuteFunction')
   @mock.patch('turbinia.client.task_manager.PSQTaskManager._backend_setup')
   @mock.patch('turbinia.state_manager.get_state_manager')
@@ -217,8 +324,8 @@ class TestTurbiniaClient(unittest.TestCase):
   @mock.patch('turbinia.client.GoogleCloudFunction.ExecuteFunction')
   @mock.patch('turbinia.client.task_manager.PSQTaskManager._backend_setup')
   @mock.patch('turbinia.state_manager.get_state_manager')
-  def testClientFormatTaskStatus(self, _, __, ___):
-    """Tests format_task_status() has valid output."""
+  def testClientFormatTaskStatusShortReport(self, _, __, ___):
+    """Tests format_task_status() has valid output with short report."""
     client = TurbiniaClient()
     client.get_task_data = mock.MagicMock()
     client.get_task_data.return_value = self.task_data
@@ -249,6 +356,71 @@ class TestTurbiniaClient(unittest.TestCase):
     self.assertEqual(result.strip(), LONG_REPORT_FILES.strip())
 
 
+class TestTurbiniaStats(unittest.TestCase):
+  """Test TurbiniaStats class."""
+
+  def testTurbiniaStatsAddTask(self):
+    """Tests TurbiniaStats.add_task() method."""
+    test_task = {'run_time': None, 'last_update': None}
+    stats = TurbiniaStats()
+    stats.add_task(test_task)
+    self.assertIn(test_task, stats.tasks)
+    self.assertEqual(stats.count, 1)
+
+  def testTurbiniaStatsCalculateStats(self):
+    """Tests TurbiniaStats.calculateStats() method."""
+    last_update = datetime.now()
+    test_task1 = {'run_time': timedelta(minutes=3), 'last_update': last_update}
+    test_task2 = {'run_time': timedelta(minutes=5), 'last_update': last_update}
+    test_task3 = {'run_time': timedelta(minutes=1), 'last_update': last_update}
+
+    stats = TurbiniaStats()
+    stats.add_task(test_task1)
+    stats.add_task(test_task2)
+    stats.add_task(test_task3)
+    stats.calculate_stats()
+
+    self.assertEqual(stats.min, timedelta(minutes=1))
+    self.assertEqual(stats.mean, timedelta(minutes=3))
+    self.assertEqual(stats.max, timedelta(minutes=5))
+    self.assertEqual(stats.count, 3)
+
+  def testTurbiniaStatsCalculateStatsEmpty(self):
+    """Tests that calculate_stats() works when no tasks are added."""
+    stats = TurbiniaStats()
+    stats.calculate_stats()
+    self.assertEqual(stats.count, 0)
+    self.assertEqual(stats.min, None)
+
+  def testTurbiniaStatsFormatStats(self):
+    """Tests TurbiniaStats.format_stats() returns valid output."""
+    test_output = (
+        'Test Task Results: Count: 1, Min: 0:03:00, Mean: 0:03:00, '
+        'Max: 0:03:00')
+    test_task1 = {
+        'run_time': timedelta(minutes=3),
+        'last_update': datetime.now()
+    }
+    stats = TurbiniaStats('Test Task Results')
+    stats.add_task(test_task1)
+    stats.calculate_stats()
+    report = stats.format_stats()
+    self.assertEqual(report, test_output)
+
+  def testTurbiniaStatsFormatStatsCsv(self):
+    """Tests TurbiniaStats.format_stats() returns valid CSV output."""
+    test_output = ('Test Task Results, 1, 0:03:00, 0:03:00, 0:03:00')
+    test_task1 = {
+        'run_time': timedelta(minutes=3),
+        'last_update': datetime.now()
+    }
+    stats = TurbiniaStats('Test Task Results')
+    stats.add_task(test_task1)
+    stats.calculate_stats()
+    report = stats.format_stats_csv()
+    self.assertEqual(report, test_output)
+
+
 class TestTurbiniaServer(unittest.TestCase):
   """Test Turbinia Server class."""
 
diff --git a/turbinia/evidence_test.py b/turbinia/evidence_test.py
index 15a9c35..d1c02f4 100644
--- a/turbinia/evidence_test.py
+++ b/turbinia/evidence_test.py
@@ -46,3 +46,24 @@ class TestTurbiniaEvidence(unittest.TestCase):
     """Test that evidence_decode throws error on dict with no type attribute."""
     test = {1: 2, 3: 4}
     self.assertRaises(TurbiniaException, evidence.evidence_decode, test)
+
+  def testEvidenceValidation(self):
+    """Test successful evidence validation."""
+    rawdisk = evidence.RawDisk(
+        name='My Evidence', local_path='/tmp/foo', mount_path='/mnt/foo')
+    rawdisk.REQUIRED_ATTRIBUTES = ['name', 'local_path', 'mount_path']
+    rawdisk.validate()
+
+  def testEvidenceValidationEmptyAttribute(self):
+    """Test failed evidence validation with an empty attribute."""
+    rawdisk = evidence.RawDisk(
+        name='My Evidence', local_path=None, mount_path='/mnt/foo')
+    rawdisk.REQUIRED_ATTRIBUTES = ['name', 'local_path', 'mount_path']
+    self.assertRaises(TurbiniaException, rawdisk.validate)
+
+  def testEvidenceValidationNoAttribute(self):
+    """Test failed evidence validation with no attribute."""
+    rawdisk = evidence.RawDisk(
+        name='My Evidence', local_path='/tmp/foo', mount_path='/mnt/foo')
+    rawdisk.REQUIRED_ATTRIBUTES = ['doesnotexist']
+    self.assertRaises(TurbiniaException, rawdisk.validate)
diff --git a/turbinia/workers/analysis/jenkins_test.py b/turbinia/workers/analysis/jenkins_test.py
index 53850d9..cba7245 100644
--- a/turbinia/workers/analysis/jenkins_test.py
+++ b/turbinia/workers/analysis/jenkins_test.py
@@ -111,7 +111,7 @@ class JenkinsAnalysisTaskTest(unittest.TestCase):
     * User \"admin\" with password \"weakpassword\"
 """
 
-  JENKINS_ANALYSIS_EMPTY_REPORT = """#### Jenkins analysis found no issues
+  JENKINS_ANALYSIS_EMPTY_REPORT = """#### Jenkins version 2.121.2 found with 1 credentials, but no issues detected
 * Jenkins version: 2.121.2"""
 
   EXPECTED_VERSION = '2.121.2'
@@ -166,8 +166,11 @@ class JenkinsAnalysisTaskTest(unittest.TestCase):
         self.EXPECTED_VERSION, self.EXPECTED_CREDENTIALS)
 
     self.assertEqual(report, self.JENKINS_ANALYSIS_EMPTY_REPORT)
-    self.assertEqual(priority, 80)
-    self.assertEqual(summary, 'Jenkins analysis found no issues')
+    self.assertEqual(priority, 50)
+    self.assertEqual(
+        summary,
+        'Jenkins version 2.121.2 found with 1 credentials, but no issues '
+        'detected')
 
 
 if __name__ == '__main__':
diff --git a/turbinia/workers/volatility_test.py b/turbinia/workers/volatility_test.py
new file mode 100644
index 0000000..179cf0a
--- /dev/null
+++ b/turbinia/workers/volatility_test.py
@@ -0,0 +1,79 @@
+# -*- coding: utf-8 -*-
+# Copyright 2015 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Tests for VolatilityTask."""
+
+from __future__ import unicode_literals
+
+import os
+import mock
+
+from turbinia.evidence import RawMemory
+from turbinia.workers.workers_test import TestTurbiniaTaskBase
+from turbinia.workers import volatility
+
+
+class VolatilityTaskTest(TestTurbiniaTaskBase):
+  """Tests for VolatilityTask."""
+
+  def setUp(self):
+    super(VolatilityTaskTest, self).setUp(
+        task_class=volatility.VolatilityTask, evidence_class=RawMemory)
+    self.original_max_report_size = volatility.MAX_REPORT_SIZE
+    self.evidence.profile = 'TestProfile'
+    self.evidence.module_list = ['TestModule']
+    self.task.output_dir = self.task.base_output_dir
+    self.output_file_path = os.path.join(
+        self.task.output_dir, '{0:s}.txt'.format(self.task.id))
+    self.remove_files.append(self.output_file_path)
+    self.setResults(mock_run=False)
+
+  def tearDown(self):
+    super(VolatilityTaskTest, self).tearDown()
+    volatility.MAX_REPORT_SIZE = self.original_max_report_size
+
+  def testVolatilityTaskRun(self):
+    """Test successful volatility task run."""
+    file_contents = 'Test file contents'
+    self.task.execute = mock.MagicMock(return_value=0)
+    with open(self.output_file_path, 'w') as fh:
+      fh.write(file_contents)
+    self.task.run(self.evidence, self.result)
+
+    self.assertEqual(file_contents, self.result.report_data)
+    self.result.close.assert_called_with(
+        self.task, success=True, status=mock.ANY)
+
+  def testVolatilityTaskRunLargeOutput(self):
+    """Test volatility run with large output."""
+    volatility.MAX_REPORT_SIZE = 9
+    file_contents = 'Test file contents'
+    truncated_file_contents = file_contents[:volatility.MAX_REPORT_SIZE]
+    self.task.execute = mock.MagicMock(return_value=0)
+    with open(self.output_file_path, 'w') as fh:
+      fh.write(file_contents)
+    self.task.run(self.evidence, self.result)
+
+    self.assertEqual(truncated_file_contents, self.result.report_data)
+    self.result.close.assert_called_with(
+        self.task, success=True, status=mock.ANY)
+
+  def testVolatilityTaskRunNoOutput(self):
+    """Test volatility task run with no output."""
+    self.task.execute = mock.MagicMock(return_value=0)
+    self.task.run(self.evidence, self.result)
+
+    self.assertEqual(None, self.result.report_data)
+    self.result.close.assert_called_with(
+        self.task, success=False, status=mock.ANY)
diff --git a/turbinia/workers/workers_test.py b/turbinia/workers/workers_test.py
index f90bb8d..1b45874 100644
--- a/turbinia/workers/workers_test.py
+++ b/turbinia/workers/workers_test.py
@@ -29,27 +29,39 @@ from turbinia.workers import TurbiniaTaskResult
 from turbinia.workers.plaso import PlasoTask
 
 
-class TestTurbiniaTask(unittest.TestCase):
-  """Test TurbiniaTask class."""
-
-  def setUp(self):
+class TestTurbiniaTaskBase(unittest.TestCase):
+  """Test TurbiniaTask class.
+
+  Attributes:
+    class_task(TurbiniaTask): The class the test should instantiated
+    remove_file(list(str)): Files that will be removed after the test run
+    remove_dirs(list(str)): Dirs that will be removed after the test run
+    base_output_dir(str): The base output directory used by the Task
+    task(TurbiniaTask): The instantiated Task under test
+    evidence(Evidence): The test evidence object used by the Task
+    result(TurbiniaResult): The result object used by the Task
+  """
+
+  def setUp(self, task_class=TurbiniaTask, evidence_class=evidence.RawDisk):
+    self.task_class = task_class
+    self.evidence_class = evidence_class
     self.remove_files = []
     self.remove_dirs = []
 
-    # Set up TurbiniaTask
+    # Set up Tasks under test
     self.base_output_dir = tempfile.mkdtemp()
     self.plaso_task = PlasoTask(base_output_dir=self.base_output_dir)
     self.plaso_task.output_manager = mock.MagicMock()
     self.plaso_task.output_manager.get_local_output_dirs.return_value = (
         None, None)
-    self.task = TurbiniaTask(base_output_dir=self.base_output_dir)
+    self.task = self.task_class(base_output_dir=self.base_output_dir)
     self.task.output_manager = mock.MagicMock()
     self.task.output_manager.get_local_output_dirs.return_value = (None, None)
 
-    # Set up RawDisk Evidence
-    test_disk_path = tempfile.mkstemp(dir=self.base_output_dir)[1]
-    self.remove_files.append(test_disk_path)
-    self.evidence = evidence.RawDisk(local_path=test_disk_path)
+    # Set up Evidence
+    test_artifact_path = tempfile.mkstemp(dir=self.base_output_dir)[1]
+    self.remove_files.append(test_artifact_path)
+    self.evidence = self.evidence_class(local_path=test_artifact_path)
 
     # Set up TurbiniaTaskResult
     self.result = TurbiniaTaskResult(base_output_dir=self.base_output_dir)
@@ -68,13 +80,15 @@ class TestTurbiniaTask(unittest.TestCase):
 
     os.rmdir(self.base_output_dir)
 
-  def setResults(self, setup=None, run=None, validate_result=None):
-    """Set up mock returns.
+  def setResults(
+      self, setup=None, run=None, validate_result=None, mock_run=True):
+    """Set up mock returns in TurbiniaTaskResult object.
 
     Args:
       setup: What value to return from setup()
       run: What value to return from run()
       validate_result: What value to return from validate_result()
+      mock_run(bool): Whether to mock out the run method
     """
     if setup is None:
       setup = self.result
@@ -86,9 +100,14 @@ class TestTurbiniaTask(unittest.TestCase):
     self.result.status = 'TestStatus'
     self.result.close = mock.MagicMock()
     self.task.setup = mock.MagicMock(return_value=setup)
-    self.task.run = mock.MagicMock(return_value=run)
+    if mock_run:
+      self.task.run = mock.MagicMock(return_value=run)
     self.task.validate_result = mock.MagicMock(return_value=validate_result)
 
+
+class TestTurbiniaTask(TestTurbiniaTaskBase):
+  """Test TurbiniaTask class."""
+
   def testTurbiniaTaskSerialize(self):
     """Test that we can properly serialize/deserialize tasks."""
     out_dict = self.plaso_task.serialize()
@@ -172,6 +191,18 @@ class TestTurbiniaTask(unittest.TestCase):
     self.assertEqual(type(new_result), TurbiniaTaskResult)
     self.assertNotEqual(new_result.error, {})
 
+  @mock.patch('turbinia.workers.evidence_decode')
+  def testTurbiniaTaskEvidenceValidationFailure(self, evidence_decode_mock):
+    """Tests Task fails when evidence validation fails."""
+    self.setResults()
+    test_evidence = evidence.RawDisk()
+    test_evidence.REQUIRED_ATTRIBUTES = ['doesnotexist']
+    evidence_decode_mock.return_value = test_evidence
+    test_result = self.task.run_wrapper(test_evidence.__dict__)
+    test_result = TurbiniaTaskResult.deserialize(test_result)
+    self.assertFalse(test_result.successful)
+    self.assertIn('validation failed', test_result.status)
+
   @mock.patch('turbinia.workers.subprocess.Popen')
   def testTurbiniaTaskExecute(self, popen_mock):
     """Test execution with success case."""