Repository: google/turbinia
Pull request #240 (2018-08-23 11:16:07)

Wordpress & Docker
This PR adds a couple Workers and analysis modules:

* Docker logs extraction job: Extract logs from Docker containers
* Wordpress analysis worker: Analyze Wordpress web access logs and assess the likelihood of compromise.

aarontp: Let me know when to take another look. It looks like some tests are failing as of now.

Patch:
diff --git a/turbinia/config/pylintrc b/.pylintrc
similarity index 100%
rename from turbinia/config/pylintrc
rename to .pylintrc
diff --git a/README.md b/README.md
index b68f05d..98306f8 100644
--- a/README.md
+++ b/README.md
@@ -104,6 +104,9 @@ optional arguments:
 * [How it works](docs/how-it-works.md)
 * [Contributing to Turbinia](docs/contributing.md)
 * [Developing new Tasks](docs/how-it-works.md)
+* [FAQ](docs/faq.md)
+* [Debugging and Common Errors](docs/debugging.md)
+
 
 ## Notes
 * Turbinia currently assumes that Evidence is equally available to all worker nodes (e.g. through locally mapped storage, or through attachable persistent Google Cloud Disks, etc).
diff --git a/docs/debugging.md b/docs/debugging.md
new file mode 100644
index 0000000..44603b0
--- /dev/null
+++ b/docs/debugging.md
@@ -0,0 +1,86 @@
+# Debugging
+
+## Logs
+
+There are a few different logs that may be useful when debugging:
+
+*   Server log: By default this is on the server in 
+    `OUTPUT_DIR/turbinia-server.log`
+*   Worker logs: By default this is on the workers in
+    `OUTPUT_DIR/turbinia-worker.log`
+*   Task logs: These are logs that are generated by a Task running in the
+    Worker. If you have GCS enabled, then these logs are saved there, and
+    in either case, the paths can be determined with
+    `turbiniactl -a status` (note that -a is required to show all data
+    associated with the tasks).
+*   Task execution logs. These are the logs that are generated by binaries
+    executed from a Task. For example, Plaso generates its own log file, and
+    that gets saved by the Task. As with the Task logs, these can be stored in
+    GCS and retrieved with `gsutil`.
+
+## Task Debugging
+
+### Task Failures
+
+The following is an example of debugging a task failure:
+
+Running turbiniactl, we see a failure of the PsortTask:
+
+```
+$ ./turbiniactl status -d1
+
+Retrieved 2 Task results:
+2017-12-06T15:09:15.013Z PsortTask Failed: Execution failed with status 1
+2017-12-06T15:08:49.616Z PlasoTask Successful: Completed successfully in 0:00:14.178579 on aaronp.dev
+```
+
+We can specify `-a` to get all info we have about that Task, and this
+will include the request Id, Task Id, and the other logs associated with
+the task.
+
+```
+$ ./turbiniactl -a status -d1
+
+Retrieved 2 Task results:
+2017-12-06T15:09:15.013Z request: None task: 1f8c4b321f444614bde296b0a00bb91f PsortTask Failed: Execution failed with status 1
+        gs://my-turbinia-bucket/output/1512601742-1f8c4b321f444614bde296b0a00bb91f-PsortTask/worker-log.txt
+2017-12-06T15:08:49.616Z request: b94ad420cf5a483fb3fc4409d5fc6dcd task: 020fe728a2b64dc7ba1da2656e1cc454 PlasoTask Successful: Completed successfully in 0:00:14.178579 on aaronp.dev
+        gs://my-turbinia-bucket/output/1512601704-020fe728a2b64dc7ba1da2656e1cc454-PlasoTask/020fe728a2b64dc7ba1da2656e1cc454.log
+        gs://my-turbinia-bucket/output/1512601704-020fe728a2b64dc7ba1da2656e1cc454-PlasoTask/worker-log.txt
+```
+
+Note that there may be local paths that show up in the
+`turbiniactl status` output, and these are local to the Worker that
+executed that Task.
+
+Using the saved paths above we can display the output with `gsutil`.
+
+```
+$ gsutil cat gs://my-turbinia-bucket/output/1512601742-1f8c4b321f444614bde296b0a00bb91f-PsortTask/worker-log.txt
+
+Running psort as [psort.py --status_view none --logfile /var/tmp/1512601742-1f8c4b321f444614bde296b0a00bb91f-PsortTask/1f8c4b321f444614bde296b0a00bb91f.log -w /var/tmp/1512601742-1f8c4b321f444614bde296b0a00bb91f-PsortTask/1f8c4b321f444614bde296b0a00bb91f.csv /var/tmp/1512601730-b04f479ef9094954968474431aa30e8a-PsortTask/b04f479ef9094954968474431aa30e8a.csv]
+Execution failed with status 1
+```
+
+### Disappearing Tasks
+
+If the Turbinia server is scheduling Tasks, but you're not seeing the
+Tasks or their output for some reason, the most common scenario is that
+either a second Turbinia server or a second set of Workers is running
+and operating on the same PubSub queues.  Make sure that you have unique
+values per each Turbinia instance for the following config variables:
+`PSQ_TOPIC`, `INSTANCE_ID`, `PUBSUB_TOPIC` and `KOMBU_CHANNEL` (if you
+are using a local Turbinia installation type.
+
+## Common Errors
+
+*   If you encounter a cycle of errors on the Turbinia server when decoding
+    specific malformed pubsub messages, and the failure happens prior to the
+    server acknowledging the pubsub message, you may need to manually clear the
+    pubsub queue. If you are sure there aren't other outstanding requests, you
+    can just pull and auto-ack the existing messages, otherwise you may need a
+    variation of this command to selectively ack messages:
+
+```
+gcloud beta pubsub subscriptions pull --auto-ack $my-pubsub-subscription-name
+```
diff --git a/docs/faq.md b/docs/faq.md
new file mode 100644
index 0000000..c8228ac
--- /dev/null
+++ b/docs/faq.md
@@ -0,0 +1,30 @@
+# FAQ
+
+
+## Where do I specify configuration options?
+
+Configuration can either go into `~/.turbiniarc` or `/etc/turbinia/turbinia.conf`
+
+## Where are the configuration options documented?
+
+The configuration options are documented in [turbinia_config.py](
+https://github.com/google/turbinia/blob/master/turbinia/config/turbinia_config.py)
+
+## How can I write new Tasks?
+
+New Task development documentation can be [found here](
+https://github.com/google/turbinia/blob/master/docs/developing-new-tasks.md)
+
+## How can I debug problems with Turbinia?
+
+Information on debugging and other common errors can be [found here](
+https://github.com/google/turbinia/blob/master/docs/debugging.md)
+
+## Where are the files listed in the turbiniactl status output?
+
+Files with local paths listed in the output for `turbiniactl status` are local
+to the Workers that ran that Task.  Files with paths starting with gs:// are
+in the Google Cloud Storage bucket (as specified by `GCS_OUTPUT_PATH` in the
+config).  Only Evidence types with the `copyable` property will actually be
+copied into Cloud Storage.
+
diff --git a/turbinia/evidence.py b/turbinia/evidence.py
index 5e31377..772baad 100644
--- a/turbinia/evidence.py
+++ b/turbinia/evidence.py
@@ -300,3 +300,4 @@ class ExportedFileArtifact(Evidence):
     """Initializes an exported file artifact."""
     super(ExportedFileArtifact, self).__init__()
     self.artifact_name = artifact_name
+    self.copyable = True
diff --git a/turbinia/jobs/__init__.py b/turbinia/jobs/__init__.py
index 8aa1b3f..e0884d2 100644
--- a/turbinia/jobs/__init__.py
+++ b/turbinia/jobs/__init__.py
@@ -33,11 +33,14 @@ def get_jobs():
   from turbinia.jobs.sshd import SSHDAnalysisJob
   from turbinia.jobs.tomcat import TomcatExtractionJob
   from turbinia.jobs.tomcat import TomcatAnalysisJob
+  from turbinia.jobs.analysis.http_access_logs import HTTPAccessLogExtractionJob
+  from turbinia.jobs.analysis.http_access_logs import HTTPAccessLogAnalysisJob
   # TODO(aarontp): Dynamically look up job objects and make enabling/disabling
   # configurable through config and/or recipes.
   return [
-    StatJob(), PlasoJob(), PsortJob(), StringsJob(), GrepJob(),
-    SSHDExtractionJob(), SSHDAnalysisJob(), TomcatExtractionJob(), TomcatAnalysisJob()]
+      StatJob(), PlasoJob(), PsortJob(), StringsJob(), GrepJob(),
+      SSHDExtractionJob(), SSHDAnalysisJob(), HTTPAccessLogExtractionJob(),
+      HTTPAccessLogAnalysisJob(), TomcatExtractionJob(), TomcatAnalysisJob()]
 
 
 class TurbiniaJob(object):
diff --git a/turbinia/jobs/analysis/__init__.py b/turbinia/jobs/analysis/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/turbinia/jobs/analysis/http_access_logs.py b/turbinia/jobs/analysis/http_access_logs.py
new file mode 100644
index 0000000..1cebee8
--- /dev/null
+++ b/turbinia/jobs/analysis/http_access_logs.py
@@ -0,0 +1,81 @@
+# -*- 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 HTTP Access logs analysis task."""
+from __future__ import unicode_literals
+
+from turbinia.workers import artifact
+
+from turbinia.evidence import Directory
+from turbinia.evidence import RawDisk
+from turbinia.evidence import GoogleCloudDisk
+from turbinia.evidence import GoogleCloudDiskRawEmbedded
+from turbinia.evidence import ExportedFileArtifact
+from turbinia.evidence import ReportText
+
+from turbinia.jobs import TurbiniaJob
+from turbinia.workers.analysis import wordpress
+
+ACCESS_LOG_ARTIFACTS = [
+    'DockerContainerLogs',
+    'NginxAccessLogs',
+    'ApacheAccessLogs'
+]
+
+class HTTPAccessLogExtractionJob(TurbiniaJob):
+  """HTTP Access log extraction job."""
+
+  evidence_input = [
+      Directory, RawDisk, GoogleCloudDisk, GoogleCloudDiskRawEmbedded]
+
+  evidence_output = [ExportedFileArtifact]
+
+  def __init__(self):
+    super(HTTPAccessLogExtractionJob, self).__init__(
+        name='HTTPAccessLogExtractionJob')
+
+  def create_tasks(self, evidence):
+    """Create task.
+
+    Args:
+      evidence: List of evidence object to process
+
+    Returns:
+        A list of tasks to schedule.
+    """
+    tasks = []
+    for artifact_name in ACCESS_LOG_ARTIFACTS:
+      tasks.extend([artifact.FileArtifactExtractionTask(artifact_name) for _
+                    in evidence])
+    return tasks
+
+class HTTPAccessLogAnalysisJob(TurbiniaJob):
+  """HTTP Access log analysis job."""
+   # Types of evidence that this Job will process.
+  evidence_input = [ExportedFileArtifact]
+  evidence_output = [ReportText]
+
+  def __init__(self):
+    super(HTTPAccessLogAnalysisJob, self).__init__(
+        name='HTTPAccessLogAnalysisJob')
+
+  def create_tasks(self, evidence):
+    """Create task.
+    Args:
+      evidence: List of evidence object to process
+    Returns:
+        A list of tasks to schedule.
+    """
+    evidence = [e for e in evidence if e.artifact_name in ACCESS_LOG_ARTIFACTS]
+    return [wordpress.WordpressAccessLogAnalysisTask() for _ in evidence]
diff --git a/turbinia/lib/google_cloud.py b/turbinia/lib/google_cloud.py
index be76465..700f81b 100644
--- a/turbinia/lib/google_cloud.py
+++ b/turbinia/lib/google_cloud.py
@@ -281,16 +281,6 @@ class GoogleComputeBaseResource(object):
 class GoogleComputeInstance(GoogleComputeBaseResource):
   """Class representing a Google Compute Engine virtual machine."""
 
-  def __init__(self, project, zone, name):
-    """Initialize the virtual machine object.
-
-    Args:
-      project: Cloud project for the instance (instance of GoogleCloudProject).
-      zone: What zone the instance is in.
-      name: Name of the instance.
-    """
-    super(GoogleComputeInstance, self).__init__(project, zone, name)
-
   def GetOperation(self):
     """Get API operation object for the virtual machine.
 
@@ -360,16 +350,6 @@ class GoogleComputeInstance(GoogleComputeBaseResource):
 class GoogleComputeDisk(GoogleComputeBaseResource):
   """Class representing a Compute Engine disk."""
 
-  def __init__(self, project, zone, name):
-    """Initialize the disk object.
-
-    Args:
-      project: Cloud project for the disk (instance of GoogleCloudProject).
-      zone: What zone the disk is in.
-      name: Name of the disk.
-    """
-    super(GoogleComputeDisk, self).__init__(project, zone, name)
-
   def GetOperation(self):
     """Get API operation object for the disk.
 
diff --git a/turbinia/lib/libcloudforensics.py b/turbinia/lib/libcloudforensics.py
index 664fc29..630c52a 100644
--- a/turbinia/lib/libcloudforensics.py
+++ b/turbinia/lib/libcloudforensics.py
@@ -421,12 +421,14 @@ class GoogleComputeInstance(GoogleComputeBaseResource):
     """Get the virtual machine boot disk.
 
     Returns:
-      Disk object (instance of GoogleComputeDisk).
+      Disk object (instance of GoogleComputeDisk) or None if no disk can be
+          found.
     """
     for disk in self.get_value('disks'):
       if disk['boot']:
         disk_name = disk['source'].split('/')[-1]
         return self.project.get_disk(disk_name=disk_name)
+    return None
 
   def get_disk(self, disk_name):
     """Gets a disk attached to this virtual machine disk by name.
@@ -629,7 +631,7 @@ def start_analysis_vm(project, vm_name, zone, boot_disk_size, attach_disk=None):
   """
   project = GoogleCloudProject(project, default_zone=zone)
   analysis_vm, created = project.get_or_create_analysis_vm(
-    vm_name, boot_disk_size)
+      vm_name, boot_disk_size)
   if attach_disk:
     analysis_vm.attach_disk(attach_disk)
   return analysis_vm, created
diff --git a/turbinia/output_manager.py b/turbinia/output_manager.py
index 0f27ef6..bf9b17a 100644
--- a/turbinia/output_manager.py
+++ b/turbinia/output_manager.py
@@ -172,12 +172,26 @@ class OutputWriter(object):
 
   NAME = 'base_output_writer'
 
-  def __init__(self, unique_dir=None, local_output_dir=None):
-    """Initialization for OutputWriter."""
-    self.base_output_dir = ''
+  def __init__(self, base_output_dir=None, unique_dir=None,
+               local_output_dir=None):
+    """Initialization for OutputWriter.
+
+    Args:
+      base_output_dir (string): The base path for output.  Set to the configured
+          OUTPUT_DIR by default.
+      local_output_dir: The full path for the local output dir.  This will be
+          generated automatically if not set.
+      unique_dir (string):  A psuedo-unique string to be used in paths. This
+          will be generated automatically if not set.
+    """
     self.unique_dir = unique_dir
     self.local_output_dir = local_output_dir
     self.name = self.NAME
+    if base_output_dir:
+      self.base_output_dir = base_output_dir
+    else:
+      config.LoadConfig()
+      self.base_output_dir = config.OUTPUT_DIR
     self.create_output_dir()
 
   def create_output_dir(self):
@@ -222,9 +236,8 @@ class LocalOutputWriter(OutputWriter):
   NAME = 'LocalWriter'
 
   def __init__(self, base_output_dir=None, *args, **kwargs):
-    self.base_output_dir = base_output_dir
-    self.local_output_dir = None
-    super(LocalOutputWriter, self).__init__(*args, **kwargs)
+    super(LocalOutputWriter, self).__init__(base_output_dir=base_output_dir,
+                                            *args, **kwargs)
 
   def create_output_dir(self):
     self.local_output_dir = os.path.join(self.base_output_dir, self.unique_dir)
diff --git a/turbinia/workers/__init__.py b/turbinia/workers/__init__.py
index 4f6c910..30ae8da 100644
--- a/turbinia/workers/__init__.py
+++ b/turbinia/workers/__init__.py
@@ -172,6 +172,7 @@ class TurbiniaTaskResult(object):
     # automatically, but the real fix is to attach this to a separate object.
     # See https://github.com/google/turbinia/issues/211 for more details.
     evidence.config = evidence_config
+
     self.evidence.append(evidence)
 
   def set_error(self, error, traceback_):
@@ -337,8 +338,8 @@ class TurbiniaTask(object):
     """Updates the last_update time of the task."""
     self.last_update = datetime.now()
 
-  def result_check(self, result):
-    """Checks to make sure that the result is serializable.
+  def validate_result(self, result):
+    """Checks to make sure that the result is valid.
 
     We occasionally get something added into a TurbiniaTaskResult that makes
     it unpickleable.  We don't necessarily know what caused it to be in that
@@ -353,30 +354,41 @@ class TurbiniaTask(object):
       The original result object if it is OK, otherwise an empty result object
       indicating a failure.
     """
-    try:
-      log.debug('Checking TurbiniaTaskResult for serializability')
-      pickle.dumps(result)
-      dump_status = 'Successful'
-    except (TypeError, pickle.PicklingError) as e:
-      msg = ('Error pickling TurbiniaTaskResult object. Returning a new result '
-             'with the pickling error, and all previous result data will be '
-             'lost. Pickle Error: {0!s}'.format(e))
-      log.error(msg)
-      log.error('Pickle error traceback: {0:s}'.format(traceback.format_exc()))
+    bad_message = None
+    check_status = 'Successful'
+
+    if not isinstance(result, TurbiniaTaskResult):
+      bad_message = (
+          'Task returned type [{0!s}] instead of TurbiniaTaskResult.').format(
+              type(result))
+    else:
+      try:
+        log.debug('Checking TurbiniaTaskResult for serializability')
+        pickle.dumps(result)
+      except (TypeError, pickle.PicklingError) as e:
+        bad_message = (
+            'Error pickling TurbiniaTaskResult object. Returning a new result '
+            'with the pickling error, and all previous result data will be '
+            'lost. Pickle Error: {0!s}'.format(e))
+
+    if bad_message:
+      log.error(bad_message)
       if result and hasattr(result, 'status') and result.status:
         old_status = result.status
       else:
         old_status = 'No previous status'
+
       result = TurbiniaTaskResult(
           task=self,
           base_output_dir=self.base_output_dir,
           request_id=self.request_id)
-      result.status = '{0:s}. Previous status: [{1:s}]'.format(msg, old_status)
-      result.set_error(e.message, traceback.format_exc())
-      result.close(self, success=False, status=msg)
-      dump_status = 'Failed, but replaced with new result object'
+      result.status = '{0:s}. Previous status: [{1:s}]'.format(
+          bad_message, old_status)
+      result.set_error(bad_message, traceback.format_exc())
+      result.close(self, success=False, status=bad_message)
+      check_status = 'Failed, but replaced with empty result'
 
-    log.info('Result check: {0:s}'.format(dump_status))
+    log.info('Result check: {0:s}'.format(check_status))
     return result
 
   def run_wrapper(self, evidence):
@@ -387,29 +399,32 @@ class TurbiniaTask(object):
     details from exceptions in the worker cannot be propagated back to the
     Turbinia TaskManager.
 
+    This method should handle (in no particular order):
+      - Exceptions thrown from run()
+      - Verifing valid TurbiniaTaskResult object is returned
+          - Check for bad results (non TurbiniaTaskResults) returned from run()
+          - Auto-close results that haven't been closed
+          - Verifying that the results are serializeable
+      - Locking to make sure only one task is active at a time
+
     Args:
       evidence: Evidence object
 
     Returns:
       A TurbiniaTaskResult object
     """
+    # TODO(aarontp): Add locking: https://github.com/google/turbinia/issues/221
     log.info('Starting Task {0:s} {1:s}'.format(self.name, self.id))
     original_result_id = None
     try:
       self.result = self.setup(evidence)
       original_result_id = self.result.id
       self._evidence_config = evidence.config
-      # Passing the result through the run function explicitly, but failing back
-      # to the original result object if there is a problem.
-      tmp_result = self.run(evidence, self.result)
-      if not isinstance(tmp_result, TurbiniaTaskResult):
-        raise TurbiniaException(
-            'Task returned type [{0:s}] instead of TurbiniaTaskResult.'.format(
-                type(tmp_result)))
-      self.result = tmp_result
+      self.result = self.run(evidence, self.result)
     # pylint: disable=broad-except
     except (TurbiniaException, Exception) as e:
-      msg = '{0:s} Task failed with exception: [{1!s}]'.format(self.name, e)
+      msg = '{0:s} Task failed with exception: [{1:s}]'.format(
+          self.name, str(e))
       log.error(msg)
       log.error(traceback.format_exc())
       if self.result:
@@ -423,6 +438,8 @@ class TurbiniaTask(object):
       else:
         log.error('No TurbiniaTaskResult object found after task execution.')
 
+    self.result = self.validate_result(self.result)
+
     # Trying to close the result if possible so that we clean up what we can.
     # This has a higher likelihood of failing because something must have gone
     # wrong as the Task should have already closed this.
@@ -448,11 +465,12 @@ class TurbiniaTask(object):
         log.error('TurbiniaTaskResult close failed: {0!s}'.format(e))
         if not self.result.status:
           self.result.status = msg
+      # Check the result again after closing to make sure it's still good.
+      self.result = self.validate_result(self.result)
 
-    self.result = self.result_check(self.result)
     if original_result_id != self.result.id:
       log.debug(
-          'Result object {0:s} is different from original {1:s} after task '
+          'Result object {0:s} is different from original {1!s} after task '
           'execution which indicates errors during execution'.format(
               self.result.id, original_result_id))
     else:
diff --git a/turbinia/workers/analysis/__init__.py b/turbinia/workers/analysis/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/turbinia/workers/analysis/wordpress.py b/turbinia/workers/analysis/wordpress.py
new file mode 100644
index 0000000..3e12945
--- /dev/null
+++ b/turbinia/workers/analysis/wordpress.py
@@ -0,0 +1,118 @@
+# -*- 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.
+"""Task for analysing Wordpress access logs."""
+
+from __future__ import unicode_literals
+
+import gzip
+import os
+import re
+
+from turbinia.evidence import ReportText
+from turbinia.workers import TurbiniaTask
+
+
+class WordpressAccessLogAnalysisTask(TurbiniaTask):
+  """Task to analyze Wordpress access logs."""
+
+  timestamp_regex = re.compile(r'\[(?P<timestamp>.+)\]')
+
+  install_step_regex = re.compile(
+      r'POST /wp-admin/install\.php\?step=2', re.IGNORECASE)
+  theme_editor_regex = re.compile(
+      r'GET /wp-admin/theme-editor\.php\?file=(?P<edited_file>.+\.php)',
+      re.IGNORECASE)
+
+  def run(self, evidence, result):
+    """Run the Wordpress access log analysis worker.
+
+    Args:
+       evidence (Evidence object):  The evidence to process
+       result (TurbiniaTaskResult): The object to place task results into.
+
+    Returns:
+      TurbiniaTaskResult object.
+    """
+    # What type of evidence we should output.
+    output_evidence = ReportText()
+
+    # Where to store the resulting output file.
+    output_file_name = 'wp_acces_log_analysis.txt'
+    output_file_path = os.path.join(self.output_dir, output_file_name)
+    # Set the output file as the data source for the output evidence.
+    output_evidence.local_path = output_file_path
+
+    # Change open function if file is GZIP compressed.
+    open_function = open
+    if evidence.local_path.lower().endswith('gz'):
+      open_function = gzip.open
+
+    # Read the input file
+    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
+
+    # Write the report to the output file.
+    with open(output_file_path, 'w') as fh:
+      fh.write(output_evidence.text_data.encode('utf-8'))
+
+    # 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)
+    return result
+
+  def _get_timestamp(self, log_line):
+    """Extracts a timestamp from an access log line."""
+    match = self.timestamp_regex.search(log_line)
+    if match:
+      return match.group('timestamp')
+    return '[N/A]'
+
+  def analyze_wp_access_logs(self, config):
+    """Analyses access logs containing Wordpress traffic.
+
+    Args:
+      config (str): access log file content.
+
+    Returns:
+      str: Activity summary of the wordpress installation.
+    """
+    findings = []
+    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)))
+        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')))
+        findings_summary.add('theme_edit')
+
+    if findings:
+      findings.insert(0, 'Wordpress access logs found ({0:s})'.format(
+          ', '.join(list(findings_summary))))
+      return '\n'.join(findings)
+
+    return 'No Wordpress install or theme editing found in access logs'
diff --git a/turbinia/workers/artifact.py b/turbinia/workers/artifact.py
index 34ad03f..1ff1049 100644
--- a/turbinia/workers/artifact.py
+++ b/turbinia/workers/artifact.py
@@ -43,7 +43,8 @@ class FileArtifactExtractionTask(TurbiniaTask):
     config.LoadConfig()
 
     export_directory = os.path.join(self.output_dir, 'export')
