Repository: google/turbinia

Issue:
[FR]: Avoid saving Plaso files with no events to task results
### What would this feature improve or what problem would it solve?

Avoid passing Plaso files with no events to other processing modules, and do not save plaso files with no events to the task results (saved_paths). This will also server as an upstream fix that avoids dfTimewolf passing these Plaso files to Timesketch

### What is the feature you are proposing?

See above

### What alternatives have you considered?

_No response_


Discussion:

Resolving patch:
diff --git a/turbinia/evidence.py b/turbinia/evidence.py
--- a/turbinia/evidence.py
+++ b/turbinia/evidence.py
@@ -25,6 +25,7 @@
 import sys
 import inspect
 import filelock
+import subprocess
 
 from turbinia import config
 from turbinia import TurbiniaException
@@ -545,6 +546,14 @@ def format_state(self):
       output.append(f'{state.name:s}: {value!s}')
     return f"[{', '.join(output):s}]"
 
+  def _validate(self):
+    """Runs additional logic to validate evidence requirements.
+    
+    Evidence subclasses can override this method to perform custom
+    validation of evidence objects.
+    """
+    pass
+
   def validate(self):
     """Runs validation to verify evidence meets minimum requirements.
 
@@ -554,7 +563,7 @@ def validate(self):
     called by the worker, prior to the pre/post-processors running.
 
     Raises:
-      TurbiniaException: If validation fails
+      TurbiniaException: If validation fails, or when encountering an error.
     """
     for attribute in self.REQUIRED_ATTRIBUTES:
       attribute_value = getattr(self, attribute, None)
@@ -565,6 +574,8 @@ def validate(self):
                 attribute, self.type))
         raise TurbiniaException(message)
 
+    self._validate()
+
 
 class EvidenceCollection(Evidence):
   """A Collection of Evidence objects.
@@ -965,6 +976,37 @@ def __init__(self, plaso_version=None, *args, **kwargs):
     self.copyable = True
     self.plaso_version = plaso_version
 
+  def _validate(self):
+    """Validates whether the Plaso file contains any events.
+    
+    Raises:
+      TurbiniaException: if validation fails.
+    """
+    cmd = [
+        'pinfo.py',
+        '--output-format',
+        'json',
+        '--sections',
+        'events',
+        self.local_path,
+    ]
+    total_file_events = 0
+
+    try:
+      log.info(f'Running pinfo.py to validate PlasoFile {self.local_path}')
+      command = subprocess.run(cmd, capture_output=True, check=True)
+      storage_counters_json = command.stdout.decode('utf-8').strip()
+      storage_counters = json.loads(storage_counters_json)
+      total_file_events = storage_counters.get('storage_counters', {}).get(
+          'parsers', {}).get('total', 0)
+      log.info(f'pinfo.py found {total_file_events} events.')
+      if not total_file_events:
+        raise TurbiniaException(
+            'PlasoFile validation failed, pinfo.py found no events.')
+    except subprocess.CalledProcessError as exception:
+      raise TurbiniaException(
+          f'Error validating plaso file: {exception!s}') from exception
+
 
 class PlasoCsvFile(Evidence):
   """Psort output file evidence.  """
diff --git a/turbinia/processors/mount_local.py b/turbinia/processors/mount_local.py
--- a/turbinia/processors/mount_local.py
+++ b/turbinia/processors/mount_local.py
@@ -49,7 +49,7 @@ def GetDiskSize(source_path):
   Returns:
     int: the size of the disk in bytes.
   """
