Repository: google/timesketch

Issue:
Sigma rules with whitespace at the beginning or end of the contains
Sigma rules with ```CommandLine|contains: '-foobar '``` are translated to something ```*foobar *``` which would be similar to:
```*foobar AND *``` which can be a different meaning 


Discussion:
To give more context:

https://github.com/SigmaHQ/sigma/blob/master/rules/windows/builtin/win_alert_mimikatz_keywords.yml

This rule currently translates to:
```
(data_type:"windows:evtx:record" AND \*:( *mimikatz* OR *mimikatz.exe* OR *mimilib.dll* OR *<3 eo.oe* OR *eo.oe.kiwi* OR *privilege::debug* OR *sekurlsa::logonpasswords* OR *lsadump::sam* OR *mimidrv.sys* OR * p::d * OR * s::l * OR *gentilkiwi.com* OR *Kiwi Legit Printer*))
```

With the following results:

```
*mimikatz* → works
*mimikatz* OR *mimikatz.exe* → works
*mimikatz* OR *mimikatz.exe* OR *mimilib.dll* → works
*mimikatz* OR *mimikatz.exe* OR *mimilib.dll* OR *<3 eo.oe* → does not work

```

Whereas:
```
*mimikatz* OR *mimikatz.exe* OR *mimilib.dll* OR *<3\ eo.oe* 
```
Would work
So tested a bit more:

```
title: This rule is full of test edge cases
description: Various edge cases in a rule
references:
    - https://github.com/google/timesketch/issues/2007
author: Alexander Jaeger
date: 2021/12/03
modified: 2021/12/03
detection:
    keywords:
        - 'Whitespace at'
        - ' beginning '
        - ' and extra text '
    condition: keywords
falsepositives:
    - Unknown
level: high
```
Is translated to: `\*:(*Whitespace at* OR * beginning * OR * and extra text *)` But should be: `("Whitespace at" OR " beginning " OR " and extra text ")`

Whereas:
```
logsource:
    product: windows
detection:
    keywords:
        - ' lorem '
    condition: keywords
```

Is translated to:
`(data_type:"windows:evtx:record" AND " lorem ")` which seem fine.

Resolving patch:
diff --git a/timesketch/lib/sigma_util.py b/timesketch/lib/sigma_util.py
--- a/timesketch/lib/sigma_util.py
+++ b/timesketch/lib/sigma_util.py
@@ -13,6 +13,7 @@
 # limitations under the License.
 """Timesketch Sigma lib functions."""
 
+import re
 import os
 import codecs
 import csv
@@ -50,27 +51,30 @@ def get_sigma_config_file(config_file=None):
         config_file_path = config_file
     else:
         config_file_path = current_app.config.get(
-            'SIGMA_CONFIG', './data/sigma_config.yaml')
+            'SIGMA_CONFIG', './data/sigma_config.yaml'
+        )
 
     if not config_file_path:
         raise ValueError('No config_file_path set via param or config file')
 
     if not os.path.isfile(config_file_path):
         raise ValueError(
-            'Unable to open file: [{0:s}], it does not exist.'.format(
-                config_file_path))
+            'Unable to open: [{0:s}], does not exist.'.format(config_file_path)
+        )
 
     if not os.access(config_file_path, os.R_OK):
         raise ValueError(
             'Unable to open file: [{0:s}], cannot open it for '
-            'read, please check permissions.'.format(config_file_path))
+            'read, please check permissions.'.format(config_file_path)
+        )
 
-    with open(config_file_path, 'r') as config_file_read:
+    with open(config_file_path, 'r', encoding='utf-8') as config_file_read:
         sigma_config_file = config_file_read.read()
 
     try:
         sigma_config = sigma_configuration.SigmaConfiguration(
-            sigma_config_file)
+            sigma_config_file
+        )
     except SigmaConfigParseError:
         logger.error('Parsing error with {0:s}'.format(sigma_config_file))
         raise
@@ -91,23 +95,24 @@ def get_sigma_rules_path():
     try:
         rules_path = current_app.config.get('SIGMA_RULES_FOLDERS', [])
     except RuntimeError as e:
-        raise ValueError(
-            'SIGMA_RULES_FOLDERS not found in config file') from e
+        raise ValueError('SIGMA_RULES_FOLDERS not found in config file') from e
 
     if not rules_path:
-        raise ValueError(
-            'SIGMA_RULES_FOLDERS not found in config file')
+        raise ValueError('SIGMA_RULES_FOLDERS not found in config file')
 
     for folder in rules_path:
         if not os.path.isdir(folder):
             raise ValueError(
                 'Unable to open dir: [{0:s}], it does not exist.'.format(
-                    folder))
+                    folder
+                )
+            )
 
         if not os.access(folder, os.R_OK):
             raise ValueError(
                 'Unable to open dir: [{0:s}], cannot open it for '
-                'read, please check permissions.'.format(folder))
+                'read, please check permissions.'.format(folder)
+            )
 
     return rules_path
 
@@ -191,12 +196,12 @@ def get_sigma_rule(filepath, sigma_config=None):
         else:
             sigma_conf_obj = get_sigma_config_file()
     except ValueError as e:
-        logger.error(
-            'Problem reading the Sigma config', exc_info=True)
+        logger.error('Problem reading the Sigma config', exc_info=True)
         raise ValueError('Problem reading the Sigma config') from e
 
     sigma_backend = sigma_es.ElasticsearchQuerystringBackend(
-        sigma_conf_obj, {})
+        sigma_conf_obj, {}
+    )
 
     try:
         sigma_rules_paths = get_sigma_rules_path()
@@ -213,50 +218,53 @@ def get_sigma_rule(filepath, sigma_config=None):
     abs_path = os.path.abspath(filepath)
 
     with codecs.open(
-            abs_path, 'r', encoding='utf-8', errors='replace') as file:
+        abs_path, 'r', encoding='utf-8', errors='replace'
+    ) as file:
         try:
             rule_return = {}
             rule_yaml_data = yaml.safe_load_all(file.read())
             for doc in rule_yaml_data:
                 rule_return.update(doc)
                 parser = sigma_collection.SigmaCollectionParser(
-                    yaml.safe_dump(doc), sigma_conf_obj, None)
+                    yaml.safe_dump(doc), sigma_conf_obj, None
+                )
                 parsed_sigma_rules = parser.generate(sigma_backend)
 
         except NotImplementedError as exception:
-            logger.error(
-                'Error generating rule in file {0:s}: {1!s}'
-                .format(abs_path, exception))
-            add_problematic_rule(filepath, doc.get('id'),
-                                 'Parts of the rule not implemented in TS')
+            logger.error('Error rule {0:s}: {1!s}'.format(abs_path, exception))
+            add_problematic_rule(
+                filepath, doc.get('id'), 'Part of the rule not supported in TS'
+            )
             return None
 
         except sigma_exceptions.SigmaParseError as exception:
             logger.error(
-                'Sigma parsing error generating rule in file {0:s}: {1!s}'
-                .format(abs_path, exception))
+                'Sigma parsing error rule in file {0:s}: {1!s}'.format(
+                    abs_path, exception
+                )
+            )
             add_problematic_rule(
-                filepath, doc.get('id'), 'sigma_exceptions.SigmaParseError')
+                filepath, doc.get('id'), 'sigma_exceptions.SigmaParseError'
+            )
             return None
 
         except yaml.parser.ParserError as exception:
             logger.error(
-                'Yaml parsing error generating rule in file {0:s}: {1!s}'
-                .format(abs_path, exception))
-            add_problematic_rule(
-                filepath, None, 'yaml.parser.ParserError')
+                'Yaml parsing error rule in file {0:s}: {1!s}'.format(
+                    abs_path, exception
+                )
+            )
+            add_problematic_rule(filepath, None, 'yaml.parser.ParserError')
             return None
 
         sigma_es_query = ''
 
         for sigma_rule in parsed_sigma_rules:
 
-            sigma_es_query = _sanatize_sigma_rule(sigma_rule)
+            sigma_es_query = _sanitize_query(sigma_rule)
 
-        rule_return.update(
-            {'es_query': sigma_es_query})
-        rule_return.update(
-            {'file_name': os.path.basename(filepath)})
+        rule_return.update({'es_query': sigma_es_query})
+        rule_return.update({'file_name': os.path.basename(filepath)})
 
         # in case multiple folders are in the config, need to remove them
         if sigma_rules_paths:
@@ -265,14 +273,13 @@ def get_sigma_rule(filepath, sigma_config=None):
         else:
             file_relpath = 'N/A'
 
-        rule_return.update(
-            {'file_relpath': file_relpath})
+        rule_return.update({'file_relpath': file_relpath})
 
         return rule_return
 
 
-def _sanatize_sigma_rule(sigma_rule_query: str) -> str:
-    """Returns a sanatized sigma rule
+def _sanitize_query(sigma_rule_query: str) -> str:
+    """Returns a sanitized query
     Args:
         sigma_rule_query: path to the sigma rule to be parsed
     Returns:
@@ -281,12 +288,45 @@ def _sanatize_sigma_rule(sigma_rule_query: str) -> str:
     # TODO: Investigate how to handle .keyword
     # fields in Sigma.
     # https://github.com/google/timesketch/issues/1199#issuecomment-639475885
+
     sigma_rule_query = sigma_rule_query.replace('.keyword:', ':')
     sigma_rule_query = sigma_rule_query.replace('\\ ', ' ')
-    sigma_rule_query = sigma_rule_query.replace('\"', '"')
     sigma_rule_query = sigma_rule_query.replace('\\:', ':')
     sigma_rule_query = sigma_rule_query.replace('\\-', '-')
     sigma_rule_query = sigma_rule_query.replace('*\\\\', ' *')
+    sigma_rule_query = sigma_rule_query.replace('::', r'\:\:')
+
+    # TODO: Improve the whitespace handling
+    # https://github.com/google/timesketch/issues/2007
+    # check if there is a ' * '
+    # if one is found split it up into elements seperated by space
+    # and go backwards to the next star
+
+    sigma_rule_query = sigma_rule_query.replace(' * OR', ' " OR')
+    sigma_rule_query = sigma_rule_query.replace(' * AND', ' " AND')
+    sigma_rule_query = sigma_rule_query.replace('OR * ', 'OR " ')
+    sigma_rule_query = sigma_rule_query.replace('AND * ', 'AND " ')
+    sigma_rule_query = sigma_rule_query.replace('(* ', '(" ')
+    sigma_rule_query = sigma_rule_query.replace(' *)', ' ")')
+    sigma_rule_query = sigma_rule_query.replace('*)', '")')
+    sigma_rule_query = sigma_rule_query.replace('(*', '("')
+    sigma_rule_query = sigma_rule_query.replace(
+        r'\*:', ''
+    )  # removes wildcard at the beginning of a rule es_query
+
+    elements = re.split(r'\s+', sigma_rule_query)
+    san = []
+    for el in elements:
+        if el.count('*') == 1:
+            # indicates a string that had a space before with only one star
+            san.append(el.replace('*', '"'))
+        else:
+            san.append(el)
+
+    sigma_rule_query = ' '.join(san)
+
+    # above method might create strings that have '' in them, workaround:
+    sigma_rule_query = sigma_rule_query.replace('""', '"')
 
     return sigma_rule_query
 
@@ -329,19 +369,23 @@ def get_sigma_blocklist_path(blocklist_path=None):
 
     if not blocklist_path or blocklist_path == '':
         blocklist_path = current_app.config.get(
-            'SIGMA_BLOCKLIST_CSV', './data/sigma_blocklist.csv')
+            'SIGMA_BLOCKLIST_CSV', './data/sigma_blocklist.csv'
+        )
     if not blocklist_path:
         raise ValueError('No blocklist_file_path set via param or config file')
 
     if not os.path.isfile(blocklist_path):
         raise ValueError(
-            'Unable to open file: [{0:s}], it does not exist.'.format(
-                blocklist_path))
+            'Unable to open file: [{0:s}] does not exist'.format(
+                blocklist_path
+            )
+        )
 
     if not os.access(blocklist_path, os.R_OK):
         raise ValueError(
             'Unable to open file: [{0:s}], cannot open it for '
-            'read, please check permissions.'.format(blocklist_path))
+            'read, please check permissions.'.format(blocklist_path)
+        )
 
     return blocklist_path
 
@@ -368,10 +412,15 @@ def add_problematic_rule(filepath, rule_uuid=None, reason=None):
             file_relpath = os.path.relpath(filepath, rule_path)
 
     # path,bad,reason,last_ckecked,rule_id
-    fields = [file_relpath, 'bad', reason,
-              datetime.now().strftime('%Y-%m-%d'), rule_uuid]
-
-    with open(blocklist_file_path, 'a') as f:
+    fields = [
+        file_relpath,
+        'bad',
+        reason,
+        datetime.now().strftime('%Y-%m-%d'),
+        rule_uuid,
+    ]
+
+    with open(blocklist_file_path, 'a', encoding='utf-8') as f:
         writer = csv.writer(f)
         writer.writerow(fields)
 
@@ -400,12 +449,12 @@ def get_sigma_rule_by_text(rule_text, sigma_config=None):
         else:
             sigma_conf_obj = get_sigma_config_file()
     except ValueError as e:
-        logger.error(
-            'Problem reading the Sigma config', exc_info=True)
+        logger.error('Problem reading the Sigma config', exc_info=True)
         raise ValueError('Problem reading the Sigma config') from e
 
     sigma_backend = sigma_es.ElasticsearchQuerystringBackend(
-        sigma_conf_obj, {})
+        sigma_conf_obj, {}
+    )
 
     rule_return = {}
     # TODO check if input validation is needed / useful.
@@ -415,36 +464,29 @@ def get_sigma_rule_by_text(rule_text, sigma_config=None):
         for doc in rule_yaml_data:
 
             parser = sigma_collection.SigmaCollectionParser(
-                str(doc), sigma_conf_obj, None)
+                str(doc), sigma_conf_obj, None
+            )
             parsed_sigma_rules = parser.generate(sigma_backend)
             rule_return.update(doc)
 
     except NotImplementedError as exception:
-        logger.error(
-            'Error generating rule {0!s}'.format(exception))
+        logger.error('Error generating rule {0!s}'.format(exception))
         raise
 
     except sigma_exceptions.SigmaParseError as exception:
-        logger.error(
-            'Sigma parsing error generating rule {0!s}'
-            .format(exception))
+        logger.error('Sigma parsing error rule {0!s}'.format(exception))
         raise
 
     except yaml.parser.ParserError as exception:
-        logger.error(
-            'Yaml parsing error generating rule {0!s}'.format(exception))
+        logger.error('Yaml parsing error rule {0!s}'.format(exception))
         raise
 
     sigma_es_query = ''
 
     for sigma_rule in parsed_sigma_rules:
-        sigma_es_query = _sanatize_sigma_rule(sigma_rule)
-
-    rule_return.update(
-        {'es_query': sigma_es_query})
-    rule_return.update(
-        {'file_name': 'N/A'})
-    rule_return.update(
-        {'file_relpath': 'N/A'})
-    logger.error(rule_return)
+        sigma_es_query = _sanitize_query(sigma_rule)
+
+    rule_return.update({'es_query': sigma_es_query})
+    rule_return.update({'file_name': 'N/A'})
+    rule_return.update({'file_relpath': 'N/A'})
     return rule_return


Test patch:
diff --git a/end_to_end_tests/client_test.py b/end_to_end_tests/client_test.py
--- a/end_to_end_tests/client_test.py
+++ b/end_to_end_tests/client_test.py
@@ -38,11 +38,11 @@ def test_client(self):
         sketch_name = 'Testing'
         sketch_description = 'This is truly a foobar'
         new_sketch = self.api.create_sketch(
-            name=sketch_name, description=sketch_description)
+            name=sketch_name, description=sketch_description
+        )
 
         self.assertions.assertEqual(new_sketch.name, sketch_name)
-        self.assertions.assertEqual(
-            new_sketch.description, sketch_description)
+        self.assertions.assertEqual(new_sketch.description, sketch_description)
 
         sketches = list(self.api.list_sketches())
         self.assertions.assertEqual(len(sketches), number_of_sketches + 1)
@@ -57,16 +57,21 @@ def test_direct_opensearch(self):
         index_name = 'direct_testing'
 
         self.import_directly_to_opensearch(
-            filename='evtx_direct.csv', index_name=index_name)
+            filename='evtx_direct.csv', index_name=index_name
+        )
 
         new_sketch = self.api.create_sketch(
-            name='Testing Direct', description='Adding data directly from ES')
+            name='Testing Direct', description='Adding data directly from ES'
+        )
 
         context = 'e2e - > test_direct_opensearch'
         timeline_name = 'Ingested Via Mechanism'
         timeline = new_sketch.generate_timeline_from_es_index(
-            es_index_name=index_name, name=timeline_name,
-            provider='end_to_end_testing_platform', context=context)
+            es_index_name=index_name,
+            name=timeline_name,
+            provider='end_to_end_testing_platform',
+            context=context,
+        )
 
         _ = new_sketch.lazyload_data(refresh_cache=True)
         self.assertions.assertEqual(len(new_sketch.list_timelines()), 1)
@@ -87,10 +92,13 @@ def test_sigma_list(self):
         self.assertions.assertIn('Installation of ZMap', rule.title)
         self.assertions.assertIn('zmap', rule.es_query)
         self.assertions.assertIn('Alexander', rule.author)
-        self.assertions.assertIn('2020/06/26',rule.date)
+        self.assertions.assertIn('2020/06/26', rule.date)
         self.assertions.assertIn('installation of ZMap', rule.description)
         self.assertions.assertEqual(len(rule.detection), 2)
