Repository: briandelmsft/STAT-Function

Issue:
Base Module option to disable extra enrichments
- [x] Disable MFA/SSPR registration enrichment
- [x] Disable Entra Roles enrichment
- [x] Verify if there are any additional optional enrichments

When triaging an incident with a large number of account entities (observed with 100+, but possibly fewer), these optional enrichments can result in the logic app connector timing out

Discussion:

Solution patch:
diff --git a/classes/__init__.py b/classes/__init__.py
index e326393..a926bf1 100644
--- a/classes/__init__.py
+++ b/classes/__init__.py
@@ -1,5 +1,6 @@
 import urllib.parse
 import json
+from shared import data
 
 class Response:
     '''A response object'''
@@ -21,6 +22,14 @@ class STATNotFound(STATError):
     '''A handled STAT exception where the API call returned a 404 error'''
     pass
 
+class STATTooManyRequests(STATError):
+    '''A handled STAT exception where the API call returned a 429 error'''
+    def __init__(self, error:str, source_error:dict={}, status_code:int=400, retry_after:int=10):
+        self.error = error
+        self.source_error = source_error
+        self.status_code = status_code
+        self.retry_after = str(retry_after)
+
 class BaseModule:
     '''A base module object'''
     
@@ -543,3 +552,11 @@ def __init__(self):
         self.Severity = ''
         self.IncidentNumber = 0
         self.IncidentUrl = ''
