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.udm("event_type") != event_type.ACCOUNT_CREATED: return False account_id = parse_new_account_id(event) event_time = resolve_timestamp_string(event.get("p_event_time")) expiry_time = event_time + TTL account_event_id = f"new_aws_account_{event.get('p_row_id')}" if account_id: put_string_set("new_account - " + account_id, [account_event_id], expiry_time.strftime("%s")) return True
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): if event.udm("event_type") != event_type.USER_ACCOUNT_CREATED: return False user_event_id = f"new_user_{event.get('p_row_id')}" new_user = event.udm("user") new_account = event.udm("user_account_id") or "<UNKNOWN_ACCOUNT>" event_time = resolve_timestamp_string(event.get("p_event_time")) expiry_time = event_time + TTL if new_user: put_string_set(new_user + "-" + str(new_account), [user_event_id], expiry_time.strftime("%s")) return True
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 store_login_info(key, event): # Map the user to the lon/lat and time of the most recent login put_string_set(key, [ dumps({ 'lon': event['client']['geographicalContext']['geolocation']['lon'], 'lat': event['client']['geographicalContext']['geolocation']['lat'], 'time': event['p_event_time'] }) ]) # Expire the entry after a week so the table doesn't fill up with past users set_key_expiration(key, str((datetime.now() + timedelta(days=7)).timestamp()))
def store_login_info(key, event): # Map the user to the lon/lat and time of the most recent login put_string_set( key, [ dumps({ "city": deep_get(event, "client", "geographicalContext", "city"), "lon": deep_get(event, "client", "geographicalContext", "geolocation", "lon"), "lat": deep_get(event, "client", "geographicalContext", "geolocation", "lat"), "time": event.get("p_event_time"), }) ], ) # Expire the entry after a week so the table doesn't fill up with past users set_key_expiration(key, str((datetime.now() + timedelta(days=7)).timestamp()))
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: 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