-  size = None
+  size = 0
 
   if not os.path.exists(source_path):
     log.error(
@@ -57,19 +57,20 @@ def GetDiskSize(source_path):
     return None
 
   cmd = ['blockdev', '--getsize64', source_path]
-  log.info(f'Running {cmd!s}')
+  log.info(f'Getting evidence size via {cmd!s}')
 
   # Run blockdev first, this will fail if evidence is not a block device
   try:
     cmd_output = subprocess.check_output(cmd, stderr=subprocess.STDOUT).split()
     size = int(cmd_output[0].decode('utf-8'))
   except subprocess.CalledProcessError:
-    log.debug('blockdev failed, attempting to get file size')
-  except ValueError:
     log.debug(
+        'blockdev failed, attempting to get file size using stat() instead')
+  except ValueError:
+    log.warning(
         f"Unexpected output from blockdev: {cmd_output[0].decode('utf-8'):s}")
 
-  if size is None:
+  if not size:
     # evidence is not a block device, check image file size
     try:
       size = os.stat(source_path).st_size
diff --git a/turbinia/workers/__init__.py b/turbinia/workers/__init__.py
--- a/turbinia/workers/__init__.py
+++ b/turbinia/workers/__init__.py
@@ -762,7 +762,6 @@ def execute(
           result.log(message, level=logging.WARN)
         else:
           result.add_evidence(evidence, self._evidence_config)
-
       if close:
         result.close(self, success=True)
 
@@ -858,9 +857,52 @@ def create_result(
       result.set_error(message, trace)
     return result
 
+  def check_serialization_errors(self, result):
+    """Checks the TurbiniaTaskResult is valid for serialization.
+    
+    This method checks the 'reuslt'' obejct is the correct type and whether
+    it is pickle/JSON serializable or not.
+
+    Args:
+      result(TurbiniaTaskResult): A TurbiniaTaskResult object.
+
+    Returns:
+      str | None: An error message, or None.
+    """
+    error_message = None
+
+    # Check for serialization errors
+    if isinstance(result, TurbiniaTaskResult):
+      try:
+        log.debug('Checking TurbiniaTaskResult for pickle serializability')
+        pickle.dumps(result.serialize())
+      except (TypeError, pickle.PicklingError) as exception:
+        error_message = (
+            f'Error pickling TurbiniaTaskResult object. Returning a new result '
+            f'with the pickling error, and all previous result data will be '
+            f'lost. Pickle Error: {exception!s}')
+      try:
+        log.debug('Checking TurbiniaTaskResult for JSON serializability')
+        json.dumps(result.serialize())
+      except (TypeError) as exception:
+        error_message = (
+            f'Error JSON serializing TurbiniaTaskResult object. Returning a new'
+            f' result with the JSON error, and all previous result data will '
+            f'be lost. JSON Error: {exception!s}')
+    else:
+      error_message = (
+          f'Task returned type [{type(result)!s}] instead of '
+          f'TurbiniaTaskResult.')
+
+    return error_message
+
   def validate_result(self, result):
     """Checks to make sure that the result is valid.
 
+    This method runs validation logic defined in Evidence.validate() to
+    ensure that newly created evidence is usable as input for TurbiniaTask
+    objects.
+
     We occasionally get something added into a TurbiniaTaskResult that makes
     it unpickleable.  We don't necessarily know what caused it to be in that
     state, so we need to create a new, mostly empty result so that the client
@@ -874,41 +916,38 @@ def validate_result(self, result):
       The original result object if it is OK, otherwise an empty result object
       indicating a failure.
     """
-    message = None
+    serialization_error = self.check_serialization_errors(result)
+    validation_error = None
     check_status = 'Successful'
 
-    if isinstance(result, TurbiniaTaskResult):
-      try:
-        log.debug('Checking TurbiniaTaskResult for pickle serializability')
-        pickle.dumps(result.serialize())
-      except (TypeError, pickle.PicklingError) as exception:
-        message = (
-            'Error pickling TurbiniaTaskResult object. Returning a new result '
-            'with the pickling error, and all previous result data will be '
-            'lost. Pickle Error: {0!s}'.format(exception))
-      try:
-        log.debug('Checking TurbiniaTaskResult for JSON serializability')
-        json.dumps(result.serialize())
-      except (TypeError) as exception:
-        message = (
-            'Error JSON serializing TurbiniaTaskResult object. Returning a new '
-            'result with the JSON error, and all previous result data will '
-            'be lost. JSON Error: {0!s}'.format(exception))
+    # Validate the new evidence objects
+    if not serialization_error:
+      for evidence in result.evidence[:]:
+        try:
+          evidence.validate()
+        except TurbiniaException as exception:
+          validation_error = (
+              f'Not adding evidence {evidence.source_path}. Evidence '
+              f'validation failed with error: {exception!s}')
+          result.evidence.remove(evidence)
+          result.saved_paths.remove(evidence.source_path)
+      # Append evidence validation error messages to the result
+      # so the client knows what happened
+      if validation_error:
+        result.log(validation_error)
+        result.status = f'{result.status}. {validation_error}'
     else:
-      message = (
-          'Task returned type [{0!s}] instead of TurbiniaTaskResult.').format(
-              type(result))
-
-    if message:
-      log.error(message)
+      # Handle any serialization type errors
+      log.error(serialization_error)
       if result and hasattr(result, 'status') and result.status:
         status = result.status
       else:
         status = 'No previous status'
 
       result = self.create_result(
-          status=status, message=message, trace=traceback.format_exc())
-      result.close(self, success=False, status=message)
+          status=status, message=serialization_error,
+          trace=traceback.format_exc())
+      result.close(self, success=False, status=serialization_error)
       check_status = 'Failed, but replaced with empty result'
 
     log.info(f'Result check: {check_status:s}')


Test patch: