Repository: google/turbinia
Pull request #465 (2019-11-28 02:52:45)

Enumerate Docker containers as new Evidences to be processed
* Add a new DockerContainer evidence type as well as processors to mount them
* Add a DockerContainersEnumerationTask to generate DockerContainer evidences to be processed
* Add DockerContainer as an input Evidence type to some tasks (Only those that are 'quick')
* Fix a couple string formatting issues (Py3 hates passing None to {0:s})

Reminder: For every found Docker container, all Tasks that can have it as input will be run. For every run, all preprocessing for every container will be re-run (attaching/mounting disks/mounting the container FS). This mean if we have N tasks taking DockerContainer Evidence as input, and M docker containers, a google cloud disk will be attached/detached M*N times

rgayon: So I tests on a googleclouddiskembedded evidence, where the disk is a GKE similar installation with 27 containers, 1 of them having compromised Wordpress & bad SShdConfigFile:

```
$ turbiniactl status -r 2c0c3447924645f98f58d566ce114b05 > /tmp/report
$ grep -v "Extracted 0" /tmp/report  | grep -v "No Wordpress"


# Turbinia report 2c0c3447924645f98f58d566ce114b05
* Processed 140 Tasks for user romaing

# High Priority Tasks
* WordpressAccessLogAnalysisTask: Wordpress access logs found (install, theme_edit)

# Successful Tasks
* FileArtifactExtractionTask: Extracted 1 new SshdConfigFile artifacts
* FileArtifactExtractionTask: Extracted 27 new GKEDockerContainerLogs artifacts
* DockerContainersEnumerationTask: Found 27 containers: bf830d68d71fb129378da9da4759a803a2a1823ed08ff72ccd8e14f766840132 6bc2a4c8051e68221f9dd408bbb0b81fa97384c9dedb1b32d53bf567df81db34 4a26bbe260b3681483436ee64e11d167712fad620ea6e5174b2662691b15e834 712d2a939ce22e7279025fb97d06442b7169046876a1a95f31786891be2d1e5d 01d2d0c1e3f6ea4818ce07882ae51e776517d78762e9b035919396dc84bb8253 7f644964d4097506be17c2481c609b2e566851339f99582d50d01a4d614f9673 ebc88302deba4854568eea9567b0bd1ee01ca8cbb8f5891aba1b8e0de242ff78 796197bb8a36f3e594a9ee452ed76455d23f1052f9f85f016e75be2887932ab7 e917f8eeab0ce0292115ad3cfc774016370f1e2164b78f0768eeb0a47a7ef71e 6393c90536c41178dd1489d0f8192a03a435f2c758847148f7b04642aba73116 2b1de554fe3f1068fd29ae156b033c2c866970b4a65afb2d9425278906bfc026 fc88af48094fc431e5fbf98c28fd0a6a5b57211c2b18f139835bbd6b1ceaac20 4335c9303a78c6172a9468908ff0ab51e9b5a8366569c42e3115aa69a35f7a44 1e9796e72d96e6733873adfc9535df64a40acc6f178aa13c8298f44a0c63d84a d4efa468e94cab28009f5a9804f5cf5ea580e8a069f62e1f8342e6d3a13ec023 c65720d0e452f610ee8f1eefd4f2e057bd6d8eec17ab3779cd6287c968378231 37c48eea7f95717609395f89af0ce87c32d9089a8a6f1306c1a73d253d0bb57d 925e2b4a2b2f6964619fb710d480293bd408c1203612b153ffb591d06a5f5615 6cf318d29ffec371df7fb212da08347890b37a3b65c4a16adc44200824537c68 ea92e82d2edda85ef15edde80793063ee01f8284eb19712aa9ad0c02d6f75b74 c8e3714b5efa123c40efe3f64e59bc3a70897f4ceffdd63eb86e5f873946ec57 569e7ac6f1188a18114d5836cfcf0070f276188371463d6e8b6ce89c82dcac69 9ed35421875db37115a7175de3bb1642cff35f59acd9[...]

# Failed Tasks
* None

# Scheduled or Running Tasks
* None

```