-        self.assertions.assertIn('zmap*', rule.es_query)
+        self.assertions.assertEqual(
+            '(data_type:("shell:zsh:history" OR "bash:history:command" OR "apt:history:line" OR "selinux:line") AND "apt-get install zmap")',  # pylint: disable=line-too-long
+            rule.es_query,
+        )
         self.assertions.assertIn('shell:zsh:history', rule.es_query)
         self.assertions.assertIn('Unknown', rule.falsepositives[0])
         self.assertions.assertEqual(len(rule.logsource), 2)
@@ -103,9 +111,10 @@ def test_sigma_list(self):
     def test_get_sigma_rule(self):
         """Client Sigma object tests."""
         rule = self.api.get_sigma_rule(
-            rule_uuid='5266a592-b793-11ea-b3de-0242ac130004')
+            rule_uuid='5266a592-b793-11ea-b3de-0242ac130004'
+        )
         rule.from_rule_uuid('5266a592-b793-11ea-b3de-0242ac130004')
-        self.assertions.assertGreater(len(rule.attributes),5)
+        self.assertions.assertGreater(len(rule.attributes), 5)
         self.assertions.assertIsNotNone(rule)
         self.assertions.assertIn('Alexander', rule.author)
         self.assertions.assertIn('Alexander', rule.get_attribute('author'))
diff --git a/test_tools/test_events/sigma_events.jsonl b/test_tools/test_events/sigma_events.jsonl
--- a/test_tools/test_events/sigma_events.jsonl
+++ b/test_tools/test_events/sigma_events.jsonl
@@ -1,4 +1,5 @@
 {"message": "A message","timestamp": 123456789,"datetime": "2015-07-24T19:01:01+00:00","timestamp_desc": "Write time","extra_field_1": "foo"}
 {"message": "Another message","timestamp": 123456790,"datetime": "2015-07-24T19:01:02+00:00","timestamp_desc": "Write time","extra_field_1": "bar"}
 {"message": "Yet more messages","timestamp": 123456791,"datetime": "2015-07-24T19:01:03+00:00","timestamp_desc": "Write time","extra_field_1": "baz"}
-{"message": "Install: zmap:amd64 (1.1.0-1) [Commandline: apt-get install zmap]","timestamp": 123456791,"datetime": "2015-07-24T19:01:03+00:00","timestamp_desc": "foo","command":"Commandline: apt-get install zmap","data_type":"apt:history:line","display_name":"GZIP:/var/log/apt/history.log.1.gz","filename":"/var/log/apt/history.log.1.gz","packages":"Install: zmap:amd64 (1.1.0-1)","parser":"apt_history"}
\ No newline at end of file
+{"message": "Install: zmap:amd64 (1.1.0-1) [Commandline: apt-get install zmap]","timestamp": 123456791,"datetime": "2015-07-24T19:01:03+00:00","timestamp_desc": "foo","command":"Commandline: apt-get install zmap","data_type":"apt:history:line","display_name":"GZIP:/var/log/apt/history.log.1.gz","filename":"/var/log/apt/history.log.1.gz","packages":"Install: zmap:amd64 (1.1.0-1)","parser":"apt_history"}
+{"message": "[11 / 0x000b] Source Name: Microsoft-Windows-Sysmon Strings: ['DLL', '2022-01-22 23:07:43.492', '{C784477D-8DE8-61EC-AAAA-000000003C00}', '7812', 'C:\\Windows\\tifubjdl\\lysjbpb.exe', 'C:\\Windows\\itfnduuui\\Corporate\\mimilib.dll', '2022-01-22 23:07:43.492'] Computer Name: DESKTOP-B0TAAAA Record Number: 913 Event Level: 4","computer_name":"DESKTOP-B0TAAAA","data_type":"windows:evtx:record","datetime":"2022-01-22T23:07:43.502205+00:00","display_name":"OS:/data/input/Microsoft-Windows-Sysmon%4Operational.evtx","event_identifier":"11","event_level":"4","message_identifier":"11","parser":"winevtx","source_name":"Microsoft-Windows-Sysmon","timestamp":"1642892863502205","timestamp_desc":"Creation Time" }
\ No newline at end of file
diff --git a/timesketch/api/v1/resources_test.py b/timesketch/api/v1/resources_test.py
--- a/timesketch/api/v1/resources_test.py
+++ b/timesketch/api/v1/resources_test.py
@@ -33,14 +33,18 @@ class ResourceMixinTest(BaseTest):
     def test_to_json_empty_list(self):
         """Behavior of to_json when given an empty list."""
         response = ResourceMixin().to_json([])
-        self.assertEqual(response.json, {
-            'meta': {},
-            'objects': [],
-        })
+        self.assertEqual(
+            response.json,
+            {
+                'meta': {},
+                'objects': [],
+            },
+        )
 
 
 class SketchListResourceTest(BaseTest):
     """Test SketchListResource."""
+
     resource_url = '/api/v1/sketches/'
 
     def test_sketch_list_resource(self):
@@ -59,16 +63,19 @@ def test_sketch_post_resource(self):
         response = self.client.post(
             self.resource_url,
             data=json.dumps(data, ensure_ascii=False),
-            content_type='application/json')
+            content_type='application/json',
+        )
         self.assertEqual(response.status_code, HTTP_STATUS_CODE_CREATED)
 
 
 class SketchResourceTest(BaseTest):
     """Test SketchResource."""
+
     resource_url = '/api/v1/sketches/1/'
 
     @mock.patch(
-        'timesketch.api.v1.resources.OpenSearchDataStore', MockDataStore)
+        'timesketch.api.v1.resources.OpenSearchDataStore', MockDataStore
+    )
     def test_sketch_resource(self):
         """Authenticated request to get a sketch."""
         self.login()
@@ -91,6 +98,7 @@ def test_sketch_acl(self):
 
 class ViewListResourceTest(BaseTest):
     """Test ViewListResource."""
