Repository: google/timesketch

Issue:
GeoIP analyzer
Have an analyzer that automatically tags events with IP addresses in them with GeoIP information. 
GeoIP data can be retrieved by having the user point to their GeoIP DB file / API endpoint. 


Discussion:

Resolving patch:
diff --git a/timesketch/lib/analyzers/__init__.py b/timesketch/lib/analyzers/__init__.py
--- a/timesketch/lib/analyzers/__init__.py
+++ b/timesketch/lib/analyzers/__init__.py
@@ -21,6 +21,7 @@
 from timesketch.lib.analyzers import domain
 from timesketch.lib.analyzers import expert_sessionizers
 from timesketch.lib.analyzers import feature_extraction
+from timesketch.lib.analyzers import geoip
 from timesketch.lib.analyzers import login
 from timesketch.lib.analyzers import phishy_domains
 from timesketch.lib.analyzers import safebrowsing
diff --git a/timesketch/lib/analyzers/geoip.py b/timesketch/lib/analyzers/geoip.py
new file mode 100644
--- /dev/null
+++ b/timesketch/lib/analyzers/geoip.py
@@ -0,0 +1,350 @@
+# Copyright 2021 Google Inc. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Sketch analyzer plugin for geolocating IP addresses."""
+
+import os
+import ipaddress
+import logging
+
+from collections import defaultdict
+from typing import Tuple, Union
+
+from flask import current_app
+
+import geoip2.database
+import geoip2.errors
+import geoip2.webservice
+import maxminddb
+
+from timesketch.lib import emojis
+from timesketch.lib.analyzers import interface
+from timesketch.lib.analyzers import manager
+
+
+logger = logging.getLogger('timesketch.analyzers.geoip')
+
+
+class GeoIpClientAdapter(object):
+    """Base adapter interface for a third party geolocation service."""
+
+    def __enter__(self):
+        """Initialise and open a new a client (self) for performing IP address
+           lookups against a database or service.
+        """
+        raise NotImplementedError
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        """Close and clean up client."""
+        raise NotImplementedError
+
+    def ip2geo(self, ip_address: str) -> Union[Tuple[
+            str, str, str, str, str], None]:
+        """Perform a IP to geolocation lookup.
+
+        Args:
+            ip_address - the IPv4 or IPv6 address to geolocate
+
+        Returns:
+            Either:
+            A tuple comprising of the following in order
+            - iso_code (str) - the ISO 31661 alpha-2 code of the country
+            - latitude (str) - the north-south coordinate
+            - longitude (str) - the east-west coordinate
+            - country_name (str) - the full country name
+            - city (str) - the city name that approximates the location
+            Or None:
+            - when the IP address does not have a resolvable location
+        """
+        raise NotImplementedError
+
+
+class MaxMindGeoDbClient(geoip2.database.Reader, GeoIpClientAdapter):
+    """A GeoIP client using the MaxMind database."""
+
+    def __init__(self):
+        self._geolite_database = current_app.config.get('MAXMIND_DB_PATH')
+        if not self._geolite_database:
+            raise RuntimeError('MaxMind Database configuration not set.')
+        if not os.path.exists(self._geolite_database):
+            raise RuntimeError('MaxMind Database does not exist.')
+        super().__init__(self._geolite_database)
+
+    def __enter__(self):
+        """Initialise and open a new a client (self) for performing IP address
+           lookups against a database or service.
+        """
+        return self
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        """Close and clean up client."""
+        return self.__exit__(exc_type, exc_value, traceback)
+
+    def ip2geo(self, ip_address) -> Union[Tuple[str, str, str, str, str], None]:
+        """Perform a IP to geolocation lookup.
+
+        Args:
+            ip_address - the IPv4 or IPv6 address to geolocate
+
+        Returns:
+            Either:
+            A tuple comprising of the following in order
+            - iso_code (str) - the ISO 31661 alpha-2 code of the country
+            - latitude (str) - the north-south coordinate
+            - longitude (str) - the east-west coordinate
+            - country_name (str) - the full country name
+            - city (str) - the city name that approximates the location
+            Or None:
+            - when the IP address does not have a resolvable location
+        """
+        try:
+            response = self.city(ip_address)
+        except geoip2.errors.AddressNotFoundError:
+            logging.debug('IP address {0} not found.'.format(ip_address))
+            return None
+        except maxminddb.InvalidDatabaseError as error:
+            logging.error('Error while geolocating {0} - {1}'.format(
+                ip_address, error))
+            return None
+
+        latitude = response.location.latitude
+        longitude = response.location.longitude
+        country_name = response.country.name
+        iso_code = response.country.iso_code
+        city = response.city.name
+
+        return (iso_code, latitude, longitude, country_name, city)
+
+
+class MaxMindGeoWebClient(geoip2.webservice.Client, GeoIpClientAdapter):
+    """A GeoIP client using the MaxMind web service api."""
+
+    def __init__(self):
+        self._account_id = current_app.config.get('MAXMIND_WEB_ACCOUNT_ID')
+        self._license_key = current_app.config.get('MAXMIND_WEB_LICENSE_KEY')
+        self._host = current_app.config.get('MAXMIND_WEB_HOST')
+        if not self._account_id:
+            raise RuntimeError('MaxMind Account ID not set.')
+        if not self._license_key:
+            raise RuntimeError('MaxMind License key not set.')
+        if not self._host:
+            raise RuntimeError('MaxMind host not set.')
+        super().__init__(self._account_id, self._license_key, host=self._host)
+
+    def __enter__(self):
+        """Initialise and open a new a client (self) for performing IP address
+           lookups against a database or service.
+        """
+        return self
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        """Close and clean up client."""
+        return self.__exit__(exc_type, exc_value, traceback)
+
+    def ip2geo(self, ip_address) -> Union[Tuple[str, str, str, str, str], None]:
+        """Perform a IP to geolocation lookup.
+
+        Args:
+            ip_address - the IPv4 or IPv6 address to geolocate
+
+        Returns:
+            Either:
+            A tuple comprising of the following in order
+            - iso_code (str) - the ISO 31661 alpha-2 code of the country
+            - latitude (str) - the north-south coordinate
+            - longitude (str) - the east-west coordinate
+            - country_name (str) - the full country name
+            - city (str) - the city name that approximates the location
+            Or None:
+            - when the IP address does not have a resolvable location
+        """
+        try:
+            response = self.city(ip_address)
+        except geoip2.errors.AddressNotFoundError:
+            logging.debug('IP address {0} not found.'.format(ip_address))
+            return None
+        except geoip2.errors.GeoIP2Error as error:
+            logging.error('Error while geolocating {0} - {1}'.format(
+                ip_address, error))
+            return None
+
+        latitude = response.location.latitude
+        longitude = response.location.longitude
+        country_name = response.country.name
+        iso_code = response.country.iso_code
+        city = response.city.name
+
+        return (iso_code, latitude, longitude, country_name, city)
+
+
+class BaseGeoIpAnalyzer(interface.BaseAnalyzer):
+    """Sketch analyzer for geolocating IP addresses.
+
+    Concrete plugin implementations should define the following attributes
+    - NAME (str)
+    - DISPLAY_NAME (str)
+    - DESCRIPTION (str)
+    - GEOIP_CLIENT (concrete subclass of GeoIpClientAdapter).
+    """
+
+    NAME = ""
+    DISPLAY_NAME = ""
+    DESCRIPTION = ""
+
+    GEOIP_CLIENT: type = None
+
+    DEPENDENCIES = frozenset(['feature_extraction'])
+    IP_FIELDS = ['ip', 'host_ip', 'src_ip', 'dst_ip', 'source_ip', 'dest_ip',
+                 'ip_address', 'client_ip', 'address', 'saddr', 'daddr',
+                 'requestMetadata_callerIp', 'a_answer']
+
+
+    def __init__(self, index_name, sketch_id, timeline_id=None):
+        """Initialize The Sketch Analyzer.
+
+        Args:
+            index_name: Elasticsearch index name
+            sketch_id: Sketch ID
+            timeline_id: Timeline ID
+        """
+        self.index_name = index_name
+        super().__init__(index_name, sketch_id, timeline_id=timeline_id)
+
+
+    def _validate_ip(self, ip_address):
+        """Validate an IP address for analysis.
+
+        Args:
+            ip_address: the IP address to validate
+
+        Returns:
+            True if given IP address is valid and global
+        """
+        try:
+            ip = ipaddress.ip_address(ip_address)
+            return ip.is_global
+        except ValueError:
+            return False
+
+    def run(self):
+        """Entry point for the analyzer.
+
+        Returns:
+            String with summary of the analyzer result
+        """
+        if self.GEOIP_CLIENT is None:
+            return 'GeoIP Client not configured in analyzer'
+
+        query = f'_exists_:({" OR ".join(self.IP_FIELDS)})'
+
+        return_fields = self.IP_FIELDS.copy()
+
+        events = self.event_stream(
+            query_string=query,
+            return_fields=return_fields
+        )
+
+        ip_addresses = defaultdict(lambda: defaultdict(list))
+
+        for event in events:
+            for ip_address_field in self.IP_FIELDS:
+                ip_address = event.source.get(ip_address_field)
+                if ip_address is None:
+                    continue
+
+                if isinstance(ip_address, str):
+                    ip_address = [ip_address]
+
+                for ip_addr in ip_address:
+                    if not self._validate_ip(ip_addr):
+                        logger.debug(f'Value {0} in {1} not valid.'.format(
+                            ip_addr, ip_address_field))
+                        continue
+                    ip_addresses[ip_addr][ip_address_field].append(event)
+
+        for ip_address, ip_address_fields in ip_addresses.items():
+            with self.GEOIP_CLIENT() as client:  # pylint: disable=E1102
+                response = client.ip2geo(ip_address)
+
+            try:
+                iso_code, latitude, longitude, country_name, city_name = \
+                    response
+            except ValueError:
+                logging.error('GeoIP client must return 5 fields: '
+                              '<iso_code, latitude, longitude, country_name, '
+                              'city_name>. '
+                              ' Number of fields returned: {0:d}'.format(
+                                  len(response)))
+            if response is None:
+                continue
+
+            if len(response) != 5:
+                logging.error('Invalid response from GeoIP client')
+
+            flag_emoji = emojis.get_emoji(f'FLAG_{iso_code}')
+
+            if flag_emoji is None:
+                logger.error(
+                    'Invalid ISO code {0} encountered for IP {1}.'.format(
+                        iso_code, ip_address))
+
+            for ip_address_field, events in ip_address_fields.items():
+                for event in events:
+
+                    new_attributes = {}
+                    if latitude and longitude:
+                        new_attributes[
+                            f'{ip_address_field}_latitude'] = latitude
+                        new_attributes[
+                            f'{ip_address_field}_longitude'] = longitude
+                    if iso_code:
+                        new_attributes[
+                            f'{ip_address_field}_iso_code'] = iso_code
+                    if city_name:
+                        new_attributes[
+                            f'{ip_address_field}_city'] = city_name
+                    event.add_attributes(new_attributes)
+
+                    if flag_emoji:
+                        event.add_emojis([flag_emoji])
+
+                    if country_name:
+                        event.add_tags([country_name])
+                    event.commit()
+
+        return f'Found {len(ip_addresses)} IP address(es).'
+
+
+class MaxMindDbGeoIPAnalyzer(BaseGeoIpAnalyzer):
+
+    GEOIP_CLIENT = MaxMindGeoDbClient
+
+    NAME = 'geo_ip_maxmind_db'
+    DISPLAY_NAME = 'Geolocate IP addresses (MaxMind Database based)'
+    DESCRIPTION = ('Find the approximate geolocation of an IP address using ' +
+                   'a MaxMind GeoLite2 database')
+
+
+class MaxMindDbWebIPAnalyzer(BaseGeoIpAnalyzer):
+
+    GEOIP_CLIENT = MaxMindGeoWebClient
+
+    NAME = 'geo_ip_maxmind_web'
+    DISPLAY_NAME = 'Geolocate IP addresses (MaxMind Web client based)'
+    DESCRIPTION = ('Find the approximate geolocation of an IP address using ' +
+                   'a MaxMind GeoLite2 web client API')
+
+
+manager.AnalysisManager.register_analyzer(MaxMindDbGeoIPAnalyzer)
+manager.AnalysisManager.register_analyzer(MaxMindDbWebIPAnalyzer)
diff --git a/timesketch/lib/emojis.py b/timesketch/lib/emojis.py
--- a/timesketch/lib/emojis.py
+++ b/timesketch/lib/emojis.py
@@ -42,7 +42,266 @@
     'SLEEPING_FACE': emoji('&#x1F634', 'Activity outside of regular hours'),
     'SPARKLES': emoji('&#x2728', 'New entity created'),
     'UNLOCK': emoji('&#x1F513', 'Logoff activity'),
