Repository: google/turbinia
Pull request #388 (2019-05-07 22:03:47)

Unify 'path' attributes accross Evidences
This is one of the first steps for #389 . We want "local_path" to be one of the "convenience path" set by preprocessors, for Tasks that don't really care about the way it's offered to it (disk image file, or mounted file system).
The property that is set during Evidence creation is now "source_path". This will always point to the "unprocessed" data, and can always be used to re-process the Evidence so it can be used by Tasks

The other commodity paths are:
- device_path: Path to the raw device for this disk
- volume_path (or partition_path): Path to the actual volume/partition
- mount_path: The mount path for this disk (if any).

We also need to make GoogleCloudDiskRawEmbedded into a "Compound" Evidence (otherwise this Evidence type would need more than one 'device_path').

Task code is adapted to use the paths accordingly.

rgayon: Woops, this is actually ready for review
aarontp: Oh, also, have you done any end to end testing on this yet?
rgayon: I still need to run a complete test before this can be merged
rgayon: Hmmm why are Py3 tests failing in volatility tests
rgayon: I hit an issue when trying to give turbinia a googlecloudembededdisk where the disk image is a partition (and not a disk). Will figure out a way to handle this
rgayon: Okay so, I hit a couple bumps in the way while testing for a googleclouddiskembed where the embed image was a "dirty" ext4 filesystem, hence most of the last significant changes.

I also made having Evidence.source_path mandatory for copyable files, as a way to make sure this is never None (like, if a Task forgets to set it).

While I still need to make sure turbinia still extracts everything we need, can you PTAL?
rgayon: Some fixes but still 2 open questions
aarontp: Sounds good.  :). Sorry for flip flopping on that, but I didn't realize how
privileged the disk group was, but it makes sense given that you could just
access the raw disk to give you whatever you want.

Maybe we could shell out to docker explorer when we get to that?

On Fri, Aug 23, 2019, 6:45 AM Romain Gayon <notifications@github.com> wrote:

> *@rgayon* commented on this pull request.
> ------------------------------
>
> In turbinia/lib/utils.py
> <https://github.com/google/turbinia/pull/388#discussion_r317138610>:
>
> > @@ -75,8 +75,8 @@ def extract_artifacts(artifact_names, disk_path, output_dir):
>    # Plaso image_export expects artifact names as a comma separated string.
>    artifacts = ','.join(artifact_names)
>    image_export_cmd = [
> -      'image_export.py', '--artifact_filters', artifacts, '--write', output_dir,
> -      '--partitions', 'all', disk_path
> +      'sudo', 'image_export.py', '--artifact_filters', artifacts, '--write',
>
> I'm keeping some sudo's then =)
>
> I *might* have issues later on with docker-explorer module, which will
> read json files and then do things.
> I think mlegin also wanted to explore a mounted file system for binaries,
> most likely with python (or some image_export.py trick maybe?)
>
> —
> You are receiving this because your review was requested.
> Reply to this email directly, view it on GitHub
> <https://github.com/google/turbinia/pull/388?email_source=notifications&email_token=AAULFO4YDUBYZNDQB6T3WPTQF7SYHA5CNFSM4HLM2MW2YY3PNVWWK3TUL52HS4DFWFIHK3DMKJSXC5LFON2FEZLWNFSXPKTDN5WW2ZLOORPWSZGOCCQVG7Y#discussion_r317138610>,
> or mute the thread
> <https://github.com/notifications/unsubscribe-auth/AAULFO6FOTKTTLOJQDYDDODQF7SYHANCNFSM4HLM2MWQ>
> .
>

rgayon: Yeah we can also call de.py script to do the mounting (even though I did package docker-explorer as a module just so we could use it in python and not have to shell out :) )

I'm still struggling with some serialization issues (see above), will continue next week
rgayon: So I implemented the solution I was talking about [here](https://github.com/google/turbinia/pull/388#discussion_r317192690), PTAL!

This meant passing source_path properly at object instantiation (because we check it is set when we __init__() and the evidence type is copyable), hence some changes in workers.

I still need to file a couple other PR to fix Py3-related bugs I found, and re-run some tests.
rgayon: 45 commits, 96 messages, are you ready for this? :)

Patch:
diff --git a/turbinia/evidence.py b/turbinia/evidence.py
index c9bb84f..6afc740 100644
--- a/turbinia/evidence.py
+++ b/turbinia/evidence.py
@@ -16,7 +16,6 @@
 
 from __future__ import unicode_literals
 
-import copy
 import json
 import os
 import sys
@@ -52,19 +51,19 @@ def evidence_decode(evidence_dict):
         'Evidence_dict is not a dictionary, type is {0:s}'.format(
             str(type(evidence_dict))))
 
-  type_ = evidence_dict.get('type', None)
+  type_ = evidence_dict.pop('type', None)
   if not type_:
     raise TurbiniaException(
         'No Type attribute for evidence object [{0:s}]'.format(
             str(evidence_dict)))
 
   try:
-    evidence = getattr(sys.modules[__name__], type_)()
+    evidence_class = getattr(sys.modules[__name__], type_)
+    evidence = evidence_class.from_dict(evidence_dict)
   except AttributeError:
     raise TurbiniaException(
         'No Evidence object of type {0:s} in evidence module'.format(type_))
 
-  evidence.__dict__ = evidence_dict
   if evidence_dict.get('parent_evidence'):
     evidence.parent_evidence = evidence_decode(evidence_dict['parent_evidence'])
   if evidence_dict.get('collection'):
@@ -98,7 +97,10 @@ class Evidence(object):
         to the saved_path location.
     source (str): String indicating where evidence came from (including tool
         version that created it, if appropriate).
-    local_path (str): A string of the local_path to the evidence.
+    local_path (str): Path to the processed data (can be a blockdevice or a
+        directory).
+    source_path (str): Path to the un-processed source data for the Evidence.
+    mount_path (str): Path to a mounted file system (if relevant).
     tags (dict): Extra tags associated with this evidence.
     request_id (str): The id of the request this evidence came from, if any.
     parent_evidence (Evidence): The Evidence object that was used to generate
@@ -114,21 +116,24 @@ class Evidence(object):
   REQUIRED_ATTRIBUTES = []
 
   def __init__(
-      self, name=None, description=None, source=None, local_path=None,
-      tags=None, request_id=None):
+      self, name=None, description=None, source=None, source_path=None,
+      tags=None, request_id=None, copyable=False):
     """Initialization for Evidence."""
-    self.copyable = False
+    self.copyable = copyable
     self.config = {}
     self.context_dependent = False
     self.cloud_only = False
     self.description = description
+    self.mount_path = None
     self.source = source
-    self.local_path = local_path
+    self.source_path = source_path
     self.tags = tags if tags else {}
     self.request_id = request_id
     self.parent_evidence = None
     self.save_metadata = False
 
+    self.local_path = source_path
+
     # List of jobs that have processed this evidence
     self.processed_by = []
     self.type = self.__class__.__name__
@@ -136,15 +141,41 @@ class Evidence(object):
     self.saved_path = None
     self.saved_path_type = None
 
+    if self.copyable and not self.local_path:
+      raise TurbiniaException(
+          '{0:s} is a copyable evidence and needs a source_path'.format(
+              self.type))
+
   def __str__(self):
-    return '{0:s}:{1:s}:{2!s}'.format(self.type, self.name, self.local_path)
+    return '{0:s}:{1:s}:{2!s}'.format(self.type, self.name, self.source_path)
 
   def __repr__(self):
     return self.__str__()
 