+
     resource_url = '/api/v1/sketches/1/views/'
 
     def test_post_view_list_resource(self):
@@ -101,23 +109,28 @@ def test_post_view_list_resource(self):
             new_searchtemplate=False,
             query='test',
             filter={},
-            dsl={})
+            dsl={},
+        )
         response = self.client.post(
             self.resource_url,
             data=json.dumps(data, ensure_ascii=False),
-            content_type='application/json')
+            content_type='application/json',
+        )
         data['from_searchtemplate_id'] = 1
         response_with_searchtemplate = self.client.post(
             self.resource_url,
             data=json.dumps(data, ensure_ascii=False),
-            content_type='application/json')
+            content_type='application/json',
+        )
         self.assertEqual(response.status_code, HTTP_STATUS_CODE_CREATED)
-        self.assertEqual(response_with_searchtemplate.status_code,
-                         HTTP_STATUS_CODE_CREATED)
+        self.assertEqual(
+            response_with_searchtemplate.status_code, HTTP_STATUS_CODE_CREATED
+        )
 
 
 class ViewResourceTest(BaseTest):
     """Test ViewResource."""
+
     resource_url = '/api/v1/sketches/1/views/1/'
 
     def test_view_resource(self):
@@ -135,7 +148,8 @@ def test_post_view_resource(self):
         response = self.client.post(
             self.resource_url,
             data=json.dumps(data, ensure_ascii=False),
-            content_type='application/json')
+            content_type='application/json',
+        )
         self.assertEqual(response.status_code, HTTP_STATUS_CODE_CREATED)
 
     def test_invalid_user_in_view(self):
@@ -153,6 +167,7 @@ def test_invalid_view(self):
 
 class SearchTemplateResourceTest(BaseTest):
     """Test Search template resource."""
+
     resource_url = '/api/v1/searchtemplate/1/'
 
     def test_searchtemplate_resource(self):
@@ -172,6 +187,7 @@ def test_invalid_searchtemplate(self):
 
 class ExploreResourceTest(BaseTest):
     """Test ExploreResource."""
+
     resource_url = '/api/v1/sketches/1/explore/'
     expected_response = {
         'meta': {
@@ -193,29 +209,32 @@ class ExploreResourceTest(BaseTest):
                 'query_dsl': None,
                 'query_filter': '{}',
                 'query_result_count': 0,
-                'query_string': 'test'
-            }
-        },
-        'objects': [{
-            'sort': [1410593223000],
-            '_type': 'plaso_event',
-            '_source': {
-                'timestamp': 1410593222543942,
-                'message': 'Test event',
-                'label': ['__ts_star'],
-                'timestamp_desc': 'Content Modification Time',
-                'datetime': '2014-09-13T07:27:03+00:00',
-                '__ts_timeline_id': 1,
+                'query_string': 'test',
             },
-            '_score': 'null',
-            'selected': False,
-            '_index': 'test',
-            '_id': 'test'
-        }]
+        },
+        'objects': [
+            {
+                'sort': [1410593223000],
+                '_type': 'plaso_event',
+                '_source': {
+                    'timestamp': 1410593222543942,
+                    'message': 'Test event',
+                    'label': ['__ts_star'],
+                    'timestamp_desc': 'Content Modification Time',
+                    'datetime': '2014-09-13T07:27:03+00:00',
+                    '__ts_timeline_id': 1,
+                },
+                '_score': 'null',
+                'selected': False,
+                '_index': 'test',
+                '_id': 'test',
+            }
+        ],
     }
 
-    @mock.patch('timesketch.api.v1.resources.OpenSearchDataStore',
-                MockDataStore)
+    @mock.patch(
+        'timesketch.api.v1.resources.OpenSearchDataStore', MockDataStore
+    )
     def test_search(self):
         """Authenticated request to query the datastore."""
         self.login()
@@ -223,7 +242,8 @@ def test_search(self):
         response = self.client.post(
             self.resource_url,
             data=json.dumps(data, ensure_ascii=False),
-            content_type='application/json')
+            content_type='application/json',
+        )
         response_json = response.json
         # Remove flaky properties (dynamically generated)
         del response_json['meta']['search_node']['created_at']
@@ -237,8 +257,9 @@ class AggregationExploreResourceTest(BaseTest):
 
     resource_url = '/api/v1/sketches/1/aggregation/explore/'
 
-    @mock.patch('timesketch.api.v1.resources.OpenSearchDataStore',
-                MockDataStore)
+    @mock.patch(
+        'timesketch.api.v1.resources.OpenSearchDataStore', MockDataStore
+    )
     def test_heatmap_aggregation(self):
         """Authenticated request to get aggregation requests."""
         self.login()
@@ -246,12 +267,14 @@ def test_heatmap_aggregation(self):
         response = self.client.post(
             self.resource_url,
             data=json.dumps(data, ensure_ascii=False),
-            content_type='application/json')
+            content_type='application/json',
+        )
         self.assert200(response)
 
 
 class EventResourceTest(BaseTest):
     """Test EventResource."""
+
     resource_url = '/api/v1/sketches/1/event/'
     expected_response = {
         'objects': {
@@ -268,18 +291,21 @@ class EventResourceTest(BaseTest):
         }
     }
 
-    @mock.patch('timesketch.api.v1.resources.OpenSearchDataStore',
-                MockDataStore)
+    @mock.patch(
+        'timesketch.api.v1.resources.OpenSearchDataStore', MockDataStore
+    )
     def test_get_event(self):
         """Authenticated request to get an event from the datastore."""
         self.login()
-        response = self.client.get(self.resource_url +
-                                   '?searchindex_id=test&event_id=test')
+        response = self.client.get(
+            self.resource_url + '?searchindex_id=test&event_id=test'
+        )
         self.assertDictContainsSubset(self.expected_response, response.json)
         self.assert200(response)
 