-    'WASTEBASKET': emoji('&#x1F5D1', 'Deletion activity')
+    'WASTEBASKET': emoji('&#x1F5D1', 'Deletion activity'),
+    'FLAG_AC': emoji('&#x1F1E6&#x1F1E8', 'Ascension Island'),
+    'FLAG_AD': emoji('&#x1F1E6&#x1F1E9', 'Andorra'),
+    'FLAG_AE': emoji('&#x1F1E6&#x1F1EA', 'United Arab Emirates'),
+    'FLAG_AF': emoji('&#x1F1E6&#x1F1EB', 'Afghanistan'),
+    'FLAG_AG': emoji('&#x1F1E6&#x1F1EC', 'Antigua & Barbuda'),
+    'FLAG_AI': emoji('&#x1F1E6&#x1F1EE', 'Anguilla'),
+    'FLAG_AL': emoji('&#x1F1E6&#x1F1F1', 'Albania'),
+    'FLAG_AM': emoji('&#x1F1E6&#x1F1F2', 'Armenia'),
+    'FLAG_AO': emoji('&#x1F1E6&#x1F1F4', 'Angola'),
+    'FLAG_AQ': emoji('&#x1F1E6&#x1F1F6', 'Antarctica'),
+    'FLAG_AR': emoji('&#x1F1E6&#x1F1F7', 'Argentina'),
+    'FLAG_AS': emoji('&#x1F1E6&#x1F1F8', 'American Samoa'),
+    'FLAG_AT': emoji('&#x1F1E6&#x1F1F9', 'Austria'),
+    'FLAG_AU': emoji('&#x1F1E6&#x1F1FA', 'Australia'),
+    'FLAG_AW': emoji('&#x1F1E6&#x1F1FC', 'Aruba'),
+    'FLAG_AX': emoji('&#x1F1E6&#x1F1FD', 'Åland Islands'),
+    'FLAG_AZ': emoji('&#x1F1E6&#x1F1FF', 'Azerbaijan'),
+    'FLAG_BA': emoji('&#x1F1E7&#x1F1E6', 'Bosnia & Herzegovina'),
+    'FLAG_BB': emoji('&#x1F1E7&#x1F1E7', 'Barbados'),
+    'FLAG_BD': emoji('&#x1F1E7&#x1F1E9', 'Bangladesh'),
+    'FLAG_BE': emoji('&#x1F1E7&#x1F1EA', 'Belgium'),
+    'FLAG_BF': emoji('&#x1F1E7&#x1F1EB', 'Burkina Faso'),
+    'FLAG_BG': emoji('&#x1F1E7&#x1F1EC', 'Bulgaria'),
+    'FLAG_BH': emoji('&#x1F1E7&#x1F1ED', 'Bahrain'),
+    'FLAG_BI': emoji('&#x1F1E7&#x1F1EE', 'Burundi'),
+    'FLAG_BJ': emoji('&#x1F1E7&#x1F1EF', 'Benin'),
+    'FLAG_BL': emoji('&#x1F1E7&#x1F1F1', 'St. Barthélemy'),
+    'FLAG_BM': emoji('&#x1F1E7&#x1F1F2', 'Bermuda'),
+    'FLAG_BN': emoji('&#x1F1E7&#x1F1F3', 'Brunei'),
+    'FLAG_BO': emoji('&#x1F1E7&#x1F1F4', 'Bolivia'),
+    'FLAG_BQ': emoji('&#x1F1E7&#x1F1F6', 'Caribbean Netherlands'),
+    'FLAG_BR': emoji('&#x1F1E7&#x1F1F7', 'Brazil'),
+    'FLAG_BS': emoji('&#x1F1E7&#x1F1F8', 'Bahamas'),
+    'FLAG_BT': emoji('&#x1F1E7&#x1F1F9', 'Bhutan'),
+    'FLAG_BV': emoji('&#x1F1E7&#x1F1FB', 'Bouvet Island'),
+    'FLAG_BW': emoji('&#x1F1E7&#x1F1FC', 'Botswana'),
+    'FLAG_BY': emoji('&#x1F1E7&#x1F1FE', 'Belarus'),
+    'FLAG_BZ': emoji('&#x1F1E7&#x1F1FF', 'Belize'),
+    'FLAG_CA': emoji('&#x1F1E8&#x1F1E6', 'Canada'),
+    'FLAG_CC': emoji('&#x1F1E8&#x1F1E8', 'Cocos (Keeling) Islands'),
+    'FLAG_CD': emoji('&#x1F1E8&#x1F1E9', 'Congo - Kinshasa'),
+    'FLAG_CF': emoji('&#x1F1E8&#x1F1EB', 'Central African Republic'),
+    'FLAG_CG': emoji('&#x1F1E8&#x1F1EC', 'Congo - Brazzaville'),
+    'FLAG_CH': emoji('&#x1F1E8&#x1F1ED', 'Switzerland'),
+    'FLAG_CI': emoji('&#x1F1E8&#x1F1EE', 'Côte d’Ivoire'),
+    'FLAG_CK': emoji('&#x1F1E8&#x1F1F0', 'Cook Islands'),
+    'FLAG_CL': emoji('&#x1F1E8&#x1F1F1', 'Chile'),
+    'FLAG_CM': emoji('&#x1F1E8&#x1F1F2', 'Cameroon'),
+    'FLAG_CN': emoji('&#x1F1E8&#x1F1F3', 'China'),
+    'FLAG_CO': emoji('&#x1F1E8&#x1F1F4', 'Colombia'),
+    'FLAG_CP': emoji('&#x1F1E8&#x1F1F5', 'Clipperton Island'),
+    'FLAG_CR': emoji('&#x1F1E8&#x1F1F7', 'Costa Rica'),
+    'FLAG_CU': emoji('&#x1F1E8&#x1F1FA', 'Cuba'),
+    'FLAG_CV': emoji('&#x1F1E8&#x1F1FB', 'Cape Verde'),
+    'FLAG_CW': emoji('&#x1F1E8&#x1F1FC', 'Curaçao'),
+    'FLAG_CX': emoji('&#x1F1E8&#x1F1FD', 'Christmas Island'),
+    'FLAG_CY': emoji('&#x1F1E8&#x1F1FE', 'Cyprus'),
+    'FLAG_CZ': emoji('&#x1F1E8&#x1F1FF', 'Czechia'),
+    'FLAG_DE': emoji('&#x1F1E9&#x1F1EA', 'Germany'),
+    'FLAG_DG': emoji('&#x1F1E9&#x1F1EC', 'Diego Garcia'),
+    'FLAG_DJ': emoji('&#x1F1E9&#x1F1EF', 'Djibouti'),
+    'FLAG_DK': emoji('&#x1F1E9&#x1F1F0', 'Denmark'),
+    'FLAG_DM': emoji('&#x1F1E9&#x1F1F2', 'Dominica'),
+    'FLAG_DO': emoji('&#x1F1E9&#x1F1F4', 'Dominican Republic'),
+    'FLAG_DZ': emoji('&#x1F1E9&#x1F1FF', 'Algeria'),
+    'FLAG_EA': emoji('&#x1F1EA&#x1F1E6', 'Ceuta & Melilla'),
+    'FLAG_EC': emoji('&#x1F1EA&#x1F1E8', 'Ecuador'),
+    'FLAG_EE': emoji('&#x1F1EA&#x1F1EA', 'Estonia'),
+    'FLAG_EG': emoji('&#x1F1EA&#x1F1EC', 'Egypt'),
+    'FLAG_EH': emoji('&#x1F1EA&#x1F1ED', 'Western Sahara'),
+    'FLAG_ER': emoji('&#x1F1EA&#x1F1F7', 'Eritrea'),
+    'FLAG_ES': emoji('&#x1F1EA&#x1F1F8', 'Spain'),
+    'FLAG_ET': emoji('&#x1F1EA&#x1F1F9', 'Ethiopia'),
+    'FLAG_EU': emoji('&#x1F1EA&#x1F1FA', 'European Union'),
+    'FLAG_FI': emoji('&#x1F1EB&#x1F1EE', 'Finland'),
+    'FLAG_FJ': emoji('&#x1F1EB&#x1F1EF', 'Fiji'),
+    'FLAG_FK': emoji('&#x1F1EB&#x1F1F0', 'Falkland Islands'),
+    'FLAG_FM': emoji('&#x1F1EB&#x1F1F2', 'Micronesia'),
+    'FLAG_FO': emoji('&#x1F1EB&#x1F1F4', 'Faroe Islands'),
+    'FLAG_FR': emoji('&#x1F1EB&#x1F1F7', 'France'),
+    'FLAG_GA': emoji('&#x1F1EC&#x1F1E6', 'Gabon'),
+    'FLAG_GB': emoji('&#x1F1EC&#x1F1E7', 'United Kingdom'),
+    'FLAG_GD': emoji('&#x1F1EC&#x1F1E9', 'Grenada'),
+    'FLAG_GE': emoji('&#x1F1EC&#x1F1EA', 'Georgia'),
+    'FLAG_GF': emoji('&#x1F1EC&#x1F1EB', 'French Guiana'),
+    'FLAG_GG': emoji('&#x1F1EC&#x1F1EC', 'Guernsey'),
+    'FLAG_GH': emoji('&#x1F1EC&#x1F1ED', 'Ghana'),
+    'FLAG_GI': emoji('&#x1F1EC&#x1F1EE', 'Gibraltar'),
+    'FLAG_GL': emoji('&#x1F1EC&#x1F1F1', 'Greenland'),
+    'FLAG_GM': emoji('&#x1F1EC&#x1F1F2', 'Gambia'),
+    'FLAG_GN': emoji('&#x1F1EC&#x1F1F3', 'Guinea'),
+    'FLAG_GP': emoji('&#x1F1EC&#x1F1F5', 'Guadeloupe'),
+    'FLAG_GQ': emoji('&#x1F1EC&#x1F1F6', 'Equatorial Guinea'),
+    'FLAG_GR': emoji('&#x1F1EC&#x1F1F7', 'Greece'),
+    'FLAG_GS': emoji('&#x1F1EC&#x1F1F8',
+        'South Georgia & South Sandwich Islands'),
+    'FLAG_GT': emoji('&#x1F1EC&#x1F1F9', 'Guatemala'),
+    'FLAG_GU': emoji('&#x1F1EC&#x1F1FA', 'Guam'),
+    'FLAG_GW': emoji('&#x1F1EC&#x1F1FC', 'Guinea-Bissau'),
+    'FLAG_GY': emoji('&#x1F1EC&#x1F1FE', 'Guyana'),
+    'FLAG_HK': emoji('&#x1F1ED&#x1F1F0', 'Hong Kong SAR China'),
+    'FLAG_HM': emoji('&#x1F1ED&#x1F1F2', 'Heard & McDonald Islands'),
+    'FLAG_HN': emoji('&#x1F1ED&#x1F1F3', 'Honduras'),
+    'FLAG_HR': emoji('&#x1F1ED&#x1F1F7', 'Croatia'),
+    'FLAG_HT': emoji('&#x1F1ED&#x1F1F9', 'Haiti'),
+    'FLAG_HU': emoji('&#x1F1ED&#x1F1FA', 'Hungary'),
+    'FLAG_IC': emoji('&#x1F1EE&#x1F1E8', 'Canary Islands'),
+    'FLAG_ID': emoji('&#x1F1EE&#x1F1E9', 'Indonesia'),
+    'FLAG_IE': emoji('&#x1F1EE&#x1F1EA', 'Ireland'),
+    'FLAG_IL': emoji('&#x1F1EE&#x1F1F1', 'Israel'),
+    'FLAG_IM': emoji('&#x1F1EE&#x1F1F2', 'Isle of Man'),
+    'FLAG_IN': emoji('&#x1F1EE&#x1F1F3', 'India'),
+    'FLAG_IO': emoji('&#x1F1EE&#x1F1F4', 'British Indian Ocean Territory'),
+    'FLAG_IQ': emoji('&#x1F1EE&#x1F1F6', 'Iraq'),
+    'FLAG_IR': emoji('&#x1F1EE&#x1F1F7', 'Iran'),
+    'FLAG_IS': emoji('&#x1F1EE&#x1F1F8', 'Iceland'),
+    'FLAG_IT': emoji('&#x1F1EE&#x1F1F9', 'Italy'),
+    'FLAG_JE': emoji('&#x1F1EF&#x1F1EA', 'Jersey'),
+    'FLAG_JM': emoji('&#x1F1EF&#x1F1F2', 'Jamaica'),
+    'FLAG_JO': emoji('&#x1F1EF&#x1F1F4', 'Jordan'),
+    'FLAG_JP': emoji('&#x1F1EF&#x1F1F5', 'Japan'),
+    'FLAG_KE': emoji('&#x1F1F0&#x1F1EA', 'Kenya'),
+    'FLAG_KG': emoji('&#x1F1F0&#x1F1EC', 'Kyrgyzstan'),
+    'FLAG_KH': emoji('&#x1F1F0&#x1F1ED', 'Cambodia'),
+    'FLAG_KI': emoji('&#x1F1F0&#x1F1EE', 'Kiribati'),
+    'FLAG_KM': emoji('&#x1F1F0&#x1F1F2', 'Comoros'),
+    'FLAG_KN': emoji('&#x1F1F0&#x1F1F3', 'St. Kitts & Nevis'),
+    'FLAG_KP': emoji('&#x1F1F0&#x1F1F5', 'North Korea'),
+    'FLAG_KR': emoji('&#x1F1F0&#x1F1F7', 'South Korea'),
+    'FLAG_KW': emoji('&#x1F1F0&#x1F1FC', 'Kuwait'),
+    'FLAG_KY': emoji('&#x1F1F0&#x1F1FE', 'Cayman Islands'),
+    'FLAG_KZ': emoji('&#x1F1F0&#x1F1FF', 'Kazakhstan'),
+    'FLAG_LA': emoji('&#x1F1F1&#x1F1E6', 'Laos'),
+    'FLAG_LB': emoji('&#x1F1F1&#x1F1E7', 'Lebanon'),
+    'FLAG_LC': emoji('&#x1F1F1&#x1F1E8', 'St. Lucia'),
+    'FLAG_LI': emoji('&#x1F1F1&#x1F1EE', 'Liechtenstein'),
+    'FLAG_LK': emoji('&#x1F1F1&#x1F1F0', 'Sri Lanka'),
+    'FLAG_LR': emoji('&#x1F1F1&#x1F1F7', 'Liberia'),
+    'FLAG_LS': emoji('&#x1F1F1&#x1F1F8', 'Lesotho'),
+    'FLAG_LT': emoji('&#x1F1F1&#x1F1F9', 'Lithuania'),
+    'FLAG_LU': emoji('&#x1F1F1&#x1F1FA', 'Luxembourg'),
+    'FLAG_LV': emoji('&#x1F1F1&#x1F1FB', 'Latvia'),
+    'FLAG_LY': emoji('&#x1F1F1&#x1F1FE', 'Libya'),
+    'FLAG_MA': emoji('&#x1F1F2&#x1F1E6', 'Morocco'),
+    'FLAG_MC': emoji('&#x1F1F2&#x1F1E8', 'Monaco'),
+    'FLAG_MD': emoji('&#x1F1F2&#x1F1E9', 'Moldova'),
+    'FLAG_ME': emoji('&#x1F1F2&#x1F1EA', 'Montenegro'),
+    'FLAG_MF': emoji('&#x1F1F2&#x1F1EB', 'St. Martin'),
+    'FLAG_MG': emoji('&#x1F1F2&#x1F1EC', 'Madagascar'),
+    'FLAG_MH': emoji('&#x1F1F2&#x1F1ED', 'Marshall Islands'),
+    'FLAG_MK': emoji('&#x1F1F2&#x1F1F0', 'Macedonia'),
+    'FLAG_ML': emoji('&#x1F1F2&#x1F1F1', 'Mali'),
+    'FLAG_MM': emoji('&#x1F1F2&#x1F1F2', 'Myanmar (Burma)'),
+    'FLAG_MN': emoji('&#x1F1F2&#x1F1F3', 'Mongolia'),
+    'FLAG_MO': emoji('&#x1F1F2&#x1F1F4', 'Macao SAR China'),
+    'FLAG_MP': emoji('&#x1F1F2&#x1F1F5', 'Northern Mariana Islands'),
+    'FLAG_MQ': emoji('&#x1F1F2&#x1F1F6', 'Martinique'),
+    'FLAG_MR': emoji('&#x1F1F2&#x1F1F7', 'Mauritania'),
+    'FLAG_MS': emoji('&#x1F1F2&#x1F1F8', 'Montserrat'),
+    'FLAG_MT': emoji('&#x1F1F2&#x1F1F9', 'Malta'),
+    'FLAG_MU': emoji('&#x1F1F2&#x1F1FA', 'Mauritius'),
+    'FLAG_MV': emoji('&#x1F1F2&#x1F1FB', 'Maldives'),
+    'FLAG_MW': emoji('&#x1F1F2&#x1F1FC', 'Malawi'),
+    'FLAG_MX': emoji('&#x1F1F2&#x1F1FD', 'Mexico'),
+    'FLAG_MY': emoji('&#x1F1F2&#x1F1FE', 'Malaysia'),
+    'FLAG_MZ': emoji('&#x1F1F2&#x1F1FF', 'Mozambique'),
+    'FLAG_NA': emoji('&#x1F1F3&#x1F1E6', 'Namibia'),
+    'FLAG_NC': emoji('&#x1F1F3&#x1F1E8', 'New Caledonia'),
+    'FLAG_NE': emoji('&#x1F1F3&#x1F1EA', 'Niger'),
+    'FLAG_NF': emoji('&#x1F1F3&#x1F1EB', 'Norfolk Island'),
+    'FLAG_NG': emoji('&#x1F1F3&#x1F1EC', 'Nigeria'),
+    'FLAG_NI': emoji('&#x1F1F3&#x1F1EE', 'Nicaragua'),
+    'FLAG_NL': emoji('&#x1F1F3&#x1F1F1', 'Netherlands'),
+    'FLAG_NO': emoji('&#x1F1F3&#x1F1F4', 'Norway'),
+    'FLAG_NP': emoji('&#x1F1F3&#x1F1F5', 'Nepal'),
+    'FLAG_NR': emoji('&#x1F1F3&#x1F1F7', 'Nauru'),
+    'FLAG_NU': emoji('&#x1F1F3&#x1F1FA', 'Niue'),
+    'FLAG_NZ': emoji('&#x1F1F3&#x1F1FF', 'New Zealand'),
+    'FLAG_OM': emoji('&#x1F1F4&#x1F1F2', 'Oman '),
+    'FLAG_PA': emoji('&#x1F1F5&#x1F1E6', 'Panama'),
+    'FLAG_PE': emoji('&#x1F1F5&#x1F1EA', 'Peru'),
+    'FLAG_PF': emoji('&#x1F1F5&#x1F1EB', 'French Polynesia'),
+    'FLAG_PG': emoji('&#x1F1F5&#x1F1EC', 'Papua New Guinea'),
+    'FLAG_PH': emoji('&#x1F1F5&#x1F1ED', 'Philippines'),
+    'FLAG_PK': emoji('&#x1F1F5&#x1F1F0', 'Pakistan'),
+    'FLAG_PL': emoji('&#x1F1F5&#x1F1F1', 'Poland'),
+    'FLAG_PM': emoji('&#x1F1F5&#x1F1F2', 'St. Pierre & Miquelon'),
+    'FLAG_PN': emoji('&#x1F1F5&#x1F1F3', 'Pitcairn Islands'),
+    'FLAG_PR': emoji('&#x1F1F5&#x1F1F7', 'Puerto Rico'),
+    'FLAG_PS': emoji('&#x1F1F5&#x1F1F8', 'Palestinian Territories'),
+    'FLAG_PT': emoji('&#x1F1F5&#x1F1F9', 'Portugal'),
+    'FLAG_PW': emoji('&#x1F1F5&#x1F1FC', 'Palau'),
+    'FLAG_PY': emoji('&#x1F1F5&#x1F1FE', 'Paraguay'),
+    'FLAG_QA': emoji('&#x1F1F6&#x1F1E6', 'Qatar'),
+    'FLAG_RE': emoji('&#x1F1F7&#x1F1EA', 'Réunion'),
+    'FLAG_RO': emoji('&#x1F1F7&#x1F1F4', 'Romania'),
+    'FLAG_RS': emoji('&#x1F1F7&#x1F1F8', 'Serbia'),
+    'FLAG_RU': emoji('&#x1F1F7&#x1F1FA', 'Russia'),
+    'FLAG_RW': emoji('&#x1F1F7&#x1F1FC', 'Rwanda'),
+    'FLAG_SA': emoji('&#x1F1F8&#x1F1E6', 'Saudi Arabia'),
+    'FLAG_SB': emoji('&#x1F1F8&#x1F1E7', 'Solomon Islands'),
+    'FLAG_SC': emoji('&#x1F1F8&#x1F1E8', 'Seychelles'),
+    'FLAG_SD': emoji('&#x1F1F8&#x1F1E9', 'Sudan'),
+    'FLAG_SE': emoji('&#x1F1F8&#x1F1EA', 'Sweden'),
+    'FLAG_SG': emoji('&#x1F1F8&#x1F1EC', 'Singapore'),
+    'FLAG_SH': emoji('&#x1F1F8&#x1F1ED', 'St. Helena'),
+    'FLAG_SI': emoji('&#x1F1F8&#x1F1EE', 'Slovenia'),
+    'FLAG_SJ': emoji('&#x1F1F8&#x1F1EF', 'Svalbard & Jan Mayen'),
+    'FLAG_SK': emoji('&#x1F1F8&#x1F1F0', 'Slovakia'),
+    'FLAG_SL': emoji('&#x1F1F8&#x1F1F1', 'Sierra Leone'),
+    'FLAG_SM': emoji('&#x1F1F8&#x1F1F2', 'San Marino'),
+    'FLAG_SN': emoji('&#x1F1F8&#x1F1F3', 'Senegal'),
+    'FLAG_SO': emoji('&#x1F1F8&#x1F1F4', 'Somalia'),
+    'FLAG_SR': emoji('&#x1F1F8&#x1F1F7', 'Suriname'),
+    'FLAG_SS': emoji('&#x1F1F8&#x1F1F8', 'South Sudan'),
+    'FLAG_ST': emoji('&#x1F1F8&#x1F1F9', 'São Tomé & Príncipe'),
+    'FLAG_SV': emoji('&#x1F1F8&#x1F1FB', 'El Salvador'),
+    'FLAG_SX': emoji('&#x1F1F8&#x1F1FD', 'Sint Maarten'),
+    'FLAG_SY': emoji('&#x1F1F8&#x1F1FE', 'Syria'),
+    'FLAG_SZ': emoji('&#x1F1F8&#x1F1FF', 'Eswatini'),
+    'FLAG_TA': emoji('&#x1F1F9&#x1F1E6', 'Tristan da Cunha'),
+    'FLAG_TC': emoji('&#x1F1F9&#x1F1E8', 'Turks & Caicos Islands'),
+    'FLAG_TD': emoji('&#x1F1F9&#x1F1E9', 'Chad'),
+    'FLAG_TF': emoji('&#x1F1F9&#x1F1EB', 'French Southern Territories'),
+    'FLAG_TG': emoji('&#x1F1F9&#x1F1EC', 'Togo'),
+    'FLAG_TH': emoji('&#x1F1F9&#x1F1ED', 'Thailand'),
+    'FLAG_TJ': emoji('&#x1F1F9&#x1F1EF', 'Tajikistan'),
+    'FLAG_TK': emoji('&#x1F1F9&#x1F1F0', 'Tokelau'),
+    'FLAG_TL': emoji('&#x1F1F9&#x1F1F1', 'Timor-Leste'),
+    'FLAG_TM': emoji('&#x1F1F9&#x1F1F2', 'Turkmenistan'),
+    'FLAG_TN': emoji('&#x1F1F9&#x1F1F3', 'Tunisia'),
+    'FLAG_TO': emoji('&#x1F1F9&#x1F1F4', 'Tonga'),
+    'FLAG_TR': emoji('&#x1F1F9&#x1F1F7', 'Turkey'),
+    'FLAG_TT': emoji('&#x1F1F9&#x1F1F9', 'Trinidad & Tobago'),
+    'FLAG_TV': emoji('&#x1F1F9&#x1F1FB', 'Tuvalu'),
+    'FLAG_TW': emoji('&#x1F1F9&#x1F1FC', 'Taiwan'),
+    'FLAG_TZ': emoji('&#x1F1F9&#x1F1FF', 'Tanzania'),
+    'FLAG_UA': emoji('&#x1F1FA&#x1F1E6', 'Ukraine'),
+    'FLAG_UG': emoji('&#x1F1FA&#x1F1EC', 'Uganda'),
+    'FLAG_UM': emoji('&#x1F1FA&#x1F1F2', 'U.S. Outlying Islands'),
+    'FLAG_UN': emoji('&#x1F1FA&#x1F1F3', 'United Nations'),
+    'FLAG_US': emoji('&#x1F1FA&#x1F1F8', 'United States'),
+    'FLAG_UY': emoji('&#x1F1FA&#x1F1FE', 'Uruguay'),
+    'FLAG_UZ': emoji('&#x1F1FA&#x1F1FF', 'Uzbekistan'),
+    'FLAG_VA': emoji('&#x1F1FB&#x1F1E6', 'Vatican City'),
+    'FLAG_VC': emoji('&#x1F1FB&#x1F1E8', 'St. Vincent & Grenadines'),
+    'FLAG_VE': emoji('&#x1F1FB&#x1F1EA', 'Venezuela'),
+    'FLAG_VG': emoji('&#x1F1FB&#x1F1EC', 'British Virgin Islands'),
+    'FLAG_VI': emoji('&#x1F1FB&#x1F1EE', 'U.S. Virgin Islands'),
+    'FLAG_VN': emoji('&#x1F1FB&#x1F1F3', 'Vietnam'),
+    'FLAG_VU': emoji('&#x1F1FB&#x1F1FA', 'Vanuatu'),
+    'FLAG_WF': emoji('&#x1F1FC&#x1F1EB', 'Wallis & Futuna'),
+    'FLAG_WS': emoji('&#x1F1FC&#x1F1F8', 'Samoa'),
+    'FLAG_XK': emoji('&#x1F1FD&#x1F1F0', 'Kosovo'),
+    'FLAG_YE': emoji('&#x1F1FE&#x1F1EA', 'Yemen'),
+    'FLAG_YT': emoji('&#x1F1FE&#x1F1F9', 'Mayotte'),
+    'FLAG_ZA': emoji('&#x1F1FF&#x1F1E6', 'South Africa'),
+    'FLAG_ZM': emoji('&#x1F1FF&#x1F1F2', 'Zambia'),
+    'FLAG_ZW': emoji('&#x1F1FF&#x1F1FC', 'Zimbabwe'),
 }
 
 


