Repository: google/turbinia
Pull request #1015 (2022-03-04 01:57:00)

Gitlab analyser
* Checks for signs of exiftool abuse in the logs
* Moves the install of turbinia later in the worker Dockerfile to make recreating a lot faster
* Provides a count of duplicate lines in the final report
* Includes fix from #1014 
* Stops image_export being run when there's no `local_path` which causes many errors


Patch:
diff --git a/docker/worker/Dockerfile b/docker/worker/Dockerfile
index 1a0a562..5438afa 100644
--- a/docker/worker/Dockerfile
+++ b/docker/worker/Dockerfile
@@ -71,10 +71,6 @@ RUN mkdir -p /opt/loki && chown -R turbinia:turbinia /opt/loki \
     && sudo chown -R turbinia:turbinia /opt/loki \
     && git clone https://github.com/Neo23x0/signature-base.git
 
-ADD . /tmp/
-# unshallow and fetch all tags so our build systems pickup the correct git tag if it's a shallow clone
-RUN if $(cd /tmp/ && git rev-parse --is-shallow-repository); then cd /tmp/ && git fetch --prune --unshallow && git fetch --depth=1 origin +refs/tags/*:refs/tags/*; fi
-RUN cd /tmp/ && python3 setup.py install
 
 RUN mkdir /etc/turbinia && mkdir -p /mnt/turbinia/ && mkdir -p /var/lib/turbinia/ \
     && mkdir -p /etc/turbinia/ && chown -R turbinia:turbinia /etc/turbinia/ \
@@ -91,6 +87,11 @@ RUN cp /home/turbinia/password.lst /root/
 COPY k8s/tools/check-lockfile.py /home/turbinia/check-lockfile.py
 RUN chown turbinia:turbinia /home/turbinia/check-lockfile.py
 
+ADD . /tmp/
+# unshallow and fetch all tags so our build systems pickup the correct git tag if it's a shallow clone
+RUN if $(cd /tmp/ && git rev-parse --is-shallow-repository); then cd /tmp/ && git fetch --prune --unshallow && git fetch --depth=1 origin +refs/tags/*:refs/tags/*; fi
+RUN cd /tmp/ && python3 setup.py install
+
 COPY docker/worker/start.sh /home/turbinia/start.sh
 RUN chmod +rwx /home/turbinia/start.sh
 USER turbinia
diff --git a/turbinia/client.py b/turbinia/client.py
index ba7c4ae..8b7a023 100644
--- a/turbinia/client.py
+++ b/turbinia/client.py
@@ -171,9 +171,9 @@ class BaseTurbiniaClient:
       jobs_allowlist=None, jobs_denylist=None, recipe_name=None, sketch_id=None,
       skip_recipe_validation=False, yara_rules=None):
     """Creates a Turbinia recipe.
-    
+
     If no recipe_name is specified, this  method returns a default recipe.
-    If a recipe_name is specified then this method will build the recipe 
+    If a recipe_name is specified then this method will build the recipe
     dictionary by reading the  contents of a recipe file. The path to
     the recipe file is inferre from the recipe_name and the RECIPE_FILE_DIR
     configuration parameter.
@@ -989,11 +989,22 @@ class BaseTurbiniaClient:
       report.append(fmt.heading1('{0:s} Tasks'.format(success_type)))
       if not task_map[success_type]:
         report.append(fmt.bullet('None'))
+      task_counter = defaultdict(int)
       for task in task_map[success_type]:
         if full_report and success_type == success_types[0]:
           report.extend(self.format_task_detail(task, show_files=all_fields))
-        else:
+        elif success_type == success_types[2]:
           report.extend(self.format_task(task, show_files=all_fields))
+        else:
+          task_counter['\n'.join(self.format_task(task,
+                                                  show_files=all_fields))] += 1
+
+      if len(task_counter):
+        for k, v in task_counter.items():
+          if v == 1:
+            report.append(k)
+          else:
+            report.append('{0:s} x {1:d}'.format(k, v))
 
     return '\n'.join(report)
 
diff --git a/turbinia/config/turbinia_config_tmpl.py b/turbinia/config/turbinia_config_tmpl.py
index e2dbf58..e77bc9c 100644
--- a/turbinia/config/turbinia_config_tmpl.py
+++ b/turbinia/config/turbinia_config_tmpl.py
@@ -153,6 +153,11 @@ DEPENDENCIES = [{
     'programs': ['fsstat'],
     'docker_image': None,
     'timeout': 1800
+}, {
+    'job': 'GitlabJob',
+    'programs': ['zgrep'],
+    'docker_image': None,
+    'timeout': 1800
 }, {
     'job': 'GrepJob',
     'programs': ['grep'],
diff --git a/turbinia/evidence.py b/turbinia/evidence.py
index 1679d18..f21ff4d 100644
--- a/turbinia/evidence.py
+++ b/turbinia/evidence.py
@@ -154,7 +154,7 @@ class Evidence:
         if that state is true.  This is used by the preprocessors to set the
         current state and Tasks can use this to determine if the Evidence is in
         the correct state for processing.
-    resource_tracked (bool): Evidence with this property set requires tracking 
+    resource_tracked (bool): Evidence with this property set requires tracking
         in a state file to allow for access amongst multiple workers.
     resource_id (str): The unique id used to track the state of a given Evidence
         type for stateful tracking.
@@ -238,6 +238,9 @@ class Evidence:
 
   def serialize(self):
     """Return JSON serializable object."""
+    # Clear any partition path_specs before serializing
+    if hasattr(self, 'path_spec'):
+      self.path_spec = None
     serialized_evidence = self.__dict__.copy()
     if self.parent_evidence:
       serialized_evidence['parent_evidence'] = self.parent_evidence.serialize()
diff --git a/turbinia/jobs/__init__.py b/turbinia/jobs/__init__.py
index e707b72..5a0d439 100644
--- a/turbinia/jobs/__init__.py
+++ b/turbinia/jobs/__init__.py
@@ -21,6 +21,7 @@ from turbinia.jobs import docker
 from turbinia.jobs import file_system_timeline
 from turbinia.jobs import finalize_request
 from turbinia.jobs import fsstat
+from turbinia.jobs import gitlab
 from turbinia.jobs import grep
 from turbinia.jobs import hadoop
 from turbinia.jobs import hindsight
diff --git a/turbinia/jobs/gitlab.py b/turbinia/jobs/gitlab.py
new file mode 100644
index 0000000..0932662
--- /dev/null
+++ b/turbinia/jobs/gitlab.py
@@ -0,0 +1,49 @@
+# -*- coding: utf-8 -*-
+# Copyright 2022 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Job to execute the Gitlab task."""
+
+from turbinia.evidence import CompressedDirectory
+from turbinia.evidence import Directory
+from turbinia.evidence import DiskPartition
+from turbinia.evidence import DockerContainer
+from turbinia.evidence import ReportText
+from turbinia.jobs import interface
+from turbinia.jobs import manager
+from turbinia.workers import gitlab
+
+
+class GitlabJob(interface.TurbiniaJob):
+  """Gitlab job."""
+
+  evidence_input = [
+      Directory, DiskPartition, CompressedDirectory, DockerContainer
+  ]
+  evidence_output = [ReportText]
+
+  NAME = 'GitlabJob'
+
+  def create_tasks(self, evidence):
+    """Create task.
+
+    Args:
+      evidence: List of evidence objects to process
+    Returns:
+        A list of tasks to schedule.
+    """
+    tasks = [gitlab.GitlabTask() for _ in evidence]
+    return tasks
+
+
+manager.JobsManager.RegisterJob(GitlabJob)
diff --git a/turbinia/lib/utils.py b/turbinia/lib/utils.py
index fefb2a2..3e76ad8 100644
--- a/turbinia/lib/utils.py
+++ b/turbinia/lib/utils.py
@@ -112,6 +112,10 @@ def extract_files(file_name, disk_path, output_dir, credentials=[]):
   Raises:
     TurbiniaException: If an error occurs when running image_export.
   """
+  if not disk_path:
+    raise TurbiniaException(
+        'image_export.py failed: Attempted to run with no local_path')
+
   image_export_cmd = [
       'sudo', 'image_export.py', '--name', file_name, '--write', output_dir,
       '--partitions', 'all'
diff --git a/turbinia/task_manager.py b/turbinia/task_manager.py
index 717bc7b..0ad0c45 100644
--- a/turbinia/task_manager.py
+++ b/turbinia/task_manager.py
@@ -804,7 +804,7 @@ class PSQTaskManager(BaseTaskManager):
         if timeout:
           log.warning(
               'Task {0:s} timed on server out after {0:d} seconds. Auto-closing Task.'
-              .format(celery_task.id, timeout))
+              .format(psq_task.id, timeout))
           task = self.timeout_task(task, timeout)
           completed_tasks.append(task)
 
diff --git a/turbinia/task_utils.py b/turbinia/task_utils.py
index cf00004..268c575 100644
--- a/turbinia/task_utils.py
+++ b/turbinia/task_utils.py
@@ -49,6 +49,7 @@ class TaskLoader():
       'FileSystemTimelineTask',
       'FinalizeRequestTask',
       'FsstatTask',
+      'GitlabTask',
       'GrepTask',
       'HadoopAnalysisTask',
       'HindsightTask',
@@ -74,10 +75,10 @@ class TaskLoader():
 
   def check_task_name(self, task_name):
     """Checks whether a given task name is a valid task
-    
+
     Args:
       task_name(str): Name of the Task to check.
-      
+
     Returns:
       bool: True if task with the given name exists, else False
     """
@@ -88,10 +89,10 @@ class TaskLoader():
 
   def get_task(self, task_name):
     """Gets an instantiated Task object for the given name.
-    
+
     Args:
       task_name(str): Name of the Task to return.
-      
+
     Returns:
       TurbiniaTask: An instantiated Task object.
     """
@@ -116,6 +117,7 @@ class TaskLoader():
     from turbinia.workers.file_system_timeline import FileSystemTimelineTask
     from turbinia.workers.finalize_request import FinalizeRequestTask
     from turbinia.workers.fsstat import FsstatTask
+    from turbinia.workers.gitlab import GitlabTask
     from turbinia.workers.grep import GrepTask
     from turbinia.workers.hadoop import HadoopAnalysisTask
     from turbinia.workers.hindsight import HindsightTask
@@ -147,7 +149,7 @@ class TaskLoader():
 
   def get_task_names(self):
     """Returns a list of Task names.
-    
+
     Returns:
       (list) All Task names.
     """
diff --git a/turbinia/workers/artifact.py b/turbinia/workers/artifact.py
index 834d931..ea4e612 100644
--- a/turbinia/workers/artifact.py
+++ b/turbinia/workers/artifact.py
@@ -77,6 +77,13 @@ class FileArtifactExtractionTask(TurbiniaTask):
 
     # Path to the source image/directory.
     cmd.append(evidence.local_path)
+    if not evidence.local_path:
+      result.log('Tried to run image_export without local_path')
+      result.close(
+          self, False,
+          'image_export.py failed for artifact {0:s} - local_path not provided.'
+          .format(self.artifact_name))
+      return result
 
     result.log('Running image_export as [{0:s}]'.format(' '.join(cmd)))
 
diff --git a/turbinia/workers/cron.py b/turbinia/workers/cron.py
index 8dfe290..28ce5fb 100644
--- a/turbinia/workers/cron.py
+++ b/turbinia/workers/cron.py
@@ -51,7 +51,14 @@ class CronAnalysisTask(TurbiniaTask):
 
     # Read the input file
     with open(evidence.local_path, 'r') as input_file:
-      crontab = input_file.read()
+      try:
+        crontab = input_file.read()
+      except UnicodeDecodeError as exception:
+        message = 'Error parsing cron file {0:s}: {1!s}'.format(
+            evidence.local_path, exception)
+        result.log(message)
+        result.close(self, success=False, status=message)
+        return result
 
     (report, priority, summary) = self.analyse_crontab(crontab)
     output_evidence.text_data = report
diff --git a/turbinia/workers/gitlab.py b/turbinia/workers/gitlab.py
new file mode 100644
index 0000000..ab463ec
--- /dev/null
+++ b/turbinia/workers/gitlab.py
@@ -0,0 +1,163 @@
+# -*- coding: utf-8 -*-
+# Copyright 2022 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Task for analysing Gitlab instances."""
+
+from __future__ import unicode_literals
+
+import os
+import glob
+
+from turbinia.evidence import EvidenceState as state
+from turbinia.evidence import ReportText
+from turbinia.lib import text_formatter as fmt
+from turbinia.workers import TurbiniaTask
+from turbinia.workers import Priority
+
+
+class GitlabTask(TurbiniaTask):
+  """Task to analyze Gitlabs."""
+
+  REQUIRED_STATES = [
+      state.ATTACHED, state.CONTAINER_MOUNTED, state.DECOMPRESSED
+  ]
+
+  def run(self, evidence, result):
+    """Run the Gitlab worker.
+
+    Args:
+        evidence (Evidence object):  The evidence we will process.
+        result (TurbiniaTaskResult): The object to place task results into.
+
+    Returns:
+        TurbiniaTaskResult object.
+    """
+    # Where to store the resulting output file.
+    output_file_name = 'gitlab_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 = ReportText(source_path=output_file_path)
+
+    reports = []
+    summaries = []
+
+    # Grep for exif in workhorse logs
+    (r, priority, s) = self._is_exif_in_logs(
+        result, evidence.local_path,
+        os.path.join('var', 'log', 'gitlab', 'workhorse.log'))
+    if r != '':
+      reports.append(r)
+    if s != '':
+      summaries.append(s)
+
+    (r, p2, s) = self._is_exif_in_logs(
+        result, evidence.local_path,
+        os.path.join('var', 'log', 'gitlab', 'gitlab-workhorse', '@*'))
+    if r != '':
+      reports.append(r)
+    if s != '':
+      summaries.append(s)
+    if p2 < priority:
+      priority = p2
+
+    # TODO: Check for Metasploit Module
+    # 'https://packetstormsecurity.com/files/160441/GitLab-File-Read-Remote-Code-Execution.html'
+
+    (r, p3, s) = self._is_traversal_in_logs(
+        result, evidence.local_path,
+        os.path.join('var', 'log', 'gitlab', 'nginx', '*access*'))
+    if r != '':
+      reports.append(r)
+    if s != '':
+      summaries.append(s)
+    if p3 < priority:
+      priority = p3
+
+    if priority == priority.LOW:
+      result.close(self, success=True, status='No Gitlab exploitation found')
+      return result
+
+    report = " ".join(reports)
+    summary = " ".join(summaries)
+
+    output_evidence.text_data = report
+    result.report_priority = priority
+    result.report_data = report
+
+    # Write the report to the output file.
+    with open(output_file_path, 'wb') as fh:
+      fh.write(output_evidence.text_data.encode('utf-8'))
+
+    # Add the resulting evidence to the result object.
+    result.add_evidence(output_evidence, evidence.config)
+    result.close(self, success=True, status=summary)
+    return result
+
+  def _is_exif_in_logs(self, result, basedir, logfiles):
+    """Checks to see if there is evidence of the exiftool exploit in the logs.
+
+    Args:
+      result (TurbiniaTaskResult): The object to place task results into.
+      basedir (str): the root of the evidence.
+      logfiles (str): The file(s) to check
+
+    Returns:
+      Tuple(
+        report_text(str): The report data
+        report_priority(int): The priority of the report (0 - 100)
+        summary(str): A summary of the report (used for task status)
+      )
+    """
+
+    check = " ".join(glob.glob(os.path.join(basedir, logfiles)))
+    if not len(check):
+      return ('', Priority.LOW, '')
+
+    cmd = ['zgrep', '"exiftool command failed"', check]
+    ret, result = self.execute(cmd, result, success_codes=[0, 1])
+    if ret == 0:
+      summary = 'exif exploit detected in {0:s}'.format(logfiles)
+      report = fmt.heading4(fmt.bold(summary))
+      return (report, Priority.HIGH, summary)
+
+    return ('', Priority.LOW, '')
+
+  def _is_traversal_in_logs(self, result, basedir, logfiles):
+    """Checks to see if there is evidence of directory traversal in the logs.
+
+    Args:
+      result (TurbiniaTaskResult): The object to place task results into.
+      basedir (str): the root of the evidence.
+      logfiles (str): The file(s) to check
+
+    Returns:
+      Tuple(
+        report_text(str): The report data
+        report_priority(int): The priority of the report (0 - 100)
+        summary(str): A summary of the report (used for task status)
+      )
+    """
+
+    check = " ".join(glob.glob(os.path.join(basedir, logfiles)))
+    if not len(check):
+      return ('', Priority.LOW, '')
+
+    cmd = ['zgrep', '"%2F..%2F..%2F..%2F"', check]
+    ret, result = self.execute(cmd, result, success_codes=[0, 1])
+    if ret == 0:
+      summary = 'directory traversal exploit detected in {}'.format(logfiles)
+      report = fmt.heading4(fmt.bold(summary))
+      return (report, Priority.HIGH, summary)
+
+    return ('', Priority.LOW, '')


Test patch:
diff --git a/test_data/var/log/gitlab/workhorse.log b/test_data/var/log/gitlab/workhorse.log
new file mode 100644
index 0000000..efcc4f9
--- /dev/null
+++ b/test_data/var/log/gitlab/workhorse.log
@@ -0,0 +1,1 @@
+time="2022-01-25T21:32:33Z" level=info msg="exiftool command failed" command="[exiftool -all= --IPTC:all --XMP-iptcExt:all -tagsFromFile @ -ResolutionUnit -XResolution -YResolution -YCbCrSubSampling -YCbCrPositioning -BitsPerSample -ImageHeight -ImageWidth -ImageSize -Copyright -CopyrightNotice -Orientation -]" error="exit status 1" stderr="  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current\n                                 Dload  Upload   Total   Spent    Left  Speed\n\r  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0\r100   600  100   600    0     0   2933      0 --:--:-- --:--:-- --:--:--  2941\nsh: 1: wget: not found\nsh: 1: rsync: not found\nError: Writing of this type of file is not supported - -\n"
diff --git a/turbinia/lib/recipe_helpers_test.py b/turbinia/lib/recipe_helpers_test.py
new file mode 100644
index 0000000..8c53955
--- /dev/null
+++ b/turbinia/lib/recipe_helpers_test.py
@@ -0,0 +1,176 @@
+# -*- coding: utf-8 -*-
+# Copyright 2022 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Tests for recipe helpers."""
+
+import io
+import os
+import unittest
+from unittest import mock
+
+from turbinia.lib import recipe_helpers
+
+
+class RecipeHelpersTest(unittest.TestCase):
+  """Tests for recipe_helpers functions."""
+
+  def setUp(self):
+    self.test_globals_recipe = {
+        'debug_tasks': False,
+        'jobs_allowlist': [],
+        'jobs_denylist': [],
+        'yara_rules': '',
+        'filter_patterns': [],
+        'sketch_id': None,
+        'group_id': ''
+    }
+    self.test_recipe_dict = {
+        'globals': self.test_globals_recipe,
+        'plaso_base': {
+            'task': 'PlasoTask'
+        }
+    }
+
+  @mock.patch(
+      'builtins.open',
+      return_value=io.StringIO('globals:\n  jobs_allowlist:\n    - PlasoJob'))
+  def testLoadRecipeFromFile(self, _):
+    """Tests that a recipe is loaded correctly."""
+    expected = {'globals': {'jobs_allowlist': ['PlasoJob']}}
+    result = recipe_helpers.load_recipe_from_file('test.yaml')
+
+    self.assertEqual(result, expected)
+
+  @mock.patch('builtins.open', side_effect=IOError)
+  @mock.patch('turbinia.lib.recipe_helpers.log.error')
+  def testLoadRecipeFromFileIOError(self, mock_log, _):
+    """Tests that an IOError is handled correctly."""
+    result = recipe_helpers.load_recipe_from_file('test.yaml')
+
+    mock_log.assert_called_with('Failed to read recipe file test.yaml: ')
+    self.assertEqual(result, {})
+
+  @mock.patch('builtins.open', return_value=io.StringIO('{'))
+  @mock.patch('turbinia.lib.recipe_helpers.log.error')
+  def testLoadRecipeFromFileInvalidYAML(self, mock_log, _):
+    """Tests that a YAML parser error is handled correctly."""
+    result = recipe_helpers.load_recipe_from_file('test.yaml', validate=False)
+
+    mock_log.assert_called_with(
+        'Invalid YAML on recipe file test.yaml: while parsing a flow node\n'
+        'expected the node content, but found \'<stream end>\'\n  in "<unicode '
+        'string>", line 1, column 2:\n    {\n     ^.')
+    self.assertEqual(result, {})
+
+  @mock.patch(
+      'builtins.open',
+      return_value=io.StringIO('globals:\n  invalid_key:\n    - invalid_value'))
+  def testLoadRecipeFromFileInvalidRecipe(self, _):
+    """Tests that an invalid recipe is handled correctly."""
+    result = recipe_helpers.load_recipe_from_file('test.yaml')
+    self.assertEqual(result, {})
+
+  def testValidateGlobalsRecipe(self):
+    """Tests validate_globals_recipe for a valid recipe."""
+    result = recipe_helpers.validate_globals_recipe(self.test_globals_recipe)
+    self.assertEqual(result, (True, ''))
+
+  @mock.patch('turbinia.lib.recipe_helpers.log.error')
+  def testValidateGlobalsRecipeInvalidKey(self, mock_log):
+    """Tests that an invalid recipe key name is handled correctly."""
+    self.test_globals_recipe['invalid_key'] = 'invalid_value'
+    expected_error_message = (
+        'Invalid recipe: Unknown keys [{\'invalid_key\'}] found in globals '
+        'recipe')
+    result = recipe_helpers.validate_globals_recipe(self.test_globals_recipe)
+
+    mock_log.assert_called_with(expected_error_message)
+    self.assertEqual(result, (False, expected_error_message))
+
+  @mock.patch('turbinia.lib.recipe_helpers.log.error')
+  def testValidateGlobalsRecipeAllowDenyListDuplicate(self, mock_log):
+    """Tests that an entry in both allow and deny lists is handled correctly."""
+    self.test_globals_recipe['jobs_allowlist'].append('job_name')
+    self.test_globals_recipe['jobs_denylist'].append('job_name')
+    expected_error_message = (
+        'Invalid recipe: Jobs cannot be in both the allow and deny lists')
+    result = recipe_helpers.validate_globals_recipe(self.test_globals_recipe)
+
+    mock_log.assert_called_with(expected_error_message)
+    self.assertEqual(result, (False, expected_error_message))
+
+  def testValidateRecipe(self):
+    """Tests that a valid recipe passes validation."""
+    result = recipe_helpers.validate_recipe(self.test_recipe_dict)
+    self.assertEqual(result, (True, ''))
+
+  @mock.patch('turbinia.lib.recipe_helpers.log.error')
+  def testValidateRecipeInvalidGlobals(self, mock_log):
+    """Tests that a recipe with invalid globals is handled correctly."""
+    recipe_dict = {
+        'globals': {
+            'invalid_key': 'invalid_value'
+        },
+        'plaso_base': {
+            'task': 'PlasoTask'
+        }
+    }
+    expected_error_message = (
+        'Invalid recipe: Unknown keys [{\'invalid_key\'}] found in globals '
+        'recipe')
+    result = recipe_helpers.validate_recipe(recipe_dict)
+
+    mock_log.assert_called_with(expected_error_message)
+    self.assertEqual(result, (False, expected_error_message))
+
+  @mock.patch('turbinia.lib.recipe_helpers.log.error')
+  def testValidateRecipeNoTask(self, mock_log):
+    """Tests that a recipe with no task key is handled correctly."""
+    self.test_recipe_dict['plaso_base'] = {'notask': 'PlasoTask'}
+    expected_error_message = (
+        'Recipe item "plaso_base" has no "task" key. All recipe items must '
+        'have a "task" key indicating the TurbiniaTask to which it relates.')
+    result = recipe_helpers.validate_recipe(self.test_recipe_dict)
+
+    mock_log.assert_called_with(expected_error_message)
+    self.assertEqual(result, (False, expected_error_message))
+
+  @mock.patch('turbinia.lib.recipe_helpers.log.error')
+  def testValidateRecipeInvalidTaskName(self, mock_log):
+    """Tests that a recipe with an invalid task name is handled correctly."""
+    self.test_recipe_dict['plaso_base'] = {'task': 'NoTask'}
+    expected_error_message = (
+        'Task NoTask defined for task recipe plaso_base does not exist.')
+    result = recipe_helpers.validate_recipe(self.test_recipe_dict)
+
+    mock_log.assert_called_with(expected_error_message)
+    self.assertEqual(result, (False, expected_error_message))
+
+  @mock.patch('turbinia.config.RECIPE_FILE_DIR', '/etc/turbinia/', create=True)
+  def testGetRecipeFromPathConfigSet(self):
+    """Check recipe path is correct when RECIPE_FILE_DIR set."""
+    file_path_with_config = recipe_helpers.get_recipe_path_from_name('name')
+    self.assertEqual(file_path_with_config, '/etc/turbinia/name.yaml')
+
+  @mock.patch('turbinia.config.RECIPE_FILE_DIR', None, create=True)
+  def testGetRecipeFromPathDefault(self):
+    """Check recipe path is correct when RECIPE_FILE_DIR is not set."""
+    file_path_default = recipe_helpers.get_recipe_path_from_name('name')
+    file_name = os.path.basename(file_path_default)
+    first_parent_dir = os.path.basename(os.path.dirname(file_path_default))
+    second_parent_dir = os.path.basename(
+        os.path.dirname(os.path.dirname(file_path_default)))
+    self.assertEqual(file_name, 'name.yaml')
+    self.assertEqual(first_parent_dir, 'recipes')
+    self.assertEqual(second_parent_dir, 'config')
diff --git a/turbinia/workers/analysis/loki_test.py b/turbinia/workers/analysis/loki_test.py
index 0e12e4f..cff3782 100644
--- a/turbinia/workers/analysis/loki_test.py
+++ b/turbinia/workers/analysis/loki_test.py
@@ -25,7 +25,7 @@ from turbinia.workers.workers_test import TestTurbiniaTaskBase
 
 
 class LokiAnalysisTaskTest(TestTurbiniaTaskBase):
-  """Tests for LokisAnalysisTask Task."""
+  """Tests for LokiAnalysisTask Task."""
 
   LOKI_SUMMARY = 'Loki analysis found 1 alert(s)'
 
diff --git a/turbinia/workers/gitlab_test.py b/turbinia/workers/gitlab_test.py
new file mode 100644
index 0000000..9b863ca
--- /dev/null
+++ b/turbinia/workers/gitlab_test.py
@@ -0,0 +1,56 @@
+# -*- coding: utf-8 -*-
+# Copyright 2022 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Tests for the Gitlab task."""
+
+import os
+import shutil
+import unittest
+
+from turbinia import config
+from turbinia.workers import gitlab
+from turbinia.workers import TurbiniaTaskResult
+from turbinia.workers.workers_test import TestTurbiniaTaskBase
+
+
+class GitlabTaskTest(TestTurbiniaTaskBase):
+  """Tests for the Gitlab Task."""
+
+  SUMMARY = '#### **exif exploit detected in var/log/gitlab/workhorse.log**'
+  TEST_DATA = None
+
+  def setUp(self):
+    super(GitlabTaskTest, self).setUp(task_class=gitlab.GitlabTask)
+    self.setResults(mock_run=False)
+    filedir = os.path.dirname(os.path.realpath(__file__))
+    self.evidence.local_path = os.path.join(filedir, '..', '..', 'test_data')
+    self.task.output_dir = self.task.base_output_dir
+
+  def testGitlabRun(self):
+    """Test Gitlab task run."""
+    config.LoadConfig()
+    result = self.task.run(self.evidence, self.result)
+
+    self.assertIsInstance(result, TurbiniaTaskResult)
+
+    self.assertEqual(result.report_priority, 20)
+    self.assertEqual(result.report_data, self.SUMMARY)
+
+  def tearDown(self):
+    if os.path.exists(self.base_output_dir):
+      shutil.rmtree(self.base_output_dir)
+
+
+if __name__ == '__main__':
+  unittest.main()