AnalysisType: rule
Filename: notion_login_from_new_location.py
RuleID: "Notion.LoginFromNewLocation"
DisplayName: "Notion Login from New Location"
Enabled: true
LogTypes:
- Notion.AuditLogs
Tags:
- Notion
- Identity & Access Management
- Login & Access Patterns
Severity: Medium
Description: A Notion User logged in from a new location.
DedupPeriodMinutes: 60
Threshold: 1 # Number of pages deleted; please change this value to suit your organization's needs.
Runbook: Possible account takeover. Follow up with the Notion User to determine if this login is genuine.
Reference: https://ipinfo.io/products/ip-geolocation-api
Tests:
- Name: Login from normal location
ExpectedResult: false
Mocks:
- objectName: get_dictionary
returnValue: '{ "Minas Tirith_Pellenor_Gondor": 1686542031 }'
- objectName: put_dictionary
returnValue: False
Log:
{
"event":
{
"actor":
{
"id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"object": "user",
"person": { "email": "aragorn.elessar@lotr.com" },
"type": "person",
},
"details": { "authType": "email" },
"id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"ip_address": "192.168.100.100",
"platform": "web",
"timestamp": "2023-06-12 21:40:28.690000000",
"type": "user.login",
"workspace_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
},
"p_enrichment":
{
"ipinfo_location":
{
"event.ip_address":
{
"city": "Minas Tirith",
"lat": "0.00000",
"lng": "0.00000",
"country": "Gondor",
"postal_code": "55555",
"region": "Pellenor",
"region_code": "PL",
"timezone": "Middle Earth/Pellenor",
},
},
},
"p_event_time": "2023-06-12 21:40:28.690000000",
"p_log_type": "Notion.AuditLogs",
"p_parse_time": "2023-06-12 22:53:51.602223297",
"p_row_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"p_schema_version": 0,
"p_source_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"p_source_label": "Notion Logs",
}
- Name: No previous recorded login
ExpectedResult: false
Mocks:
- objectName: get_dictionary
returnValue: ""
- objectName: put_dictionary
returnValue: False
Log:
{
"event":
{
"actor":
{
"id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"object": "user",
"person": { "email": "aragorn.elessar@lotr.com" },
"type": "person",
},
"details": { "authType": "email" },
"id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"ip_address": "192.168.100.100",
"platform": "web",
"timestamp": "2023-06-12 21:40:28.690000000",
"type": "user.login",
"workspace_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
},
"p_enrichment":
{
"ipinfo_location":
{
"event.ip_address":
{
"city": "Minas Tirith",
"lat": "0.00000",
"lng": "0.00000",
"country": "Gondor",
"postal_code": "55555",
"region": "Pellenor",
"region_code": "PL",
"timezone": "Middle Earth/Pellenor",
},
},
},
"p_event_time": "2023-06-12 21:40:28.690000000",
"p_log_type": "Notion.AuditLogs",
"p_parse_time": "2023-06-12 22:53:51.602223297",
"p_row_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"p_schema_version": 0,
"p_source_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"p_source_label": "Notion Logs",
}
- Name: Login from different location
ExpectedResult: true
Mocks:
- objectName: get_dictionary
returnValue: '{ "Minas Tirith_Pellenor_Gondor": 1686542031 }'
- objectName: put_dictionary
returnValue: False
Log:
{
"event":
{
"actor":
{
"id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"object": "user",
"person": { "email": "aragorn.elessar@lotr.com" },
"type": "person",
},
"details": { "authType": "email" },
"id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"ip_address": "192.168.100.100",
"platform": "web",
"timestamp": "2023-06-12 21:40:28.690000000",
"type": "user.login",
"workspace_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
},
"p_enrichment":
{
"ipinfo_location":
{
"event.ip_address":
{
"city": "Barad-Dur",
"lat": "0.00000",
"lng": "0.00000",
"country": "Mordor",
"postal_code": "55555",
"region": "Mount Doom",
"region_code": "MD",
"timezone": "Middle Earth/Mordor",
},
},
},
"p_event_time": "2023-06-12 21:40:28.690000000",
"p_log_type": "Notion.AuditLogs",
"p_parse_time": "2023-06-12 22:53:51.602223297",
"p_row_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"p_schema_version": 0,
"p_source_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"p_source_label": "Notion Logs",
}
- Name: Missing enrichment
ExpectedResult: false
Log:
{
"event":
{
"actor":
{
"id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"object": "user",
"person": { "email": "aragorn.elessar@lotr.com" },
"type": "person",
},
"details": { "authType": "email" },
"id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"ip_address": "192.168.100.100",
"platform": "web",
"timestamp": "2023-06-12 21:40:28.690000000",
"type": "user.login",
"workspace_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
},
"p_enrichment": {},
"p_event_time": "2023-06-12 21:40:28.690000000",
"p_log_type": "Notion.AuditLogs",
"p_parse_time": "2023-06-12 22:53:51.602223297",
"p_row_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"p_schema_version": 0,
"p_source_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"p_source_label": "Notion Logs",
}
- Name: Unrelated event
ExpectedResult: false
Log:
{
"event":
{
"actor":
{
"id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"object": "user",
"person": { "email": "aragorn.elessar@lotr.com" },
"type": "person",
},
"details": { "authType": "email" },
"id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"ip_address": "192.168.100.100",
"platform": "web",
"timestamp": "2023-06-12 21:40:28.690000000",
"type": "page.viewed",
"workspace_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
},
"p_enrichment":
{
"ipinfo_location":
{
"event.ip_address":
{
"city": "Barad-Dur",
"lat": "0.00000",
"lng": "0.00000",
"country": "Mordor",
"postal_code": "55555",
"region": "Mount Doom",
"region_code": "MD",
"timezone": "Middle Earth/Mordor",
},
},
},
"p_event_time": "2023-06-12 21:40:28.690000000",
"p_log_type": "Notion.AuditLogs",
"p_parse_time": "2023-06-12 22:53:51.602223297",
"p_row_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"p_schema_version": 0,
"p_source_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"p_source_label": "Notion Logs",
}
- Name: Login from different location - no region
ExpectedResult: true
Mocks:
- objectName: get_dictionary
returnValue: '{ "Minas Tirith_Pellenor_Gondor": 1686542031 }'
- objectName: put_dictionary
returnValue: False
Log:
{
"event":
{
"actor":
{
"id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"object": "user",
"person": { "email": "aragorn.elessar@lotr.com" },
"type": "person",
},
"details": { "authType": "email" },
"id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"ip_address": "192.168.100.100",
"platform": "web",
"timestamp": "2023-06-12 21:40:28.690000000",
"type": "user.login",
"workspace_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
},
"p_enrichment":
{
"ipinfo_location":
{
"event.ip_address":
{
"city": "Barad-Dur",
"lat": "0.00000",
"lng": "0.00000",
"country": "Mordor",
"postal_code": "55555",
"region_code": "MD",
"timezone": "Middle Earth/Mordor",
},
},
},
"p_event_time": "2023-06-12 21:40:28.690000000",
"p_log_type": "Notion.AuditLogs",
"p_parse_time": "2023-06-12 22:53:51.602223297",
"p_row_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"p_schema_version": 0,
"p_source_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"p_source_label": "Notion Logs",
}
# ------ paired body: notion_login_from_new_location.py ------
import datetime
import json
import time
from panther_detection_helpers.caching import get_dictionary, put_dictionary
from panther_ipinfo_helpers import IPInfoLocation
from panther_notion_helpers import notion_alert_context
# How long (in seconds) to keep previous login locations in cached memory
DEFAULT_CACHE_PERIOD = 2419200
def rule(event):
# Only focused on login events
if event.deep_walk("event", "type") != "user.login":
return False
# Get the user's location, via IPInfo
# Return False if we have no location information
if "ipinfo_location" not in event.get("p_enrichment", {}):
return False
# pylint: disable=global-variable-undefined
global IPINFO_LOC
IPINFO_LOC = IPInfoLocation(event)
path_to_ip = "event.ip_address"
city = IPINFO_LOC.city(path_to_ip) or ""
region = IPINFO_LOC.region(path_to_ip) or ""
country = IPINFO_LOC.country(path_to_ip) or ""
loc_string = "_".join((city, region, country))
# Store the login location. The premise is to create a new entry for each combimation of user
# and location, and then have those records persist for some length of time (4 weeks by
# default).
# Store the login location. Here, we use Panther's cache to store a dictionary, using the
# user's unique ID to ensure it hold data unique to them. In this dictionary, we'll use the
# location strings (loc_string) as the key, and the values will be the timestamp of the last
# recorded login from that location.
user = event.deep_walk("event", "actor", "id")
cache = get_dictionary(user) or {}
# If this is a unit test, convert cache from string
if isinstance(cache, str):
cache = json.loads(cache)
# -- Step 1: Record this login.
new_cache = cache.copy()
new_cache[loc_string] = time.time()
put_dictionary(user, new_cache)
# -- Step 2: Determine if we shoul raise an alert.
if not cache:
# User hasn't been recorded logging in before. Since this is their first login, we don't
# have a baseline to know if it's unusual, so we won't raise an alert.
return False
if is_recent_login(cache, loc_string, event.get("p_parse_time")):
# User has logged in from this location in the recent past. No need to raise an alert.
return False
# User has NOT logged in from this location in the recent past - we should trigger an alert!
return True
def title(event):
path_to_ip = "event.ip_address"
city = IPINFO_LOC.city(path_to_ip)
region = IPINFO_LOC.region(path_to_ip)
country = IPINFO_LOC.country(path_to_ip)
user_email = event.deep_walk("event", "actor", "person", "email", default="UNKNOWN_EMAIL")
return f"Notion [{user_email}] logged in from a new location: {city}, {region}, {country}."
def alert_context(event):
path_to_ip = "event.ip_address"
city = IPINFO_LOC.city(path_to_ip)
region = IPINFO_LOC.region(path_to_ip)
country = IPINFO_LOC.country(path_to_ip)
user_email = event.deep_walk("event", "actor", "person", "email", default="UNKNOWN_EMAIL")
context = notion_alert_context(event)
context["user_email"] = user_email
context["location"] = {"city": city, "region": region, "country": country}
return context
def is_recent_login(cache: dict, loc_string: str, parse_time: str) -> bool:
# Use p_parse_time to calculate current timestamp, so that unit tests work.
now = time.mktime(datetime.datetime.fromisoformat(parse_time[:23]).timetuple())
return (
loc_string in cache # location was previously recorded
and cache[loc_string] > now - DEFAULT_CACHE_PERIOD # last recorded login is recent
)