-    image_export_log = os.path.join(self.output_dir, '{0:s}.log'.format(self.id))
+    image_export_log = os.path.join(
+        self.output_dir, '{0:s}.log'.format(self.id))
 
     cmd = [
         'image_export.py',
@@ -64,7 +65,7 @@ class FileArtifactExtractionTask(TurbiniaTask):
 
     ret, _ = self.execute(cmd, result, save_files=[image_export_log])
     if ret:
-      result.close(False, 'image_export.py failed.')
+      result.close(self, False, 'image_export.py failed.')
 
     for dirpath, _, filenames in os.walk(export_directory):
       for filename in filenames:
diff --git a/turbinia/workers/grep.py b/turbinia/workers/grep.py
index 7acbfe5..89cd097 100644
--- a/turbinia/workers/grep.py
+++ b/turbinia/workers/grep.py
@@ -57,10 +57,10 @@ class GrepTask(TurbiniaTask):
 
     output_evidence.local_path = output_file_path
     cmd = 'grep -E -b -n -f {0:s} {1:s} > {2:s}'.format(
-      patterns_file_path, evidence.local_path, output_file_path)
+        patterns_file_path, evidence.local_path, output_file_path)
 
     result.log('Running [{0:s}]'.format(cmd))
     self.execute(
-      cmd, result, new_evidence=[output_evidence], close=True, shell=True)
+        cmd, result, new_evidence=[output_evidence], close=True, shell=True)
 
     return result