Patch:
diff --git a/turbinia/client.py b/turbinia/client.py
index 2128b60..cde7c3a 100644
--- a/turbinia/client.py
+++ b/turbinia/client.py
@@ -41,6 +41,7 @@ from turbinia.workers.artifact import FileArtifactExtractionTask
 from turbinia.workers.analysis.wordpress import WordpressAccessLogAnalysisTask
 from turbinia.workers.analysis.jenkins import JenkinsAnalysisTask
 from turbinia.workers.finalize_request import FinalizeRequestTask
+from turbinia.workers.docker import DockerContainersEnumerationTask
 from turbinia.workers.grep import GrepTask
 from turbinia.workers.hadoop import HadoopAnalysisTask
 from turbinia.workers.hindsight import HindsightTask
@@ -74,7 +75,8 @@ TASK_MAP = {
     'volatilitytask': VolatilityTask,
     'stattask': StatTask,
     'binaryextractor': BinaryExtractorTask,
-    'bulkextractortask': BulkExtractorTask
+    'bulkextractortask': BulkExtractorTask,
+    'dockertask': DockerContainersEnumerationTask
 }
 
 config.LoadConfig()
diff --git a/turbinia/evidence.py b/turbinia/evidence.py
index 977d85b..1b791e1 100644
--- a/turbinia/evidence.py
+++ b/turbinia/evidence.py
@@ -22,6 +22,7 @@ import sys
 
 from turbinia import config
 from turbinia import TurbiniaException
+from turbinia.processors import docker
 from turbinia.processors import mount_local
 from turbinia.processors import archive
 
@@ -615,3 +616,40 @@ class RawMemory(Evidence):
 class BinaryExtraction(CompressedDirectory):
   """Binaries extracted from evidence."""
   pass
+
+
+class DockerContainer(Evidence):
+  """Evidence object for a DockerContainer filesystem.
+
+  Attributes:
+    container_id(str): The ID of the container to mount.
+    _container_fs_path(str): Full path to where the container filesystem will
+      be mounted.
+    _docker_root_directory(str): Full path to the docker root directory.
+  """
+
+  # ABSOLUTELY NO LEADING / HERE
+  DEFAULT_DOCKER_DIRECTORY_PATH = 'var/lib/docker'
+
+  def __init__(self, container_id=None, *args, **kwargs):
+    """Initialization for Docker Container."""
+    super(DockerContainer, self).__init__(*args, **kwargs)
+    self.container_id = container_id
+    self._container_fs_path = None
+    self._docker_root_directory = None
+
+    self.context_dependent = True
+
+  def _preprocess(self, _):
+
+    self._docker_root_directory = os.path.join(
+        self.parent_evidence.mount_path, self.DEFAULT_DOCKER_DIRECTORY_PATH)
+    # Mounting the container's filesystem
+    self._container_fs_path = docker.PreprocessMountDockerFS(
+        self._docker_root_directory, self.container_id)
+    self.mount_path = self._container_fs_path
+    self.local_path = self.mount_path
+
+  def _postprocess(self):
+    # Unmount the container's filesystem
+    mount_local.PostprocessUnmountPath(self._container_fs_path)
diff --git a/turbinia/jobs/__init__.py b/turbinia/jobs/__init__.py
index b60b1f2..b189b10 100644
--- a/turbinia/jobs/__init__.py
+++ b/turbinia/jobs/__init__.py
@@ -14,6 +14,7 @@
 # limitations under the License.
 """Turbinia jobs."""
 from turbinia.jobs import finalize_request
+from turbinia.jobs import docker
 from turbinia.jobs import grep
 from turbinia.jobs import hadoop
 from turbinia.jobs import http_access_logs