+
+class DebugModule:
+    '''A Debug Module Instance'''
+    def __init__(self, req_body):
+        self.ModuleName = 'DebugModule'
+        self.STATVersion = data.get_current_version()
+        self.Test = req_body.get('Test', 'Default')
+        self.Params = req_body.get('Params', {})
\ No newline at end of file
diff --git a/debug/__init__.py b/debug/__init__.py
new file mode 100644
index 0000000..139da44
--- /dev/null
+++ b/debug/__init__.py
@@ -0,0 +1,32 @@
+import logging
+import traceback as tb
+import json
+import azure.functions as func
+from classes import STATError
+from shared import data
+from . import debug
+
+def main(req: func.HttpRequest, context: func.Context) -> func.HttpResponse:
+    logging.debug('STAT Debug Function started processing a request.')
+
+    try:
+        req_body = req.get_json()
+    except ValueError:
+        logging.error(msg={'Error': 'Invalid Request Body', 'InvocationId': context.invocation_id})
+        return func.HttpResponse(json.dumps({'Error': 'Invalid Request Body', 'InvocationId': context.invocation_id}), status_code=400, mimetype='application/json')
+
+    try:
+        return_data = debug.debug_module(req_body)
+    except STATError as e:
+        trace = tb.format_exception(None, e, e.__traceback__)
+        logging.error(msg={'Error': e.error, 'SourceError': e.source_error, 'InvocationId': context.invocation_id}, exc_info=True)
+        return func.HttpResponse(json.dumps({'Error': e.error, 'InvocationId': context.invocation_id, 'SourceError': e.source_error, 'STATVersion': data.get_current_version(), 'Traceback': trace}), status_code=e.status_code, mimetype='application/json')
+    except Exception as e:
+        trace = tb.format_exception(None, e, e.__traceback__)
+        logging.error(e, exc_info=True)
+        return func.HttpResponse(json.dumps({'Error': 'Module processing failed, an unknown exception has occurred.', 'InvocationId': context.invocation_id, 'STATVersion': data.get_current_version(), 'Traceback': trace}), status_code=400, mimetype='application/json')
+    except:
+        logging.error(msg={'Error': 'Module processing failed, an unknown exception has occurred.', 'InvocationId': context.invocation_id}, exc_info=True)
+        return func.HttpResponse(json.dumps({'Error': 'Module processing failed, an unknown exception has occurred.', 'InvocationId': context.invocation_id, 'STATVersion': data.get_current_version()}), status_code=400, mimetype='application/json')
+    
+    return func.HttpResponse(body=json.dumps(return_data.body.__dict__), status_code=return_data.statuscode, mimetype=return_data.contenttype)
\ No newline at end of file
diff --git a/debug/debug.py b/debug/debug.py
new file mode 100644
index 0000000..e9b7884
--- /dev/null
+++ b/debug/debug.py
@@ -0,0 +1,104 @@
+from classes import Response, STATError, DebugModule, BaseModule
+from shared import rest
+import os, sys, json, base64
+import datetime as dt
+
+def debug_module (req_body):
+
+    #Inputs Test, Params
+
+    if os.getenv('DEBUG_MODE') != "1":
+        raise STATError('DEBUG_MODE environment variable must be set to 1 to run this module')
+
+    debug_out = DebugModule(req_body)
+
+    match debug_out.Test.lower():
+        case 'token':
+            default_debug(debug_out)
+            token_debug(debug_out)
+        case 'rest':
+            default_debug(debug_out)
+            rest_debug(debug_out)
+        case 'exception':
+            exception_debug(debug_out)
+        case _:
+            default_debug(debug_out)
+
+    return Response(debug_out)
+
+def default_debug(debug_out:DebugModule):
+    debug_out.GraphEndpoint = os.getenv('GRAPH_ENDPOINT')
+    debug_out.ARMEndpoint = os.getenv('ARM_ENDPOINT')
+    debug_out.LAEndpoint = os.getenv('LOGANALYTICS_ENDPOINT')
+    debug_out.M365Endpoint = os.getenv('M365_ENDPOINT')
+    debug_out.MDEEndpoint = os.getenv('MDE_ENDPOINT')
+    debug_out.MDCAEndpoint = os.getenv('MDCA_ENDPOINT')
+    debug_out.TenantId = os.getenv('AZURE_TENANT_ID')
+    debug_out.KVEndpoint = os.getenv('KEYVAULT_ENDPOINT')
+    debug_out.KVSecretName = os.getenv('KEYVAULT_SECRET_NAME')
+    debug_out.KVClientId = os.getenv('KEYVAULT_CLIENT_ID')
+    debug_out.PackageUrl = os.getenv('WEBSITE_RUN_FROM_PACKAGE')
+    debug_out.PythonVersion = sys.version
+
+def token_debug(debug_out:DebugModule):
+    base_module = BaseModule()
+    base_module.MultiTenantConfig = debug_out.Params.get('MultiTenantConfig', {})
+    token_type = debug_out.Params.get('TokenType', 'msgraph')
+    token = rest.token_cache(base_module, token_type)
+
+    content = token.token.split('.')[1] + '=='
+    b64_decoded = base64.urlsafe_b64decode(content)
+    decoded_token = json.loads(b64_decoded) 
+
+    debug_out.Audience = decoded_token.get('aud')
+    debug_out.Issuer = decoded_token.get('iss')
+    debug_out.Expiration = dt.datetime.fromtimestamp(token.expires_on).isoformat()
+    debug_out.AppDisplayName = decoded_token.get('app_displayname')
+    debug_out.AppId = decoded_token.get('appid')
+    debug_out.Idp = decoded_token.get('idp')
+    debug_out.AppRoles = decoded_token.get('roles')
+    debug_out.TenantId = decoded_token.get('tid')
+
+def rest_debug(debug_out:DebugModule):
+    base_module = BaseModule()
+    base_module.MultiTenantConfig = debug_out.Params.get('MultiTenantConfig', {})
+    token_type = debug_out.Params.get('TokenType', 'msgraph')
+    rest_call_method = debug_out.Params.get('Method').lower()
+    rest_call_path = debug_out.Params.get('Path')
+    rest_call_body = debug_out.Params.get('Body')
+
+    match rest_call_method:
+        case 'get':
+            response = rest.rest_call_get(base_module, token_type, rest_call_path)
+            parse_response(debug_out, response)
+        case 'put':
+            response = rest.rest_call_put(base_module, token_type, rest_call_path, rest_call_body)
+            parse_response(debug_out, response)
+        case 'post':
+            response = rest.rest_call_post(base_module, token_type, rest_call_path, rest_call_body)
+            parse_response(debug_out, response)
+        case _:
+            raise STATError(error=f'Invalid Method sent to debug module: {rest_call_method}.', status_code=400)
+
+
+def exception_debug(debug_out:DebugModule):
+    exception_type = debug_out.Params.get('ExceptionType', 'STATError')
+    match exception_type:
+        case 'STATError':
+            raise STATError('STATError raised from debug module.')
+        case 'Exception':
+            raise Exception()
+        case 'BaseException':
+            raise BaseException()
+        case _:
+            raise STATError(error=f'Invalid exception type sent to debug module: {exception_type}.', status_code=400)
+        
+def parse_response(debug_out:DebugModule, response):
+    try:
+        debug_out.JSONContent = json.loads(response.content)
+    except:
+        debug_out.JSONContent = {'Status': 'Failed to load JSON content'}
+
+    debug_out.StatusCode = response.status_code
+    debug_out.Url = response.url
+    debug_out.TextContent = response.text
\ No newline at end of file
diff --git a/debug/function.json b/debug/function.json
new file mode 100644
index 0000000..fb831ae
--- /dev/null
+++ b/debug/function.json
@@ -0,0 +1,20 @@
+{
+    "scriptFile": "__init__.py",
+    "bindings": [
+        {
+            "authLevel": "function",
+            "type": "httpTrigger",
+            "direction": "in",
+            "name": "req",
+            "methods": [
+                "get",
+                "post"
+            ]
+        },
+        {
+            "type": "http",
+            "direction": "out",
+            "name": "$return"
+        }
+    ]
+}
\ No newline at end of file
diff --git a/modules/base.py b/modules/base.py
index 11bc227..83e32de 100644
--- a/modules/base.py
+++ b/modules/base.py
@@ -9,12 +9,18 @@
 
 def execute_base_module (req_body):
     global base_object