-    @mock.patch('timesketch.api.v1.resources.OpenSearchDataStore',
-                MockDataStore)
+    @mock.patch(
+        'timesketch.api.v1.resources.OpenSearchDataStore', MockDataStore
+    )
     def test_invalid_index(self):
         """
         Authenticated request to get an event from the datastore, but in the
@@ -287,33 +313,34 @@ def test_invalid_index(self):
         """
         self.login()
         response_400 = self.client.get(
-            self.resource_url + '?searchindex_id=wrong_index&event_id=test')
+            self.resource_url + '?searchindex_id=wrong_index&event_id=test'
+        )
         self.assert400(response_400)
 
 
 class EventAnnotationResourceTest(BaseTest):
     """Test EventAnnotationResource."""
+
     resource_url = '/api/v1/sketches/1/event/annotate/'
 
-    @mock.patch('timesketch.api.v1.resources.OpenSearchDataStore',
-                MockDataStore)
+    @mock.patch(
+        'timesketch.api.v1.resources.OpenSearchDataStore', MockDataStore
+    )
     def test_post_annotate_resource(self):
         """Authenticated request to create an annotation."""
         self.login()
         for annotation_type in ['comment', 'label']:
-            event = {
-                '_type': 'test_event',
-                '_index': 'test',
-                '_id': 'test'
-            }
+            event = {'_type': 'test_event', '_index': 'test', '_id': 'test'}
             data = dict(
                 annotation='test',
                 annotation_type=annotation_type,
-                events=[event])
+                events=[event],
+            )
             response = self.client.post(
                 self.resource_url,
                 data=json.dumps(data),
-                content_type='application/json')
+                content_type='application/json',
+            )
             self.assertIsInstance(response.json, dict)
             self.assertEqual(response.status_code, HTTP_STATUS_CODE_CREATED)
 
@@ -326,35 +353,42 @@ def test_post_annotate_invalid_index_resource(self):
             annotation='test',
             annotation_type='comment',
             event_id='test',
-            searchindex_id='invalid_searchindex')
+            searchindex_id='invalid_searchindex',
+        )
         response = self.client.post(
             self.resource_url,
             data=json.dumps(data),
-            content_type='application/json')
+            content_type='application/json',
+        )
         self.assertEqual(response.status_code, HTTP_STATUS_CODE_BAD_REQUEST)
 
 
 class SearchIndexResourceTest(BaseTest):
     """Test SearchIndexResource."""
+
     resource_url = '/api/v1/searchindices/'
 
-    @mock.patch('timesketch.api.v1.resources.OpenSearchDataStore',
-                MockDataStore)
+    @mock.patch(
+        'timesketch.api.v1.resources.OpenSearchDataStore', MockDataStore
+    )
     def test_post_create_searchindex(self):
         """Authenticated request to create a searchindex."""
         self.login()
         data = dict(
-            searchindex_name='test3', es_index_name='test3', public=False)
+            searchindex_name='test3', es_index_name='test3', public=False
+        )
         response = self.client.post(
             self.resource_url,
             data=json.dumps(data),
-            content_type='application/json')
+            content_type='application/json',
+        )
         self.assertIsInstance(response.json, dict)
         self.assertEqual(response.status_code, HTTP_STATUS_CODE_CREATED)
 
 
 class TimelineListResourceTest(BaseTest):
     """Test TimelineList resource."""
+
     resource_url = '/api/v1/sketches/1/timelines/'
 
     def test_add_existing_timeline_resource(self):
@@ -364,7 +398,8 @@ def test_add_existing_timeline_resource(self):
         response = self.client.post(
             self.resource_url,
             data=json.dumps(data, ensure_ascii=False),
-            content_type='application/json')
+            content_type='application/json',
+        )
         self.assertEqual(response.status_code, HTTP_STATUS_CODE_OK)
 
     def test_add_new_timeline_resource(self):
@@ -374,21 +409,21 @@ def test_add_new_timeline_resource(self):
         response = self.client.post(
             self.resource_url,
             data=json.dumps(data, ensure_ascii=False),
-            content_type='application/json')
+            content_type='application/json',
+        )
         self.assertEqual(response.status_code, HTTP_STATUS_CODE_CREATED)
 
 
 class SigmaResourceTest(BaseTest):
     """Test Sigma resource."""
+
     resource_url = '/api/v1/sigma/rule/'
     expected_response = {
         'objects': {
             'description': 'Detects suspicious installation of ZMap',
             'id': '5266a592-b793-11ea-b3de-0242ac130004',
             'level': 'high',
-            'logsource': {
-                'product': 'linux', 'service': 'shell'
-                },
+            'logsource': {'product': 'linux', 'service': 'shell'},
             'title': 'Suspicious Installation of ZMap',
         }
     }
@@ -396,50 +431,48 @@ class SigmaResourceTest(BaseTest):
     def test_get_sigma_rule(self):
         """Authenticated request to get an sigma rule."""
         self.login()
-        response = self.client.get(self.resource_url +
-                                   '5266a592-b793-11ea-b3de-0242ac130004')
+        response = self.client.get(
+            self.resource_url + '5266a592-b793-11ea-b3de-0242ac130004'
+        )
         self.assertIsNotNone(response)
 
 
 class SigmaListResourceTest(BaseTest):
     """Test Sigma resource."""
