RankServiceOwners
Recommend most likely service owners from those surfaced by Cortex ASM Enrichment.
python · Cortex Attack Surface Management
Source
from collections.abc import Iterable, Callable from typing import Any import google.cloud.storage import numpy as np import posixpath import dill as pickle import os import itertools import string import re import math from itertools import groupby import traceback import demistomock as demisto # noqa: F401 from CommonServerPython import * # noqa: F401 demisto.debug("pack name = Cortex Attack Surface Management, pack version = 1.7.65") """Script for identifying and recommending the most likely owners of a discovered service from those surfaced by Cortex ASM Enrichment. """ STRING_DELIMITER = " | " # delimiter used for joining source fields and any additional fields of type string # Normalize owner scores to be within the following bounds. # We want to use a standard scale (e.g. between 0 and 1) for interpretability. # However, we expect that normalizing to greater-than-half "probabilities" is # likely more accurate, # given that there are stringent conditions on initial detection # such that any name should be considered well-attested and likely to be an owner. SCORE_LOWER_BOUND = 0.5 SCORE_UPPER_BOUND = 1.0 def load_pickled_xpanse_object(file_name: str, cache_path: str = "/tmp/xpanse-ml") -> Any: """ Returns the pickled object at `file_name` as a Python object, either using the local cache or retrieving from the remote bucket as needed. The default cache is a subdirectory of /tmp directory will cache persistently across interactions. Data saved to /var/lib/demisto will be lost betwen interactions (not cached). """ remote_gcs_bucket = "xpanse-service-ownership-ml-models" remote_gcs_path = "" # ok for this to be empty string os.makedirs(cache_path, exist_ok=True) cached_file_path = os.path.join(cache_path, file_name) # check that file is not empty; if authorization fails it will # create the cache_path but the file will be empty if not (os.path.exists(cached_file_path) and os.path.getsize(cached_file_path)): # The relevant infrastructure-related service account needs to be granted # read access to the GCS bucket, or at least the resource at `remote_path` remote_path = posixpath.join(remote_gcs_path, file_name) demisto.info(f"Starting download of '{file_name}' from gs://{remote_gcs_bucket}/{remote_path}") client = google.cloud.storage.client.Client() bucket = client.bucket(remote_gcs_bucket) blob = bucket.blob(remote_path) blob.download_to_filename(cached_file_path) demisto.info(f"Downloaded '{file_name}' from gs://{remote_gcs_bucket}/{remote_path}") else: demisto.info(f"Found '{file_name}' locally") with open(cached_file_path, "rb") as f: return pickle.load(f) def featurize(asm_system_ids: list[str], owners: list[dict[str, Any]]) -> np.ndarray: """ Convert owners information into numerical array for model inference """ pipeline = OwnerFeaturizationPipeline() feats = pipeline.featurize(asm_system_ids, owners) return feats def normalize_scores( scores: list[float], lower_bound: float = SCORE_LOWER_BOUND, upper_bound: float = SCORE_UPPER_BOUND, ) -> list[float]: """ Normalizes a list of non-negative reals to values in range specified by `lower_bound` and `upper_bound` """ if lower_bound < 0 or upper_bound < 0: raise ValueError("Lower and upper bounds must be non-negative") if lower_bound > upper_bound: raise ValueError("Lower bound must be greater than or equal to upper bound") if not len(scores): return scores max_val = max(scores) min_val = min(scores) if max_val == min_val: return [upper_bound] * len(scores) return [ round(((score - min_val) / (max_val - min_val) * (upper_bound - lower_bound) + lower_bound), ndigits=2) for score in scores ] def score(owners: list[dict[str, Any]], asm_system_ids: list[str]) -> list[dict[str, Any]]: """ Load the model, featurize inputs, score owners, normalize scores, and update the owners dicts If we fail to load or run inference with the model, return uniform scores of SCORE_LOWER_BOUND """ def scoring_fallback(owners: list[dict[str, Any]]): for owner in owners: owner["ranking_score"] = SCORE_LOWER_BOUND return owners try: model = load_pickled_xpanse_object("service_owner_model.pkl") except Exception as ex: demisto.info(f"Error loading the model: {ex}. Using fallback scores") return scoring_fallback(owners) try: featurized = featurize(asm_system_ids=asm_system_ids, owners=owners) scores = model.predict(featurized) except Exception as ex: demisto.info(f"Error scoring the owners: {ex}. Using fallback scores") return scoring_fallback(owners) normalized = normalize_scores(scores) for owner, score in zip(owners, normalized): owner["ranking_score"] = score return owners def rank(owners: list[dict[str, Any]]) -> list[dict[str, Any]]: """ Sort owners by ranking score and use data-driven algorithm to return the top k, where k is a dynamic value based on the relative scores See _get_k for hyperparameters that can be used to adjust the target value of k """ k = _get_k(scores=(owner["ranking_score"] for owner in owners)) return sorted(owners, key=lambda x: x["ranking_score"], reverse=True)[:k] def justify(owners: list[dict[str, str]]) -> list[dict[str, str]]: """ For now, `justification` is the same as `source`; in the future, will sophisticate Strip "Chain: " from both `source` and `justification` fields as post-processing step. The "Chain: " prefix in the source indicates that the attribution of this owner is multi-step: e.g. first we recovered the service account, then we recovered the project owner for that service account. Future work may further unroll this chain, for instance: recover the manager of the project owner of the service account, which we would denote using a `source` value of: "Chain: Chain: Manager of GCP project owner of service account". The model takes the length of the chain into the account, with longer chains carrying less weight """ for owner in owners: normalized_source = owner.get("source", "").replace("Chain: ", "") owner["source"] = normalized_source owner["justification"] = normalized_source return owners def _canonicalize(owner: dict[str, Any]) -> dict[str, Any]: """ Canonicalizes an owner dictionary and adds a deduplication key `canonicalization` whose value is either: 1. whitespace-stripped and lower-cased email, if email exists 2. whitespace-stripped and lower-cased name 3. empty string if neither exists """ if owner.get("email", ""): owner["canonicalization"] = owner["email"].strip().lower() owner["email"] = owner["canonicalization"] elif owner.get("name", ""): owner["canonicalization"] = owner["name"].strip().lower() owner["name"] = owner["canonicalization"] else: owner["canonicalization"] = "" return owner def canonicalize(owners: list[dict[str, str]]) -> list[dict[str, Any]]: """ Calls _canonicalize on each well-formatted owner; drops and logs malformated inputs """ canonicalized = [] try: for owner in owners: try: canonicalized.append(_canonicalize(owner)) except Exception as e: demisto.error(f"Unable to canonicalize {owner}: {e}") except Exception as e: demisto.error(f"`owners` must be iterable: {e}") return canonicalized def aggregate(owners: list[dict[str, str]]) -> list[dict[str, Any]]: """ Aggregate owners by their canonicalization. If canonicalized form is email, preserve longest name. Preserve max timestamp and union over sources. Aggregate remaining keys by type: union over strings, and max over numerical types. If type is neither of the above, all values of that key will be dropped from the aggregated owner. """ deduped = [] sorted_owners = sorted(owners, key=lambda owner: owner["canonicalization"]) for key, group in groupby(sorted_owners, key=lambda owner: owner["canonicalization"]): duplicates = list(group) email = duplicates[0].get("email", "") # the if condition in the list comprehension below defends against owners whose name value is None (not sortable) names = sorted([owner.get("name", "") for owner in duplicates if owner.get("name")], key=lambda x: len(x), reverse=True) name = names[0] if names else "" # aggregate source by union source = STRING_DELIMITER.join(sorted({owner.get("source", "") for owner in duplicates if owner.get("source", "")})) # take max timestamp if there's at least one; else empty string timestamps = sorted([owner.get("timestamp", "") for owner in duplicates if owner.get("timestamp", "")], reverse=True) timestamp = timestamps[0] if timestamps else "" owner = {"name": name, "email": email, "source": source, "timestamp": timestamp} # aggregate remaining keys according to type all_keys = {k for owner in duplicates for k in owner} keys_to_types = {k: type(owner[k]) for owner in duplicates for k in owner} other_keys = [] for key in all_keys: if key.lower() not in {"name", "email", "source", "timestamp", "canonicalization"}: other_keys.append(key) for other in other_keys: if keys_to_types[other] is str: # union over strings owner[other] = STRING_DELIMITER.join( sorted({owner.get(other, "") for owner in duplicates if owner.get(other, "")}) ) elif keys_to_types[other] in (int, float): # max over numerical types owner[other] = max(owner.get(other, 0) for owner in duplicates) # type: ignore else: demisto.info(f"Cannot aggregate owner detail {other} -- removing from service owner") continue deduped.append(owner) return deduped def _get_k( scores: Iterable[float], target_k: int = 5, k_tol: int = 2, a_tol: float = 1.0, min_score_proportion: float = 0.75 ) -> int: """ Return a value of k such that: - target_k >= k <= target_k + k_tol - the top k scores comprise minimum specified proportion of the total score mass See unit tests in RankServiceOwners_test.py for a more detailed specification of the expected behavior. Notable hyperparameters (which are tuned to target_k=5) and where they come from: :param target_k: the value of k we are roughly targeting (set by discussion with PM) :param k_tol: our tolerance for k, or how many additional owners above `target_k` we are willing to show (set by intuition/discussion with PM) :param a_tol: max expected absolute different between two scores in the same "tier" (set by intuition; see unit tests) :param min_score_proportion: the targeted min proportion of the score mass (identified using a gridsearch over values to find best outcome on unit tests) """ if target_k < 0: raise ValueError("target_k must be non-negative") if k_tol < 0: raise ValueError("k_tol must be non-negative") if a_tol < 0: raise ValueError("a_tol must be non-negative") if min_score_proportion < 0 or min_score_proportion > 1: raise ValueError("min_score_proportion must be a value between 0 and 1") # get up to target_k scores that comprise the desired score proportion scores_desc = sorted(scores, reverse=True) min_score_proportion = sum(scores_desc) * min_score_proportion k = 0 cumulative_score = 0.0 while cumulative_score < min_score_proportion and k < target_k: cumulative_score += scores_desc[k] k += 1 # score values are likely groupable into "tiers"; try to find a cutoff between tiers # look for the end of the next element's tier (may be the current or next tier), # where a tier is (arbitrarily) defined by an absolute difference of `a_tol` tier_index = k while tier_index < len(scores_desc) and math.isclose(scores_desc[tier_index], scores_desc[tier_index - 1], abs_tol=a_tol): tier_index += 1 # add additional score(s) if within tolerance for k if math.isclose(target_k, tier_index, abs_tol=k_tol): k = tier_index return k # Begin: Model Featurization Code def generate_all_spaceless_monikers(personal_monikers: Iterable[str]) -> set[str]: """ Return all the spaceless ways that `personal_monikers` (such as a name or email address) might manifest. Guaranteed lower case. Removes hyphens and quotes, and anything that looks like a domain of an email address. Example: personal_monikers = ["mike@example.com", "Michael Jordan"] returns: {"mike", "michael", "jordan", "mj", "mjordan"} """ result_set = set() for moniker in personal_monikers: moniker = moniker.lower() if "@" in moniker: moniker = moniker[: moniker.index("@")] split_full_moniker: list[str] = [t.replace("-", "").replace("'", "") for t in moniker.split()] result_set |= set(split_full_moniker) if len(split_full_moniker) >= 2: canonical_first_name: str = split_full_moniker[0] all_possible_first_names: list[str] = [canonical_first_name] last_name: str = split_full_moniker[-1] middle_names: list[str] = split_full_moniker[1:-1] # each name as a separate word result_set |= set(all_possible_first_names) # firstmiddlelast result_set.add(f"{canonical_first_name}{''.join(middle_names)}{last_name}") # firstlast for fname in all_possible_first_names: result_set.add(f"{fname}{last_name}") # fm+l for fname in all_possible_first_names: result_set.add(f"{fname[0]}{''.join([m[0] for m in middle_names])}{last_name[0]}") # fl for fname in all_possible_first_names: result_set.add(f"{fname[0]}{last_name[0]}") # flast for fname in all_possible_first_names: result_set.add(f"{fname[0]}{last_name}") # fm*last for fname in all_possible_first_names: result_set.add(f"{fname[0]}{''.join([m[0] for m in middle_names])}{last_name}") return result_set def split_phrase(phrase: str) -> set[str]: """ Return the human-readable subcomponents of `phrase`. Keep both sides of :-delimited pairs (kv pairs). This object allows us to run `in` commands correctly for `asset_name`. If asset_name has internal string structure, make it foremost. If it has no structure, run on the raw string. """ SPLITTER = re.compile(r"[:\*_\.-]") all_components = set() if ":" in phrase: all_components |= {t.strip() for t in phrase.split(":")} all_components |= set(re.split(SPLITTER, phrase)) for w in all_components.copy(): all_components |= set(itertools.chain.from_iterable(re.findall(r"(\d*)([a-zA-Z]*)(\d*)", w))) all_components = {c.strip() for c in all_components if c} all_components -= {"", None} return all_components def get_possible_3initials(personal_monikers: Iterable[str]) -> set[str]: """ Tries to generate 3 initials from `personal_monikers`. If there is a middle name in `personal_monikers`, returns those results. Otherwise generates all possible middle initials for first/last. """ result_set = set() for moniker in personal_monikers: moniker = moniker.lower() split_full_moniker: list[str] = moniker.split() if len(split_full_moniker) < 2: continue canonical_first_initial: str = split_full_moniker[0][0] last_initial: str = split_full_moniker[-1][0] middle_names: list[str] = split_full_moniker[1:-1] if middle_names: # abort early return {f"{canonical_first_initial}{''.join([m[0] for m in middle_names])}{last_initial}"} else: for hypothesized_letter in string.ascii_lowercase: result_set.add(f"{canonical_first_initial}{hypothesized_letter}{last_initial}") return result_set def get_name_similarity_index( personal_monikers: Iterable[str], constant_name: str, ) -> float: """ Returns an index into name similarity between `personal_monikers` and `constant_name`. Returns >=1 if there is a blatant match. Returns 0 if there is no match at all. Returns 0 to 1 if there is a potential match. Example: personal_monikers = ["mike@example.com", "Michael Jordan"] constant_name = "mj-test" returns: 1.0 personal_monikers = ["mike@example.com", "Michael Jordan"] constant_name = "mbj-test" returns: .1 """ total_indicators = 0.0 all_monikers: set[str] = map( # type: ignore str.lower, generate_all_spaceless_monikers(personal_monikers), ) all_monikers = {m for m in all_monikers if len(m) > 1} all_names = split_phrase(constant_name.lower()) all_names = {n for n in all_names if len(n) > 1} for moniker in all_monikers: if moniker in all_names: demisto.info(f"Name similarity match: {constant_name} and {moniker}") total_indicators += 1 else: for n in all_names: if moniker in n: demisto.info(f"Name substring match: {constant_name} and {moniker}") total_indicators += 0.01 # check for a hypothesized-middle-initial match # for example, this may help us attest a dev server named mjj-test # (or mbj-test, or mij-test) to Michael Jordan hypothesized_initials = get_possible_3initials(personal_monikers) - all_monikers for hypothesized_initial in hypothesized_initials: if hypothesized_initial in all_names: demisto.info(f"Hypothesized initial match: {constant_name} and {moniker}") total_indicators += 0.1 return total_indicators class OwnerFeaturizationPipeline: def __init__(self, sources: list | None = None): """ Initialize a featurization pipeline. """ if sources is None: self.SOURCES = ["Azure", "GCP", "AWS", "Tenable", "Rapid7", "Qualys", "SNOW-CMDB", "Splunk", "PrismaCloud"] else: self.SOURCES = sources.copy() # features which only require contents of asmserviceowner as input self.OWNER_FEATURES: list[tuple[str, Callable]] = [ ("num_reasons", self.get_num_reasons), ("num_distinct_sources", self.get_num_distinct_sources), ("min_path_length", self.get_min_path_length), ("is_attested_in_cmdb", self.get_in_cmdb), ("is_attested_in_recent_logs", self.get_in_logs), ] # features that require asmsystemid as an additional input self.SYSTEM_ID_FEATURES: list[tuple[str, Callable]] = [ ("name_similarity_person_asset", self.get_name_similarity_person_asset), ] self.NUM_FEATURES = len(self.SYSTEM_ID_FEATURES) + len(self.OWNER_FEATURES) @staticmethod def _get_sources(owner: dict[str, Any]) -> list[str]: """ Return a list of sources. """ return owner.get("source", "").split(STRING_DELIMITER) def get_num_reasons(self, owner: dict[str, Any]) -> int: """ Returns the number of reasons on `owner`. """ return len(self._get_sources(owner)) def get_num_distinct_sources(self, owner: dict[str, Any]) -> int: """ Returns the number of distinct sources on `owner`. """ distinct_sources = set() for src in self.SOURCES: if src.lower() in owner.get("source", "").lower(): distinct_sources.add(src.lower()) return len(distinct_sources) def get_min_path_length(self, owner: dict[str, Any]) -> Union[float, int]: """ Returns the minimum path length to reach this owner. """ min_path_length = float("inf") for src in self._get_sources(owner): src_path_length = 1 while src.startswith("Chain: "): src_path_length += 1 src = src[len("Chain: ") :] if min_path_length is None or src_path_length < min_path_length: min_path_length = src_path_length return min_path_length def get_name_similarity_person_asset(self, service_identifiers: Iterable[str], owner: dict[str, Any]) -> float: """ Returns >=1 if there is a blatant match between any `service_identifiers` and `owner`. Returns 0 if there is no match at all. Returns 0 to 1 if there is a potential match. """ personal_monikers = [owner.get("email", ""), owner.get("name", "")] best_similarity = 0.0 for service_id in service_identifiers: similarity = get_name_similarity_index(personal_monikers, service_id) if similarity > best_similarity: best_similarity = similarity return best_similarity def get_in_cmdb(self, owner: dict[str, Any]) -> int: """ Return 1 if any `owner` is attested in any CMDB; 0 otherwise. """ for src in self._get_sources(owner): if "CMDB" in src: return 1 return 0 def get_in_logs(self, owner: dict[str, Any]) -> int: """ Return 1 if any `owner` is attested in any logs; 0 otherwise. """ for src in self._get_sources(owner): if "Splunk" in src or "log" in src.lower(): return 1 return 0 def featurize(self, service_identifiers: Iterable[str], owners: list[dict[str, Any]]) -> np.ndarray: """ Generate a featurized numpy array from `service_identifiers` and `owners`. """ X = np.zeros((len(owners), self.NUM_FEATURES)) for sample_idx, owner in enumerate(owners): # Iterate over features which require both system ID and owner as inputs feature_idx = 0 for method_name, method in self.SYSTEM_ID_FEATURES: try: X[sample_idx, feature_idx] = method(service_identifiers, owner) except Exception as e: demisto.info(f"Setting 0 for {method_name} because of processing exception: {e}") X[sample_idx, feature_idx] = 0 finally: feature_idx += 1 # Iterate over features which only require the owner as input for method_name, method in self.OWNER_FEATURES: try: X[sample_idx, feature_idx] = method(owner) except Exception as e: demisto.info(f"Setting 0 for {method_name} because of processing exception: {e}") X[sample_idx, feature_idx] = 0 finally: feature_idx += 1 return X def write_output_to_context_key(final_owners: list[dict[str, str]], owner_related_field: str, platform_tenant: str): if not final_owners or not owner_related_field: return_results(CommandResults(readable_output="No owners found")) # For platform we are assuming that a multi-array normalized field will be used. if platform_tenant.lower() == "true": # Get list of emails, unless "n/a" and then use name. final_owners_list = [ owner["email"] if "email" in owner and owner["email"].lower() != "n/a" else owner.get("name", "n/a") for owner in final_owners ] res = demisto.executeCommand("setIssue", {owner_related_field: final_owners_list}) else: res = demisto.executeCommand("setAlert", {owner_related_field: final_owners}) if isError(res): raise ValueError(f"Unable to update field: {res}") return_results(CommandResults(readable_output=f"Owners ranked and written to {owner_related_field}")) def main(): try: # parse inputs unranked = demisto.args().get("owners", []) if isinstance(unranked, dict): unranked = [unranked] asm_system_ids = demisto.args().get("asmsystemids", []) owner_related_field = demisto.args().get("ownerrelatedfield", "asmserviceowner") platform_tenant_usage = demisto.args().get("tenantcommand", "False") # deduplicate/normalize, score, and rank owners normalized = aggregate(canonicalize(unranked)) final_owners = justify(rank(score(owners=normalized, asm_system_ids=asm_system_ids))) write_output_to_context_key( final_owners=final_owners, owner_related_field=owner_related_field, platform_tenant=platform_tenant_usage ) except Exception as ex: demisto.error(traceback.format_exc()) # print the traceback return_error(f"Failed to execute RankServiceOwners. Error: {str(ex)}") if __name__ in ("__main__", "__builtin__", "builtins"): main()
README
Recommend most likely service owners from those surfaced by Cortex ASM Enrichment.
Script Data
| Name | Description |
|---|---|
| Script Type | python3 |
| Cortex XSOAR Version | 6.8.0 |
Inputs
| Argument Name | Description |
|---|---|
| owners | List of potential service owners |
| asmsystemids | System IDs or names associated with the compute instance |
| owners | List of potential service owners. |
| ownerrelatedfield | The field of the alert or issue that owners should be stored. |
| tenantcommand | False will use !setAlert, True will use !setIssue. |
Outputs
There are no outputs for this script.