diff --git a/turbinia/jobs/docker.py b/turbinia/jobs/docker.py
new file mode 100644
index 0000000..00b7789
--- /dev/null
+++ b/turbinia/jobs/docker.py
@@ -0,0 +1,50 @@
+# -*- 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 docker-explorer, and generate new Evidence per container"""
+
+from __future__ import unicode_literals
+
+from turbinia.evidence import DockerContainer
+from turbinia.evidence import GoogleCloudDisk
+from turbinia.evidence import GoogleCloudDiskRawEmbedded
+from turbinia.evidence import RawDisk
+from turbinia.jobs import interface
+from turbinia.jobs import manager
+from turbinia.workers.docker import DockerContainersEnumerationTask
+
+
+class DockerContainersEnumerationJob(interface.TurbiniaJob):
+  """Use docker-explorer to list all containers in a Linux docker environment"""
+
+  # Types of evidence that this Job will process.
+  evidence_input = [GoogleCloudDisk, GoogleCloudDiskRawEmbedded, RawDisk]
+  evidence_output = [DockerContainer]
+
+  NAME = 'DockerContainersEnumerationJob'
+
+  def create_tasks(self, evidence):
+    """Create task.
+
+    Args:
+      evidence: List of evidence object to process
+
+    Returns:
+        A list of tasks to schedule.
+    """
+    tasks = [DockerContainersEnumerationTask() for _ in evidence]
+    return tasks
+
+
+manager.JobsManager.RegisterJob(DockerContainersEnumerationJob)
diff --git a/turbinia/jobs/hadoop.py b/turbinia/jobs/hadoop.py
index f85cc4e..d23534d 100644
--- a/turbinia/jobs/hadoop.py
+++ b/turbinia/jobs/hadoop.py
@@ -16,6 +16,7 @@
 
 from __future__ import unicode_literals
 
+from turbinia.evidence import DockerContainer
 from turbinia.evidence import GoogleCloudDisk
 from turbinia.evidence import GoogleCloudDiskRawEmbedded
 from turbinia.evidence import RawDisk
@@ -28,7 +29,9 @@ from turbinia.workers.hadoop import HadoopAnalysisTask
 class HadoopAnalysisJob(interface.TurbiniaJob):
   """Analyzes Hadoop AppRoot files."""
 
-  evidence_input = [GoogleCloudDisk, GoogleCloudDiskRawEmbedded, RawDisk]
+  evidence_input = [
+      DockerContainer, GoogleCloudDisk, GoogleCloudDiskRawEmbedded, RawDisk
+  ]
   evidence_output = [ReportText]
 
   NAME = 'HadoopAnalysisJob'
diff --git a/turbinia/jobs/http_access_logs.py b/turbinia/jobs/http_access_logs.py
index 8ef0a29..b85f067 100644
--- a/turbinia/jobs/http_access_logs.py
+++ b/turbinia/jobs/http_access_logs.py
@@ -18,6 +18,7 @@ from __future__ import unicode_literals
 from turbinia.workers import artifact
 
 from turbinia.evidence import Directory
+from turbinia.evidence import DockerContainer
 from turbinia.evidence import RawDisk
 from turbinia.evidence import GoogleCloudDisk
 from turbinia.evidence import GoogleCloudDiskRawEmbedded
@@ -36,7 +37,8 @@ class HTTPAccessLogExtractionJob(interface.TurbiniaJob):
   """HTTP Access log extraction job."""
 
   evidence_input = [
-      Directory, RawDisk, GoogleCloudDisk, GoogleCloudDiskRawEmbedded
+      Directory, DockerContainer, RawDisk, GoogleCloudDisk,
+      GoogleCloudDiskRawEmbedded
   ]
 
   evidence_output = [ExportedFileArtifact]
diff --git a/turbinia/jobs/jenkins.py b/turbinia/jobs/jenkins.py
index c3a961b..6db7c60 100644
--- a/turbinia/jobs/jenkins.py
+++ b/turbinia/jobs/jenkins.py
@@ -17,6 +17,7 @@
 from __future__ import unicode_literals
 
 from turbinia.evidence import Directory
+from turbinia.evidence import DockerContainer
 from turbinia.evidence import RawDisk
 from turbinia.evidence import GoogleCloudDisk
 from turbinia.evidence import GoogleCloudDiskRawEmbedded
@@ -30,7 +31,8 @@ class JenkinsAnalysisJob(interface.TurbiniaJob):
   """Jenkins analysis job."""
 
   evidence_input = [
-      Directory, RawDisk, GoogleCloudDisk, GoogleCloudDiskRawEmbedded
+      Directory, DockerContainer, RawDisk, GoogleCloudDisk,
+      GoogleCloudDiskRawEmbedded
   ]
   evidence_output = [ReportText]
 
diff --git a/turbinia/jobs/sshd.py b/turbinia/jobs/sshd.py
index 004d1d8..04a3dd4 100644
--- a/turbinia/jobs/sshd.py
+++ b/turbinia/jobs/sshd.py
@@ -19,6 +19,7 @@ from __future__ import unicode_literals
 from turbinia.workers import artifact
 from turbinia.workers import sshd
 from turbinia.evidence import Directory