diff --git a/turbinia/workers/sshd.py b/turbinia/workers/sshd.py
index 023326d..53ed491 100644
--- a/turbinia/workers/sshd.py
+++ b/turbinia/workers/sshd.py
@@ -72,12 +72,12 @@ class SSHDAnalysisTask(TurbiniaTask):
     """
     findings = []
     permit_root_login_re = re.compile(
-        '^\s*PermitRootLogin\s*(yes|prohibit-password|without-password)',
+        r'^\s*PermitRootLogin\s*(yes|prohibit-password|without-password)',
         re.IGNORECASE | re.MULTILINE)
     password_authentication_re = re.compile(
-        '^\s*PasswordAuthentication[\s"]*No', re.IGNORECASE | re.MULTILINE)
+        r'^\s*PasswordAuthentication[\s"]*No', re.IGNORECASE | re.MULTILINE)
     permit_empty_passwords_re = re.compile(
-        '^\s*PermitEmptyPasswords[\s"]*Yes', re.IGNORECASE | re.MULTILINE)
+        r'^\s*PermitEmptyPasswords[\s"]*Yes', re.IGNORECASE | re.MULTILINE)
 
     if re.search(permit_root_login_re, config):
       findings.append('\tRoot login enabled.')


Test patch:
diff --git a/test_data/wordpress_access_logs.txt b/test_data/wordpress_access_logs.txt
new file mode 100644
index 0000000..6f29468
--- /dev/null
+++ b/test_data/wordpress_access_logs.txt
@@ -0,0 +1,10 @@
+10.128.0.2 - - [27/Jun/2018:19:29:54 +0000] "POST /wp-admin/install.php?step=2 HTTP/1.1" 200 872 "-" "Mozilla/5.0 (Windows; U; Windows NT 10.0) http/2.8.11 Tcl/8.6.7"
+10.4.3.1 - - [27/Jun/2018:19:31:14 +0000] "POST /wp-login.php HTTP/1.1" 200 3798 "-" "Mozilla/5.0 (X11; U; Linux i686; en-US) AppleWebKit/532.2 (KHTML, like Gecko) Chrome/4.0.223.2 Safari/532.2"
+10.128.0.5 - - [27/Jun/2018:19:31:15 +0000] "GET /wp-admin/themes.php HTTP/1.1" 200 44751 "-" "Java/1.8.0_171"
+10.4.3.1 - - [27/Jun/2018:19:31:15 +0000] "POST /wp-cron.php?doing_wp_cron=1530127875.8426339626312255859375 HTTP/1.1" 200 191 "http://127.0.0.1/wp-cron.php?doing_wp_cron=1530127875.8426339626312255859375" "WordPress/4.9.6; http://127.0.0.1"
+10.128.0.5 - - [27/Jun/2018:19:31:15 +0000] "GET /wp-admin/theme-editor.php?file=header.php&theme=twentyseventeen&scrollto=0 HTTP/1.1" 200 56416 "-" "Java/1.8.0_171"
+10.4.3.1 - - [27/Jun/2018:19:31:16 +0000] "GET /wp-admin/theme-editor.php?theme=twentyseventeen&file=header.php&wp_scrape_key=e96348d34766954930dbc5bce454cf10&wp_scrape_nonce=796547960 HTTP/1.1" 200 13979 "http://127.0.0.1/wp-admin/theme-editor.php?theme=twentyseventeen&file=header.php&wp_scrape_key=e96348d34766954930dbc5bce454cf10&wp_scrape_nonce=796547960" "WordPress/4.9.6; http://127.0.0.1"
+10.4.3.1 - - [27/Jun/2018:19:31:16 +0000] "GET /?wp_scrape_key=e96348d34766954930dbc5bce454cf10&wp_scrape_nonce=796547960 HTTP/1.1" 200 20309 "http://127.0.0.1/?wp_scrape_key=e96348d34766954930dbc5bce454cf10&wp_scrape_nonce=796547960" "WordPress/4.9.6; http://127.0.0.1"
+10.128.0.5 - - [27/Jun/2018:19:31:16 +0000] "POST /wp-admin/admin-ajax.php HTTP/1.1" 200 526 "-" "Mozilla/4.0 (compatible; MSIE 5.5; Windows NT 6.0; SLCC1; .NET CLR 2.0.50727; .NET CLR 3.5.30729; .NET CLR 3.0.30618)"
+10.4.3.1 - - [27/Jun/2018:19:31:16 +0000] "GET /wp-content/themes/twentyseventeen/header.php?install2 HTTP/1.1" 200 494 "-" "Mozilla/5.0 (Windows NT 6.2; WOW64) AppleWebKit/537.15 (KHTML, like Gecko) Chrome/24.0.1295.0 Safari/537.15"
+10.128.0.2 - - [27/Jun/2018:19:31:17 +0000] "GET /wp-content/themes/twentyseventeen/DDC001155BB439567FE503EC27CB6E60.php HTTP/1.1" 200 1063 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.6 Safari/537.11"
diff --git a/turbinia/config/turbinia_config_test.py b/turbinia/config/turbinia_config_test.py
index e7a69c1..4978dab 100644
--- a/turbinia/config/turbinia_config_test.py
+++ b/turbinia/config/turbinia_config_test.py
@@ -24,6 +24,7 @@ from turbinia import config
 
 
 class TestTurbiniaConfig(unittest.TestCase):
+  """Tests for the Turbinia configuration module."""
 
   @classmethod
   def setUpClass(cls):
@@ -48,6 +49,7 @@ class TestTurbiniaConfig(unittest.TestCase):
 
   @classmethod
   def tearDownClass(cls):
+    """Called after tests in the class have been run."""
     config.CONFIGPATH = cls.CONFIGPATH_SAVE
     config.CONFIGFILES = cls.CONFIGFILES_SAVE
     config.CONFIGVARS = cls.CONFIGVARS_SAVE
@@ -59,9 +61,14 @@ class TestTurbiniaConfig(unittest.TestCase):
     [delattr(config, a) for a in dir(config) if a not in self.config_attrs]
     config.CONFIG = None
 
-  def WriteConfig(self, data):
+  def WriteConfig(self, text):
+    """Helper to write text to a configuration file.
+
+    Args:
+      text (str): data to write to the file.
+    """
     with open(self.config_file, 'w') as config_file:
-      config_file.write(data)
+      config_file.write(text)
 
   def testBasicConfig(self):
     """Test out a basic config."""
diff --git a/turbinia/lib/libcloudforensics_test.py b/turbinia/lib/libcloudforensics_test.py
index f240f16..0d91ac8 100644
--- a/turbinia/lib/libcloudforensics_test.py
+++ b/turbinia/lib/libcloudforensics_test.py
@@ -16,9 +16,9 @@
 
 from __future__ import unicode_literals
 
-import mock
 import unittest
 
+import mock
 import six
 
 from turbinia.lib import libcloudforensics
diff --git a/turbinia/workers/analysis/wordpress_test.py b/turbinia/workers/analysis/wordpress_test.py
new file mode 100644
index 0000000..1ef539e
--- /dev/null
+++ b/turbinia/workers/analysis/wordpress_test.py
@@ -0,0 +1,52 @@
+# -*- 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.
+"""Tests for the Wordpress access log analysis task."""
+
+from __future__ import unicode_literals
+
+import os
+import unittest
+
+from turbinia import config
+from turbinia.workers.analysis import wordpress
+
+
+class WordpressAccessLogAnalysisTaskTest(unittest.TestCase):
+  """Tests for WordpressAccessLogAnalysis Task."""
+
+  WORDPRESS_ACCESS_LOGS = None
+  WORDPRESS_PWNED_REPORT = """Wordpress access logs found (theme_edit, install)
+\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
+
+  def setUp(self):
+    filedir = os.path.dirname(os.path.realpath(__file__))
+    test_data = os.path.join(
+        filedir, '..', '..', '..', 'test_data', 'wordpress_access_logs.txt')
+    with open(test_data, 'r') as data:
+      self.WORDPRESS_ACCESS_LOGS = data.read()
+
+  def test_analyze_wp_access_logs(self):
+    """Tests the analyze_wp_access_logs method."""
+    config.LoadConfig()
+    task = wordpress.WordpressAccessLogAnalysisTask()
+
+    report = task.analyze_wp_access_logs(self.WORDPRESS_ACCESS_LOGS)
+    self.assertEqual(report, self.WORDPRESS_PWNED_REPORT)
+
+
+if __name__ == '__main__':
+  unittest.main()
diff --git a/turbinia/workers/sshd_test.py b/turbinia/workers/sshd_test.py
index 69b9eda..1948e5d 100644
--- a/turbinia/workers/sshd_test.py
+++ b/turbinia/workers/sshd_test.py
@@ -23,6 +23,8 @@ from turbinia.workers import sshd
 
 
 class SSHDAnalysisTaskTest(unittest.TestCase):