+  @classmethod
+  def from_dict(cls, dictionary):
+    """Instanciate an Evidence object from a dictionary of attributes.
+
+    Args:
+      dictionary(dict): the attributes to set for this object.
+    Returns:
+      Evidence: the instantiated evidence.
+    """
+    name = dictionary.pop('name', None)
+    description = dictionary.pop('description', None)
+    source = dictionary.pop('source', None)
+    source_path = dictionary.pop('source_path', None)
+    tags = dictionary.pop('tags', None)
+    request_id = dictionary.pop('request_id', None)
+    new_object = cls(
+        name=name, description=description, source=source,
+        source_path=source_path, tags=tags, request_id=request_id)
+    new_object.__dict__.update(dictionary)
+    return new_object
+
   def serialize(self):
     """Return JSON serializable object."""
-    serialized_evidence = copy.deepcopy(self.__dict__)
+    serialized_evidence = self.__dict__.copy()
     if self.parent_evidence:
       serialized_evidence['parent_evidence'] = self.parent_evidence.serialize()
     return serialized_evidence
@@ -197,7 +228,11 @@ class Evidence(object):
                        Task will write to.
 
     """
-    if self.parent_evidence:
+    if self.context_dependent:
+      if not self.parent_evidence:
+        raise TurbiniaException(
+            'Evidence of type {0:s} needs parent_evidence to be set'.format(
+                self.type))
       self.parent_evidence.preprocess()
     self._preprocess(tmp_dir)
 
@@ -313,37 +348,44 @@ class ChromiumProfile(Evidence):
 
   def __init__(self, browser_type=None, output_format=None, *args, **kwargs):
     """Initialization for chromium profile evidence object."""
-    super(ChromiumProfile, self).__init__(*args, **kwargs)
+    super(ChromiumProfile, self).__init__(copyable=True, *args, **kwargs)
     self.browser_type = browser_type
     self.output_format = output_format
-    self.copyable = True
 
 
 class RawDisk(Evidence):
   """Evidence object for Disk based evidence.
 
   Attributes:
-    loopdevice_path: Path to the losetup device for this disk.
-    mount_path: The mount path for this disk (if any).
+    device_path (str): Path to a relevant 'raw' data source (ie: a block
+        device or a raw disk image).
     mount_partition: The mount partition for this disk (if any).
-    size:  The size of the disk in bytes.
+    size: The size of the disk in bytes.
   """
 
-  def __init__(
-      self, mount_path=None, mount_partition=None, size=None, *args, **kwargs):
+  def __init__(self, mount_partition=1, size=None, *args, **kwargs):
     """Initialization for raw disk evidence object."""
-    self.loopdevice_path = None
-    self.mount_path = mount_path
+
+    if mount_partition < 1:
+      raise TurbiniaException(
+          'Partition numbers start at 1, but was given {0:d}'.format(
+              mount_partition))
+
+    self.device_path = None
     self.mount_partition = mount_partition
     self.size = size
     super(RawDisk, self).__init__(*args, **kwargs)
 
   def _preprocess(self, _):
-    self.loopdevice_path = mount_local.PreprocessLosetup(self.local_path)
+    self.device_path, partition_paths = mount_local.PreprocessLosetup(
+        self.source_path)
+    self.mount_path = mount_local.PreprocessMountDisk(
+        partition_paths, self.mount_partition)
+    self.local_path = self.device_path
 
   def _postprocess(self):
-    mount_local.PostprocessDeleteLosetup(self.loopdevice_path)
-    self.loopdevice_path = None
+    mount_local.PostprocessUnmountPath(self.mount_path)
+    mount_local.PostprocessDeleteLosetup(self.device_path)
 
 
 class EncryptedDisk(RawDisk):
@@ -408,7 +450,7 @@ class APFSEncryptedDisk(EncryptedDisk):
 
 
 class GoogleCloudDisk(RawDisk):
-  """Evidence object for Google Cloud Disks.
+  """Evidence object for a Google Cloud Disk.
 
   Attributes:
     project: The cloud project name this disk is associated with.
@@ -427,11 +469,16 @@ class GoogleCloudDisk(RawDisk):
     self.cloud_only = True
 
   def _preprocess(self, _):
-    self.local_path = google_cloud.PreprocessAttachDisk(self.disk_name)
+    self.device_path, partition_paths = google_cloud.PreprocessAttachDisk(
+        self.disk_name)
+
+    self.mount_path = mount_local.PreprocessMountDisk(
+        partition_paths, self.mount_partition)
+    self.local_path = self.device_path
 
   def _postprocess(self):
-    google_cloud.PostprocessDetachDisk(self.disk_name, self.local_path)
-    self.local_path = None
+    mount_local.PostprocessUnmountPath(self.mount_path)
+    google_cloud.PostprocessDetachDisk(self.disk_name, self.device_path)
 
 
 class GoogleCloudDiskRawEmbedded(GoogleCloudDisk):
@@ -446,24 +493,37 @@ class GoogleCloudDiskRawEmbedded(GoogleCloudDisk):
     embedded_path: The path of the raw disk image inside the Persistent Disk
   """
 
-  REQUIRED_ATTRIBUTES = ['disk_name', 'project', 'zone', 'embedded_path']
+  REQUIRED_ATTRIBUTES = [
+      'disk_name', 'project', 'zone', 'embedded_partition', 'embedded_path'
+  ]
 
-  def __init__(self, embedded_path=None, *args, **kwargs):
-    """Initialization for Google Cloud Disk."""
+  def __init__(
+      self, embedded_path=None, embedded_partition=None, *args, **kwargs):
+    """Initialization for Google Cloud Disk containing a raw disk image."""
     self.embedded_path = embedded_path
+    self.embedded_partition = embedded_partition
     super(GoogleCloudDiskRawEmbedded, self).__init__(*args, **kwargs)
 
+    # This Evidence needs to have a GoogleCloudDisk as a parent
+    self.context_dependent = True
+
   def _preprocess(self, _):
-    self.local_path = google_cloud.PreprocessAttachDisk(self.disk_name)
-    self.loopdevice_path = mount_local.PreprocessLosetup(self.local_path)
+    rawdisk_path = os.path.join(
+        self.parent_evidence.mount_path, self.embedded_path)
+    if not os.path.exists(rawdisk_path):
+      raise TurbiniaException(
+          'Unable to find raw disk image {0:s} in GoogleCloudDisk'.format(
+              rawdisk_path))
+    self.device_path, partition_paths = mount_local.PreprocessLosetup(
+        rawdisk_path)
+
     self.mount_path = mount_local.PreprocessMountDisk(
-        self.loopdevice_path, self.mount_partition)
-    self.local_path = os.path.join(self.mount_path, self.embedded_path)
+        partition_paths, self.mount_partition)
+    self.local_path = self.device_path
 
   def _postprocess(self):
-    google_cloud.PostprocessDetachDisk(self.disk_name, self.local_path)
     mount_local.PostprocessUnmountPath(self.mount_path)
-    mount_local.PostprocessDeleteLosetup(self.loopdevice_path)
+    mount_local.PostprocessDeleteLosetup(self.device_path)
 
 
 class PlasoFile(Evidence):
@@ -476,8 +536,7 @@ class PlasoFile(Evidence):
   def __init__(self, plaso_version=None, *args, **kwargs):
     """Initialization for Plaso File evidence."""
     self.plaso_version = plaso_version
-    super(PlasoFile, self).__init__(*args, **kwargs)
-    self.copyable = True
+    super(PlasoFile, self).__init__(copyable=True, *args, **kwargs)
     self.save_metadata = True
 
 
@@ -487,7 +546,7 @@ class PlasoCsvFile(PlasoFile):
   def __init__(self, plaso_version=None, *args, **kwargs):
     """Initialization for Plaso File evidence."""
     self.plaso_version = plaso_version
-    super(PlasoCsvFile, self).__init__(*args, **kwargs)
+    super(PlasoCsvFile, self).__init__(copyable=True, *args, **kwargs)
     self.save_metadata = False
 
 