+from turbinia.evidence import DockerContainer
 from turbinia.evidence import GoogleCloudDisk
 from turbinia.evidence import GoogleCloudDiskRawEmbedded
 from turbinia.evidence import ExportedFileArtifact
@@ -33,7 +34,8 @@ class SSHDExtractionJob(interface.TurbiniaJob):
 
   # The types of evidence that this Job will process
   evidence_input = [
-      Directory, RawDisk, GoogleCloudDisk, GoogleCloudDiskRawEmbedded
+      Directory, DockerContainer, RawDisk, GoogleCloudDisk,
+      GoogleCloudDiskRawEmbedded
   ]
 
   evidence_output = [ExportedFileArtifact]
diff --git a/turbinia/jobs/tomcat.py b/turbinia/jobs/tomcat.py
index e13c293..08d280f 100644
--- a/turbinia/jobs/tomcat.py
+++ b/turbinia/jobs/tomcat.py
@@ -17,6 +17,7 @@ from __future__ import unicode_literals
 from turbinia.workers import artifact
 from turbinia.workers import tomcat
 from turbinia.evidence import Directory
+from turbinia.evidence import DockerContainer
 from turbinia.evidence import GoogleCloudDisk
 from turbinia.evidence import GoogleCloudDiskRawEmbedded
 from turbinia.evidence import ExportedFileArtifact
@@ -31,7 +32,8 @@ class TomcatExtractionJob(interface.TurbiniaJob):
 
   # The types of evidence that this Job will process
   evidence_input = [
-      Directory, RawDisk, GoogleCloudDisk, GoogleCloudDiskRawEmbedded
+      Directory, DockerContainer, RawDisk, GoogleCloudDisk,
+      GoogleCloudDiskRawEmbedded
   ]
 
   evidence_output = [ExportedFileArtifact]
diff --git a/turbinia/output_manager.py b/turbinia/output_manager.py
index 6e39f1b..32fb89a 100644
--- a/turbinia/output_manager.py
+++ b/turbinia/output_manager.py
@@ -169,8 +169,9 @@ class OutputManager(object):
       evidence_.local_path = local_path
     evidence_.saved_path = path
     evidence_.saved_path_type = path_type
-    log.info(
-        'Saved copyable evidence data to {0!s}'.format(evidence_.saved_path))
+    if evidence_.saved_path:
+      log.info(
+          'Saved copyable evidence data to {0:s}'.format(evidence_.saved_path))
     return evidence_
 
   def save_local_file(self, file_, result):
