Repository: google/turbinia
Pull request #1220 (2023-01-06 03:11:36)

Add APFS Support
Adding support for APFS via pre-processors.

APFS volumes can't be "attached", so we attach the containing partition, or the whole disk if there isn't a containing partition.

aarontp: LGTM

Patch:
diff --git a/docker/worker/Dockerfile b/docker/worker/Dockerfile
index 0fce10b..b6ed944 100644
--- a/docker/worker/Dockerfile
+++ b/docker/worker/Dockerfile
@@ -53,6 +53,7 @@ RUN pip3 install dfDewey
 #   dfImageTools
 #   docker-explorer
 #   libbde-tools
+#   libfsapfs-tools
 #   Plaso
 #   Sleuthkit
 RUN apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys 0x5e80511b10c598b8 \
@@ -65,6 +66,7 @@ RUN apt-get update && apt-get -y install \
     libewf \
     libewf-python3 \
     libewf-tools \
+    libfsapfs-tools \
     plaso-tools \
     python3-dfimagetools \
     python3-dfvfs \
diff --git a/turbinia/config/turbinia_config_tmpl.py b/turbinia/config/turbinia_config_tmpl.py
index ae98e83..b507bf4 100644
--- a/turbinia/config/turbinia_config_tmpl.py
+++ b/turbinia/config/turbinia_config_tmpl.py
@@ -208,7 +208,7 @@ DEPENDENCIES = [{
     'timeout': 14400
 }, {
     'job': 'PartitionEnumerationJob',
-    'programs': ['bdemount', 'blockdev'],
+    'programs': ['bdemount', 'blockdev', 'fsapfsmount'],
     'docker_image': None,
     'timeout': 1200
 }, {
diff --git a/turbinia/evidence.py b/turbinia/evidence.py
index 6545487..91ce88a 100644
--- a/turbinia/evidence.py
+++ b/turbinia/evidence.py
@@ -788,8 +788,12 @@ class DiskPartition(Evidence):
         self.local_path = self.device_path
 
     if EvidenceState.MOUNTED in required_states or self.has_child_evidence:
-      self.mount_path = mount_local.PreprocessMountPartition(
-          self.device_path, self.path_spec.type_indicator)
+      if self.path_spec.type_indicator == 'APFS':
+        self.mount_path = mount_local.PreprocessAPFS(
+            self.device_path, self.parent_evidence.credentials)
+      else:
+        self.mount_path = mount_local.PreprocessMountPartition(
+            self.device_path, self.path_spec.type_indicator)
       if self.mount_path:
         self.local_path = self.mount_path
         self.state[EvidenceState.MOUNTED] = True
diff --git a/turbinia/processors/mount_local.py b/turbinia/processors/mount_local.py
index f30092b..4620e39 100644
--- a/turbinia/processors/mount_local.py
+++ b/turbinia/processors/mount_local.py
@@ -83,6 +83,89 @@ def GetDiskSize(source_path):
   return size
 
 
+def PreprocessAPFS(source_path, credentials=None):
+  """Uses libfsapfs on a target block device or image file.
+
+  Args:
+    source_path(str): the source path to run fsapfsmount on.
+    credentials(list[(str, str)]): decryption credentials set in evidence setup
+
+  Raises:
+    TurbiniaException: if source_path doesn't exist or if the fsapfsmount
+      command failed to create a virtual device.
+
+  Returns:
+    str: the path to the mounted filesystem.
+  """
+  config.LoadConfig()
+  mount_prefix = config.MOUNT_DIR_PREFIX
+
+  if not os.path.exists(source_path):
+    raise TurbiniaException(
+        'Could not mount partition {0:s}, the path does not exist'.format(
+            source_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))
+  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 exception:
+      raise TurbiniaException(
+          'Could not create mount directory {0:s}: {1!s}'.format(
+              mount_prefix, exception))
+
+  mount_path = tempfile.mkdtemp(prefix='turbinia', dir=mount_prefix)
+  mounted = False
+
+  log.debug('Mounting APFS volume')
+
+  if credentials:
+    for credential_type, credential_data in credentials:
+      mount_cmd = ['sudo', 'fsapfsmount']
+      if credential_type == 'password':
+        mount_cmd.extend(['-p', credential_data])
+      elif credential_type == 'recovery_password':
+        mount_cmd.extend(['-r', credential_data])
+      else:
+        # Unsupported credential type, try the next
+        log.warning(
+            'Unsupported credential type: {0!s}'.format(credential_type))
+        continue
+      mount_cmd.extend(['-X', 'allow_other', source_path, mount_path])
+      # Not logging full command since it will contain credentials
+      log.info(
+          'Running fsapfsmount with credential type: {0:s}'.format(
+              credential_type))
+      try:
+        subprocess.check_call(mount_cmd)
+      except subprocess.CalledProcessError as exception:
+        # Decryption failed with these credentials, try the next
+        continue
+      # Decrypted volume was mounted
+      mounted = True
+      break
+  else:
+    mount_cmd = [
+        'sudo', 'fsapfsmount', '-X', 'allow_other', source_path, mount_path
+    ]
+    log.info('Running: {0:s}'.format(' '.join(mount_cmd)))
+    try:
+      subprocess.check_call(mount_cmd)
+    except subprocess.CalledProcessError as exception:
+      raise TurbiniaException(
+          'Could not mount directory {0!s}'.format(exception))
+    mounted = True
+
+  if not mounted:
+    log.warning('Could not mount APFS volume {0:s}'.format(source_path))
+    mount_path = None
+
+  return mount_path
+
+
 def PreprocessBitLocker(source_path, partition_offset=None, credentials=None):
   """Uses libbde on a target block device or image file.
 
@@ -139,6 +222,8 @@ def PreprocessBitLocker(source_path, partition_offset=None, credentials=None):
     libbde_command.extend(['-X', 'allow_other', source_path, mount_path])
 
     # Not logging command since it will contain credentials
+    log.info(
+        'Running bdemount with credential type: {0:s}'.format(credential_type))
     try:
       subprocess.check_call(libbde_command)
     except subprocess.CalledProcessError as exception:
@@ -400,6 +485,9 @@ def PreprocessMountPartition(partition_path, filesystem_type):
               mount_prefix, exception))
 
   mount_path = tempfile.mkdtemp(prefix='turbinia', dir=mount_prefix)
+  mounted = True
+
+  log.debug('Mounting filesystem type: {0:s}'.format(filesystem_type))
 
   mount_cmd = ['sudo', 'mount', '-o', 'ro']
   if filesystem_type == 'EXT':
@@ -414,7 +502,24 @@ def PreprocessMountPartition(partition_path, filesystem_type):
   try:
     subprocess.check_call(mount_cmd)
   except subprocess.CalledProcessError as exception:
-    raise TurbiniaException('Could not mount directory {0!s}'.format(exception))
+    mounted = False
+    log.info('Mount failed: {0!s}'.format(exception))
+
+  if filesystem_type == 'EXT' and not mounted:
+    # ext2 will not mount with the noload option, so this may be the cause of
+    # the error.
+    mounted = True
+    mount_cmd = ['sudo', 'mount', '-o', 'ro', partition_path, mount_path]
+    log.info('Trying again with: {0:s}'.format(' '.join(mount_cmd)))
+    try:
+      subprocess.check_call(mount_cmd)
+    except subprocess.CalledProcessError as exception:
+      mounted = False
+      log.info('Mount failed: {0!s}'.format(exception))
+
+  if not mounted:
+    raise TurbiniaException(
+        'Could not mount partition {0:s}'.format(partition_path))
 
   return mount_path
 
diff --git a/turbinia/workers/partitions.py b/turbinia/workers/partitions.py
index 80c4433..85476bd 100644
--- a/turbinia/workers/partitions.py
+++ b/turbinia/workers/partitions.py
@@ -101,14 +101,25 @@ class PartitionEnumerationTask(TurbiniaTask):
     is_lvm = False
     # File system location / identifier
     location = self._GetLocation(path_spec)
+    container_location = None
     log.debug(
         'Got location {0:s} for path_spec {1!s} with type {2:s}'.format(
             location, path_spec.CopyToDict(), path_spec.type_indicator))
     while child_path_spec.HasParent():
       type_indicator = child_path_spec.type_indicator
+      log.debug('Path spec type: {0:s}'.format(type_indicator))
+
       if type_indicator == dfvfs_definitions.TYPE_INDICATOR_APFS_CONTAINER:
         # APFS volume index
         volume_index = getattr(child_path_spec, 'volume_index', None)
+        # Since APFS can't be attached, we'll need to look for a container
+        if child_path_spec.HasParent():
+          container_location = getattr(child_path_spec.parent, 'location', None)
+          log.debug('Container location: {0!s}'.format(container_location))
+          # We only need the container if it's a partition, else we'll attach
+          # the whole disk
+          if container_location and container_location[:2] != '/p':
+            container_location = None
 
       if type_indicator in (dfvfs_definitions.TYPE_INDICATOR_GPT,
                             dfvfs_definitions.TYPE_INDICATOR_LVM,
@@ -125,7 +136,10 @@ class PartitionEnumerationTask(TurbiniaTask):
           volume_system = gpt_volume_system.GPTVolumeSystem()
         try:
           volume_system.Open(child_path_spec)
-          volume_identifier = location.replace('/', '')
+          if container_location:
+            volume_identifier = container_location.replace('/', '')
+          else:
+            volume_identifier = location.replace('/', '')
           volume = volume_system.GetVolumeByIdentifier(volume_identifier)
 
           if is_lvm:


Test patch:
diff --git a/turbinia/processors/mount_local_test.py b/turbinia/processors/mount_local_test.py
index fd69bf4..6790e19 100644
--- a/turbinia/processors/mount_local_test.py
+++ b/turbinia/processors/mount_local_test.py
@@ -80,6 +80,79 @@ class MountLocalProcessorTest(unittest.TestCase):
     size = mount_local.GetDiskSize(source_path)
     self.assertIsNone(size)
 
+  @mock.patch('turbinia.processors.mount_local.config')
+  @mock.patch('tempfile.mkdtemp')
+  @mock.patch('subprocess.check_call')
+  @mock.patch('os.path.isdir')
+  @mock.patch('os.path.exists')
+  @mock.patch('os.makedirs')
+  def testPreprocessAPFS(
+      self, _, mock_path_exists, mock_path_isdir, mock_subprocess, mock_mkdtemp,
+      mock_config):
+    """Test PreprocessAPFS method."""
+    mock_config.MOUNT_DIR_PREFIX = '/mnt/turbinia'
+    mock_path_exists.side_effect = _mock_bitlocker_returns
+    mock_mkdtemp.return_value = '/mnt/turbinia/turbinia0ckdntz0'
+
+    current_path = os.path.abspath(os.path.dirname(__file__))
+    source_path = os.path.join(
+        current_path, '..', '..', 'test_data', 'apfs.raw')
+    credentials = [('password', '123456')]
+
+    # Test APFS volume
+    mock_path_isdir.return_value = True
+    mount_path = mount_local.PreprocessAPFS(source_path, credentials=None)
+    expected_args = [
+        'sudo', 'fsapfsmount', '-X', 'allow_other', source_path,
+        '/mnt/turbinia/turbinia0ckdntz0'
+    ]
+    mock_subprocess.assert_called_once_with(expected_args)
+    self.assertEqual(mount_path, '/mnt/turbinia/turbinia0ckdntz0')
+
+    # Test encrypted APFS volume
+    mock_subprocess.reset_mock()
+    mount_path = mount_local.PreprocessAPFS(
+        source_path, credentials=credentials)
+    expected_args = [
+        'sudo', 'fsapfsmount', '-p', '123456', '-X', 'allow_other', source_path,
+        '/mnt/turbinia/turbinia0ckdntz0'
+    ]
+    mock_subprocess.assert_called_once_with(expected_args)
+    self.assertEqual(mount_path, '/mnt/turbinia/turbinia0ckdntz0')
+
+    # Test with recovery password
+    mock_subprocess.reset_mock()
+    credentials = [('recovery_password', '123456')]
+    mount_local.PreprocessAPFS(source_path, credentials=credentials)
+    expected_args = [
+        'sudo', 'fsapfsmount', '-r', '123456', '-X', 'allow_other', source_path,
+        '/mnt/turbinia/turbinia0ckdntz0'
+    ]
+    mock_subprocess.assert_called_once_with(expected_args)
+
+    # Test if source does not exist
+    with self.assertRaises(TurbiniaException):
+      mount_local.PreprocessAPFS('/dev/loop0p4', credentials=credentials)
+
+    # Test if mount path not directory
+    mock_path_isdir.return_value = False
+    with self.assertRaises(TurbiniaException):
+      mount_local.PreprocessAPFS(source_path, credentials=credentials)
+    mock_path_isdir.return_value = True
+
+    # Test decryption failure
+    mock_subprocess.reset_mock()
+    mock_subprocess.side_effect = CalledProcessError(1, 'fsapfsmount')
+    mount_path = mount_local.PreprocessAPFS(
+        source_path, credentials=credentials)
+    self.assertEqual(mount_path, None)
+
+    # Test with unsupported credential type
+    mock_subprocess.reset_mock()
+    credentials = [('startup_key', 'key.BEK')]
+    mount_local.PreprocessAPFS(source_path, credentials=credentials)
+    mock_subprocess.assert_not_called()
+
   @mock.patch('turbinia.processors.mount_local.config')
   @mock.patch('tempfile.mkdtemp')
   @mock.patch('subprocess.check_call')