+
     resource_url = '/api/v1/sigma/'
     expected_response = {
-        'meta': {
-            'current_user': 'test1', 'rules_count': 1
-            },
-        'objects':[{
-            'author': 'Alexander Jaeger',
-            'date': '2020/06/26',
-            'description': 'Detects suspicious installation of ZMap',
-            'detection': {
-                'condition': 'keywords',
-                'keywords': [
-                    '*apt-get install zmap*'
-                    ]
+        'meta': {'current_user': 'test1', 'rules_count': 1},
+        'objects': [
+            {
+                'author': 'Alexander Jaeger',
+                'date': '2020/06/26',
+                'description': 'Detects suspicious installation of ZMap',
+                'detection': {
+                    'condition': 'keywords',
+                    'keywords': ['*apt-get install zmap*'],
                 },
-            'es_query':
-                '(data_type:("shell:zsh:history" OR '\
-                '"bash:history:command" OR '\
-                '"apt:history:line" OR '\
-                '"selinux:line") AND '\
-                '"*apt-get install zmap*")',
-            'falsepositives': ['Unknown'],
-            'file_name': 'lnx_susp_zmap.yml',
-            'file_relpath': 'lnx_susp_zmap.yml',
-            'id': '5266a592-b793-11ea-b3de-0242ac130004',
-            'level': 'high',
-            'logsource': {
-                'product': 'linux',
-                'service': 'shell'
-            },
-            'modified': '2020/06/26',
-            'tags': ['attack.discovery', 'attack.t1046'],
-            'references': [
-                'https://rmusser.net/docs/ATT&CK-Stuff/ATT&CK/Discovery.html'
-            ],
-            'title': 'Suspicious Installation of ZMap'
-        }]}
+                'es_query': '(data_type:("shell:zsh:history" OR '
+                '"bash:history:command" OR '
+                '"apt:history:line" OR '
+                '"selinux:line") AND '
+                '"apt-get install zmap")',
+                'falsepositives': ['Unknown'],
+                'file_name': 'lnx_susp_zmap.yml',
+                'file_relpath': 'lnx_susp_zmap.yml',
+                'id': '5266a592-b793-11ea-b3de-0242ac130004',
+                'level': 'high',
+                'logsource': {'product': 'linux', 'service': 'shell'},
+                'modified': '2020/06/26',
+                'tags': ['attack.discovery', 'attack.t1046'],
+                'references': [
+                    'https://rmusser.net/docs/ATT&CK-Stuff/ATT&CK/Discovery.html'  # pylint: disable=line-too-long
+                ],
+                'title': 'Suspicious Installation of ZMap',
+            }
+        ],
+    }
+
     def test_get_sigma_rule_list(self):
         self.login()
         response = self.client.get(self.resource_url)
@@ -448,6 +481,7 @@ def test_get_sigma_rule_list(self):
         self.assertDictContainsSubset(self.expected_response, response.json)
         self.assertIsNotNone(response)
 
+
 class SigmaByTextResourceTest(BaseTest):
     """Test Sigma by text resource."""
 