diff --git a/turbinia/processors/docker.py b/turbinia/processors/docker.py
new file mode 100644
index 0000000..0fc5551
--- /dev/null
+++ b/turbinia/processors/docker.py
@@ -0,0 +1,93 @@
+# -*- coding: utf-8 -*-
+# 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.
+"""Evidence processor to mount local Docker containers."""
+
+from __future__ import unicode_literals
+
+import logging
+import os
+import subprocess
+import tempfile
+
+from turbinia import config
+from turbinia import TurbiniaException
+
+log = logging.getLogger('turbinia')
+
+
+def PreprocessMountDockerFS(docker_dir, container_id):
+  """Mounts a Docker container Filesystem locally.
+
+  We use subprocess to run the DockerExplorer script, instead of using the
+  Python module, because we need to make sure all DockerExplorer code runs
+  as root.
+
+  Args:
+    docker_dir(str): the root Docker directory.
+    container_id(str): the complete ID of the container.
+
+  Returns:
+    The path to the mounted container file system, as a string.
+
+  Raises:
+    TurbiniaException: if there was an error trying to mount the filesystem.
+  """
+  # Most of the code is copied from PreprocessMountDisk
+  # Only the mount command changes
+  config.LoadConfig()
+  mount_prefix = config.MOUNT_DIR_PREFIX
+
+  if os.path.exists(mount_prefix) and not os.path.isdir(mount_prefix):
+    raise TurbiniaException(
+        'Mount dir {0:s} exists, but is not a directory'.format(mount_prefix))
+  if not os.path.exists(mount_prefix):
+    log.info('Creating local mount parent directory {0:s}'.format(mount_prefix))
+    try:
+      os.makedirs(mount_prefix)
+    except OSError as e:
+      raise TurbiniaException(
+          'Could not create mount directory {0:s}: {1!s}'.format(
+              mount_prefix, e))
+
+  container_mount_path = tempfile.mkdtemp(prefix='turbinia', dir=mount_prefix)
+
+  log.info(
+      'Using docker_explorer to mount container {0:s} on {1:s}'.format(
+          container_id, container_mount_path))
+  de_binary = None
+  for path in os.environ['PATH'].split(os.pathsep):
+    tentative_path = os.path.join(path, 'de.py')
+    if os.path.exists(tentative_path):
+      de_binary = tentative_path
+      break
+
+  if not de_binary:
+    raise TurbiniaException('Could not find docker-explorer script: de.py')
+
+  # TODO(aarontp): Remove hard-coded sudo in commands:
+  # https://github.com/google/turbinia/issues/73
+  mount_cmd = [
+      'sudo', de_binary, '-r', docker_dir, 'mount', container_id,
+      container_mount_path
+  ]
+  log.info('Running: {0:s}'.format(' '.join(mount_cmd)))
+
+  try:
+    subprocess.check_call(mount_cmd)
+  except subprocess.CalledProcessError as e:
+    raise TurbiniaException(
+        'Could not mount container {0:s}: {1!s}'.format(container_id, e))
+
+  return container_mount_path
diff --git a/turbinia/workers/__init__.py b/turbinia/workers/__init__.py
index d5ea9db..62e4d0d 100644
--- a/turbinia/workers/__init__.py
+++ b/turbinia/workers/__init__.py
@@ -168,14 +168,19 @@ class TurbiniaTaskResult(object):
     self.status = status
 
     for evidence in self.evidence:
-      if evidence.source_path and os.path.exists(evidence.source_path):
-        self.saved_paths.append(evidence.source_path)
-        if not task.run_local and evidence.copyable:
-          task.output_manager.save_evidence(evidence, self)
+      if evidence.source_path:
+        if os.path.exists(evidence.source_path):
+          self.saved_paths.append(evidence.source_path)
+          if not task.run_local and evidence.copyable:
+            task.output_manager.save_evidence(evidence, self)
+        else:
+          self.log(
+              'Evidence {0:s} has missing file at source_path {1!s} so '
+              'not saving.'.format(evidence.name, evidence.source_path))
       else:
         self.log(
-            'Evidence {0!s} has empty or missing file at source_path {1!s} so '
-            'not saving.'.format(evidence.name, evidence.source_path))
+            'Evidence {0:s} has empty source_path so '
+            'not saving.'.format(evidence.name))
 
       if not evidence.request_id:
         evidence.request_id = self.request_id