Test patch:
diff --git a/test_tools/timesketch/lib/emojis.py b/test_tools/timesketch/lib/emojis.py
--- a/test_tools/timesketch/lib/emojis.py
+++ b/test_tools/timesketch/lib/emojis.py
@@ -38,7 +38,266 @@
     'SKULL_CROSSBONE': emoji('&#x2620', 'Suspicious entry'),
     'SLEEPING_FACE': emoji('&#x1F634', 'Activity outside of regular hours'),
     'UNLOCK': emoji('&#x1F513', 'Logoff activity'),
-    'WASTEBASKET': emoji('&#x1F5D1', 'Deletion activity')
+    'WASTEBASKET': emoji('&#x1F5D1', 'Deletion activity'),
+    'FLAG_AC': emoji('&#x1F1E6&#x1F1E8', 'Ascension Island'),
+    'FLAG_AD': emoji('&#x1F1E6&#x1F1E9', 'Andorra'),
+    'FLAG_AE': emoji('&#x1F1E6&#x1F1EA', 'United Arab Emirates'),
+    'FLAG_AF': emoji('&#x1F1E6&#x1F1EB', 'Afghanistan'),
+    'FLAG_AG': emoji('&#x1F1E6&#x1F1EC', 'Antigua & Barbuda'),
+    'FLAG_AI': emoji('&#x1F1E6&#x1F1EE', 'Anguilla'),
+    'FLAG_AL': emoji('&#x1F1E6&#x1F1F1', 'Albania'),
+    'FLAG_AM': emoji('&#x1F1E6&#x1F1F2', 'Armenia'),
+    'FLAG_AO': emoji('&#x1F1E6&#x1F1F4', 'Angola'),
+    'FLAG_AQ': emoji('&#x1F1E6&#x1F1F6', 'Antarctica'),
+    'FLAG_AR': emoji('&#x1F1E6&#x1F1F7', 'Argentina'),
+    'FLAG_AS': emoji('&#x1F1E6&#x1F1F8', 'American Samoa'),
+    'FLAG_AT': emoji('&#x1F1E6&#x1F1F9', 'Austria'),
+    'FLAG_AU': emoji('&#x1F1E6&#x1F1FA', 'Australia'),
+    'FLAG_AW': emoji('&#x1F1E6&#x1F1FC', 'Aruba'),
+    'FLAG_AX': emoji('&#x1F1E6&#x1F1FD', 'Åland Islands'),
+    'FLAG_AZ': emoji('&#x1F1E6&#x1F1FF', 'Azerbaijan'),
+    'FLAG_BA': emoji('&#x1F1E7&#x1F1E6', 'Bosnia & Herzegovina'),
+    'FLAG_BB': emoji('&#x1F1E7&#x1F1E7', 'Barbados'),
+    'FLAG_BD': emoji('&#x1F1E7&#x1F1E9', 'Bangladesh'),
+    'FLAG_BE': emoji('&#x1F1E7&#x1F1EA', 'Belgium'),
+    'FLAG_BF': emoji('&#x1F1E7&#x1F1EB', 'Burkina Faso'),
+    'FLAG_BG': emoji('&#x1F1E7&#x1F1EC', 'Bulgaria'),
+    'FLAG_BH': emoji('&#x1F1E7&#x1F1ED', 'Bahrain'),
+    'FLAG_BI': emoji('&#x1F1E7&#x1F1EE', 'Burundi'),
+    'FLAG_BJ': emoji('&#x1F1E7&#x1F1EF', 'Benin'),
+    'FLAG_BL': emoji('&#x1F1E7&#x1F1F1', 'St. Barthélemy'),
+    'FLAG_BM': emoji('&#x1F1E7&#x1F1F2', 'Bermuda'),
+    'FLAG_BN': emoji('&#x1F1E7&#x1F1F3', 'Brunei'),
+    'FLAG_BO': emoji('&#x1F1E7&#x1F1F4', 'Bolivia'),
+    'FLAG_BQ': emoji('&#x1F1E7&#x1F1F6', 'Caribbean Netherlands'),
+    'FLAG_BR': emoji('&#x1F1E7&#x1F1F7', 'Brazil'),
+    'FLAG_BS': emoji('&#x1F1E7&#x1F1F8', 'Bahamas'),
+    'FLAG_BT': emoji('&#x1F1E7&#x1F1F9', 'Bhutan'),
+    'FLAG_BV': emoji('&#x1F1E7&#x1F1FB', 'Bouvet Island'),
+    'FLAG_BW': emoji('&#x1F1E7&#x1F1FC', 'Botswana'),
+    'FLAG_BY': emoji('&#x1F1E7&#x1F1FE', 'Belarus'),
+    'FLAG_BZ': emoji('&#x1F1E7&#x1F1FF', 'Belize'),
+    'FLAG_CA': emoji('&#x1F1E8&#x1F1E6', 'Canada'),
+    'FLAG_CC': emoji('&#x1F1E8&#x1F1E8', 'Cocos (Keeling) Islands'),
+    'FLAG_CD': emoji('&#x1F1E8&#x1F1E9', 'Congo - Kinshasa'),
+    'FLAG_CF': emoji('&#x1F1E8&#x1F1EB', 'Central African Republic'),
+    'FLAG_CG': emoji('&#x1F1E8&#x1F1EC', 'Congo - Brazzaville'),
+    'FLAG_CH': emoji('&#x1F1E8&#x1F1ED', 'Switzerland'),
+    'FLAG_CI': emoji('&#x1F1E8&#x1F1EE', 'Côte d’Ivoire'),
+    'FLAG_CK': emoji('&#x1F1E8&#x1F1F0', 'Cook Islands'),
+    'FLAG_CL': emoji('&#x1F1E8&#x1F1F1', 'Chile'),
+    'FLAG_CM': emoji('&#x1F1E8&#x1F1F2', 'Cameroon'),
+    'FLAG_CN': emoji('&#x1F1E8&#x1F1F3', 'China'),
+    'FLAG_CO': emoji('&#x1F1E8&#x1F1F4', 'Colombia'),
+    'FLAG_CP': emoji('&#x1F1E8&#x1F1F5', 'Clipperton Island'),
+    'FLAG_CR': emoji('&#x1F1E8&#x1F1F7', 'Costa Rica'),
+    'FLAG_CU': emoji('&#x1F1E8&#x1F1FA', 'Cuba'),
+    'FLAG_CV': emoji('&#x1F1E8&#x1F1FB', 'Cape Verde'),
+    'FLAG_CW': emoji('&#x1F1E8&#x1F1FC', 'Curaçao'),
+    'FLAG_CX': emoji('&#x1F1E8&#x1F1FD', 'Christmas Island'),
+    'FLAG_CY': emoji('&#x1F1E8&#x1F1FE', 'Cyprus'),
+    'FLAG_CZ': emoji('&#x1F1E8&#x1F1FF', 'Czechia'),
+    'FLAG_DE': emoji('&#x1F1E9&#x1F1EA', 'Germany'),
+    'FLAG_DG': emoji('&#x1F1E9&#x1F1EC', 'Diego Garcia'),
+    'FLAG_DJ': emoji('&#x1F1E9&#x1F1EF', 'Djibouti'),
+    'FLAG_DK': emoji('&#x1F1E9&#x1F1F0', 'Denmark'),
+    'FLAG_DM': emoji('&#x1F1E9&#x1F1F2', 'Dominica'),
+    'FLAG_DO': emoji('&#x1F1E9&#x1F1F4', 'Dominican Republic'),
+    'FLAG_DZ': emoji('&#x1F1E9&#x1F1FF', 'Algeria'),
+    'FLAG_EA': emoji('&#x1F1EA&#x1F1E6', 'Ceuta & Melilla'),
+    'FLAG_EC': emoji('&#x1F1EA&#x1F1E8', 'Ecuador'),
+    'FLAG_EE': emoji('&#x1F1EA&#x1F1EA', 'Estonia'),
+    'FLAG_EG': emoji('&#x1F1EA&#x1F1EC', 'Egypt'),
+    'FLAG_EH': emoji('&#x1F1EA&#x1F1ED', 'Western Sahara'),
+    'FLAG_ER': emoji('&#x1F1EA&#x1F1F7', 'Eritrea'),
+    'FLAG_ES': emoji('&#x1F1EA&#x1F1F8', 'Spain'),
+    'FLAG_ET': emoji('&#x1F1EA&#x1F1F9', 'Ethiopia'),
+    'FLAG_EU': emoji('&#x1F1EA&#x1F1FA', 'European Union'),
+    'FLAG_FI': emoji('&#x1F1EB&#x1F1EE', 'Finland'),
+    'FLAG_FJ': emoji('&#x1F1EB&#x1F1EF', 'Fiji'),
+    'FLAG_FK': emoji('&#x1F1EB&#x1F1F0', 'Falkland Islands'),
+    'FLAG_FM': emoji('&#x1F1EB&#x1F1F2', 'Micronesia'),
+    'FLAG_FO': emoji('&#x1F1EB&#x1F1F4', 'Faroe Islands'),
+    'FLAG_FR': emoji('&#x1F1EB&#x1F1F7', 'France'),
+    'FLAG_GA': emoji('&#x1F1EC&#x1F1E6', 'Gabon'),
+    'FLAG_GB': emoji('&#x1F1EC&#x1F1E7', 'United Kingdom'),
+    'FLAG_GD': emoji('&#x1F1EC&#x1F1E9', 'Grenada'),
+    'FLAG_GE': emoji('&#x1F1EC&#x1F1EA', 'Georgia'),
+    'FLAG_GF': emoji('&#x1F1EC&#x1F1EB', 'French Guiana'),
+    'FLAG_GG': emoji('&#x1F1EC&#x1F1EC', 'Guernsey'),
+    'FLAG_GH': emoji('&#x1F1EC&#x1F1ED', 'Ghana'),
+    'FLAG_GI': emoji('&#x1F1EC&#x1F1EE', 'Gibraltar'),
+    'FLAG_GL': emoji('&#x1F1EC&#x1F1F1', 'Greenland'),
+    'FLAG_GM': emoji('&#x1F1EC&#x1F1F2', 'Gambia'),
+    'FLAG_GN': emoji('&#x1F1EC&#x1F1F3', 'Guinea'),
+    'FLAG_GP': emoji('&#x1F1EC&#x1F1F5', 'Guadeloupe'),
+    'FLAG_GQ': emoji('&#x1F1EC&#x1F1F6', 'Equatorial Guinea'),
+    'FLAG_GR': emoji('&#x1F1EC&#x1F1F7', 'Greece'),
+    'FLAG_GS': emoji('&#x1F1EC&#x1F1F8',
+        'South Georgia & South Sandwich Islands'),
+    'FLAG_GT': emoji('&#x1F1EC&#x1F1F9', 'Guatemala'),
+    'FLAG_GU': emoji('&#x1F1EC&#x1F1FA', 'Guam'),
+    'FLAG_GW': emoji('&#x1F1EC&#x1F1FC', 'Guinea-Bissau'),
+    'FLAG_GY': emoji('&#x1F1EC&#x1F1FE', 'Guyana'),
+    'FLAG_HK': emoji('&#x1F1ED&#x1F1F0', 'Hong Kong SAR China'),
+    'FLAG_HM': emoji('&#x1F1ED&#x1F1F2', 'Heard & McDonald Islands'),
+    'FLAG_HN': emoji('&#x1F1ED&#x1F1F3', 'Honduras'),
+    'FLAG_HR': emoji('&#x1F1ED&#x1F1F7', 'Croatia'),
+    'FLAG_HT': emoji('&#x1F1ED&#x1F1F9', 'Haiti'),
+    'FLAG_HU': emoji('&#x1F1ED&#x1F1FA', 'Hungary'),
+    'FLAG_IC': emoji('&#x1F1EE&#x1F1E8', 'Canary Islands'),
+    'FLAG_ID': emoji('&#x1F1EE&#x1F1E9', 'Indonesia'),
+    'FLAG_IE': emoji('&#x1F1EE&#x1F1EA', 'Ireland'),
+    'FLAG_IL': emoji('&#x1F1EE&#x1F1F1', 'Israel'),
+    'FLAG_IM': emoji('&#x1F1EE&#x1F1F2', 'Isle of Man'),
+    'FLAG_IN': emoji('&#x1F1EE&#x1F1F3', 'India'),
+    'FLAG_IO': emoji('&#x1F1EE&#x1F1F4', 'British Indian Ocean Territory'),
+    'FLAG_IQ': emoji('&#x1F1EE&#x1F1F6', 'Iraq'),
+    'FLAG_IR': emoji('&#x1F1EE&#x1F1F7', 'Iran'),
+    'FLAG_IS': emoji('&#x1F1EE&#x1F1F8', 'Iceland'),
+    'FLAG_IT': emoji('&#x1F1EE&#x1F1F9', 'Italy'),
+    'FLAG_JE': emoji('&#x1F1EF&#x1F1EA', 'Jersey'),
+    'FLAG_JM': emoji('&#x1F1EF&#x1F1F2', 'Jamaica'),
+    'FLAG_JO': emoji('&#x1F1EF&#x1F1F4', 'Jordan'),
+    'FLAG_JP': emoji('&#x1F1EF&#x1F1F5', 'Japan'),
+    'FLAG_KE': emoji('&#x1F1F0&#x1F1EA', 'Kenya'),
+    'FLAG_KG': emoji('&#x1F1F0&#x1F1EC', 'Kyrgyzstan'),
+    'FLAG_KH': emoji('&#x1F1F0&#x1F1ED', 'Cambodia'),
+    'FLAG_KI': emoji('&#x1F1F0&#x1F1EE', 'Kiribati'),
+    'FLAG_KM': emoji('&#x1F1F0&#x1F1F2', 'Comoros'),
+    'FLAG_KN': emoji('&#x1F1F0&#x1F1F3', 'St. Kitts & Nevis'),
+    'FLAG_KP': emoji('&#x1F1F0&#x1F1F5', 'North Korea'),
+    'FLAG_KR': emoji('&#x1F1F0&#x1F1F7', 'South Korea'),
+    'FLAG_KW': emoji('&#x1F1F0&#x1F1FC', 'Kuwait'),
+    'FLAG_KY': emoji('&#x1F1F0&#x1F1FE', 'Cayman Islands'),
+    'FLAG_KZ': emoji('&#x1F1F0&#x1F1FF', 'Kazakhstan'),
+    'FLAG_LA': emoji('&#x1F1F1&#x1F1E6', 'Laos'),
+    'FLAG_LB': emoji('&#x1F1F1&#x1F1E7', 'Lebanon'),
+    'FLAG_LC': emoji('&#x1F1F1&#x1F1E8', 'St. Lucia'),
+    'FLAG_LI': emoji('&#x1F1F1&#x1F1EE', 'Liechtenstein'),
+    'FLAG_LK': emoji('&#x1F1F1&#x1F1F0', 'Sri Lanka'),
+    'FLAG_LR': emoji('&#x1F1F1&#x1F1F7', 'Liberia'),
+    'FLAG_LS': emoji('&#x1F1F1&#x1F1F8', 'Lesotho'),
+    'FLAG_LT': emoji('&#x1F1F1&#x1F1F9', 'Lithuania'),
+    'FLAG_LU': emoji('&#x1F1F1&#x1F1FA', 'Luxembourg'),
+    'FLAG_LV': emoji('&#x1F1F1&#x1F1FB', 'Latvia'),
+    'FLAG_LY': emoji('&#x1F1F1&#x1F1FE', 'Libya'),
+    'FLAG_MA': emoji('&#x1F1F2&#x1F1E6', 'Morocco'),
+    'FLAG_MC': emoji('&#x1F1F2&#x1F1E8', 'Monaco'),
+    'FLAG_MD': emoji('&#x1F1F2&#x1F1E9', 'Moldova'),
+    'FLAG_ME': emoji('&#x1F1F2&#x1F1EA', 'Montenegro'),
+    'FLAG_MF': emoji('&#x1F1F2&#x1F1EB', 'St. Martin'),
+    'FLAG_MG': emoji('&#x1F1F2&#x1F1EC', 'Madagascar'),
+    'FLAG_MH': emoji('&#x1F1F2&#x1F1ED', 'Marshall Islands'),
+    'FLAG_MK': emoji('&#x1F1F2&#x1F1F0', 'Macedonia'),
+    'FLAG_ML': emoji('&#x1F1F2&#x1F1F1', 'Mali'),
+    'FLAG_MM': emoji('&#x1F1F2&#x1F1F2', 'Myanmar (Burma)'),
+    'FLAG_MN': emoji('&#x1F1F2&#x1F1F3', 'Mongolia'),
+    'FLAG_MO': emoji('&#x1F1F2&#x1F1F4', 'Macao SAR China'),
+    'FLAG_MP': emoji('&#x1F1F2&#x1F1F5', 'Northern Mariana Islands'),
+    'FLAG_MQ': emoji('&#x1F1F2&#x1F1F6', 'Martinique'),
+    'FLAG_MR': emoji('&#x1F1F2&#x1F1F7', 'Mauritania'),
+    'FLAG_MS': emoji('&#x1F1F2&#x1F1F8', 'Montserrat'),
+    'FLAG_MT': emoji('&#x1F1F2&#x1F1F9', 'Malta'),
+    'FLAG_MU': emoji('&#x1F1F2&#x1F1FA', 'Mauritius'),
+    'FLAG_MV': emoji('&#x1F1F2&#x1F1FB', 'Maldives'),
+    'FLAG_MW': emoji('&#x1F1F2&#x1F1FC', 'Malawi'),
+    'FLAG_MX': emoji('&#x1F1F2&#x1F1FD', 'Mexico'),
+    'FLAG_MY': emoji('&#x1F1F2&#x1F1FE', 'Malaysia'),
+    'FLAG_MZ': emoji('&#x1F1F2&#x1F1FF', 'Mozambique'),
+    'FLAG_NA': emoji('&#x1F1F3&#x1F1E6', 'Namibia'),
+    'FLAG_NC': emoji('&#x1F1F3&#x1F1E8', 'New Caledonia'),
+    'FLAG_NE': emoji('&#x1F1F3&#x1F1EA', 'Niger'),
+    'FLAG_NF': emoji('&#x1F1F3&#x1F1EB', 'Norfolk Island'),
+    'FLAG_NG': emoji('&#x1F1F3&#x1F1EC', 'Nigeria'),
+    'FLAG_NI': emoji('&#x1F1F3&#x1F1EE', 'Nicaragua'),
+    'FLAG_NL': emoji('&#x1F1F3&#x1F1F1', 'Netherlands'),
+    'FLAG_NO': emoji('&#x1F1F3&#x1F1F4', 'Norway'),
+    'FLAG_NP': emoji('&#x1F1F3&#x1F1F5', 'Nepal'),
+    'FLAG_NR': emoji('&#x1F1F3&#x1F1F7', 'Nauru'),
+    'FLAG_NU': emoji('&#x1F1F3&#x1F1FA', 'Niue'),
+    'FLAG_NZ': emoji('&#x1F1F3&#x1F1FF', 'New Zealand'),
+    'FLAG_OM': emoji('&#x1F1F4&#x1F1F2', 'Oman '),
+    'FLAG_PA': emoji('&#x1F1F5&#x1F1E6', 'Panama'),
+    'FLAG_PE': emoji('&#x1F1F5&#x1F1EA', 'Peru'),
+    'FLAG_PF': emoji('&#x1F1F5&#x1F1EB', 'French Polynesia'),
+    'FLAG_PG': emoji('&#x1F1F5&#x1F1EC', 'Papua New Guinea'),
+    'FLAG_PH': emoji('&#x1F1F5&#x1F1ED', 'Philippines'),
+    'FLAG_PK': emoji('&#x1F1F5&#x1F1F0', 'Pakistan'),
+    'FLAG_PL': emoji('&#x1F1F5&#x1F1F1', 'Poland'),
+    'FLAG_PM': emoji('&#x1F1F5&#x1F1F2', 'St. Pierre & Miquelon'),
+    'FLAG_PN': emoji('&#x1F1F5&#x1F1F3', 'Pitcairn Islands'),
+    'FLAG_PR': emoji('&#x1F1F5&#x1F1F7', 'Puerto Rico'),
+    'FLAG_PS': emoji('&#x1F1F5&#x1F1F8', 'Palestinian Territories'),
+    'FLAG_PT': emoji('&#x1F1F5&#x1F1F9', 'Portugal'),
+    'FLAG_PW': emoji('&#x1F1F5&#x1F1FC', 'Palau'),
+    'FLAG_PY': emoji('&#x1F1F5&#x1F1FE', 'Paraguay'),
+    'FLAG_QA': emoji('&#x1F1F6&#x1F1E6', 'Qatar'),
+    'FLAG_RE': emoji('&#x1F1F7&#x1F1EA', 'Réunion'),
+    'FLAG_RO': emoji('&#x1F1F7&#x1F1F4', 'Romania'),
+    'FLAG_RS': emoji('&#x1F1F7&#x1F1F8', 'Serbia'),
+    'FLAG_RU': emoji('&#x1F1F7&#x1F1FA', 'Russia'),
+    'FLAG_RW': emoji('&#x1F1F7&#x1F1FC', 'Rwanda'),
+    'FLAG_SA': emoji('&#x1F1F8&#x1F1E6', 'Saudi Arabia'),
+    'FLAG_SB': emoji('&#x1F1F8&#x1F1E7', 'Solomon Islands'),
+    'FLAG_SC': emoji('&#x1F1F8&#x1F1E8', 'Seychelles'),
+    'FLAG_SD': emoji('&#x1F1F8&#x1F1E9', 'Sudan'),
+    'FLAG_SE': emoji('&#x1F1F8&#x1F1EA', 'Sweden'),
+    'FLAG_SG': emoji('&#x1F1F8&#x1F1EC', 'Singapore'),
+    'FLAG_SH': emoji('&#x1F1F8&#x1F1ED', 'St. Helena'),
+    'FLAG_SI': emoji('&#x1F1F8&#x1F1EE', 'Slovenia'),
+    'FLAG_SJ': emoji('&#x1F1F8&#x1F1EF', 'Svalbard & Jan Mayen'),
+    'FLAG_SK': emoji('&#x1F1F8&#x1F1F0', 'Slovakia'),
+    'FLAG_SL': emoji('&#x1F1F8&#x1F1F1', 'Sierra Leone'),
+    'FLAG_SM': emoji('&#x1F1F8&#x1F1F2', 'San Marino'),
+    'FLAG_SN': emoji('&#x1F1F8&#x1F1F3', 'Senegal'),
+    'FLAG_SO': emoji('&#x1F1F8&#x1F1F4', 'Somalia'),
+    'FLAG_SR': emoji('&#x1F1F8&#x1F1F7', 'Suriname'),
+    'FLAG_SS': emoji('&#x1F1F8&#x1F1F8', 'South Sudan'),
+    'FLAG_ST': emoji('&#x1F1F8&#x1F1F9', 'São Tomé & Príncipe'),
+    'FLAG_SV': emoji('&#x1F1F8&#x1F1FB', 'El Salvador'),
+    'FLAG_SX': emoji('&#x1F1F8&#x1F1FD', 'Sint Maarten'),
+    'FLAG_SY': emoji('&#x1F1F8&#x1F1FE', 'Syria'),
+    'FLAG_SZ': emoji('&#x1F1F8&#x1F1FF', 'Eswatini'),
+    'FLAG_TA': emoji('&#x1F1F9&#x1F1E6', 'Tristan da Cunha'),
+    'FLAG_TC': emoji('&#x1F1F9&#x1F1E8', 'Turks & Caicos Islands'),
+    'FLAG_TD': emoji('&#x1F1F9&#x1F1E9', 'Chad'),
+    'FLAG_TF': emoji('&#x1F1F9&#x1F1EB', 'French Southern Territories'),
+    'FLAG_TG': emoji('&#x1F1F9&#x1F1EC', 'Togo'),
+    'FLAG_TH': emoji('&#x1F1F9&#x1F1ED', 'Thailand'),
+    'FLAG_TJ': emoji('&#x1F1F9&#x1F1EF', 'Tajikistan'),
+    'FLAG_TK': emoji('&#x1F1F9&#x1F1F0', 'Tokelau'),
+    'FLAG_TL': emoji('&#x1F1F9&#x1F1F1', 'Timor-Leste'),
+    'FLAG_TM': emoji('&#x1F1F9&#x1F1F2', 'Turkmenistan'),
+    'FLAG_TN': emoji('&#x1F1F9&#x1F1F3', 'Tunisia'),
+    'FLAG_TO': emoji('&#x1F1F9&#x1F1F4', 'Tonga'),
+    'FLAG_TR': emoji('&#x1F1F9&#x1F1F7', 'Turkey'),
+    'FLAG_TT': emoji('&#x1F1F9&#x1F1F9', 'Trinidad & Tobago'),
+    'FLAG_TV': emoji('&#x1F1F9&#x1F1FB', 'Tuvalu'),
+    'FLAG_TW': emoji('&#x1F1F9&#x1F1FC', 'Taiwan'),
+    'FLAG_TZ': emoji('&#x1F1F9&#x1F1FF', 'Tanzania'),
+    'FLAG_UA': emoji('&#x1F1FA&#x1F1E6', 'Ukraine'),
+    'FLAG_UG': emoji('&#x1F1FA&#x1F1EC', 'Uganda'),
+    'FLAG_UM': emoji('&#x1F1FA&#x1F1F2', 'U.S. Outlying Islands'),
+    'FLAG_UN': emoji('&#x1F1FA&#x1F1F3', 'United Nations'),
+    'FLAG_US': emoji('&#x1F1FA&#x1F1F8', 'United States'),
+    'FLAG_UY': emoji('&#x1F1FA&#x1F1FE', 'Uruguay'),
+    'FLAG_UZ': emoji('&#x1F1FA&#x1F1FF', 'Uzbekistan'),
+    'FLAG_VA': emoji('&#x1F1FB&#x1F1E6', 'Vatican City'),
+    'FLAG_VC': emoji('&#x1F1FB&#x1F1E8', 'St. Vincent & Grenadines'),
+    'FLAG_VE': emoji('&#x1F1FB&#x1F1EA', 'Venezuela'),
+    'FLAG_VG': emoji('&#x1F1FB&#x1F1EC', 'British Virgin Islands'),
+    'FLAG_VI': emoji('&#x1F1FB&#x1F1EE', 'U.S. Virgin Islands'),
+    'FLAG_VN': emoji('&#x1F1FB&#x1F1F3', 'Vietnam'),
+    'FLAG_VU': emoji('&#x1F1FB&#x1F1FA', 'Vanuatu'),
+    'FLAG_WF': emoji('&#x1F1FC&#x1F1EB', 'Wallis & Futuna'),
+    'FLAG_WS': emoji('&#x1F1FC&#x1F1F8', 'Samoa'),
+    'FLAG_XK': emoji('&#x1F1FD&#x1F1F0', 'Kosovo'),
+    'FLAG_YE': emoji('&#x1F1FE&#x1F1EA', 'Yemen'),
+    'FLAG_YT': emoji('&#x1F1FE&#x1F1F9', 'Mayotte'),
+    'FLAG_ZA': emoji('&#x1F1FF&#x1F1E6', 'South Africa'),
+    'FLAG_ZM': emoji('&#x1F1FF&#x1F1F2', 'Zambia'),
+    'FLAG_ZW': emoji('&#x1F1FF&#x1F1FC', 'Zimbabwe'),
 }
 
 