@@ -477,10 +511,8 @@ class SigmaByTextResourceTest(BaseTest):
         level: high
         '''
     expected_response = {
-        'meta': {
-            'parsed': True
-        },
-        'objects':[
+        'meta': {'parsed': True},
+        'objects': [
             {
                 'title': 'Installation of foobar',
                 'id': 'bb1e0d1d-cd13-4b65-bf7e-69b4e740266b',
@@ -490,22 +522,18 @@ class SigmaByTextResourceTest(BaseTest):
                 'date': '2020/12/10',
                 'modified': '2020/12/10',
                 'tags': ['attack.discovery', 'attack.t1046'],
-                'logsource': {
-                    'product': 'linux',
-                    'service': 'shell'
-                },
+                'logsource': {'product': 'linux', 'service': 'shell'},
                 'detection': {
                     'keywords': ['*apt-get install foobar*'],
-                    'condition': 'keywords'
+                    'condition': 'keywords',
                 },
                 'falsepositives': ['Unknown'],
                 'level': 'high',
-                'es_query':
-                    '(data_type:("shell:zsh:history" OR "bash:history:command" OR "apt:history:line" OR "selinux:line") AND "*apt-get install foobar*")',# pylint: disable=line-too-long
+                'es_query': '(data_type:("shell:zsh:history" OR "bash:history:command" OR "apt:history:line" OR "selinux:line") AND "apt-get install foobar")',  # pylint: disable=line-too-long
                 'file_name': 'N/A',
-                'file_relpath': 'N/A'
+                'file_relpath': 'N/A',
             }
-        ]
+        ],
     }
 
     def test_get_sigma_rule(self):
@@ -516,7 +544,8 @@ def test_get_sigma_rule(self):
         response = self.client.post(
             self.resource_url,
             data=json.dumps(data, ensure_ascii=False),
-            content_type='application/json')
+            content_type='application/json',
+        )
         self.assertIsNotNone(response)
         self.assertEqual(response.status_code, HTTP_STATUS_CODE_OK)
         self.assertDictContainsSubset(self.expected_response, response.json)
@@ -527,7 +556,8 @@ def test_get_sigma_rule(self):
         response = self.client.post(
             self.resource_url,
             data=json.dumps(data, ensure_ascii=False),
-            content_type='application/json')
+            content_type='application/json',
+        )
         data = json.loads(response.get_data(as_text=True))
 
         self.assertIn('No detection definitions found', data['message'])
@@ -538,7 +568,8 @@ def test_get_sigma_rule(self):
         response = self.client.post(
             self.resource_url,
             data=json.dumps(data, ensure_ascii=False),
-            content_type='application/json')
+            content_type='application/json',
+        )
         data = json.loads(response.get_data(as_text=True))
 
         self.assertIn('Missing values from the request', data['message'])
diff --git a/timesketch/lib/sigma_util_test.py b/timesketch/lib/sigma_util_test.py
--- a/timesketch/lib/sigma_util_test.py
+++ b/timesketch/lib/sigma_util_test.py
@@ -47,9 +47,36 @@
 level: high
 """
 
+MOCK_SIGMA_RULE_2 = """
+title: This rule is full of test edge cases
+description: Various edge cases in a rule
+references:
+    - https://github.com/google/timesketch/issues/2007
+author: Alexander Jaeger
+date: 2021/12/03
+modified: 2021/12/03
+detection:
+    keywords:
+        - 'Whitespace at'
+        - ' beginning '
+        - ' and extra text '
+    condition: keywords
+falsepositives:
+    - Unknown
+level: high
+"""
+
+MOCK_SIGMA_RULE_3 = """
+logsource:
+    product: windows
+detection:
+    keywords:
+        - ' lorem '
+    condition: keywords
+"""
+
 MOCK_SIGMA_RULE_ERROR1 = """
 title: Suspicious Foobar
-id: 5266a592-b793-11ea-b3de-0242ac130004
 description: Detects suspicious installation of zmap
 references:
     - https://rmusser.net/docs/ATT&CK-Stuff/ATT&CK/Discovery.html
@@ -58,6 +85,19 @@
 modified: 2020/06/26
 """
 
+
+COUNT_RULE_1 = """
+detection:
+  selection:
+    action: failure
+  timeframe: 10s
+  condition: selection | count(category) by foo > 30
+fields:
+  - foo
+  - bar
+  - user
+"""
+
 MOCK_SIGMA_RULE_DATE_ERROR1 = """
 title: Wrong dateformat
 id: 67b9a11a-03ae-490a-9156-9be9900f86b0
@@ -73,10 +113,55 @@
     condition: keywords
 """
 
+MOCK_SIGMA_RULE_DOTS = """
+title: Two dots
+id: 67b9a11a-03ae-490a-9156-9be9900aaaaa
+description: Similar to a mimikatz rule
+references:
+    - https://github.com/google/timesketch/issues/2007
+author: Alexander Jaeger
+date: 2022-01-25
+modified: 2022-01-25
+detection:
+    keywords:
+        - 'aaa:bbb'
+        - 'ccc::ddd'
+    condition: keywords
+"""
+
 
 class TestSigmaUtilLib(BaseTest):
     """Tests for the sigma support library."""
 
+    def test_sanitize_rule_string(self):
+        """Testing the string sanitization"""
+
+        # pylint: disable=protected-access
+        test_1 = sigma_util._sanitize_query("(* lorem * OR * lorema *)")
+        self.assertIsNotNone(test_1)
+
+        self.assertEqual(
+            sigma_util._sanitize_query("test.keyword:foobar"),
+            "test:foobar",
+        )
+        self.assertEqual(
+            sigma_util._sanitize_query("(* foobar *)"),
+            '(" foobar ")',
+        )
+        self.assertEqual(sigma_util._sanitize_query("*foo bar*"), '"foo bar"')
+
+        # test that the function does not break regular queries
+        self.assertEqual(
+            sigma_util._sanitize_query(
+                "*mimikatz* OR *mimikatz.exe* OR *mimilib.dll*"
+            ),
+            '*mimikatz* OR *mimikatz.exe* OR *mimilib.dll*',
+        )
+
+        test_2 = sigma_util._sanitize_query("(*a:b* OR *c::d*)")
+        self.assertEqual(test_2, r'("a:b" OR "c\:\:d")')
+        # pylint: enable=protected-access
+
     def test_get_rule_by_text(self):
         """Test getting sigma rule by text."""
 
@@ -85,15 +170,37 @@ def test_get_rule_by_text(self):
         self.assertIsNotNone(MOCK_SIGMA_RULE)
         self.assertIsNotNone(rule)
         self.assertIn("zmap", rule.get("es_query"))
-        self.assertIn("b793", rule.get("id"))
-        self.assertRaises(
-            sigma_exceptions.SigmaParseError,
-            sigma_util.get_sigma_rule_by_text,
-            MOCK_SIGMA_RULE_ERROR1,
+        self.assertEqual(
+            '(data_type:("shell:zsh:history" OR "bash:history:command" OR "apt:history:line" OR "selinux:line") AND "apt-get install zmap")',  # pylint: disable=line-too-long
+            rule.get("es_query"),
         )
+        self.assertIn("b793", rule.get('id'))
+
+        with self.assertRaises(sigma_exceptions.SigmaParseError):
+            sigma_util.get_sigma_rule_by_text(MOCK_SIGMA_RULE_ERROR1)
+
         self.assertIn("2020/06/26", rule.get("date"))
         self.assertIsInstance(rule.get("date"), str)
 
+        rule = sigma_util.get_sigma_rule_by_text(MOCK_SIGMA_RULE_2)
+
+        self.assertIsNotNone(rule)
+        self.assertEqual(
+            '("Whitespace at" OR " beginning " OR " and extra text ")',
+            rule.get("es_query"),
+        )
+
+        rule = sigma_util.get_sigma_rule_by_text(MOCK_SIGMA_RULE_3)
+
+        self.assertIsNotNone(rule)
+        self.assertEqual(
+            '(data_type:"windows:evtx:record" AND " lorem ")',
+            rule.get("es_query"),
+        )
+
+        with self.assertRaises(NotImplementedError):
+            sigma_util.get_sigma_rule_by_text(COUNT_RULE_1)
+
         rule = sigma_util.get_sigma_rule_by_text(MOCK_SIGMA_RULE_DATE_ERROR1)
         self.assertIsNotNone(MOCK_SIGMA_RULE_DATE_ERROR1)
         # it is actually: 'date': datetime.date(2022, 1, 10)
@@ -101,9 +208,21 @@ def test_get_rule_by_text(self):
         self.assertIsNot("2022-01-10", rule.get("date"))
         self.assertIn("dd23d2323432", rule.get("modified"))
 
+        rule = sigma_util.get_sigma_rule_by_text(MOCK_SIGMA_RULE_DOTS)
+        self.assertIsNotNone(MOCK_SIGMA_RULE_DOTS)
+        self.assertIsNotNone(rule)
+        self.assertEqual(
+            '67b9a11a-03ae-490a-9156-9be9900aaaaa', rule.get('id')
+        )
+        self.assertEqual(
+            r'("aaa:bbb" OR "ccc\:\:ddd")',
+            rule.get("es_query"),
+        )
+
     def test_get_sigma_config_file(self):
         """Test getting sigma config file"""
-        self.assertRaises(ValueError, sigma_util.get_sigma_config_file, "/foo")
+        with self.assertRaises(ValueError):
+            sigma_util.get_sigma_config_file("/foo")
         self.assertIsNotNone(sigma_util.get_sigma_config_file())
 
     def test_get_blocklist_file(self):