Repository: google/turbinia (python)

Issue:
Turbiniactl Fails When the Config File is None
```
Traceback (most recent call last):                                                                                                                                                                                                                                                        
  File "/usr/local/google/home/amisi/Documents/turbinia/turbinia/turbiniactl.py", line 378, in main                                                                                                                                                                                       
    config.LoadConfig()                                                                                                                                                                                                                                                                   
  File "/usr/local/google/home/amisi/Documents/turbinia/turbinia/config/__init__.py", line 149, in LoadConfig                                                                                                                                                                             
    ValidateAndSetConfig(_config)                                                                                                                                                                                                                                                         
  File "/usr/local/google/home/amisi/Documents/turbinia/turbinia/config/__init__.py", line 179, in ValidateAndSetConfig                                                                                                                                                                   
    _config.configSource, var))                                                                                                                                                                                                                                                           
turbinia.TurbiniaException: Required config attribute /usr/local/google/home/amisi/.turbiniarc:DEPENDENCIES not in config

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/usr/local/google/home/amisi/Documents/env/bin/turbiniactl", line 11, in <module>
    load_entry_point('turbinia', 'console_scripts', 'turbiniactl')()
  File "/usr/local/google/home/amisi/Documents/turbinia/turbinia/turbiniactl.py", line 382, in main
    exception, config.CONFIG_MSG))
TypeError: unsupported format string passed to TurbiniaException.__format__
```

Pull request:
Fix format strings for exceptions
Fixes #483 

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/config/logger.py b/turbinia/config/logger.py
index 4879d6e..bc1b168 100644
--- a/turbinia/config/logger.py
+++ b/turbinia/config/logger.py
@@ -57,7 +57,7 @@ def setup(need_file_handler=True, need_stream_handler=True):
       config.LoadConfig()
     except TurbiniaException as exception:
       print(
-          'Could not load config file ({0:s}).\n{1:s}'.format(
+          'Could not load config file ({0!s}).\n{1:s}'.format(
               exception, config.CONFIG_MSG))
       sys.exit(1)
 
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/turbiniactl.py b/turbinia/turbiniactl.py
index d538e41..09e334b 100644
--- a/turbinia/turbiniactl.py
+++ b/turbinia/turbiniactl.py
@@ -378,7 +378,7 @@ def main():
       config.LoadConfig()
   except TurbiniaException as exception:
     print(
-        'Could not load config file ({0:s}).\n{1:s}'.format(
+        'Could not load config file ({0!s}).\n{1:s}'.format(
             exception, config.CONFIG_MSG))
     sys.exit(1)
 
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()