@@ -497,8 +556,7 @@ class ReportText(Evidence):
 
   def __init__(self, text_data=None, *args, **kwargs):
     self.text_data = text_data
-    super(ReportText, self).__init__(*args, **kwargs)
-    self.copyable = True
+    super(ReportText, self).__init__(copyable=True, *args, **kwargs)
 
 
 class FinalReport(ReportText):
@@ -513,8 +571,7 @@ class TextFile(Evidence):
   """Text data."""
 
   def __init__(self, *args, **kwargs):
-    super(TextFile, self).__init__(*args, **kwargs)
-    self.copyable = True
+    super(TextFile, self).__init__(copyable=True, *args, **kwargs)
 
 
 class FilteredTextFile(TextFile):
@@ -529,9 +586,8 @@ class ExportedFileArtifact(Evidence):
 
   def __init__(self, artifact_name=None, *args, **kwargs):
     """Initializes an exported file artifact."""
-    super(ExportedFileArtifact, self).__init__(*args, **kwargs)
+    super(ExportedFileArtifact, self).__init__(copyable=True, *args, **kwargs)
     self.artifact_name = artifact_name
-    self.copyable = True
 
 
 class VolatilityReport(TextFile):
diff --git a/turbinia/lib/google_cloud.py b/turbinia/lib/google_cloud.py
index b78cdc0..0cc9e2d 100644
--- a/turbinia/lib/google_cloud.py
+++ b/turbinia/lib/google_cloud.py
@@ -191,7 +191,7 @@ class GoogleCloudProject(object):
         zone = instance['zone']
       return GoogleComputeInstance(project=self, zone=zone, name=instance_name)
     except KeyError:
-      raise TurbiniaException('Unknown instance {0:s}'.format(instance_name))
+      raise TurbiniaException('Unknown instance: {0!s}'.format(instance_name))
 
 
 class GoogleCloudFunction(GoogleCloudProject):
diff --git a/turbinia/lib/utils.py b/turbinia/lib/utils.py
index 0605e70..691ceea 100644
--- a/turbinia/lib/utils.py
+++ b/turbinia/lib/utils.py
@@ -75,8 +75,8 @@ def extract_artifacts(artifact_names, disk_path, output_dir):
   # Plaso image_export expects artifact names as a comma separated string.
   artifacts = ','.join(artifact_names)
   image_export_cmd = [
-      'image_export.py', '--artifact_filters', artifacts, '--write', output_dir,
-      '--partitions', 'all', disk_path
+      'sudo', 'image_export.py', '--artifact_filters', artifacts, '--write',
+      output_dir, '--partitions', 'all', disk_path
   ]
 
   return _image_export(image_export_cmd, output_dir)
diff --git a/turbinia/processors/google_cloud.py b/turbinia/processors/google_cloud.py
index ef25133..563c9f3 100644
--- a/turbinia/processors/google_cloud.py
+++ b/turbinia/processors/google_cloud.py
@@ -16,6 +16,7 @@
 
 from __future__ import unicode_literals
 
+import glob
 import logging
 import os
 import stat
@@ -73,12 +74,17 @@ def PreprocessAttachDisk(disk_name):
     disk_name(str): The name of the Cloud Disk to attach.
 
   Returns:
-    str: the path to the attached block device.
+    (str, list(str)): a tuple consisting of the path to the 'disk' block device
+      and a list of paths to partition block devices. For example:
+      (
+       '/dev/disk/by-id/google-disk0',
+       ['/dev/disk/by-id/google-disk0-part1', '/dev/disk/by-id/google-disk0-p2']
+      )
   """
   path = '/dev/disk/by-id/google-{0:s}'.format(disk_name)
   if IsBlockDevice(path):
     log.info('Disk {0:s} already attached!'.format(disk_name))
-    return path
+    return (path, glob.glob('{0:s}-part*'.format(path)))
 
   config.LoadConfig()
   instance_name = GetLocalInstanceName()
@@ -102,7 +108,7 @@ def PreprocessAttachDisk(disk_name):
                                                   os.stat(path).st_mode))
     time.sleep(1)
 
-  return path
+  return (path, glob.glob('{0:s}-part*'.format(path)))
 
 
 def PostprocessDetachDisk(disk_name, local_path):
diff --git a/turbinia/processors/mount_local.py b/turbinia/processors/mount_local.py
index bde60c2..4d40a24 100644
--- a/turbinia/processors/mount_local.py
+++ b/turbinia/processors/mount_local.py
@@ -16,10 +16,12 @@
 
 from __future__ import unicode_literals
 
+import glob
 import logging
 import os
 import subprocess
 import tempfile
+import time
 
 from turbinia import config
 from turbinia import TurbiniaException
@@ -34,15 +36,29 @@ def PreprocessLosetup(source_path):
     source_path(str): the source path to run losetup on.
 
   Raises:
-    TurbiniaException: if the losetup command failed to run.
+    TurbiniaException: if source_path doesn't exist or if the losetup command
+      failed to run in anyway.
 
   Returns:
-    str: the path to the created loopdevice (ie: /dev/loopX)
+    (str, list(str)): a tuple consisting of the path to the 'disk' block device
+      and a list of paths to partition block devices. For example:
+      ('/dev/loop0', ['/dev/loop0p1', '/dev/loop0p2'])
   """
   losetup_device = None
+
+  if not os.path.exists(source_path):
+    raise TurbiniaException(
+        'Cannot process non-existing source_path {0!s}'.format(source_path))
+
   # TODO(aarontp): Remove hard-coded sudo in commands:
   # https://github.com/google/turbinia/issues/73