diff --git a/turbinia/workers/docker.py b/turbinia/workers/docker.py
new file mode 100644
index 0000000..97cd300
--- /dev/null
+++ b/turbinia/workers/docker.py
@@ -0,0 +1,117 @@
+# Copyright 2015 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Task for running docker-explorer."""
+
+from __future__ import unicode_literals
+
+import json
+import logging
+import os
+import subprocess
+
+from turbinia import TurbiniaException
+from turbinia.evidence import DockerContainer
+from turbinia.workers import Priority
+from turbinia.workers import TurbiniaTask
+
+log = logging.getLogger('turbinia')
+
+
+class DockerContainersEnumerationTask(TurbiniaTask):
+  """Enumerates Docker containers on Linux"""
+
+  def GetContainers(self, evidence):
+    """Lists the containers from an input Evidence.
+
+    We use subprocess to run the DockerExplorer script, instead of using the
+    Python module, because we need to make sure all DockerExplorer code runs
+    as root.
+
+    Args:
+      evidence (Evidence): the input Evidence.
+
+    Returns:
+      a list(dict) containing information about the containers found.
+
+    Raises:
+      TurbiniaException: when the docker-explorer tool failed to run.
+    """
+
+    docker_dir = os.path.join(evidence.mount_path, 'var', 'lib', 'docker')
+
+    containers_info = None
+
+    de_binary = None
+    for path in os.environ['PATH'].split(os.pathsep):
+      tentative_path = os.path.join(path, 'de.py')
+      if os.path.exists(tentative_path):
+        de_binary = tentative_path
+        break
+    if not de_binary:
+      raise TurbiniaException('Could not find docker-explorer script: de.py')
+
+    # TODO(rgayon): use docker-explorer exposed constant when
+    # https://github.com/google/docker-explorer/issues/80 is in.
+    docker_explorer_command = [
+        'sudo', de_binary, '-r', docker_dir, 'list', 'all_containers'
+    ]
+    log.info('Running {0:s}'.format(' '.join(docker_explorer_command)))
+    try:
+      json_string = subprocess.check_output(docker_explorer_command).decode(
+          'utf-8')
+    except json.JSONDecodeError as e:
+      raise TurbiniaException(
+          'Error decoding JSON output from de.py: {0!s}'.format(e))
+    except subprocess.CalledProcessError as e:
+      raise TurbiniaException('de.py returned an error: {0!s}'.format(e))
+
+    containers_info = json.loads(json_string)
+
+    return containers_info
+
+  def run(self, evidence, result):
+    """Run the docker-explorer tool to list containerss.
+
+    Args:
+       evidence (Evidence object):  The evidence to process
+       result (TurbiniaTaskResult): The object to place task results into.
+
+    Returns:
+      TurbiniaTaskResult object.
+    """
+
+    status_report = ''
+    success = False
+
+    status_report = (
+        'Error enumerating Docker containers, evidence has no mounted '
+        'filesystem')
+    found_containers = []
+    try:
+      containers_info = self.GetContainers(evidence)
+      for container_info in containers_info:
+        container_id = container_info.get('container_id')
+        found_containers.append(container_id)
+        container_evidence = DockerContainer(container_id=container_id)
+        result.add_evidence(container_evidence, evidence.config)
+      success = True
+      status_report = 'Found {0!s} containers: {1:s}'.format(
+          len(found_containers), ' '.join(found_containers))
+    except TurbiniaException as e:
+      status_report = 'Error enumerating Docker containers: {0!s}'.format(e)
+
+    result.report_priority = Priority.LOW
+    result.report_data = status_report
+    result.close(self, success=success, status=status_report)
+    return result


Test patch:
diff --git a/turbinia/workers/docker_test.py b/turbinia/workers/docker_test.py
new file mode 100644
index 0000000..cbdea8c
--- /dev/null
+++ b/turbinia/workers/docker_test.py
@@ -0,0 +1,66 @@
+# -*- 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 the Docker Containers Enumeration job."""
+
+from __future__ import unicode_literals
+from io import StringIO
+
+import unittest
+import textwrap
+import mock
+
+from turbinia.evidence import BulkExtractorOutput
+from turbinia.workers import docker
+from turbinia.workers.workers_test import TestTurbiniaTaskBase
+from turbinia.workers import TurbiniaTaskResult
+
+
+class DockerTaskTest(TestTurbiniaTaskBase):
+  """Tests for DockerContainersEnumerationTask."""
+
+  def setUp(self):
+    # pylint: disable=arguments-differ
+    super(DockerTaskTest, self).setUp(
+        task_class=docker.DockerContainersEnumerationTask,
+        evidence_class=docker.DockerContainer)
+    self.setResults(mock_run=False)
+    self.task.output_dir = self.task.base_output_dir
+    self.evidence.mount_path = 'non_existent'
+
+  # pylint: disable=line-too-long
+  @mock.patch(
+      'turbinia.workers.docker.DockerContainersEnumerationTask.GetContainers')
+  def testDockerContainersEnumerationRun(self, get_containers_mock):
+    """Test DockerContainersEnumeration task run."""
+
+    get_containers_mock.return_value = [
+        {
+            'container_id': '12'
+        },
+        {
+            'container_id': '3a'
+        },
+    ]
+    result = self.task.run(self.evidence, self.result)
+
+    # Ensure run method returns a TurbiniaTaskResult instance.
+    self.assertIsInstance(result, TurbiniaTaskResult)
+    self.assertEqual(result.task_name, 'DockerContainersEnumerationTask')
+    self.assertEqual(len(result.evidence), 2)
+    self.assertEqual(result.report_data, 'Found 2 containers: 12 3a')
+
+
+if __name__ == '__main__':
+  unittest.main()