+  """test for the SSHD analysis task."""
+
   SSH_INSECURE_EVERYTHING = """PermitRootLogin Yes
 PasswordAuthentication yes
 PermitEmptyPasswords Yes
diff --git a/turbinia/workers/tomcat_test.py b/turbinia/workers/tomcat_test.py
index ad6ca0e..764a5b5 100644
--- a/turbinia/workers/tomcat_test.py
+++ b/turbinia/workers/tomcat_test.py
@@ -23,6 +23,8 @@ from turbinia.workers import tomcat
 
 
 class TomcatAnalysisTaskTest(unittest.TestCase):
+  """Test for the Tomcat Task."""
+
   TOMCAT_PASSWORD_FILE = """<?xml version='1.0' encoding='utf-8'?>
 <tomcat-users>
     <role rolename="tomcat"/>
@@ -31,21 +33,26 @@ class TomcatAnalysisTaskTest(unittest.TestCase):
     <user username="both" password="tomcat" roles="tomcat,role1"/>
 </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"/>"""
 
+  # 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"""
 
+  # 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
 1.2.3.4 - admin [12/Apr/2018:14:01:09 -0100] "GET /manager/html HTTP/1.1" 200 22130
 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"""
 
diff --git a/turbinia/workers/workers_test.py b/turbinia/workers/workers_test.py
index 001817d..ff438ef 100644
--- a/turbinia/workers/workers_test.py
+++ b/turbinia/workers/workers_test.py
@@ -16,12 +16,14 @@
 
 from __future__ import unicode_literals
 
-import mock
+import json
 import os
 import tempfile
 import unittest
+import mock
 
 from turbinia import evidence
+from turbinia import TurbiniaException
 from turbinia.workers import TurbiniaTask
 from turbinia.workers import TurbiniaTaskResult
 
@@ -50,10 +52,105 @@ class TestTurbiniaTask(unittest.TestCase):
     self.result.output_dir = self.base_output_dir
 
   def tearDown(self):
-    [os.remove(f) for f in self.remove_files if os.path.exists(f)]
-    [os.rmdir(d) for d in self.remove_dirs if os.path.exists(d)]
+    for remove_file in self.remove_files:
+      if os.path.exists(remove_file):
+        os.remove(remove_file)
+
+    for directory in self.remove_dirs:
+      if os.path.exists(directory):
+        os.rmdir(directory)
+
     os.rmdir(self.base_output_dir)
 
+  def setResults(self, setup=None, run=None, validate_result=None):
+    """Set up mock returns.
+
+    Args:
+      setup: What value to return from setup()
+      run: What value to return from run()
+      validate_result: What value to return from validate_result()
+    """
+    if setup is None:
+      setup = self.result
+    if run is None:
+      run = self.result
+    if validate_result is None:
+      validate_result = self.result
+
+    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)
+    self.task.validate_result = mock.MagicMock(return_value=validate_result)
+
+  def testTurbiniaTaskRunWrapper(self):
+    """Test that the run wrapper executes task run."""
+    self.setResults()
+    self.result.closed = True
+    new_result = self.task.run_wrapper(self.evidence)
+    self.assertEqual(new_result.status, 'TestStatus')
+    self.result.close.assert_not_called()
+
+  def testTurbiniaTaskRunWrapperAutoClose(self):
+    """Test that the run wrapper closes the task."""
+    self.setResults()
+    new_result = self.task.run_wrapper(self.evidence)
+    self.assertEqual(new_result.status, 'TestStatus')
+    self.result.close.assert_called()
+
+  def testTurbiniaTaskRunWrapperBadResult(self):
+    """Test that the run wrapper recovers from run returning bad result."""
+    bad_result = 'Not a TurbiniaTaskResult'
+    checked_result = TurbiniaTaskResult(
+        task=self.task, base_output_dir=self.base_output_dir)
+    checked_result.status = 'CheckedResult'
+    self.setResults(run=bad_result, validate_result=checked_result)
+
+    new_result = self.task.run_wrapper(self.evidence)
+
+    self.task.validate_result.assert_any_call(bad_result)
+    self.assertEqual(type(new_result), TurbiniaTaskResult)
+    self.assertIn('CheckedResult', new_result.status)
+
+  def testTurbiniaTaskRunWrapperExceptionThrown(self):
+    """Test that the run wrapper recovers from run throwing an exception."""
+    self.setResults()
+    self.task.run = mock.MagicMock(side_effect=TurbiniaException)
+
+    new_result = self.task.run_wrapper(self.evidence)
+    self.assertEqual(type(new_result), TurbiniaTaskResult)
+    self.assertIn('failed', new_result.status)
+
+  def testTurbiniaTaskRunWrapperSetupFail(self):
+    """Test that the run wrapper recovers from setup failing."""
+    self.task.result = None
+    canary_status = 'ReturnedFromValidateResult'
+    self.result.status = canary_status
+    self.task.validate_result = mock.MagicMock(return_value=self.result)
+    self.task.setup = mock.MagicMock(side_effect=TurbiniaException)
+    self.remove_files.append(os.path.join(
+        self.task.base_output_dir, 'worker-log.txt'))
+
+    new_result = self.task.run_wrapper(self.evidence)
+    self.assertEqual(type(new_result), TurbiniaTaskResult)
+    self.assertIn(canary_status, new_result.status)
+
+  def testTurbiniaTaskValidateResultGoodResult(self):
+    """Tests validate_result with good result."""
+    self.result.status = 'GoodStatus'
+    new_result = self.task.validate_result(self.result)
+    self.assertEqual(new_result.status, 'GoodStatus')
+    self.assertDictEqual(new_result.error, {})
+
+  @mock.patch('turbinia.workers.TurbiniaTaskResult.close')
+  def testTurbiniaTaskValidateResultBadResult(self, _):
+    """Tests validate_result with bad result."""
+    # Passing in an unpickleable object (json module) and getting back a
+    # TurbiniaTaskResult
+    new_result = self.task.validate_result(json)
+    self.assertEqual(type(new_result), TurbiniaTaskResult)
+    self.assertNotEqual(new_result.error, {})
+
   @mock.patch('turbinia.workers.subprocess.Popen')
   def testTurbiniaTaskExecute(self, popen_mock):
     """Test execution with success case."""