-  losetup_command = ['sudo', 'losetup', '--show', '--find', '-P', source_path]
+  if not os.path.exists(source_path):
+    raise TurbiniaException(
+        'Cannot mount non-existing source_path {0!s}'.format(source_path))
+
+  losetup_command = [
+      'sudo', 'losetup', '--show', '--find', '-P', '-r', source_path
+  ]
   log.info('Running command {0:s}'.format(' '.join(losetup_command)))
   try:
     losetup_device = subprocess.check_output(
@@ -50,15 +66,22 @@ def PreprocessLosetup(source_path):
   except subprocess.CalledProcessError as e:
     raise TurbiniaException('Could not set losetup devices {0!s}'.format(e))
 
-  return losetup_device
+  partitions = glob.glob('{0:s}p*'.format(losetup_device))
+  if not partitions:
+    # In this case, the image was of a partition, and not a full disk with a
+    # partition table
+    return (losetup_device, [losetup_device])
 
+  return (losetup_device, partitions)
 
-def PreprocessMountDisk(loopdevice_path, partition_number):
+
+def PreprocessMountDisk(partition_paths, partition_number):
   """Locally mounts disk in an instance.
 
   Args:
-    loopdevice_path(str): The path to the block device to mount.
-    partition_number(int): The partition number.
+    partition_paths(list(str)): A list of paths to partition block devices;
+    partition_number(int): the number of the partition to mount. Remember these
+      are 1-indexed (first partition is 1).
 
   Raises:
     TurbiniaException: if the mount command failed to run.
@@ -69,6 +92,24 @@ def PreprocessMountDisk(loopdevice_path, partition_number):
   config.LoadConfig()
   mount_prefix = config.MOUNT_DIR_PREFIX
 
+  if partition_number > len(partition_paths):
+    raise TurbiniaException(
+        'Can not mount partition {0:d}: found only {1:d} partitions in '
+        'Evidence.'.format(partition_number, len(partition_paths)))
+
+  # Partitions are 1-indexed for the user and the system
+  if partition_number < 1:
+    raise TurbiniaException(
+        'Can not mount partition {0:d}: partition numbering starts at 1'.format(
+            partition_number))
+
+  partition_path = partition_paths[partition_number - 1]
+
+  if not os.path.exists(partition_path):
+    raise TurbiniaException(
+        'Could not mount partition {0:s}, the path does not exist'.format(
+            partition_path))
+
   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))
@@ -83,20 +124,14 @@ def PreprocessMountDisk(loopdevice_path, partition_number):
 
   mount_path = tempfile.mkdtemp(prefix='turbinia', dir=mount_prefix)
 
-  if not partition_number:
-    # The first partition loop-device made by losetup is loopXp1
-    partition_number = 1
-
-  path_to_partition = '{0:s}p{1:d}'.format(loopdevice_path, partition_number)
+  mount_cmd = ['sudo', 'mount', '-o', 'ro']
+  fstype = GetFilesystem(partition_path)
+  if fstype in ['ext3', 'ext4']:
+    # This is in case the underlying filesystem is dirty, as we want to mount
+    # everything read-only.
+    mount_cmd.extend(['-o', 'noload'])
+  mount_cmd.extend([partition_path, mount_path])
 
-  if not os.path.exists(path_to_partition):
-    log.info(
-        'Could not find {0:s}, trying {1:s}'.format(
-            path_to_partition, loopdevice_path))
-    # Else, the partition's block device is actually /dev/loopX
-    path_to_partition = loopdevice_path
-
-  mount_cmd = ['sudo', 'mount', path_to_partition, mount_path]
   log.info('Running: {0:s}'.format(' '.join(mount_cmd)))
   try:
     subprocess.check_call(mount_cmd)
@@ -106,11 +141,34 @@ def PreprocessMountDisk(loopdevice_path, partition_number):
   return mount_path
 
 
-def PostprocessDeleteLosetup(loopdevice_path):
+def GetFilesystem(path):
+  """Uses lsblk to detect the filesystem of a partition block device.
+
+  Args:
+    path(str): the full path to the block device.
+  Returns:
+    str: the filesystem detected (for example: 'ext4')
+  """
+  cmd = ['lsblk', path, '-f', '-o', 'FSTYPE', '-n']
+  log.info('Running {0!s}'.format(cmd))
+  fstype = subprocess.check_output(cmd).split()
+  if not fstype:
+    # Lets wait a bit for any previous blockdevice operation to settle
+    time.sleep(2)
+    fstype = subprocess.check_output(cmd).split()
+
+  if len(fstype) != 1:
+    raise TurbiniaException(
+        '{0:s} should contain exactly one partition, found {1:d}'.format(
+            path, len(fstype)))
+  return fstype[0].decode('utf-8').strip()
+
+
+def PostprocessDeleteLosetup(device_path):
   """Removes a loop device.
 
   Args:
-    loopdevice_path(str): the path to the block device to remove
+    device_path(str): the path to the block device to remove
       (ie: /dev/loopX).
 
   Raises:
@@ -118,7 +176,7 @@ def PostprocessDeleteLosetup(loopdevice_path):
   """
   # TODO(aarontp): Remove hard-coded sudo in commands:
   # https://github.com/google/turbinia/issues/73
-  losetup_cmd = ['sudo', 'losetup', '-d', loopdevice_path]
+  losetup_cmd = ['sudo', 'losetup', '-d', device_path]
   log.info('Running: {0:s}'.format(' '.join(losetup_cmd)))
   try:
     subprocess.check_call(losetup_cmd)
diff --git a/turbinia/turbiniactl.py b/turbinia/turbiniactl.py
index 40f5700..2a9d385 100644
--- a/turbinia/turbiniactl.py
+++ b/turbinia/turbiniactl.py
@@ -143,7 +143,7 @@ def main():
   parser_rawdisk = subparsers.add_parser(
       'rawdisk', help='Process RawDisk as Evidence')
   parser_rawdisk.add_argument(
-      '-l', '--local_path', help='Local path to the evidence', required=True)
+      '-l', '--source_path', help='Local path to the evidence', required=True)
   parser_rawdisk.add_argument(
       '-P', '--mount_partition', default=1, type=int,
       help='The partition number to use when mounting this disk.  Defaults to '
@@ -159,7 +159,7 @@ def main():
   parser_apfs = subparsers.add_parser(
       'apfs', help='Process APFSEncryptedDisk as Evidence')
   parser_apfs.add_argument(
-      '-l', '--local_path', help='Local path to the encrypted APFS evidence',
+      '-l', '--source_path', help='Local path to the encrypted APFS evidence',
       required=True)
   parser_apfs.add_argument(
       '-r', '--recovery_key', help='Recovery key for the APFS evidence.  '
@@ -178,7 +178,7 @@ def main():
   parser_bitlocker = subparsers.add_parser(
       'bitlocker', help='Process Bitlocker Disk as Evidence')
   parser_bitlocker.add_argument(
-      '-l', '--local_path',
+      '-l', '--source_path',
       help='Local path to the encrypted Bitlocker evidence', required=True)
   parser_bitlocker.add_argument(
       '-r', '--recovery_key', help='Recovery key for the Bitlocker evidence.  '
@@ -208,7 +208,7 @@ def main():
       'with. If this is different from the project that Turbinia is running '
       'in, it will be copied to the Turbinia project.')
   parser_googleclouddisk.add_argument(
-      '-P', '--mount_partition', default=0, type=int,
+      '-P', '--mount_partition', default=1, type=int,
       help='The partition number to use when mounting this disk.  Defaults to '
       'the entire raw disk.  Only affects mounting, and not what gets '
       'processed.')
@@ -240,10 +240,14 @@ def main():
       'with. If this is different from the project that Turbinia is running '
       'in, it will be copied to the Turbinia project.')
   parser_googleclouddiskembedded.add_argument(
-      '-P', '--mount_partition', default=0, type=int,
+      '-P', '--mount_partition', default=1, type=int,
       help='The partition number to use when mounting this disk.  Defaults to '
       'the entire raw disk.  Only affects mounting, and not what gets '
       'processed.')
+  parser_googleclouddiskembedded.add_argument(
+      '--embedded_mount_partition', default=1, type=int,
+      help='The partition number to use when mounting this embedded disk image.'
+      ' Defaults to the first partition')
   parser_googleclouddiskembedded.add_argument(
       '-z', '--zone', help='Geographic zone the disk exists in')
   parser_googleclouddiskembedded.add_argument(
@@ -256,7 +260,7 @@ def main():
   parser_rawmemory = subparsers.add_parser(
       'rawmemory', help='Process RawMemory as Evidence')
   parser_rawmemory.add_argument(
-      '-l', '--local_path', help='Local path to the evidence', required=True)
+      '-l', '--source_path', help='Local path to the evidence', required=True)
   parser_rawmemory.add_argument(
       '-P', '--profile', help='Profile to use with Volatility', required=True)
   parser_rawmemory.add_argument(
@@ -269,7 +273,7 @@ def main():
   parser_directory = subparsers.add_parser(
       'directory', help='Process a directory as Evidence')
   parser_directory.add_argument(
-      '-l', '--local_path', help='Local path to the evidence', required=True)
+      '-l', '--source_path', help='Local path to the evidence', required=True)
   parser_directory.add_argument(
       '-s', '--source', help='Description of the source of the evidence',
       required=False)
@@ -291,7 +295,7 @@ def main():
   parser_hindsight = subparsers.add_parser(
       'hindsight', help='Process ChromiumProfile as Evidence')
   parser_hindsight.add_argument(
-      '-l', '--local_path', help='Local path to the evidence', required=True)
+      '-l', '--source_path', help='Local path to the evidence', required=True)
   parser_hindsight.add_argument(
       '-f', '--format', help='Output format (supported types are '
       'xlsx, sqlite, jsonl)', default='sqlite')
@@ -487,40 +491,40 @@ def main():
   # Start Evidence configuration
   evidence_ = None
   if args.command == 'rawdisk':
-    args.name = args.name if args.name else args.local_path
-    local_path = os.path.abspath(args.local_path)
+    args.name = args.name if args.name else args.source_path
+    source_path = os.path.abspath(args.source_path)
     evidence_ = evidence.RawDisk(
-        name=args.name, local_path=local_path,
+        name=args.name, source_path=source_path,
         mount_partition=args.mount_partition, source=args.source)
   elif args.command == 'apfs':
     if not args.password and not args.recovery_key:
       log.error('Neither recovery key nor password is specified.')
       sys.exit(1)
-    args.name = args.name if args.name else args.local_path
-    local_path = os.path.abspath(args.local_path)
+    args.name = args.name if args.name else args.source_path
+    source_path = os.path.abspath(args.source_path)
     evidence_ = evidence.APFSEncryptedDisk(
-        name=args.name, local_path=local_path, recovery_key=args.recovery_key,
+        name=args.name, source_path=source_path, recovery_key=args.recovery_key,
         password=args.password, source=args.source)
   elif args.command == 'bitlocker':
     if not args.password and not args.recovery_key:
       log.error('Neither recovery key nor password is specified.')
       sys.exit(1)
-    args.name = args.name if args.name else args.local_path
-    local_path = os.path.abspath(args.local_path)
+    args.name = args.name if args.name else args.source_path
+    source_path = os.path.abspath(args.source_path)
     evidence_ = evidence.BitlockerDisk(
-        name=args.name, local_path=local_path, recovery_key=args.recovery_key,
+        name=args.name, source_path=source_path, recovery_key=args.recovery_key,
         password=args.password, source=args.source)
   elif args.command == 'directory':
-    args.name = args.name if args.name else args.local_path
-    local_path = os.path.abspath(args.local_path)
+    args.name = args.name if args.name else args.source_path
+    source_path = os.path.abspath(args.source_path)
     evidence_ = evidence.Directory(
-        name=args.name, local_path=local_path, source=args.source)
+        name=args.name, source_path=source_path, source=args.source)
   elif args.command == 'compressedirectory':
-    archive.ValidateTarFile(args.local_path)
-    args.name = args.name if args.name else args.local_path
-    local_path = os.path.abspath(args.local_path)
+    archive.ValidateTarFile(args.source_path)
+    args.name = args.name if args.name else args.source_path
+    source_path = os.path.abspath(args.source_path)
     evidence_ = evidence.CompressedDirectory(
-        name=args.name, local_path=local_path, source=args.source)
+        name=args.name, source_path=source_path, source=args.source)
   elif args.command == 'googleclouddisk':
     args.name = args.name if args.name else args.disk_name
     evidence_ = evidence.GoogleCloudDisk(
@@ -529,10 +533,16 @@ def main():
         source=args.source)
   elif args.command == 'googleclouddiskembedded':
     args.name = args.name if args.name else args.disk_name
+    parent_evidence_ = evidence.GoogleCloudDisk(
+        name=args.name, disk_name=args.disk_name, project=args.project,
+        mount_partition=args.mount_partition, zone=args.zone,
+        source=args.source)
     evidence_ = evidence.GoogleCloudDiskRawEmbedded(
-        name=args.name, disk_name=args.disk_name,
-        embedded_path=args.embedded_path, mount_partition=args.mount_partition,
-        project=args.project, zone=args.zone, source=args.source)
+        name=args.name, disk_name=args.disk_name, project=args.project,
+        mount_partition=args.mount_partition, zone=args.zone,
+        embedded_path=args.embedded_path,
+        embedded_partition=args.embedded_mount_partition)
+    evidence_.parent_evidence = parent_evidence_
   elif args.command == 'hindsight':
     if args.format not in ['xlsx', 'sqlite', 'jsonl']:
       log.error('Invalid output format.')
@@ -540,16 +550,16 @@ def main():
     if args.browser_type not in ['Chrome', 'Brave']:
       log.error('Browser type not supported.')
       sys.exit(1)
-    args.name = args.name if args.name else args.local_path
-    local_path = os.path.abspath(args.local_path)
+    args.name = args.name if args.name else args.source_path
+    source_path = os.path.abspath(args.source_path)
     evidence_ = evidence.ChromiumProfile(
-        name=args.name, local_path=local_path, output_format=args.format,
+        name=args.name, source_path=source_path, output_format=args.format,
         browser_type=args.browser_type)
   elif args.command == 'rawmemory':
-    args.name = args.name if args.name else args.local_path
-    local_path = os.path.abspath(args.local_path)
+    args.name = args.name if args.name else args.source_path
+    source_path = os.path.abspath(args.source_path)
     evidence_ = evidence.RawMemory(
-        name=args.name, local_path=local_path, profile=args.profile,
+        name=args.name, source_path=source_path, profile=args.profile,
         module_list=args.module_list)
   elif args.command == 'psqworker':
     # Set up root logger level which is normally set by the psqworker command
diff --git a/turbinia/workers/__init__.py b/turbinia/workers/__init__.py
index b0381b4..68188b1 100644
--- a/turbinia/workers/__init__.py
+++ b/turbinia/workers/__init__.py
@@ -168,14 +168,14 @@ class TurbiniaTaskResult(object):
     self.status = status
 
     for evidence in self.evidence:
-      if evidence.local_path and os.path.exists(evidence.local_path):
-        self.saved_paths.append(evidence.local_path)
+      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)
       else:
         self.log(
-            'Evidence {0:s} has empty or missing file at local_path {1:s} so '
-            'not saving.'.format(evidence.name, evidence.local_path))
+            'Evidence {0:s} has empty or missing file at source_path {1:s} so '
+            'not saving.'.format(evidence.name, evidence.source_path))
 
       if not evidence.request_id:
         evidence.request_id = self.request_id
@@ -464,16 +464,16 @@ class TurbiniaTask(object):
       for evidence in new_evidence:
         # If the local path is set in the Evidence, we check to make sure that
         # the path exists and is not empty before adding it.
-        if evidence.local_path and not os.path.exists(evidence.local_path):
+        if evidence.source_path and not os.path.exists(evidence.source_path):
           message = (
-              'Evidence {0:s} local_path {1:s} does not exist. Not returning '
-              'empty Evidence.'.format(evidence.name, evidence.local_path))
+              'Evidence {0:s} source_path {1:s} does not exist. Not returning '
+              'empty Evidence.'.format(evidence.name, evidence.source_path))
           result.log(message, level=logging.WARN)
-        elif (evidence.local_path and os.path.exists(evidence.local_path) and
-              os.path.getsize(evidence.local_path) == 0):
+        elif (evidence.source_path and os.path.exists(evidence.source_path) and
+              os.path.getsize(evidence.source_path) == 0):
           message = (
-              'Evidence {0:s} local_path {1:s} is empty. Not returning '
-              'empty new Evidence.'.format(evidence.name, evidence.local_path))
+              'Evidence {0:s} source_path {1:s} is empty. Not returning '
+              'empty new Evidence.'.format(evidence.name, evidence.source_path))
           result.log(message, level=logging.WARN)
         else:
           result.add_evidence(evidence, self._evidence_config)
@@ -511,10 +511,10 @@ class TurbiniaTask(object):
       if evidence.copyable and not config.SHARED_FILESYSTEM:
         self.output_manager.retrieve_evidence(evidence)
 
-    if evidence.local_path and not os.path.exists(evidence.local_path):
+    if evidence.source_path and not os.path.exists(evidence.source_path):
       raise TurbiniaException(
-          'Evidence local path {0:s} does not exist'.format(
-              evidence.local_path))
+          'Evidence source path {0:s} does not exist'.format(
+              evidence.source_path))
     evidence.preprocess(self.tmp_dir)
     return self.result
 
diff --git a/turbinia/workers/analysis/jenkins.py b/turbinia/workers/analysis/jenkins.py
index bec4390..f70b9df 100644
--- a/turbinia/workers/analysis/jenkins.py
+++ b/turbinia/workers/analysis/jenkins.py
@@ -41,15 +41,13 @@ class JenkinsAnalysisTask(TurbiniaTask):
     Returns:
         TurbiniaTaskResult object.
     """
-    # What type of evidence we should output.
-    output_evidence = ReportText()
 
     # Where to store the resulting output file.
     output_file_name = 'jenkins_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
+    # What type of evidence we should output.
+    output_evidence = ReportText(source_path=output_file_path)
 
     # TODO(aarontp): We should find a more optimal solution for this because
     # this requires traversing the entire filesystem and extracting more files
diff --git a/turbinia/workers/analysis/wordpress.py b/turbinia/workers/analysis/wordpress.py
index 8f05212..94ee869 100644
--- a/turbinia/workers/analysis/wordpress.py
+++ b/turbinia/workers/analysis/wordpress.py
@@ -47,14 +47,11 @@ class WordpressAccessLogAnalysisTask(TurbiniaTask):
     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
+    output_evidence = ReportText(source_path=output_file_path)
 
     # Change open function if file is GZIP compressed.
     open_function = open
diff --git a/turbinia/workers/artifact.py b/turbinia/workers/artifact.py
index 2e43acb..878ad34 100644
--- a/turbinia/workers/artifact.py
+++ b/turbinia/workers/artifact.py
@@ -47,6 +47,7 @@ class FileArtifactExtractionTask(TurbiniaTask):
         self.output_dir, '{0:s}.log'.format(self.id))
 
     cmd = [
+        'sudo',
         'image_export.py',
         '--logfile',
         image_export_log,
@@ -75,8 +76,8 @@ class FileArtifactExtractionTask(TurbiniaTask):
     for dirpath, _, filenames in os.walk(export_directory):
       for filename in filenames:
         exported_artifact = ExportedFileArtifact(
-            artifact_name=self.artifact_name)
-        exported_artifact.local_path = os.path.join(dirpath, filename)
+            artifact_name=self.artifact_name, source_path=os.path.join(
+                dirpath, filename))
         result.log('Adding artifact {0:s}'.format(filename))
         result.add_evidence(exported_artifact, evidence.config)
 
diff --git a/turbinia/workers/finalize_request.py b/turbinia/workers/finalize_request.py
index 263ae3c..bb90463 100644
--- a/turbinia/workers/finalize_request.py
+++ b/turbinia/workers/finalize_request.py
@@ -41,12 +41,11 @@ class FinalizeRequestTask(TurbiniaTask):
     """
     # Doing a delayed import to avoid circular dependencies.
     from turbinia.client import TurbiniaClient
-    report = FinalReport()
     client = TurbiniaClient()
 
     report_file = os.path.join(
         self.tmp_dir, 'final_turbinia_report_{0:s}.md'.format(self.id))
-    report.local_path = report_file
+    report = FinalReport(source_path=report_file)
     report_data = client.format_task_status(
         config.INSTANCE_ID, config.TURBINIA_PROJECT, config.TURBINIA_REGION,
         request_id=evidence.request_id, full_report=True)
diff --git a/turbinia/workers/grep.py b/turbinia/workers/grep.py
index f462389..dabcd54 100644
--- a/turbinia/workers/grep.py
+++ b/turbinia/workers/grep.py
@@ -36,7 +36,6 @@ class GrepTask(TurbiniaTask):
     Returns:
         TurbiniaTaskResult object.
     """
-    output_evidence = FilteredTextFile()
 
     patterns = evidence.config.get('filter_patterns')
     if not patterns:
@@ -55,7 +54,7 @@ class GrepTask(TurbiniaTask):
     output_file_path = os.path.join(
         self.output_dir, '{0:s}.filtered'.format(base_name))
 
-    output_evidence.local_path = output_file_path
+    output_evidence = FilteredTextFile(source_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)
 
diff --git a/turbinia/workers/hadoop.py b/turbinia/workers/hadoop.py
index 6ac9253..7e8f345 100644
--- a/turbinia/workers/hadoop.py
+++ b/turbinia/workers/hadoop.py
@@ -106,21 +106,19 @@ class HadoopAnalysisTask(TurbiniaTask):
         TurbiniaTaskResult object.
     """
 
-    # What type of evidence we should output.
-    output_evidence = ReportText()
-
     # Where to store the resulting output file.
     output_file_name = 'hadoop_analysis.txt'
     output_file_path = os.path.join(self.output_dir, output_file_name)
 
-    output_evidence.local_path = output_file_path
+    # What type of evidence we should output.
+    output_evidence = ReportText(source_path=output_file_path)
 
     try:
       # We don't use FileArtifactExtractionTask as it export one evidence per
       # file extracted
       output_dir = os.path.join(self.output_dir, 'artifacts')
       collected_artifacts = extract_artifacts(
-          artifact_names=['HadoopAppRoot'], disk_path=evidence.local_path,
+          artifact_names=['HadoopAppRoot'], disk_path=evidence.device_path,
           output_dir=output_dir)
 
       (report, priority, summary) = self._AnalyzeHadoopAppRoot(
diff --git a/turbinia/workers/hindsight.py b/turbinia/workers/hindsight.py
index f679aa3..178b821 100644
--- a/turbinia/workers/hindsight.py
+++ b/turbinia/workers/hindsight.py
@@ -39,12 +39,11 @@ class HindsightTask(TurbiniaTask):
         TurbiniaTaskResult object.
     """
 
-    # Create the new Evidence object that will be generated by this Task.
-    output_evidence = TextFile()
-
     # Create a path that we can write the new file to.
     output_file_path = os.path.join(self.output_dir, 'hindsight_report')
-    output_evidence.local_path = output_file_path
+
+    # Create the new Evidence object that will be generated by this Task.
+    output_evidence = TextFile(source_path=output_file_path)
 
     # Create a path for the log file
     hindsight_log = os.path.join(self.output_dir, 'hindsight.log')
diff --git a/turbinia/workers/plaso.py b/turbinia/workers/plaso.py
index 2906ca7..1c740de 100644
--- a/turbinia/workers/plaso.py
+++ b/turbinia/workers/plaso.py
@@ -39,12 +39,11 @@ class PlasoTask(TurbiniaTask):
         TurbiniaTaskResult object.
     """
     config.LoadConfig()
-    plaso_evidence = PlasoFile()
 
     # Write plaso file into tmp_dir because sqlite has issues with some shared
     # filesystems (e.g NFS).
     plaso_file = os.path.join(self.tmp_dir, '{0:s}.plaso'.format(self.id))
-    plaso_evidence.local_path = plaso_file
+    plaso_evidence = PlasoFile(source_path=plaso_file)
     plaso_log = os.path.join(self.output_dir, '{0:s}.log'.format(self.id))
 
     # TODO(aarontp): Move these flags into a recipe
@@ -69,7 +68,7 @@ class PlasoTask(TurbiniaTask):
         return result
 
     cmd.extend(['--logfile', plaso_log])
-    cmd.extend([plaso_file, evidence.local_path])
+    cmd.extend([plaso_file, evidence.device_path])
 
     result.log('Running plaso as [{0:s}]'.format(' '.join(cmd)))
 
diff --git a/turbinia/workers/psort.py b/turbinia/workers/psort.py
index df44dd1..d02ec9a 100644
--- a/turbinia/workers/psort.py
+++ b/turbinia/workers/psort.py
@@ -37,16 +37,15 @@ class PsortTask(TurbiniaTask):
         TurbiniaTaskResult object.
     """
     config.LoadConfig()