+    global enrich_mfa
+    global enrich_roles
+    global enrich_mde_device
     
     base_object = BaseModule()
 
     trigger_type = req_body['Body'].get('objectSchemaType', 'alert')
 
     base_object.MultiTenantConfig = req_body.get('MultiTenantConfig', {})
+    enrich_mfa = req_body.get('EnrichAccountsWithMFA', True)
+    enrich_roles = req_body.get('EnrichAccountsWithRoles', True)
+    enrich_mde_device = req_body.get('EnrichHostsWithMDE', True)
 
     if trigger_type.lower() == 'incident':
         entities = process_incident_trigger(req_body)
@@ -150,6 +156,7 @@ def enrich_accounts(entities):
         friendly_name = data.coalesce(account.get('properties',{}).get('friendlyName'), account.get('DisplayName'), account.get('Name'))
         sid = data.coalesce(account.get('properties',{}).get('sid'), account.get('Sid'))
         nt_domain = data.coalesce(account.get('properties',{}).get('ntDomain'), account.get('NTDomain'))
+        object_guid = data.coalesce(account.get('properties',{}).get('objectGuid'), account.get('ObjectGuid'))
         properties = data.coalesce(account.get('properties'), account)
 
         if aad_id:
@@ -160,8 +167,12 @@ def enrich_accounts(entities):
             get_account_by_sid(sid, attributes, properties)
         elif nt_domain and account_name:
             get_account_by_samaccountname(account_name, attributes, properties)
+        elif object_guid:
+            get_account_by_upn_or_id(object_guid, attributes, properties)
         else:
-            if friendly_name.__contains__('@'):
+            if friendly_name is None:
+                base_object.add_account_entity({'RawEntity': properties})
+            elif friendly_name.__contains__('@'):
                 get_account_by_upn_or_id(friendly_name, attributes, properties)
             elif friendly_name.__contains__('S-1-'):
                 get_account_by_sid(friendly_name, attributes, properties)
@@ -283,12 +294,14 @@ def append_account_details(account, user_info, raw_entity):
     security_info = {}
     
     try: 
-        assigned_roles = get_account_roles(user_info['id'])
+        if enrich_roles:
+            assigned_roles = get_account_roles(user_info['id'])
     except:
         pass
     
     try:
-        security_info = json.loads(rest.rest_call_get(base_object, api='msgraph', path=f"/v1.0/reports/authenticationMethods/userRegistrationDetails/{user_info['id']}").content)
+        if enrich_mfa:
+            security_info = json.loads(rest.rest_call_get(base_object, api='msgraph', path=f"/v1.0/reports/authenticationMethods/userRegistrationDetails/{user_info['id']}").content)
     except:
         pass
 
@@ -319,7 +332,7 @@ def enrich_hosts(entities):
         mde_device_id = data.coalesce(host.get('properties',{}).get('additionalData', {}).get('MdatpDeviceId'), host.get('MdatpDeviceId'))
         fqdn = data.coalesce(host.get('properties',{}).get('additionalData', {}).get('FQDN'), host.get('FQDN'), f'{host_name}.{domain_name}') 
         
-        if not(mde_device_id):
+        if not(mde_device_id) and enrich_mde_device:
             query = f'''DeviceInfo
 | where Timestamp > ago(14d)
 | where DeviceName =~ '{fqdn}' or DeviceName has '{host_name}'
diff --git a/modules/ti.py b/modules/ti.py
index b26562b..9857b93 100644
--- a/modules/ti.py
+++ b/modules/ti.py
@@ -82,6 +82,13 @@ def execute_ti_module (req_body):
     ti_object.AnyTIFound = bool(ti_object.DetailedResults)
     ti_object.TotalTIMatchCount = len(ti_object.DetailedResults)
 
+    if ti_object.DetailedResults:
+        try:
+            ti_object.DetailedResults = data.replace_column_value_in_list(ti_object.DetailedResults, 'Description', '[', '(')
+            ti_object.DetailedResults = data.replace_column_value_in_list(ti_object.DetailedResults, 'Description', ']', ')')
+        except:
+            pass
+
     if req_body.get('AddIncidentComments', True) and base_object.IncidentAvailable:
         
         html_table = data.list_to_html_table(ti_object.DetailedResults)
diff --git a/modules/version.json b/modules/version.json
index ab573ed..3b44419 100644
--- a/modules/version.json
+++ b/modules/version.json
@@ -1,3 +1,3 @@
 {
-    "FunctionVersion": "2.0.9"
+    "FunctionVersion": "2.0.14"
 }
diff --git a/shared/data.py b/shared/data.py
index fe56280..ef2e0a7 100644
--- a/shared/data.py
+++ b/shared/data.py
@@ -12,7 +12,7 @@ def list_to_html_table(input_list:list, max_rows:int=20, max_cols:int=10, nan_st
     return html_table
 
 def update_column_value_in_list(input_list:list, col_name:str, update_str:str):
-    '''Updates the value of a column in each dict in the list, to include the column value in your replacement use [col_value]'''
+    '''Updates the value of a column in each dict in the list with a value from another column, to include the column value in your replacement use [col_value]'''
     updated_list = []
     for row in input_list:
         current_row = copy.copy(row)
@@ -21,6 +21,16 @@ def update_column_value_in_list(input_list:list, col_name:str, update_str:str):
 
     return updated_list
 
+def replace_column_value_in_list(input_list:list, col_name:str, original_value:str, replacement_value:str):
+    '''Updates the value of a column in each dict in the list, with a static value'''
+    updated_list = []
+    for row in input_list:
+        current_row = copy.copy(row)
+        current_row[col_name] = current_row[col_name].replace(original_value, replacement_value)
+        updated_list.append(current_row)
+
+    return updated_list
+
 def return_highest_value(input_list:list, key:str, order:list=['High','Medium','Low','Informational','None','Unknown']):
     '''Locate the highest value in a list of dictionaries by key'''
     
diff --git a/shared/rest.py b/shared/rest.py
index b31f6b9..08bd6eb 100644
--- a/shared/rest.py
+++ b/shared/rest.py
@@ -1,11 +1,13 @@
 from azure.identity import DefaultAzureCredential, ClientSecretCredential
 from azure.keyvault.secrets import SecretClient
 import requests
+from requests import Response, ConnectionError
 import datetime as dt
 import json
 import os
 import uuid
-from classes import STATError, STATNotFound, BaseModule
+import time
+from classes import STATError, STATNotFound, BaseModule, STATTooManyRequests
 
 stat_token = {}
 graph_endpoint = os.getenv('GRAPH_ENDPOINT')
@@ -98,56 +100,76 @@ def get_kv_secret():
 
 def rest_call_get(base_module:BaseModule, api:str, path:str, headers:dict={}):
     '''Perform a GET HTTP call to a REST API.  Accepted API values are arm, msgraph, la, m365 and mde'''
-    token = token_cache(base_module, api)
-    url = get_endpoint(api) + path
-    headers['Authorization'] = 'Bearer ' + token.token
-    response = requests.get(url=url, headers=headers)
 
-    if response.status_code == 404:
-        raise STATNotFound(f'The API call to {api} with path {path} failed with status {response.status_code}', source_error={'status_code': int(response.status_code), 'reason': str(response.reason)})
-    elif response.status_code >= 300:
-        raise STATError(f'The API call to {api} with path {path} failed with status {response.status_code}', source_error={'status_code': int(response.status_code), 'reason': str(response.reason)})
+    response = execute_rest_call(base_module, 'get', api, path, None, headers)
     
     return response
 
 def rest_call_post(base_module:BaseModule, api:str, path:str, body, headers:dict={}):
     '''Perform a POST HTTP call to a REST API.  Accepted API values are arm, msgraph, la, m365 and mde'''
-    token = token_cache(base_module, api)
-    url = get_endpoint(api) + path
-    headers['Authorization'] = 'Bearer ' + token.token
-    response = requests.post(url=url, json=body, headers=headers)
 
-    if response.status_code == 404:
-        raise STATNotFound(f'The API call to {api} with path {path} failed with status {response.status_code}', source_error={'status_code': int(response.status_code), 'reason': str(response.reason)})
-    elif response.status_code >= 300:
-        raise STATError(f'The API call to {api} with path {path} failed with status {response.status_code}', source_error={'status_code': int(response.status_code), 'reason': str(response.reason)})
+    response = execute_rest_call(base_module, 'post', api, path, body, headers)
     
     return response
 
 def rest_call_put(base_module:BaseModule, api:str, path:str, body, headers:dict={}):
     '''Perform a PUT HTTP call to a REST API.  Accepted API values are arm, msgraph, la, m365 and mde'''
+    
+    response = execute_rest_call(base_module, 'put', api, path, body, headers)
+    
+    return response
+
+def execute_rest_call(base_module:BaseModule, method:str, api:str, path:str, body=None, headers:dict={}):
     token = token_cache(base_module, api)
     url = get_endpoint(api) + path
     headers['Authorization'] = 'Bearer ' + token.token
-    response = requests.put(url=url, json=body, headers=headers)
 
+    retry_call = True
+    wait_time = 0
+
+    while retry_call:
+        try:
+            match method:
+                case 'get':
+                    response = requests.get(url=url, headers=headers)
+                case 'post':
+                    response = requests.post(url=url, json=body, headers=headers)
+                case 'put':
+                    response = requests.put(url=url, json=body, headers=headers)
+                case _:
+                    raise STATError(error=f'Invalid rest method: {method}.', status_code=400)
+            check_rest_response(response, api, path)
+        except STATTooManyRequests as e:
+            wait_time += int(e.retry_after)
+            if wait_time >= 40:
+                raise STATTooManyRequests(error=e.error, source_error=e.source_error, status_code=429, retry_after=e.retry_after)
+            time.sleep(int(e.retry_after))
+        except ConnectionError as e:
+            wait_time += 20
+            if wait_time >= 40:
+                raise STATError(error=f'Failed to establish a new connection to {url}', source_error=e, status_code=500)
+            time.sleep(20)
+        else:
+            retry_call = False
+    
+    return response
+   
+
+def check_rest_response(response:Response, api, path):
     if response.status_code == 404:
         raise STATNotFound(f'The API call to {api} with path {path} failed with status {response.status_code}', source_error={'status_code': int(response.status_code), 'reason': str(response.reason)})
+    elif response.status_code == 429:
+        raise STATTooManyRequests(f'The API call to {api} with path {path} failed with status {response.status_code}', source_error={'status_code': int(response.status_code), 'reason': str(response.reason)}, retry_after=response.headers.get('Retry-After'), status_code=429)
     elif response.status_code >= 300:
         raise STATError(f'The API call to {api} with path {path} failed with status {response.status_code}', source_error={'status_code': int(response.status_code), 'reason': str(response.reason)})
-    
-    return response
+    return
 
 def execute_la_query(base_module:BaseModule, query:str, lookbackindays:int):
-    token = token_cache(base_module, 'la')
-    url = get_endpoint('la') + '/v1/workspaces/' + base_module.WorkspaceId + '/query'
+    path = '/v1/workspaces/' + base_module.WorkspaceId + '/query'
     duration = 'P' + str(lookbackindays) + 'D'
     body = {'query': query, 'timespan': duration}
-    response = requests.post(url=url, json=body, headers={"Authorization": "Bearer " + token.token})
+    response = rest_call_post(base_module, 'la', path, body)
     data = json.loads(response.content)
-
-    if response.status_code >= 300:
-        raise STATError('Microsoft Sentinel KQL Query failed to execute', data)
  
     columns = data['tables'][0]['columns']
     rows = data['tables'][0]['rows']
@@ -163,26 +185,18 @@ def execute_la_query(base_module:BaseModule, query:str, lookbackindays:int):
     return query_results
 
 def execute_m365d_query(base_module:BaseModule, query:str):
-    token = token_cache(base_module, 'm365')
-    url = get_endpoint('m365') + '/api/advancedhunting/run'
+    path = '/api/advancedhunting/run'
     body = {'Query': query}
-    response = requests.post(url=url, json=body, headers={"Authorization": "Bearer " + token.token})
+    response = rest_call_post(base_module, 'm365', path, body)
     data = json.loads(response.content)
-
-    if response.status_code >= 300:
-        raise STATError('Microsoft 365 Advanced Hunting Query failed to execute', data)
     
     return data['Results']
 
 def execute_mde_query(base_module:BaseModule, query:str):
-    token = token_cache(base_module, 'mde')
-    url = get_endpoint('mde') + '/api/advancedqueries/run'
+    path = '/api/advancedqueries/run'
     body = {'Query': query}
-    response = requests.post(url=url, json=body, headers={"Authorization": "Bearer " + token.token})
+    response = rest_call_post(base_module, 'mde', path, body)
     data = json.loads(response.content)
-
-    if response.status_code >= 300:
-        raise STATError('Microsoft 365 Advanced Hunting Query failed to execute', data)
     
     return data['Results']
 
@@ -207,18 +221,25 @@ def get_endpoint(api:str):
                         '(ARM_ENDPOINT, GRAPH_ENDPOINT, LOGANALYTICS_ENDPOINT, M365_ENDPOINT, MDE_ENDPOINT, and MDCA_ENDPOINT).')
     
 def add_incident_comment(base_module:BaseModule, comment:str):
-    token = token_cache(base_module, 'arm')
-    endpoint = get_endpoint('arm')
-    url = endpoint + base_module.IncidentARMId + '/comments/' + str(uuid.uuid4()) + '?api-version=2023-02-01'
-    return requests.put(url=url, json={'properties': {'message': comment[:30000]}}, headers={"Authorization": "Bearer " + token.token})
+    path = base_module.IncidentARMId + '/comments/' + str(uuid.uuid4()) + '?api-version=2023-02-01'
+    try:
+        response = rest_call_put(base_module, 'arm', path, {'properties': {'message': comment[:30000]}})
+    except:
+        response = 'Comment failed'
+    return response
 
 def add_incident_task(base_module:BaseModule, title:str, description:str, status:str='New'):
-    token = token_cache(base_module, 'arm')
-    endpoint = get_endpoint('arm')
-    url = endpoint + base_module.IncidentARMId + '/tasks/' + str(uuid.uuid4()) + '?api-version=2024-03-01'
+    path = base_module.IncidentARMId + '/tasks/' + str(uuid.uuid4()) + '?api-version=2024-03-01'
 
     if description is None or description == '':
-        return requests.put(url=url, json={'properties': {'title': title, 'status': status}}, headers={"Authorization": "Bearer " + token.token})
+        try:
+            response = rest_call_put(base_module, 'arm', path, {'properties': {'title': title, 'status': status}})
+        except:
+            response = 'Task addition failed'
     else:
-        return requests.put(url=url, json={'properties': {'title': title, 'description': description[:3000], 'status': status}}, headers={"Authorization": "Bearer " + token.token})
-    
+        try:
+            response = rest_call_put(base_module, 'arm', path, {'properties': {'title': title, 'description': description[:3000], 'status': status}})
+        except:
+            response = 'Task addition failed'
+    return response
+    
\ No newline at end of file


Test patch:
diff --git a/tests/test_data.py b/tests/test_data.py
index 4cf3e04..f33bc84 100644
--- a/tests/test_data.py
+++ b/tests/test_data.py
@@ -36,6 +36,12 @@ def test_update_column_values_in_list():
 
     assert updated_list[0]['Description'] == 'New Value 4 data'
 
+def test_replace_column_values_in_list():
+
+    updated_list = data.replace_column_value_in_list(list_data(), 'Description', '4', '7')
+
+    assert updated_list[0]['Description'] == 'Value 7'
+
 def test_join_lists():
 
     merged_data = data.join_lists(list_data(), list_data2(), 'left', 'Description', 'Description', fill_nan=0)