def rule(event): # Pre-filter to save compute time where possible. if event.udm("event_type") != event_type.SUCCESSFUL_LOGIN: return False # we use udm 'actor_user' field as a ddb and 'source_ip' in the api call if not event.udm("actor_user") or not event.udm("source_ip"): return False # Lookup geo-ip data via API call url = "https://ipinfo.io/" + event.udm("source_ip") + "/geo" # Skip API call if this is a unit test if "panther_api_data" in event: resp = lambda: None setattr(resp, "status_code", 200) setattr(resp, "text", event.get("panther_api_data")) else: # This response looks like the following: # {‘ip': '8.8.8.8', 'city': 'Mountain View', 'region': 'California', 'country': 'US', # 'loc': '37.4056,-122.0775', 'postal': '94043', 'timezone': 'America/Los_Angeles'} resp = requests.get(url) if resp.status_code != 200: raise Exception( f"API call failed: GET {url} returned {resp.status_code}") login_info = json.loads(resp.text) # The idea is to create a fingerprint of this login, and then keep track of all the fingerprints # for a given user's logins. In this way, we can detect unusual logins. login_tuple = login_info.get("region", "<REGION>") + ":" + login_info.get( "city", "<CITY>") EVENT_LOGIN_INFO[event.get("p_row_id")] = login_tuple # Lookup & store persistent data event_key = get_key(event) last_login_info = get_string_set(event_key) fingerprint_timestamp = str(datetime.datetime.now()) if not last_login_info: # Store this as the first login if we've never seen this user login before put_string_set(event_key, [json.dumps({login_tuple: fingerprint_timestamp})]) return False last_login_info = json.loads(last_login_info.pop()) # update the timestamp associated with this fingerprint last_login_info[login_tuple] = fingerprint_timestamp put_string_set(event_key, [json.dumps(last_login_info)]) # fire an alert when number of unique, recent fingerprints is greater than a threshold if len(last_login_info) > FINGERPRINT_THRESHOLD: oldest = login_tuple for fp_tuple, fp_time in last_login_info.items(): if fp_time < oldest: oldest = fp_tuple # remove oldest login tuple last_login_info.pop(oldest) put_string_set(event_key, [json.dumps(last_login_info)]) return True return False
def rule(event): # Pre-filter to save compute time where possible. event_type_id = 5 is login events. if event.get('event_type_id') != 5 or event.get('ipaddr') is None: return False # Lookup geo-ip data via API call url = 'https://ipinfo.io/' + event['ipaddr'] + '/geo' # Skip API call if this is a unit test if 'panther_api_data' in event: resp = lambda: None setattr(resp, 'status_code', 200) setattr(resp, 'text', event['panther_api_data']) else: # This response looks like the following: # {‘ip': '8.8.8.8', 'city': 'Mountain View', 'region': 'California', 'country': 'US', # 'loc': '37.4056,-122.0775', 'postal': '94043', 'timezone': 'America/Los_Angeles'} resp = requests.get(url) if resp.status_code != 200: # Could raise an exception here for ops team to look into return False login_info = json.loads(resp.text) # The idea is to create a fingerprint of this login, and then keep track of all the fingerprints # for a given user's logins. In this way, we can detect unusual logins. login_tuple = login_info.get('region', '<REGION>') + ":" + login_info.get( 'city', '<CITY>') EVENT_LOGIN_INFO[event['p_row_id']] = login_tuple # Lookup & store persistent data event_key = get_key(event) last_login_info = get_string_set(event_key) if not last_login_info: # Store this as the first login if we've never seen this user login before put_string_set(event_key, [json.dumps({login_tuple: 1})]) return False last_login_info = json.loads(last_login_info.pop()) last_login_info[login_tuple] = last_login_info.get(login_tuple, 0) + 1 put_string_set(event_key, [json.dumps(last_login_info)]) # Here we are checking if this login's fingerprint is one of the top three most common # fingerprints for this user. If it is not, we fire an alert. tuple_count = last_login_info[login_tuple] higher_tuples = 0 for tcount in last_login_info.values(): if tcount > tuple_count: higher_tuples += 1 if higher_tuples >= FINGERPRINT_THRESHOLD: return True return False
def rule(event): if event.get("action").startswith( "git.") and not event.get("repository_public"): # if the actor field is empty, short circuit the rule if not event.udm("actor_user"): return False # otherwise trigger on any of the git actions, http or ssh key = get_key(event) previous_access = get_string_set(key) if not previous_access: put_string_set(key, key) return True return False
def rule(event): # Only evaluate successful logins if (event.get("eventType") != "user.session.start" or deep_get(event, "outcome", "result") == "FAILURE"): return False new_login_stats = { "city": deep_get(event, "client", "geographicalContext", "city"), "lon": deep_get(event, "client", "geographicalContext", "geolocation", "lon"), "lat": deep_get(event, "client", "geographicalContext", "geolocation", "lat"), } # Bail out if we have a None value in set as it causes false positives if None in new_login_stats.values(): return False # Generate a unique cache key for each user login_key = gen_key(event) # Retrieve the prior login info from the cache, if any last_login = get_string_set(login_key) # If we haven't seen this user login recently, store this login for future use and don't alert if not last_login: store_login_info(login_key, event) return False # Load the last login from the cache into an object we can compare old_login_stats = loads(last_login.pop()) distance = haversine_distance(old_login_stats, new_login_stats) old_time = datetime.strptime(old_login_stats["time"][:26], PANTHER_TIME_FORMAT) new_time = datetime.strptime( event.get("p_event_time")[:26], PANTHER_TIME_FORMAT) time_delta = (new_time - old_time).total_seconds() / 3600 # seconds in an hour # Don't let time_delta be 0 (divide by zero error below) time_delta = time_delta or 0.0001 # Calculate speed in Kilometers / Hour speed = distance / time_delta # Calculation is complete, so store the most recent login for the next check store_login_info(login_key, event) EVENT_CITY_TRACKING[event.get("p_row_id")] = { "new_city": new_login_stats.get("city", "<UNKNOWN_NEW_CITY>"), "old_city": old_login_stats.get("city", "<UNKNOWN_OLD_CITY>"), } return speed > 900 # Boeing 747 cruising speed
def rule(event): # Only evaluate successful logins if (event['eventType'] != 'user.session.start' or deep_get(event, 'outcome', 'result') == 'FAILURE'): return False # Generate a unique cache key for each user login_key = gen_key(event) # Retrieve the prior login info from the cache, if any last_login = get_string_set(login_key) # If we haven't seen this user login recently, store this login for future use and don't alert if not last_login: store_login_info(login_key, event) return False # Load the last login from the cache into an object we can compare old_login_stats = loads(last_login.pop()) new_login_stats = { 'city': deep_get(event, 'client', 'geographicalContext', 'city'), 'lon': deep_get(event, 'client', 'geographicalContext', 'geolocation', 'lon'), 'lat': deep_get(event, 'client', 'geographicalContext', 'geolocation', 'lat'), } distance = haversine_distance(old_login_stats, new_login_stats) old_time = datetime.strptime(old_login_stats['time'][:26], PANTHER_TIME_FORMAT) new_time = datetime.strptime(event['p_event_time'][:26], PANTHER_TIME_FORMAT) time_delta = (new_time - old_time).total_seconds() / 3600 # seconds in an hour # Don't let time_delta be 0 (divide by zero error below) time_delta = time_delta or .0001 # Calculate speed in Kilometers / Hour speed = distance / time_delta # Calculation is complete, so store the most recent login for the next check store_login_info(login_key, event) EVENT_CITY_TRACKING[event['p_row_id']] = { 'new_city': new_login_stats.get('city', 'Not Found'), 'old_city': old_login_stats.get('city', 'Not Found'), } return speed > 900 # Boeing 747 cruising speed
def rule(event): # if the actor field is empty, short circuit the rule if not event.udm("actor_user"): return False if event.get("action") in CODE_ACCESS_ACTIONS and not event.get( "repository_public"): # Compute unique entry for this user + repo key = get_key(event) previous_access = get_string_set(key) if not previous_access: put_string_set(key, key) return True return False
def rule(event): # We only want to successful evaluate user logins if event['eventType'] != 'user.session.start' or event.get( 'outcome', {}).get('result') == 'FAILURE': return False # Generate a unique key for each user login_key = gen_key(event) # Retrieve the prior login info, if any last_login = get_string_set(login_key) # If we haven't seen this user login recently, store this login for future # use and don't alert if not last_login: store_login_info(login_key, event) return False # Load the last login into an object we can compare old_login_stats = loads(last_login.pop()) new_login_stats = { 'lon': event['client']['geographicalContext']['geolocation']['lon'], 'lat': event['client']['geographicalContext']['geolocation']['lat'], } distance = haversine_distance(old_login_stats, new_login_stats) old_time = datetime.strptime(old_login_stats['time'][:26], PANTHER_TIME_FORMAT) new_time = datetime.strptime(event['p_event_time'][:26], PANTHER_TIME_FORMAT) time_delta = (new_time - old_time).total_seconds() / 3600 # seconds in an hour # Don't let time_delta be 0 (divide by zero error below) time_delta = time_delta or .0001 # Calculate speed in Kilometers / Hour speed = distance / time_delta # Calculation is complete, so store the most recent login for the next check store_login_info(login_key, event) return speed > 900 # Boeing 747 cruising speed
def rule(event): # Pre-filter: event_type_id = 5 is login events. if event.get('event_type_id') != 5 or not event.get( 'ipaddr') or not event.get('user_id'): return False # We expect to see multiple user logins from these shared, common ip addresses if is_ip_in_network(event.get('ipaddr'), SHARED_IP_SPACE): return False # This tracks multiple successful logins for different accounts from the same ip address # First, keep a list of unique user ids that have logged in from this ip address event_key = get_key(event) user_ids = get_string_set(event_key) # the user id of the user that has just logged in user_id = str(event.get('user_id')) if not user_ids: # store this as the first user login from this ip address put_string_set(event_key, [user_id]) set_key_expiration(event_key, int(time.time()) + THRESH_TTL) return False # add a new username if this is a unique user from this ip address if user_id not in user_ids: user_ids = add_to_string_set(event_key, user_id) set_key_expiration(event_key, int(time.time()) + THRESH_TTL) return len(user_ids) > THRESH
def rule(event): # unique key for global dictionary log = event.get("p_row_id") # GEO_INFO is mocked as a string in unit tests and redeclared as a dict global GEO_INFO # pylint: disable=global-statement # Pre-filter to save compute time where possible. if event.udm("event_type") != event_type.SUCCESSFUL_LOGIN: return False # we use udm 'actor_user' field as a ddb and 'source_ip' in the api call if not event.udm("actor_user") or not event.udm("source_ip"): return False # Lookup geo-ip data via API call # Mocked during unit testing GEO_INFO[log] = geoinfo_from_ip(event.udm("source_ip")) # As of Panther 1.19, mocking returns all mocked objects in a string # GEO_INFO must be converted back to a dict to mimic the API call if isinstance(GEO_INFO[log], str): GEO_INFO[log] = json.loads(GEO_INFO[log]) # Look up history of unique geolocations event_key = get_key(event) # Mocked during unit testing previous_geo_logins = get_string_set(event_key) # As of Panther 1.19, mocking returns all mocked objects in a string # previous_geo_logins must be converted back to a set to mimic the API call if isinstance(previous_geo_logins, str): logging.debug("previous_geo_logins is a mocked string:") logging.debug(previous_geo_logins) if previous_geo_logins: previous_geo_logins = ast.literal_eval(previous_geo_logins) else: previous_geo_logins = set() logging.debug("new type of previous_geo_logins should be 'set':") logging.debug(type(previous_geo_logins)) new_login_geo = (f"{GEO_INFO[log].get('region', '<UNKNOWN_REGION>')}" ":" f"{GEO_INFO[log].get('city', '<UNKNOWN_CITY>')}") new_login_timestamp = event.get("p_event_time", "") # convert set of single string to dictionary if previous_geo_logins: previous_geo_logins = json.loads(previous_geo_logins.pop()) else: previous_geo_logins = {} logging.debug("new type of previous_geo_logins should be 'dict':") logging.debug(type(previous_geo_logins)) # don't alert if the geo is already in the history if previous_geo_logins.get(new_login_geo): # update timestamp of the existing geo in the history previous_geo_logins[new_login_geo] = new_login_timestamp # write the dictionary of geolocs:timestamps back to Dynamo # Mocked during unit testing put_string_set(event_key, [json.dumps(previous_geo_logins)]) return False # fire an alert when there are more unique geolocs:timestamps in the login history # add a new geo to the dictionary updated_geo_logins = previous_geo_logins updated_geo_logins[new_login_geo] = new_login_timestamp # remove the oldest geo from the history if the updated dict exceeds the # specified history length if len(updated_geo_logins) > GEO_HISTORY_LENGTH: oldest = updated_geo_logins[new_login_geo] for geo, time in updated_geo_logins.items(): if time < oldest: oldest = time oldest_login = geo logging.debug("updated_geo_logins before removing oldest entry:") logging.debug(updated_geo_logins) updated_geo_logins.pop(oldest_login) logging.debug("updated_geo_logins after removing oldest entry:") logging.debug(updated_geo_logins) # Mocked during unit testing put_string_set(event_key, [json.dumps(updated_geo_logins)]) global GEO_HISTORY # pylint: disable=global-statement GEO_HISTORY[log] = updated_geo_logins logging.debug("GEO_HISTORY in main rule:\n%s", json.dumps(GEO_HISTORY[log])) return True