-    psort_evidence = PlasoCsvFile()
 
     psort_file = os.path.join(self.output_dir, '{0:s}.csv'.format(self.id))
-    psort_evidence.local_path = psort_file
+    psort_evidence = PlasoCsvFile(source_path=psort_file)
     psort_log = os.path.join(self.output_dir, '{0:s}.log'.format(self.id))
 
     cmd = ['psort.py', '--status_view', 'none', '--logfile', psort_log]
     if config.DEBUG_TASKS:
       cmd.append('-d')
-    cmd.extend(['-w', psort_file, evidence.local_path])
+    cmd.extend(['-w', psort_file, evidence.source_path])
 
     result.log('Running psort as [{0:s}]'.format(' '.join(cmd)))
 
diff --git a/turbinia/workers/sshd.py b/turbinia/workers/sshd.py
index d8ff667..36c618a 100644
--- a/turbinia/workers/sshd.py
+++ b/turbinia/workers/sshd.py
@@ -38,14 +38,11 @@ class SSHDAnalysisTask(TurbiniaTask):
     Returns:
         TurbiniaTaskResult object.
     """
-    # What type of evidence we should output.
-    output_evidence = ReportText()
-
     # Where to store the resulting output file.
     output_file_name = 'sshd_config_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
+    output_evidence = ReportText(source_path=output_file_path)
 
     # Read the input file
     with open(evidence.local_path, 'r') as input_file:
diff --git a/turbinia/workers/strings.py b/turbinia/workers/strings.py
index 447958e..a19a54a 100644
--- a/turbinia/workers/strings.py
+++ b/turbinia/workers/strings.py
@@ -35,19 +35,16 @@ class StringsAsciiTask(TurbiniaTask):
     Returns:
         TurbiniaTaskResult object.
     """
-    # Create the new Evidence object that will be generated by this Task.
-    output_evidence = TextFile()
     # Create a path that we can write the new file to.
-    base_name = os.path.basename(evidence.local_path)
+    base_name = os.path.basename(evidence.device_path)
     output_file_path = os.path.join(
         self.output_dir, '{0:s}.ascii'.format(base_name))
-    # Add the output path to the evidence so we can automatically save it
-    # later.
-    output_evidence.local_path = output_file_path
+    # Create the new Evidence object that will be generated by this Task.
+    output_evidence = TextFile(source_path=output_file_path)
 
     # Generate the command we want to run.
     cmd = 'strings -a -t d {0:s} > {1:s}'.format(
-        evidence.local_path, output_file_path)
+        evidence.device_path, output_file_path)
     # Add a log line to the result that will be returned.
     result.log('Running strings as [{0:s}]'.format(cmd))
     # Actually execute the binary
@@ -70,19 +67,16 @@ class StringsUnicodeTask(TurbiniaTask):
     Returns:
         TurbiniaTaskResult object.
     """
-    # Create the new Evidence object that will be generated by this Task.
-    output_evidence = TextFile()
     # Create a path that we can write the new file to.
-    base_name = os.path.basename(evidence.local_path)
+    base_name = os.path.basename(evidence.device_path)
     output_file_path = os.path.join(
         self.output_dir, '{0:s}.uni'.format(base_name))
-    # Add the output path to the evidence so we can automatically save it
-    # later.
-    output_evidence.local_path = output_file_path
+    # Create the new Evidence object that will be generated by this Task.
+    output_evidence = TextFile(source_path=output_file_path)
 
     # Generate the command we want to run.
     cmd = 'strings -a -t d -e l {0:s} > {1:s}'.format(
-        evidence.local_path, output_file_path)
+        evidence.device_path, output_file_path)
     # Add a log line to the result that will be returned.
     result.log('Running strings as [{0:s}]'.format(cmd))
     # Actually execute the binary
diff --git a/turbinia/workers/tomcat.py b/turbinia/workers/tomcat.py
index ee83db3..348c2f2 100644
--- a/turbinia/workers/tomcat.py
+++ b/turbinia/workers/tomcat.py
@@ -38,14 +38,12 @@ class TomcatAnalysisTask(TurbiniaTask):
     Returns:
         TurbiniaTaskResult object.
     """
-    # What type of evidence we should output.
-    output_evidence = ReportText()
 
     # Where to store the resulting output file.
     output_file_name = 'tomcat_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
+    output_evidence = ReportText(source_path=output_file_path)
 
     # Read the input file
     with open(evidence.local_path, 'r') as input_file:
diff --git a/turbinia/workers/volatility.py b/turbinia/workers/volatility.py
index a8384c6..893f1b0 100644
--- a/turbinia/workers/volatility.py
+++ b/turbinia/workers/volatility.py
@@ -48,13 +48,11 @@ class VolatilityTask(TurbiniaTask):
     """
     config.LoadConfig()
 
-    # Create the new Evidence object that will be generated by this Task.
-    output_evidence = VolatilityReport()
-
     # Create a path that we can write the new file to.
     output_file_path = os.path.join(
         self.output_dir, '{0:s}.txt'.format(self.id))
-    output_evidence.local_path = output_file_path
+    # Create the new Evidence object that will be generated by this Task.
+    output_evidence = VolatilityReport(source_path=output_file_path)
 
     # TODO: Add in config options for Turbinia
     cmd = (
diff --git a/turbinia/workers/worker_stat.py b/turbinia/workers/worker_stat.py
index b21d708..e241ac8 100644
--- a/turbinia/workers/worker_stat.py
+++ b/turbinia/workers/worker_stat.py
@@ -40,10 +40,11 @@ class StatTask(TurbiniaTask):
     Returns:
         TurbiniaTaskResult: object.
     """
-    report = ReportText()
-    result.log('Running stat on evidence {0:s}'.format(evidence.local_path))
-    report.text_data = str(os.stat(evidence.local_path))
-    with open(os.path.join(self.output_dir, 'report.txt'), 'w') as f:
+    result.log('Running stat on evidence {0:s}'.format(evidence.source_path))
+    report_path = os.path.join(self.output_dir, 'report.txt')
+    report = ReportText(source_path=report_path)
+    report.text_data = str(os.stat(evidence.source_path))
+    with open(report_path, 'w') as f:
       f.write(report.text_data)
 
     result.add_evidence(report, evidence.config)


Test patch:
diff --git a/turbinia/evidence_test.py b/turbinia/evidence_test.py
index 9759be1..846ec95 100644
--- a/turbinia/evidence_test.py
+++ b/turbinia/evidence_test.py
@@ -28,20 +28,17 @@ class TestTurbiniaEvidence(unittest.TestCase):
 
   def testEvidenceSerialization(self):
     """Test that evidence serializes/unserializes."""
-    rawdisk = evidence.RawDisk(
-        name='My Evidence', local_path='/tmp/foo', mount_path='/mnt/foo')
+    rawdisk = evidence.RawDisk(name='My Evidence', source_path='/tmp/foo')
     rawdisk_json = rawdisk.to_json()
     self.assertTrue(isinstance(rawdisk_json, str))
 
     rawdisk_new = evidence.evidence_decode(json.loads(rawdisk_json))
     self.assertIsInstance(rawdisk_new, evidence.RawDisk)
     self.assertEqual(rawdisk_new.name, 'My Evidence')
-    self.assertEqual(rawdisk_new.mount_path, '/mnt/foo')
 
   def testEvidenceCollectionDeserialization(self):
     """Test that EvidenceCollection deserializes."""
-    rawdisk = evidence.RawDisk(
-        name='My Evidence', local_path='/tmp/foo', mount_path='/mnt/foo')
+    rawdisk = evidence.RawDisk(name='My Evidence', source_path='/tmp/foo.img')
     collection = evidence.EvidenceCollection()
     collection.name = 'testCollection'
     collection.add_evidence(rawdisk)