diff --git a/timesketch/lib/analyzers/geoip_test.py b/timesketch/lib/analyzers/geoip_test.py
new file mode 100644
--- /dev/null
+++ b/timesketch/lib/analyzers/geoip_test.py
@@ -0,0 +1,421 @@
+# Copyright 2021 Google Inc. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Tests for GeoIP analyzer."""
+from __future__ import unicode_literals
+
+import mock
+
+from timesketch.lib.analyzers.geoip import MaxMindDbGeoIPAnalyzer
+from timesketch.lib.analyzers.geoip import MaxMindDbWebIPAnalyzer
+
+from timesketch.lib import emojis
+from timesketch.lib.testlib import BaseTest
+from timesketch.lib.testlib import MockDataStore
+
+# TODO _create_mock_event will be renamed in another pull request. It's name
+# should be also changed here.
+from timesketch.lib.analyzers.base_sessionizer_test \
+    import _create_mock_event
+
+
+class MockReader(object):
+    """A mock implementation of a GeoLite2 database reader"""
+    def __init__(self, *args, **kwargs):
+        pass
+
+    def __enter__(self):
+        return self
+
+    def open(self, *unused_args):
+        return self
+
+    def __exit__(self, *args):
+        pass
+
+    def ip2geo(self, *unused_args):
+        return 'a', 'b', 'c', 'd', 'e'
+
+
+class TestMaxMindDbGeoIPAnalyzer(BaseTest):
+    """Tests for the functionality of the MaxMind Database based Geo IP
+       Analyzer."""
+
+    _TEST_ISO_CODE = 'FLAG_US'
+    _TEST_EMOJI = '&#x1F1FA&#x1F1F8'
+
+
+    def testEmoji(self):
+        """Test a flag emoji exists"""
+        flag_emoji = emojis.get_emoji(self._TEST_ISO_CODE)
+        self.assertEqual(flag_emoji, self._TEST_EMOJI)
+
+    @mock.patch('timesketch.lib.analyzers.interface.ElasticsearchDataStore',
+                MockDataStore)
+    def testValidIPv4(self):
+        """Test valid IPv4 addresses result in new attributes"""
+        analyzer = MaxMindDbGeoIPAnalyzer('test', 1)
+        analyzer.GEOIP_CLIENT = MockReader
+
+        analyzer.datastore.client = mock.Mock()
+
+        IP_FIELDS = ['ip', 'host_ip', 'src_ip', 'dst_ip', 'source_ip',
+                     'dest_ip', 'ip_address', 'client_ip', 'address', 'saddr',
+                     'daddr', 'requestMetadata_callerIp', 'a_answer']
+
+        _create_mock_event(analyzer.datastore, 0, 1,
+                           source_attrs={
+                               ip_field: '8.8.8.8' for ip_field in IP_FIELDS
+                           })
+
+        message = analyzer.run()
+        event = analyzer.datastore.event_store['0']
+
+        for ip_field in IP_FIELDS:
+            self.assertTrue('{0}_latitude'.format(ip_field)
+                            in event['_source'])
+            self.assertTrue('{0}_longitude'.format(ip_field)
+                            in event['_source'])
+            self.assertTrue('{0}_iso_code'.format(ip_field)
+                            in event['_source'])
+            self.assertTrue('{0}_city'.format(ip_field)
+                            in event['_source'])
+        self.assertEqual(message, 'Found 1 IP address(es).')
+
+
+    @mock.patch('timesketch.lib.analyzers.interface.ElasticsearchDataStore',
+                MockDataStore)
+    def testValidIPv6(self):
+        """Test valid IPv6 addresses result in new attributes"""
+        analyzer = MaxMindDbGeoIPAnalyzer('test', 1)
+        analyzer.GEOIP_CLIENT = MockReader
+
+        analyzer.datastore.client = mock.Mock()
+
+        IP_FIELDS = ['ip', 'host_ip', 'src_ip', 'dst_ip', 'source_ip',
+                     'dest_ip', 'ip_address', 'client_ip', 'address', 'saddr',
+                     'daddr', 'requestMetadata_callerIp', 'a_answer']
+
+        _create_mock_event(analyzer.datastore, 0, 1,
+                           source_attrs={
+                               ip_field: '2001:4860:4860::8888'
+                               for ip_field in IP_FIELDS
+                           })
+
+        message = analyzer.run()
+        event = analyzer.datastore.event_store['0']
+
+        for ip_field in IP_FIELDS:
+            self.assertTrue('{0}_latitude'.format(ip_field)
+                            in event['_source'])
+            self.assertTrue('{0}_longitude'.format(ip_field)
+                            in event['_source'])
+            self.assertTrue('{0}_iso_code'.format(ip_field)
+                            in event['_source'])
+            self.assertTrue('{0}_city'.format(ip_field)
+                            in event['_source'])
+        self.assertEqual(message, 'Found 1 IP address(es).')
+
+
+    @mock.patch('timesketch.lib.analyzers.interface.ElasticsearchDataStore',
+                MockDataStore)
+    def testPrivateIPv4(self):
+        """Test private IPv4 address does not result in new attributes"""
+        analyzer = MaxMindDbGeoIPAnalyzer('test', 1)
+        analyzer.GEOIP_CLIENT = MockReader
+        analyzer.datastore.client = mock.Mock()
+
+        _create_mock_event(analyzer.datastore, 0, 1,
+                           source_attrs={
+                               'ip_address': '127.0.0.1'
+                           })
+
+        message = analyzer.run()
+        event = analyzer.datastore.event_store['0']
+
+        self.assertTrue('ip_address_latitude' not in event['_source'])
+        self.assertTrue('ip_address_longitude' not in event['_source'])
+        self.assertTrue('ip_address_iso_code' not in event['_source'])
+        self.assertTrue('ip_address_city' not in event['_source'])
+        self.assertEqual(message, 'Found 0 IP address(es).')
+
+
+    @mock.patch('timesketch.lib.analyzers.interface.ElasticsearchDataStore',
+                MockDataStore)
+    def testInvalidIPv4(self):
+        """Test invalid IP address"""
+        analyzer = MaxMindDbGeoIPAnalyzer('test', 1)
+        analyzer.GEOIP_CLIENT = MockReader
+        analyzer.datastore.client = mock.Mock()
+
+        _create_mock_event(analyzer.datastore, 0, 1,
+                           source_attrs={
+                               'ip_address': None
+                           })
+
+        message = analyzer.run()
+        event = analyzer.datastore.event_store['0']
+
+        self.assertTrue('ip_address_latitude' not in event['_source'])
+        self.assertTrue('ip_address_longitude' not in event['_source'])
+        self.assertTrue('ip_address_iso_code' not in event['_source'])
+        self.assertTrue('ip_address_city' not in event['_source'])
+        self.assertEqual(message, 'Found 0 IP address(es).')
+
+
+    @mock.patch('timesketch.lib.analyzers.interface.ElasticsearchDataStore',
+                MockDataStore)
+    def testNoEvents(self):
+        """Test no events"""
+        analyzer = MaxMindDbGeoIPAnalyzer('test', 1)
+        analyzer.GEOIP_CLIENT = MockReader
+        analyzer.datastore.client = mock.Mock()
+
+        message = analyzer.run()
+
+        self.assertEqual(message, 'Found 0 IP address(es).')
+
+
+    @mock.patch('timesketch.lib.analyzers.interface.ElasticsearchDataStore',
+                MockDataStore)
+    @mock.patch('geoip2.database.Reader', MockReader)
+    def testMultipleValidIPv4(self):
+        """Test valid IPv4 addresses result in new attributes"""
+        analyzer = MaxMindDbGeoIPAnalyzer('test', 1)
+        analyzer.GEOIP_CLIENT = MockReader
+        analyzer.datastore.client = mock.Mock()
+
+        _create_mock_event(analyzer.datastore, 0, 1,
+                           source_attrs={
+                               'ip_address': ['8.8.8.8', '8.8.4.4']
+                           })
+
+        message = analyzer.run()
+        event = analyzer.datastore.event_store['0']
+
+        self.assertTrue('ip_address_latitude' in event['_source'])
+        self.assertTrue('ip_address_longitude' in event['_source'])
+        self.assertTrue('ip_address_iso_code' in event['_source'])
+        self.assertTrue('ip_address_city' in event['_source'])
+        self.assertEqual(message, 'Found 2 IP address(es).')
+
+    @mock.patch('timesketch.lib.analyzers.interface.ElasticsearchDataStore',
+                MockDataStore)
+    @mock.patch('geoip2.database.Reader', MockReader)
+    def testMixedValidIP(self):
+        """Test valid IPv4 addresses result in new attributes"""
+        analyzer = MaxMindDbGeoIPAnalyzer('test', 1)
+        analyzer.GEOIP_CLIENT = MockReader
+        analyzer.datastore.client = mock.Mock()
+
+        _create_mock_event(analyzer.datastore, 0, 1,
+                           source_attrs={
+                               'ip_address': ['8.8.8.8', '2001:4860:4860::8844']
+                           })
+
+        message = analyzer.run()
+        event = analyzer.datastore.event_store['0']
+
+        self.assertTrue('ip_address_latitude' in event['_source'])
+        self.assertTrue('ip_address_longitude' in event['_source'])
+        self.assertTrue('ip_address_iso_code' in event['_source'])
+        self.assertTrue('ip_address_city' in event['_source'])
+        self.assertEqual(message, 'Found 2 IP address(es).')
+
+
+
+class TestMaxMindDbWebIPAnalyzer(BaseTest):
+    """Tests for the functionality of the MaxMind web service based Geo IP
+       Analyzer."""
+
+    _TEST_ISO_CODE = 'FLAG_US'
+    _TEST_EMOJI = '&#x1F1FA&#x1F1F8'
+
+
+    def testEmoji(self):
+        """Test a flag emoji exists"""
+        flag_emoji = emojis.get_emoji(self._TEST_ISO_CODE)
+        self.assertEqual(flag_emoji, self._TEST_EMOJI)
+
+    @mock.patch('timesketch.lib.analyzers.interface.ElasticsearchDataStore',
+                MockDataStore)
+    def testValidIPv4(self):
+        """Test valid IPv4 addresses result in new attributes"""
+        analyzer = MaxMindDbWebIPAnalyzer('test', 1)
+        analyzer.GEOIP_CLIENT = MockReader
+
+        analyzer.datastore.client = mock.Mock()
+
+        IP_FIELDS = ['ip', 'host_ip', 'src_ip', 'dst_ip', 'source_ip',
+                     'dest_ip', 'ip_address', 'client_ip', 'address', 'saddr',
+                     'daddr', 'requestMetadata_callerIp', 'a_answer']
+
+        _create_mock_event(analyzer.datastore, 0, 1,
+                           source_attrs={
+                               ip_field: '8.8.8.8' for ip_field in IP_FIELDS
+                           })
+
+        message = analyzer.run()
+        event = analyzer.datastore.event_store['0']
+
+        for ip_field in IP_FIELDS:
+            self.assertTrue('{0}_latitude'.format(ip_field)
+                            in event['_source'])
+            self.assertTrue('{0}_longitude'.format(ip_field)
+                            in event['_source'])
+            self.assertTrue('{0}_iso_code'.format(ip_field)
+                            in event['_source'])
+            self.assertTrue('{0}_city'.format(ip_field)
+                            in event['_source'])
+        self.assertEqual(message, 'Found 1 IP address(es).')
+
+
+    @mock.patch('timesketch.lib.analyzers.interface.ElasticsearchDataStore',
+                MockDataStore)
+    def testValidIPv6(self):
+        """Test valid IPv6 addresses result in new attributes"""
+        analyzer = MaxMindDbWebIPAnalyzer('test', 1)
+        analyzer.GEOIP_CLIENT = MockReader
+
+        analyzer.datastore.client = mock.Mock()
+
+        IP_FIELDS = ['ip', 'host_ip', 'src_ip', 'dst_ip', 'source_ip',
+                     'dest_ip', 'ip_address', 'client_ip', 'address', 'saddr',
+                     'daddr', 'requestMetadata_callerIp', 'a_answer']
+
+        _create_mock_event(analyzer.datastore, 0, 1,
+                           source_attrs={
+                               ip_field: '2001:4860:4860::8888'
+                               for ip_field in IP_FIELDS
+                           })
+
+        message = analyzer.run()
+        event = analyzer.datastore.event_store['0']
+
+        for ip_field in IP_FIELDS:
+            self.assertTrue('{0}_latitude'.format(ip_field)
+                            in event['_source'])
+            self.assertTrue('{0}_longitude'.format(ip_field)
+                            in event['_source'])
+            self.assertTrue('{0}_iso_code'.format(ip_field)
+                            in event['_source'])
+            self.assertTrue('{0}_city'.format(ip_field)
+                            in event['_source'])
+        self.assertEqual(message, 'Found 1 IP address(es).')
+
+
+    @mock.patch('timesketch.lib.analyzers.interface.ElasticsearchDataStore',
+                MockDataStore)
+    def testPrivateIPv4(self):
+        """Test private IPv4 address does not result in new attributes"""
+        analyzer = MaxMindDbWebIPAnalyzer('test', 1)
+        analyzer.GEOIP_CLIENT = MockReader
+        analyzer.datastore.client = mock.Mock()
+
+        _create_mock_event(analyzer.datastore, 0, 1,
+                           source_attrs={
+                               'ip_address': '127.0.0.1'
+                           })
+
+        message = analyzer.run()
+        event = analyzer.datastore.event_store['0']
+
+        self.assertTrue('ip_address_latitude' not in event['_source'])
+        self.assertTrue('ip_address_longitude' not in event['_source'])
+        self.assertTrue('ip_address_iso_code' not in event['_source'])
+        self.assertTrue('ip_address_city' not in event['_source'])
+        self.assertEqual(message, 'Found 0 IP address(es).')
+
+
+    @mock.patch('timesketch.lib.analyzers.interface.ElasticsearchDataStore',
+                MockDataStore)
+    def testInvalidIPv4(self):
+        """Test invalid IP address"""
+        analyzer = MaxMindDbWebIPAnalyzer('test', 1)
+        analyzer.GEOIP_CLIENT = MockReader
+        analyzer.datastore.client = mock.Mock()
+
+        _create_mock_event(analyzer.datastore, 0, 1,
+                           source_attrs={
+                               'ip_address': None
+                           })
+
+        message = analyzer.run()
+        event = analyzer.datastore.event_store['0']
+
+        self.assertTrue('ip_address_latitude' not in event['_source'])
+        self.assertTrue('ip_address_longitude' not in event['_source'])
+        self.assertTrue('ip_address_iso_code' not in event['_source'])
+        self.assertTrue('ip_address_city' not in event['_source'])
+        self.assertEqual(message, 'Found 0 IP address(es).')
+
+
+    @mock.patch('timesketch.lib.analyzers.interface.ElasticsearchDataStore',
+                MockDataStore)
+    def testNoEvents(self):
+        """Test no events"""
+        analyzer = MaxMindDbWebIPAnalyzer('test', 1)
+        analyzer.GEOIP_CLIENT = MockReader
+        analyzer.datastore.client = mock.Mock()
+
+        message = analyzer.run()
+
+        self.assertEqual(message, 'Found 0 IP address(es).')
+
+
+    @mock.patch('timesketch.lib.analyzers.interface.ElasticsearchDataStore',
+                MockDataStore)
+    @mock.patch('geoip2.database.Reader', MockReader)
+    def testMultipleValidIPv4(self):
+        """Test valid IPv4 addresses result in new attributes"""
+        analyzer = MaxMindDbWebIPAnalyzer('test', 1)
+        analyzer.GEOIP_CLIENT = MockReader
+        analyzer.datastore.client = mock.Mock()
+
+        _create_mock_event(analyzer.datastore, 0, 1,
+                           source_attrs={
+                               'ip_address': ['8.8.8.8', '8.8.4.4']
+                           })
+
+        message = analyzer.run()
+        event = analyzer.datastore.event_store['0']
+
+        self.assertTrue('ip_address_latitude' in event['_source'])
+        self.assertTrue('ip_address_longitude' in event['_source'])
+        self.assertTrue('ip_address_iso_code' in event['_source'])
+        self.assertTrue('ip_address_city' in event['_source'])
+        self.assertEqual(message, 'Found 2 IP address(es).')
+
+    @mock.patch('timesketch.lib.analyzers.interface.ElasticsearchDataStore',
+                MockDataStore)
+    @mock.patch('geoip2.database.Reader', MockReader)
+    def testMixedValidIP(self):
+        """Test valid IPv4 addresses result in new attributes"""
+        analyzer = MaxMindDbWebIPAnalyzer('test', 1)
+        analyzer.GEOIP_CLIENT = MockReader
+        analyzer.datastore.client = mock.Mock()
+
+        _create_mock_event(analyzer.datastore, 0, 1,
+                           source_attrs={
+                               'ip_address': ['8.8.8.8', '2001:4860:4860::8844']
+                           })
+
+        message = analyzer.run()
+        event = analyzer.datastore.event_store['0']
+
+        self.assertTrue('ip_address_latitude' in event['_source'])
+        self.assertTrue('ip_address_longitude' in event['_source'])
+        self.assertTrue('ip_address_iso_code' in event['_source'])
+        self.assertTrue('ip_address_city' in event['_source'])
+        self.assertEqual(message, 'Found 2 IP address(es).')