Repository: google/turbinia
Pull request #1210 (2022-12-21 22:59:43)

Web UI V2 - TaskList TaskDetails and export task and request output
![Web UI V2](https://user-images.githubusercontent.com/54372074/209018445-b4c1939f-fd20-4dfb-a153-35f4f53f06a8.png)

* Adds TaskList view which is shown by clicking right of a request and shows processing details of Tasks related to the Request.
* Adds TaskDetails view which is shown by clicking on a Task and displays all details of a given Task.
* Adds the ability to export Task output from the UI via the TaskDetails view.
* Adds the ability to export Report output from the UI via the RequestList view.
* Clean up UI adding various icons displaying its status as well as a progress circle that measures total tasks - completed and failed tasks as best guess estimate. 

wajihyassine: Tests failing from unrelated issue seems to be due to fraken

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/k8s/celery/turbinia-api-server.yaml b/k8s/celery/turbinia-api-server.yaml
index 7c12102..be088d5 100644
--- a/k8s/celery/turbinia-api-server.yaml
+++ b/k8s/celery/turbinia-api-server.yaml
@@ -51,11 +51,11 @@ spec:
             - containerPort: 8000
           resources: 
             requests:
-              memory: "256Mi"
-              cpu: "500m"
-            limits:
               memory: "4096Mi"
               cpu: "2000m"
+            limits:
+              memory: "16384Mi"
+              cpu: "4000m"
           securityContext:
             readOnlyRootFilesystem: true
             runAsNonRoot: true
diff --git a/k8s/celery/turbinia-ingress.yaml b/k8s/celery/turbinia-ingress.yaml
index 79301c4..895a2aa 100644
--- a/k8s/celery/turbinia-ingress.yaml
+++ b/k8s/celery/turbinia-ingress.yaml
@@ -3,6 +3,7 @@ kind: BackendConfig
 metadata:
   name: turbinia-neg-healthcheck
 spec:
+  timeoutSec: 300
   healthCheck:
     checkIntervalSec: 5
     timeoutSec: 5
diff --git a/k8s/common/turbinia-worker.yaml b/k8s/common/turbinia-worker.yaml
index 793b198..797dfaa 100644
--- a/k8s/common/turbinia-worker.yaml
+++ b/k8s/common/turbinia-worker.yaml
@@ -68,7 +68,7 @@ spec:
               memory: "2048Mi"
               cpu: "1500m"
             limits:
-              memory: "8192Mi"
+              memory: "65536Mi"
               cpu: "32000m"
       volumes:
         - name: dev
diff --git a/requirements.txt b/requirements.txt
index 3e13219..c6e0318 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,5 +1,5 @@
 celery>=5.2.2
-cryptography>=2.0.2,<3.4
+cryptography>=2.0.2,>3.8
 docker
 fakeredis>=1.8.1
 fastapi>=0.75.0
@@ -22,7 +22,7 @@ proto-plus<2.0.0dev,>=1.22.0
 psq
 pyparsing<3
 pyyaml>=5.4.1
-pyOpenSSL<=21.0.0
+pyOpenSSL
 redis<4.2
 six>=1.15.0
 starlette-oauth2-api
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 bb94a1d..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
@@ -1091,7 +1095,7 @@ class EwfDisk(Evidence):
     ewf_path (str): Path to mounted EWF image.
     ewf_mount_path (str): Path to EWF mount directory.
   """
-  REQUIRED_ATTRIBUTES = ['source_path', 'ewf_path', 'ewf_mount_path']
+  REQUIRED_ATTRIBUTES = ['source_path']
   POSSIBLE_STATES = [EvidenceState.ATTACHED]
 
   def __init__(
@@ -1106,8 +1110,10 @@ class EwfDisk(Evidence):
 
   def _preprocess(self, _, required_states):
     if EvidenceState.ATTACHED in required_states or self.has_child_evidence:
-      self.ewf_mount_path = mount_local.PreprocessMountEwfDisk(self.source_path)
-      self.ewf_path = mount_local.GetEwfDiskPath(self.ewf_mount_path)
+      if not self.ewf_path and not self.ewf_mount_path:
+        self.ewf_mount_path = mount_local.PreprocessMountEwfDisk(
+            self.source_path)
+        self.ewf_path = mount_local.GetEwfDiskPath(self.ewf_mount_path)
       self.device_path = self.ewf_path
       self.local_path = self.ewf_path
       self.state[EvidenceState.ATTACHED] = 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:
diff --git a/web/package-lock.json b/web/package-lock.json
index dcb94b3..0969207 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -6892,9 +6892,9 @@
       "dev": true
     },
     "node_modules/json5": {
-      "version": "2.2.1",
-      "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz",
-      "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==",
+      "version": "2.2.3",
+      "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+      "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
       "dev": true,
       "bin": {
         "json5": "lib/cli.js"
@@ -7004,9 +7004,9 @@
       }
     },
     "node_modules/loader-utils/node_modules/json5": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
-      "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
+      "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
       "dev": true,
       "dependencies": {
         "minimist": "^1.2.0"
@@ -16715,9 +16715,9 @@
       "dev": true
     },
     "json5": {
-      "version": "2.2.1",
-      "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz",
-      "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==",
+      "version": "2.2.3",
+      "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+      "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
       "dev": true
     },
     "jsonfile": {
@@ -16801,9 +16801,9 @@
       },
       "dependencies": {
         "json5": {
-          "version": "1.0.1",
-          "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
-          "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
+          "version": "1.0.2",
+          "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
+          "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
           "dev": true,
           "requires": {
             "minimist": "^1.2.0"
diff --git a/web/src/App.vue b/web/src/App.vue
index e2bc67b..814e224 100644
--- a/web/src/App.vue
+++ b/web/src/App.vue
@@ -29,31 +29,76 @@ limitations under the License.
                 <v-icon>mdi-brightness-6</v-icon>
               </v-btn>
             </template>
-            Switch to dark mode
+            Toggle dark mode
           </v-tooltip>
         </div>
       </v-app-bar>
     </nav>
     <v-main>
-      <request-list></request-list>
+      <v-container fluid>
+        <v-row class="justify-center align-center">
+          <v-col cols="9" align-self="start">
+            <v-sheet rounded>
+              <request-list></request-list>
+            </v-sheet>
+          </v-col>
+          <v-col cols="3" align-self="start">
+            <v-card rounded v-if="Object.keys(taskDetails).length">
+              <task-details :taskDetails="this.taskDetails"></task-details>
+            </v-card>
+            <v-card rounded v-else>
+              <v-card-title> Task Details </v-card-title>
+              <v-card-subtitle> No Task Selected. Please click on a Task ID to see its details. </v-card-subtitle>
+            </v-card>
+          </v-col>
+        </v-row>
+      </v-container>
     </v-main>
   </v-app>
 </template>
 
 <script>
 import RequestList from './components/RequestList'
+import TaskDetails from './components/TaskDetails'
+import ApiClient from './utils/RestApiClient.js'
 
 export default {
   name: 'app',
-  components: { RequestList },
+  components: { RequestList, TaskDetails },
+  provide() {
+    return {
+      getTaskDetails: this.getTaskDetails,
+    }
+  },
   data() {
-    return {}
+    return {
+      taskDetails: {},
+    }
   },
   methods: {
     toggleTheme: function () {
       this.$vuetify.theme.dark = !this.$vuetify.theme.dark
       localStorage.setItem('isDarkTheme', this.$vuetify.theme.dark.toString())
     },
+    getTaskDetails: function (task_id) {
+      ApiClient.getTaskDetails(task_id)
+        .then((response) => {
+          this.taskDetails = response.data
+        })
+        .catch((e) => {
+          console.error(e)
+        })
+    },
+  },
+  mounted() {
+    const isDark = localStorage.getItem('isDarkTheme')
+    if (isDark) {
+      if (isDark === 'true') {
+        this.$vuetify.theme.dark = true
+      } else {
+        this.$vuetify.theme.dark = false
+      }
+    }
   },
 }
 </script>
@@ -63,6 +108,6 @@ html,
 body {
   height: 100%;
   overflow: auto;
-  font-family: 'Arial';
+  font-family: 'Roboto';
 }
 </style>
diff --git a/web/src/components/RequestList.vue b/web/src/components/RequestList.vue
index 2c84f25..eec7663 100644
--- a/web/src/components/RequestList.vue
+++ b/web/src/components/RequestList.vue
@@ -33,7 +33,61 @@ limitations under the License.
         :items="requestSummary"
         :search="search"
         :footer-props="{ itemsPerPageOptions: [10, 20, 40, -1] }"
+        multi-sort
+        item-key="request_id_reason"
+        show-expand
+        single-expand
       >
+        <template v-slot:[`item.status`]="{ item }">
+          <div v-if="item.status === 'successful'">
+            <v-tooltip right>
+              Completed successfully
+              <template v-slot:activator="{ on, attrs }">
+                <v-icon v-on="on" v-bind="attrs" color="green"> mdi-check </v-icon>
+              </template>
+            </v-tooltip>
+          </div>
+          <div v-else-if="item.status === 'completed_with_errors'">
+            <v-tooltip right>
+              Completed with Task failures
+              <template v-slot:activator="{ on, attrs }">
+                <v-icon v-on="on" v-bind="attrs" color="orange"> mdi-alert </v-icon>
+              </template>
+            </v-tooltip>
+          </div>
+          <div v-else-if="item.status === 'failed'">
+            <v-tooltip right>
+              <template v-slot:activator="{ on, attrs }">
+                <v-icon v-on="on" v-bind="attrs" color="red"> mdi-alert-circle </v-icon>
+              </template>
+            </v-tooltip>
+          </div>
+          <div v-else>
+            <v-tooltip right>
+              {{ item.total_tasks - item.successful_tasks - item.failed_tasks }} Tasks remaining
+              <template v-slot:activator="{ on, attrs }">
+                <v-progress-circular v-on="on" v-bind="attrs" :value="item.outstanding_perc" color="blue">
+                  {{ item.outstanding_perc }}
+                </v-progress-circular>
+              </template>
+            </v-tooltip>
+          </div>
+        </template>
+        <template v-slot:expanded-item="{ headers, item }">
+          <td :colspan="headers.length">
+            <task-list :request-id="item.request_id" :key="item.request_id"> </task-list>
+          </td>
+        </template>
+        <template v-slot:[`item.request_results`]="{ item }">
+          <v-tooltip right>
+            Download Request output
+            <template v-slot:activator="{ on, attrs }">
+              <v-btn icon v-on="on" v-bind="attrs" @click="getRequestOutput(item.request_id)">
+                <v-icon> mdi-folder-arrow-down-outline </v-icon>
+              </v-btn>
+            </template>
+          </v-tooltip>
+        </template>
       </v-data-table>
     </v-card>
   </section>
@@ -41,13 +95,15 @@ limitations under the License.
 
 <script>
 import ApiClient from '../utils/RestApiClient.js'
+import TaskList from './TaskList'
 
 export default {
+  components: { TaskList },
   data() {
     return {
       search: '',
       headers: [
-        { text: 'Request', value: 'request_id' },
+        { text: 'Request', value: 'request_id_reason' },
         { text: 'Last Task Update Time', value: 'last_task_update_time' },
         { text: 'Requester', value: 'requester' },
         { text: 'Total Tasks', value: 'total_tasks' },
@@ -55,6 +111,7 @@ export default {
         { text: 'Successful Tasks', value: 'successful_tasks' },
         { text: 'Failed Tasks', value: 'failed_tasks' },
         { text: 'Status', value: 'status' },
+        { text: 'Results', value: 'request_results' },
       ],
       requestSummary: [],
     }
@@ -66,14 +123,19 @@ export default {
           let requestSummary = []
           let data = response.data['requests_status']
           for (const req in data) {
+            let outstanding_perc = Math.round(
+              ((data[req].failed_tasks + data[req].successful_tasks) / data[req].task_count) * 100
+            )
             requestSummary.push({
-              request_id: data[req].request_id + ' - ' + data[req].reason,
+              request_id_reason: data[req].request_id + ' - ' + data[req].reason,
+              request_id: data[req].request_id,
               last_task_update_time: data[req].last_task_update_time,
               requester: data[req].requester,
               total_tasks: data[req].task_count,
               running_tasks: data[req].running_tasks,
               successful_tasks: data[req].successful_tasks,
               failed_tasks: data[req].failed_tasks,
+              outstanding_perc: outstanding_perc,
               status: data[req].status,
             })
           }
@@ -83,6 +145,21 @@ export default {
           console.error(e)
         })
     },
+    getRequestOutput: function (request_id) {
+      ApiClient.getRequestOutput(request_id)
+        .then(({ data }) => {
+          const downloadObj = window.URL.createObjectURL(new Blob([data]))
+          const link = document.createElement('a')
+          link.href = downloadObj
+          link.setAttribute('download', request_id + '.zip')
+          document.body.appendChild(link)
+          link.click()
+          link.remove()
+        })
+        .catch((e) => {
+          console.error(e)
+        })
+    },
   },
   mounted() {
     this.getRequestList()
diff --git a/web/src/components/TaskDetails.vue b/web/src/components/TaskDetails.vue
new file mode 100644
index 0000000..c1b50da
--- /dev/null
+++ b/web/src/components/TaskDetails.vue
@@ -0,0 +1,158 @@
+<!--
+Copyright 2022 Google Inc. All rights reserved.
+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.
+-->
+
+<template>
+  <div>
+    <v-card-title>
+      {{ taskDetails.name }}
+      <v-tooltip top>
+        Download Task output
+        <template v-slot:activator="{ on, attrs }">
+          <v-btn icon v-on="on" v-bind="attrs" @click="getTaskOutput(taskDetails.id)">
+            <v-icon> mdi-file-download-outline </v-icon>
+          </v-btn>
+        </template>
+      </v-tooltip>
+    </v-card-title>
+    <v-alert v-if="taskDetails.successful === true" type="success" border="bottom" colored-border>
+      {{ taskDetails.status }}
+    </v-alert>
+    <v-alert v-else-if="taskDetails.successful === null" type="info" border="bottom" colored-border>
+      {{ taskDetails.status }}
+    </v-alert>
+    <v-alert v-else type="error" border="bottom" colored-border>
+      {{ taskDetails.status }}
+    </v-alert>
+    <v-card>
+      <v-list dense>
+        <v-list-group :value="true">
+          <template v-slot:activator>
+            <v-list-item-title>Associated IDs</v-list-item-title>
+          </template>
+          <v-list-item>
+            <v-list-item-content>
+              <v-list-item-title> Task ID: </v-list-item-title>
+              {{ taskDetails.id }}
+            </v-list-item-content>
+          </v-list-item>
+          <v-list-item>
+            <v-list-item-content>
+              <v-list-item-title> Request ID: </v-list-item-title>
+              {{ taskDetails.request_id }}
+            </v-list-item-content>
+          </v-list-item>
+          <v-list-item>
+            <v-list-item-content>
+              <v-list-item-title> Group ID: </v-list-item-title> {{ taskDetails.group_id }}
+            </v-list-item-content>
+          </v-list-item>
+          <v-list-item>
+            <v-list-item-content>
+              <v-list-item-title> Job ID: </v-list-item-title>{{ taskDetails.job_id }}
+            </v-list-item-content>
+          </v-list-item>
+        </v-list-group>
+        <v-list-group :value="true">
+          <template v-slot:activator>
+            <v-list-item-title>Processing Details</v-list-item-title>
+          </template>
+          <v-list-item>
+            <v-list-item-content>
+              <v-list-item-title> Evidence Name: </v-list-item-title>
+              {{ taskDetails.evidence_name }}
+            </v-list-item-content>
+          </v-list-item>
+          <v-list-item>
+            <v-list-item-content>
+              <v-list-item-title> Arguments: </v-list-item-title> {{ taskDetails.all_args }}
+            </v-list-item-content>
+          </v-list-item>
+          <v-list-item>
+            <v-list-item-content>
+              <v-list-item-title> Last Update: </v-list-item-title> {{ taskDetails.last_update }}
+            </v-list-item-content>
+          </v-list-item>
+          <v-list-item>
+            <v-list-item-content>
+              <v-list-item-title> Requester: </v-list-item-title>{{ taskDetails.requester }}
+            </v-list-item-content>
+          </v-list-item>
+          <v-list-item>
+            <v-list-item-content>
+              <v-list-item-title> Worker: </v-list-item-title>{{ taskDetails.worker_name }}
+            </v-list-item-content>
+          </v-list-item>
+          <v-list-item>
+            <v-list-item-content>
+              <v-list-item-title> Successful: </v-list-item-title>{{ taskDetails.successful }}
+            </v-list-item-content>
+          </v-list-item>
+          <v-list-item>
+            <v-list-item-content>
+              <v-list-item-title> Run Time: </v-list-item-title>{{ taskDetails.run_time }} seconds
+            </v-list-item-content>
+          </v-list-item>
+        </v-list-group>
+        <div v-if="taskDetails.saved_paths">
+          <v-list-group :value="false">
+            <template v-slot:activator>
+              <v-list-item-title>Saved Paths: </v-list-item-title>
+            </template>
+            <v-list-item dense v-for="(path, idx) in taskDetails.saved_paths" :key="idx">
+              {{ path }}
+            </v-list-item>
+          </v-list-group>
+        </div>
+      </v-list>
+    </v-card>
+  </div>
+</template>
+
+<script>
+import ApiClient from '../utils/RestApiClient.js'
+
+export default {
+  props: ['taskDetails'],
+  data() {
+    return {}
+  },
+  methods: {
+    getTaskOutput: function (task_id) {
+      ApiClient.getTaskOutput(task_id)
+        .then(({ data }) => {
+          const downloadObj = window.URL.createObjectURL(new Blob([data]))
+          const link = document.createElement('a')
+          link.href = downloadObj
+          link.setAttribute('download', task_id + '.zip')
+          document.body.appendChild(link)
+          link.click()
+          link.remove()
+        })
+        .catch((e) => {
+          console.error(e)
+        })
+    },
+  },
+}
+</script>
+
+<style scoped>
+.v-list-item {
+  font-size: 12px;
+}
+
+.v-list-item__action {
+  margin-top: 0;
+  margin-bottom: 0;
+}
+</style>
diff --git a/web/src/components/TaskList.vue b/web/src/components/TaskList.vue
new file mode 100644
index 0000000..0bae602
--- /dev/null
+++ b/web/src/components/TaskList.vue
@@ -0,0 +1,120 @@
+<!--
+Copyright 2022 Google Inc. All rights reserved.
+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.
+-->
+
+<template>
+  <div>
+    <v-list dense>
+      <v-virtual-scroll :items="taskList" :item-height="40" :height="400">
+        <template v-slot:default="{ item }">
+          <v-list-item class="mb-8" :key="item.task_id">
+            <div v-if="item.task_success">
+              <v-list-item-avatar> <v-icon color="green"> mdi-check </v-icon> </v-list-item-avatar>
+            </div>
+            <div v-else-if="item.task_success === null">
+              <v-list-item-avatar>
+                <v-progress-circular indeterminate color="blue" :size="20"></v-progress-circular>
+              </v-list-item-avatar>
+            </div>
+            <div v-else>
+              <v-list-item-avatar> <v-icon color="red"> mdi-alert-circle </v-icon> </v-list-item-avatar>
+            </div>
+            <div>
+              <v-list-item-action>
+                <v-btn
+                  text
+                  :ripple="true"
+                  :key="item.task_id"
+                  active-class="activated"
+                  class="text-lowercase"
+                  :class="{ activated: isActive == item.task_id }"
+                  @click="getTaskDetails(item.task_id) + selectActiveStatus(item.task_id)"
+                >
+                  {{ item.task_id }}
+                </v-btn>
+              </v-list-item-action>
+            </div>
+            <v-list-item-content>
+              <v-list-item-title>
+                {{ item.task_name }}
+                >: {{ item.task_status }}
+              </v-list-item-title>
+            </v-list-item-content>
+          </v-list-item>
+          <v-divider> </v-divider>
+        </template>
+      </v-virtual-scroll>
+    </v-list>
+  </div>
+</template>
+
+<script>
+import ApiClient from '../utils/RestApiClient.js'
+
+export default {
+  props: ['requestId'],
+  inject: ['getTaskDetails'],
+  data() {
+    return {
+      headers: [
+        { text: 'Task', value: 'task_name_id' },
+        { text: 'Status', value: 'task_status' },
+      ],
+      taskList: [],
+      isActive: false,
+    }
+  },
+  methods: {
+    getTaskList: function (request_id) {
+      ApiClient.getTaskList(request_id)
+        .then((response) => {
+          let taskList = []
+          let data = response.data['tasks']
+          for (const task in data) {
+            let task_dict = data[task]
+            taskList.push({
+              task_name: task_dict.name,
+              task_id: task_dict.id,
+              task_status: task_dict.status,
+              task_success: task_dict.successful,
+            })
+          }
+          this.taskList = taskList
+        })
+        .catch((e) => {
+          console.error(e)
+        })
+    },
+    selectActiveStatus: function (task_id) {
+      this.isActive = task_id
+    },
+  },
+  created() {
+    this.getTaskList(this.requestId)
+  },
+}
+</script>
+
+<style scoped>
+.v-btn {
+  font-family: 'Roboto Mono', monospace;
+  font-weight: 500;
+}
+
+.v-item {
+  font-family: 'Roboto Mono', monospace;
+}
+
+.activated {
+  background-color: rgba(128, 128, 128, 0.4);
+}
+</style>
diff --git a/web/src/utils/RestApiClient.js b/web/src/utils/RestApiClient.js
index a8a0a67..97057b1 100644
--- a/web/src/utils/RestApiClient.js
+++ b/web/src/utils/RestApiClient.js
@@ -31,4 +31,24 @@ export default {
   getRequestList() {
     return RestApiClient.get('/api/request/summary')
   },
+
+  // Request Output
+  getRequestOutput(request_id) {
+    return RestApiClient.get('/api/result/request/' + request_id, { responseType: 'blob' })
+  },
+
+  // Task List
+  getTaskList(request_id) {
+    return RestApiClient.get('/api/request/' + request_id)
+  },
+
+  // Task Details
+  getTaskDetails(task_id) {
+    return RestApiClient.get('/api/task/' + task_id)
+  },
+
+  // Task Output
+  getTaskOutput(task_id) {
+    return RestApiClient.get('/api/result/task/' + task_id, { responseType: 'blob' })
+  },
 }


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')