#!/usr/bin/env python3 import csv import datetime from email.message import EmailMessage from email.headerregistry import Address import smtplib import sys import time import packaging.version import pytz import requests from nspylib.clients import PWSafeAPI from nspylib.clients.ca_certs import rs_root_ca_1 from nscli.auth.ns_auth import Token from nscli.plugins.nscli_core.version import get_standard_versions, get_cache_standard_version def query_newrelic(api_key, nrql, account="929627"): """ Query the newrelic insights API using the given NRQL query string and API key. :param api_key: API query key :type api_key: str :param nrql: NRQL query :type nrql: str :param account: NewRelic Insights account number :type account: str :return: The relevant event series :rtype: list """ r = requests.get( "https://insights-api.newrelic.com/v1/accounts/{}/query".format(account), params={"nrql": nrql}, headers={"X-Query-Key": api_key} ) r.raise_for_status() return r.json()["results"][0]["events"] def event_generator(api_key, max_age, chunk_size=1000, delay=0): """ The NewRelic Insights API doesn't provide a straightforward way to grab all of the records for a given time- frame. There is a hard limit of 1000 records returned per query. So following the advice of https://discuss.newrelic.com/t/paging-data-through-insights-query-api/14920, this function returns a generator that will perform sequential API queries backwards in time until reaching records that occur at least `max_age` in the past. Note: Because of the timestamp criteria we are using in our query, this generator will return duplicate events in consecutive chunks. There should always be an overlap of one event per pair of chunks, or potentially more in rare circumstances. :param api_key: query key :type api_key: str :param max_age: how far back to poll :type max_age: datetime.timedelta :param chunk_size: How many events to gather per API call :type chunk_size: int :param delay: How long to wait between API calls (self-throttling) :type delay: float :return: generator :rtype: generator """ # 1 min from now in milliseconds soon_int = int((datetime.datetime.now() + datetime.timedelta(minutes=1)).timestamp() * 1000) # max_age (60 days) in milliseconds max_age_int = int((datetime.datetime.now() - max_age).timestamp() * 1000) # max_age days as int (60) max_age_days = int(max_age.days) if max_age_days < 0: max_age_days = 0 max_age_days += 1 print("Polling NewRelic ...") oldest_record_int = soon_int # loop through chunk_size events going back until you reach timestamp matching max_age ago while oldest_record_int > max_age_int: nrql = ( f"SELECT User, `nscli.version` FROM NSCLI WHERE timestamp <= {oldest_record_int} SINCE {max_age_days} days ago LIMIT {chunk_size}" ) try: events = query_newrelic(api_key, nrql) except Exception as e: print(f"Failed at query {nrql}. Error: {e}") print("Trying last query again.") events = query_newrelic(api_key, nrql) if not events: return yield events oldest_record_int = min([i["timestamp"] for i in events]) time.sleep(delay) def process(events, min_version, token): """ Returns a list of users who have consistently used an out-of-date version of nscli over the event series given.""" old_users = dict() current_users = set() flagrant_errors = dict() for event in events: sso = event["User"] timestamp = event["timestamp"] try: version = packaging.version.parse(event["nscli.version"]) except Exception: raw_version = event.get("nscli.version") flagrant_errors.setdefault(raw_version, set()) flagrant_errors[raw_version].update([sso]) else: if version >= min_version: current_users.update([sso]) else: old_users.setdefault(sso, timestamp) if old_users[sso] < timestamp: old_users[sso] = timestamp if flagrant_errors: print("FLAGRANT SYSTEM ERROR\n---------------------\nThere is something deeply weird with these logs:") for error in flagrant_errors: print(" {}".format(error)) for user in flagrant_errors[error]: print(" {}".format(user)) print("\n") bad_users = { user: timestamp for user, timestamp in old_users.items() if user not in current_users } records = [] if bad_users: print("Polling identity.corp ...") for user in bad_users: try: last_usage = datetime.datetime.fromtimestamp(bad_users[user] / 1000, tz=pytz.utc).isoformat() except Exception: last_usage = "Unknown" data = identity_lookup(token.identity_token, user) if not data: print("Finder API lookup failed for {}".format(user)) continue if data.get("userAccountControl") != "NORMAL_ACCOUNT": print("{} was terminated.".format(data.get("displayName") or user)) continue records.append({ "sso": user, "email": data.get("mail"), "name": data.get("displayName"), # "manager_sso": data.get("manager", {}).get("samAccountName"), "location": data.get("l"), "last_usage": last_usage }) # managers = {} # for manager_sso in set([i["manager_sso"] for i in records]): # data = identity_lookup(token.identity_token, manager_sso) # if not data: # print("Finder API lookup failed for {}".format(manager_sso)) # continue # if data.get("userAccountControl") != "NORMAL_ACCOUNT": # print("{} was terminated.".format(data.get("displayName") or manager_sso)) # continue # managers.update({manager_sso: {"name": data.get("displayName"), "mail": data.get("mail")}}) # for record in records: # manager = managers[record["manager_sso"]] # record.update({ # "manager_name": manager["name"], # "manager_mail": manager["mail"] # }) # del record["manager_sso"] return records def identity_lookup(token, samaccountname): headers = { "X-Auth-Token": token, "Content-Type": "application/json" } url = "https://api.identity.rackspace.corp/v1.0/ad/user/{}".format(samaccountname) r = requests.get(url, headers=headers, verify=str(rs_root_ca_1)) if not r.ok: return None try: return r.json()["data"] except Exception: return None # TODO: Dynamically format "In the past 60 days" to max_age days def assemble_message(record): message = EmailMessage() sso = record["sso"] name = record["name"] email = record["email"] # manager_name = record["manager_name"] # manager_email = record["manager_mail"] last_usage = datetime.datetime.strptime(record["last_usage"], "%Y-%m-%dT%H:%M:%S.%f%z") message["Subject"] = "NSCLI out of compliance" message["From"] = Address("NSCLI Dev", addr_spec="nscli@rackspace.com") message["To"] = Address(name, addr_spec=email) # message["CC"] = Address(manager_name, addr_spec=manager_email) txt_body = (f"""Hello Racker, We have noticed that you are using an out-of-date version of NSCLI. Last NSCLI usage by {sso}: {last_usage.strftime("%c")} UTC In the past 60 days, you have never used a version of NSCLI that was up-to-date at the time. New releases of NSCLI often contain time-sensitive bugfixes that account for changes to Rackspace infrastructure or are prompted by RCAs. For this reason, and because it is required by the service bulletin at https://raxglobal.sharepoint.com/sites/OneWiki-NetworkSecurity/SitePages/Service%20Bulletin%20-%20Upgrade%20NSCLI.aspx , you should upgrade immediately. Please refer to https://pages.github.rackspace.com/nsi/nscli/Installation.html for instructions on how to upgrade NSCLI. If you encounter any issues, help is available in the #nscli Yammer community and on the nscli@rackspace.com mailing list. Reminder: If you are using Ubuntu 18.04 or MacOS 10.14 (Mojave), please follow alternative installation instructions on our NSCLI Troubleshooting wiki at https://raxglobal.sharepoint.com/sites/OneWiki-NetworkSecurity/SitePages/NSCLI%20-%20Issues%20-%20Bugs%20-%20Known%20Workarounds.aspx . Thank You, The NSI Team """) message.set_content(txt_body) html_paragraphs = txt_body.split("\n\n") html_body = "".join([f"
{i}
" for i in html_paragraphs]) html_body = html_body.replace( "https://raxglobal.sharepoint.com/sites/OneWiki-NetworkSecurity/SitePages/Service%20Bulletin%20-%20Upgrade%20NSCLI.aspx ", "https://raxglobal.sharepoint.com/sites/OneWiki-NetworkSecurity/SitePages/Service%20Bulletin%20-%20Upgrade%20NSCLI.aspx", 1 ) html_body = html_body.replace( "https://pages.github.rackspace.com/nsi/nscli/Installation.html", "https://pages.github.rackspace.com/nsi/nscli/Installation.html", 1 ) html_body = html_body.replace( "https://raxglobal.sharepoint.com/sites/OneWiki-NetworkSecurity/SitePages/NSCLI%20-%20Issues%20-%20Bugs%20-%20Known%20Workarounds.aspx", "NSCLI - Issues / Bugs / Known Workarounds", 1 ) html_body = html_body.replace( "#nscli", "NSCLI", 1 ) html_body = html_body.replace( "nscli@rackspace.com", "nscli@rackspace.com", 1 ) html_body = html_body.replace( "Thank You,\nThe NSI Team", "Thank You,