@@ -56,13 +53,12 @@ class TestTurbiniaEvidence(unittest.TestCase):
     self.assertIsInstance(rawdisk_new, evidence.RawDisk)
     self.assertEqual(collection_new.name, 'testCollection')
     self.assertEqual(rawdisk_new.name, 'My Evidence')
-    self.assertEqual(rawdisk_new.mount_path, '/mnt/foo')
+    self.assertEqual(rawdisk_new.source_path, '/tmp/foo.img')
 
   def testEvidenceCollectionSerialization(self):
     """Test that EvidenceCollection serializes/unserializes."""
     evidence_ = evidence.EvidenceCollection()
-    rawdisk = evidence.RawDisk(
-        name='My Evidence', local_path='/tmp/foo', mount_path='/mnt/foo')
+    rawdisk = evidence.RawDisk(name='My Evidence', source_path='/tmp/foo.img')
     evidence_.add_evidence(rawdisk)
     serialized_evidence = evidence_.serialize()
     collection_evidence = serialized_evidence['collection'][0]
@@ -81,21 +77,18 @@ class TestTurbiniaEvidence(unittest.TestCase):
 
   def testEvidenceValidation(self):
     """Test successful evidence validation."""
-    rawdisk = evidence.RawDisk(
-        name='My Evidence', local_path='/tmp/foo', mount_path='/mnt/foo')
-    rawdisk.REQUIRED_ATTRIBUTES = ['name', 'local_path', 'mount_path']
+    rawdisk = evidence.RawDisk(name='My Evidence', source_path='/tmp/foo')
+    rawdisk.REQUIRED_ATTRIBUTES = ['name', 'source_path']
     rawdisk.validate()
 
   def testEvidenceValidationEmptyAttribute(self):
     """Test failed evidence validation with an empty attribute."""
-    rawdisk = evidence.RawDisk(
-        name='My Evidence', local_path=None, mount_path='/mnt/foo')
-    rawdisk.REQUIRED_ATTRIBUTES = ['name', 'local_path', 'mount_path']
+    rawdisk = evidence.RawDisk(name='My Evidence', source_path=None)
+    rawdisk.REQUIRED_ATTRIBUTES = ['name', 'source_path']
     self.assertRaises(TurbiniaException, rawdisk.validate)
 
   def testEvidenceValidationNoAttribute(self):
     """Test failed evidence validation with no attribute."""
-    rawdisk = evidence.RawDisk(
-        name='My Evidence', local_path='/tmp/foo', mount_path='/mnt/foo')
+    rawdisk = evidence.RawDisk(name='My Evidence', source_path='/tmp/foo')
     rawdisk.REQUIRED_ATTRIBUTES = ['doesnotexist']
     self.assertRaises(TurbiniaException, rawdisk.validate)
diff --git a/turbinia/pubsub_test.py b/turbinia/pubsub_test.py
index 0ea1065..e4a60da 100644
--- a/turbinia/pubsub_test.py
+++ b/turbinia/pubsub_test.py
@@ -37,8 +37,7 @@ def getTurbiniaRequest():
   """
   request = message.TurbiniaRequest(
       request_id='deadbeef', context={'kw': [1, 2]})
-  rawdisk = evidence.RawDisk(
-      name='My Evidence', local_path='/tmp/foo', mount_path='/mnt/foo')
+  rawdisk = evidence.RawDisk(name='My Evidence', source_path='/tmp/foo')
   request.evidence.append(rawdisk)
   return request
 
@@ -77,7 +76,7 @@ class TestTurbiniaRequest(unittest.TestCase):
 
   def testTurbiniaRequestSerializationBadJSON(self):
     """Tests that TurbiniaRequest will raise error on wrong JSON object."""
-    rawdisk = evidence.RawDisk(name='My Evidence', local_path='/tmp/foo')
+    rawdisk = evidence.RawDisk(name='My Evidence', source_path='/tmp/foo')
     rawdisk_json = rawdisk.to_json()
     self.assertTrue(isinstance(rawdisk_json, str))
 
diff --git a/turbinia/workers/workers_test.py b/turbinia/workers/workers_test.py
index 1b45874..b3cb350 100644
--- a/turbinia/workers/workers_test.py
+++ b/turbinia/workers/workers_test.py
@@ -58,10 +58,10 @@ class TestTurbiniaTaskBase(unittest.TestCase):
     self.task.output_manager = mock.MagicMock()
     self.task.output_manager.get_local_output_dirs.return_value = (None, None)
 
-    # Set up Evidence
-    test_artifact_path = tempfile.mkstemp(dir=self.base_output_dir)[1]
-    self.remove_files.append(test_artifact_path)
-    self.evidence = self.evidence_class(local_path=test_artifact_path)
+    # Set up RawDisk Evidence
+    test_disk_path = tempfile.mkstemp(dir=self.base_output_dir)[1]
+    self.remove_files.append(test_disk_path)
+    self.evidence = evidence.RawDisk(source_path=test_disk_path)
 
     # Set up TurbiniaTaskResult
     self.result = TurbiniaTaskResult(base_output_dir=self.base_output_dir)
@@ -246,7 +246,7 @@ class TestTurbiniaTask(TestTurbiniaTaskBase):
 
   @mock.patch('turbinia.workers.subprocess.Popen')
   def testTurbiniaTaskExecuteEvidenceExists(self, popen_mock):
-    """Test execution with new evidence that has valid a local_path."""
+    """Test execution with new evidence that has valid a source_path."""
     cmd = 'test cmd'
     output = ('test stdout', 'test stderr')
 
@@ -257,7 +257,7 @@ class TestTurbiniaTask(TestTurbiniaTaskBase):
     popen_mock.return_value = proc_mock
 
     # Create our evidence local path file
-    with open(self.evidence.local_path, 'w') as evidence_path:
+    with open(self.evidence.source_path, 'w') as evidence_path:
       evidence_path.write('test')
 
     self.task.execute(
@@ -266,7 +266,7 @@ class TestTurbiniaTask(TestTurbiniaTaskBase):
 
   @mock.patch('turbinia.workers.subprocess.Popen')
   def testTurbiniaTaskExecuteEvidenceDoesNotExist(self, popen_mock):
-    """Test execution with new evidence that does not have a local_path."""
+    """Test execution with new evidence that does not have a source_path."""
     cmd = 'test cmd'
     output = ('test stdout', 'test stderr')
 
@@ -276,15 +276,13 @@ class TestTurbiniaTask(TestTurbiniaTaskBase):
     proc_mock.returncode = 0
     popen_mock.return_value = proc_mock
 
-    os.remove(self.evidence.local_path)
-
     self.task.execute(
         cmd, self.result, new_evidence=[self.evidence], close=True)
     self.assertNotIn(self.evidence, self.result.evidence)
 
   @mock.patch('turbinia.workers.subprocess.Popen')
   def testTurbiniaTaskExecuteEvidenceExistsButEmpty(self, popen_mock):
-    """Test execution with new evidence local_path that exists but is empty."""
+    """Test execution with new evidence source_path that exists but is empty."""
     cmd = 'test cmd'
     output = ('test stdout', 'test stderr')
 
@@ -295,8 +293,8 @@ class TestTurbiniaTask(TestTurbiniaTaskBase):
     popen_mock.return_value = proc_mock
 
     # Exists and is empty
-    self.assertTrue(os.path.exists(self.evidence.local_path))
-    self.assertEqual(os.path.getsize(self.evidence.local_path), 0)
+    self.assertTrue(os.path.exists(self.evidence.source_path))
+    self.assertEqual(os.path.getsize(self.evidence.source_path), 0)
 
     self.task.execute(
         cmd, self.result, new_evidence=[self.evidence], close=True)