FindEmailCampaign
Find a campaign of emails based on their textual similarity.
python · Phishing Campaign
Source
import itertools from collections import Counter from email.utils import parseaddr import dateutil import demistomock as demisto # noqa: F401 import numpy as np import pandas as pd import pytz import tldextract from CommonServerPython import * # noqa: F401 from nltk import sent_tokenize, word_tokenize from nltk.corpus import stopwords from numpy import dot from numpy.linalg import norm from sklearn.feature_extraction.text import CountVectorizer from CommonServerUserPython import * no_fetch_extract = tldextract.TLDExtract(suffix_list_urls=None, cache_dir=False) # type: ignore[arg-type] utc = pytz.UTC SELF_IN_CONTEXT = False EMAIL_BODY_FIELD = "emailbody" EMAIL_SUBJECT_FIELD = "emailsubject" EMAIL_HTML_FIELD = "emailbodyhtml" FROM_FIELD = "emailfrom" FROM_DOMAIN_FIELD = "fromdomain" PREPROCESSED_EMAIL_BODY = "preprocessedemailbody" PREPROCESSED_EMAIL_SUBJECT = "preprocessedemailsubject" MERGED_TEXT_FIELD = "mereged_text" EMAIL_TO_FIELD = "emailto" EMAIL_CC_FIELD = "emailcc" EMAIL_BCC_FIELD = "emailbcc" RECIPIENTS_COLUMNS = [EMAIL_TO_FIELD, EMAIL_CC_FIELD, EMAIL_BCC_FIELD] MIN_CAMPAIGN_SIZE = int(demisto.args().get("minIncidentsForCampaign", 3)) MIN_UNIQUE_RECIPIENTS = int(demisto.args().get("minUniqueRecipients", 2)) DUPLICATE_SENTENCE_THRESHOLD = 0.95 TO_PLOT_CANVAS = demisto.args().get("plotCanvas", "false") == "true" MAX_INCIDENTS_FOR_CANVAS_PLOTTING = 6 MAX_INDICATORS_FOR_CANVAS_PLOTTING = 10 KEYWORDS = [ "#1", "100%", "access", "accordance", "account", "act", "action", "activate", "ad", "affordable", "amazed", "amazing", "apply", "asap", "asked", "attach", "attached", "attachment", "attachments", "attention", "authorize", "authorizing", "avoid", "bank", "bargain", "billing", "bonus", "boss", "bucks", "bulk", "buy", "can't", "cancel", "candidate", "capacity", "card", "cards", "cash", "casino", "caution", "cents", "certified", "chance", "charges", "claim", "claims", "clearance", "click", "collect", "confidentiality", "confirm", "confirmation", "confirmed", "congratulations", "consideration", "consolidate", "consultation", "contact", "contract", "credentials", "credit", "day", "days", "deadline", "deal", "deals", "dear", "debt", "delivered", "delivery", "deposit", "detected", "dhl", "disabled", "discount", "discounts", "document", "documents", "dollar", "dollars", "dropbox", "drugs", "due", "earn", "earnings", "enlarge", "enlargement", "equity", "erection", "erections", "exclusive", "expire", "expires", "fedex", "fees", "file", "finance", "financial", "fraud", "free", "friend", "from", "funds", "gas", "gift", "gimmick", "giveaway", "great", "growth", "guarantee", "guaranteed", "hack", "hacked", "hacker", "hormone", "hosting", "hours", "hurry", "immediate", "immediately", "important", "income", "increase", "instant", "interest", "investment", "invoice", "kindly", "last", "lender", "lenders", "lifetime", "limited", "loan", "loans", "login", "lose", "loss", "luxury", "market", "marketing", "mass", "mastrubate", "mastrubating", "med", "medications", "medicine", "meds", "member", "membership", "million", "millions", "miracle", "money", "monthly", "months", "mortgage", "newsletter", "notification", "notify", "obligation", "offer", "offers", "oil", "only", "open", "opt", "order", "package", "paid", "parcel", "partners", "password", "passwords", "payment", "payments", "paypal", "payroll", "pdf", "penis", "pennies", "permanently", "pharmacy", "pics", "pictures", "pill", "pills", "porn", "porno", "postal", "potential", "pre-approved", "presently", "preview", "price", "prize", "profit", "promise", "promotion", "purchase", "pure", "qualifies", "qualify", "quote", "rates", "receipt", "record", "recorded", "recording", "refund", "request", "requested", "requires", "reserve", "reserves", "review", "risk", "sales", "satisfactin", "satisfaction", "satisfied", "save", "scam", "security", "sensitive", "sex", "share", "shared", "sharing", "shipment", "shipping", "sir", "spam", "special", "spend", "spending", "started", "starting", "stock", "success", "supplies", "supply", "suspended", "temporarily", "terms", "trader", "trading", "traffic", "transaction", "transfer", "trial", "unlimited", "unsecured", "unsolicited", "unsubscribe", "update", "ups", "urgent", "user", "usps", "valium", "verification", "verify", "viagra", "vicodin", "videos", "vids", "viedo", "virus", "waiting", "wallet", "warranty", "web", "weight", "win", "winner", "winning", "wire", "xanax", ] STATUS_DICT = { 0: "Pending", 1: "Active", 2: "Closed", 3: "Archive", } INVALID_KEY_WARNING = ( "Warning: the fields {fields} was not found in the phishing incidents. Please make sure that " "you've specified the machine-name of the fields. The machine name can be found in the " "settings of the incident field you are trying to search." ) INCIDENTS_CONTEXT_TD = "incidents(obj.id == val.id)" def return_outputs_custom(readable_output, outputs=None, tag=None): demisto.debug(f"Entering return_outputs_custom with {tag=}") return_entry = { "Type": entryTypes["note"], "HumanReadable": readable_output, "ContentsFormat": formats["json"], "Contents": outputs, "EntryContext": outputs, } if tag is not None: return_entry["Tags"] = [f"campaign_{tag}"] demisto.results(return_entry) demisto.debug("Exiting return_outputs_custom") def add_context_key(entry_context): demisto.debug("Entering add_context_key") new_context = {} for k, v in entry_context.items(): new_context["{}.{}".format("EmailCampaign", k)] = v demisto.debug(f"Exiting add_context_key, created {len(new_context)} new keys.") return new_context def get_recipients(row): global RECIPIENTS_COLUMNS return list(itertools.chain(*[row[col] for col in RECIPIENTS_COLUMNS])) def extract_domain(address): global no_fetch_extract if address == "": return "" email_address = parseaddr(address)[1] ext = no_fetch_extract(email_address) return f"{ext.domain}.{ext.suffix}" def extract_domain_from_recipients(row): domains_list = [] for address in row["recipients"]: try: domain = extract_domain(address) except Exception: domain = "" domains_list.append(domain) return domains_list def create_context_for_campaign_details(campaign_found=False, incidents_df=None, additional_context_fields: list = None): demisto.debug(f"Entering create_context_for_campaign_details. campaign_found: {campaign_found}") if not campaign_found: demisto.debug("No campaign found, returning minimal context.") return { "isCampaignFound": campaign_found, } else: demisto.debug("Campaign found, building full context.") incident_id = demisto.incident()["id"] incidents_df["recipients"] = incidents_df.apply(lambda row: get_recipients(row), axis=1) incidents_df["recipientsdomain"] = incidents_df.apply(lambda row: extract_domain_from_recipients(row), axis=1) if "removedfromcampaigns" not in incidents_df.columns.tolist(): incidents_df["removedfromcampaigns"] = pd.NA incidents_df["removedfromcampaigns"] = incidents_df["removedfromcampaigns"].apply(lambda x: [] if pd.isna(x) else x) context_keys = { "id", "similarity", FROM_FIELD, FROM_DOMAIN_FIELD, "recipients", "recipientsdomain", "removedfromcampaigns", } invalid_context_keys = set() if additional_context_fields is not None: for key in additional_context_fields: if key in incidents_df.columns: context_keys.add(key) else: invalid_context_keys.add(key) if invalid_context_keys: demisto.debug(f"Found invalid context keys: {invalid_context_keys}") return_warning(INVALID_KEY_WARNING.format(fields=invalid_context_keys)) incidents_context_df = incidents_df.copy(deep=True) incident_df = incidents_context_df[list(context_keys)] # lgtm [py/hash-unhashable-value] if not SELF_IN_CONTEXT: incident_df = incident_df[incident_df["id"] != incident_id] incident_df = incident_df.rename({FROM_DOMAIN_FIELD: "emailfromdomain"}, axis=1) incidents_context = incident_df.fillna(1).to_dict(orient="records") datetimes: pd.DataFrame = incidents_context_df["created_dt"].dropna() min_datetime = min(datetimes).isoformat() demisto.info("Successfully created campaign details context.") return { "isCampaignFound": campaign_found, "involvedIncidentsCount": len(incidents_context_df) if incidents_context_df is not None else 0, "firstIncidentDate": min_datetime, "fieldsToDisplay": additional_context_fields, INCIDENTS_CONTEXT_TD: incidents_context, } def create_context_for_indicators(indicators_df=None): demisto.debug("Entering create_context_for_indicators.") if indicators_df is None: demisto.debug("No indicators DataFrame provided.") indicators_context = [] else: demisto.debug(f"Creating context for {len(indicators_df)} indicators.") indicators_df = indicators_df.rename({"Value": "value"}, axis=1) indicators_df = indicators_df[["id", "value"]] indicators_context = indicators_df.to_dict(orient="records") return {"indicators": indicators_context} def create_empty_context(): demisto.debug("Entering create_empty_context.") context = create_context_for_campaign_details(campaign_found=False) context = add_context_key(context) return context def is_number_of_incidents_too_low(res, incidents): demisto.debug("Entering is_number_of_incidents_too_low check.") if not res["EntryContext"]["isDuplicateIncidentFound"] or len(incidents) < MIN_CAMPAIGN_SIZE: demisto.info(f"Number of incidents ({len(incidents)}) is less than min ({MIN_CAMPAIGN_SIZE}). Not a campaign.") return_outputs_custom("No possible campaign was detected", create_empty_context()) return True demisto.debug("Number of incidents is sufficient.") return False def is_number_of_unique_recipients_is_too_low(incidents): demisto.debug("Entering is_number_of_unique_recipients_is_too_low check.") unique_recipients = Counter([str(i.get(EMAIL_TO_FIELD, "None")) for i in incidents]) unique_recipients += Counter([str(i[EMAIL_CC_FIELD]) for i in incidents if EMAIL_CC_FIELD in i]) unique_recipients += Counter([str(i[EMAIL_BCC_FIELD]) for i in incidents if EMAIL_BCC_FIELD in i]) missing_recipients = unique_recipients["None"] unique_recipients.pop("None", None) if (len(unique_recipients) < MIN_UNIQUE_RECIPIENTS and missing_recipients == 0) or ( 0 < len(unique_recipients) < MIN_UNIQUE_RECIPIENTS and missing_recipients > 0 ): demisto.info( f"Number of unique recipients ({len(unique_recipients)}) is less than min ({MIN_UNIQUE_RECIPIENTS}). Not a campaign." ) msg = "Similar emails were found, but the number of their unique recipients is too low to consider them as campaign.\n " msg += ( "If you wish to consider similar emails as campaign even with low number of unique recipients, " "please change *minUniqueRecipients* argument's value.\n" ) msg += "Details:\n" msg += f"* Found {len(incidents)} similar incidents.\n" msg += f"* Those incidents have {len(unique_recipients)} unique recipients" msg += " ({}).\n".format(", ".join(unique_recipients)) msg += f"* The minimum number of unique recipients for similar emails as campaign: {MIN_UNIQUE_RECIPIENTS}\n" if missing_recipients > 0: msg += ( f"* Could not find email recipient for {missing_recipients}/{len(incidents)} incidents " "(*Email To* field is empty)" ) return_outputs_custom(msg, create_empty_context()) return True demisto.debug("Number of unique recipients is sufficient.") return False def get_str_representation_top_n_values(values_list, counter_tuples_list, top_n): domains_counter_top = counter_tuples_list[:top_n] if len(counter_tuples_list) > top_n: domains_counter_top += [("Other", len(values_list) - sum(x[1] for x in domains_counter_top))] return ", ".join(f"{domain} ({count})" for domain, count in domains_counter_top) def standardize_recipients_column(df, column): if column not in df.columns: df[column] = [[] for _ in range(len(df))] return df df[column] = df[column].apply(argToList) df[column] = df[column].apply(lambda x: [value.strip() for value in x if isinstance(value, str)]) df[column] = df[column].apply(lambda x: [value for value in x if "@" in value]) return df def calculate_campaign_details_table(incidents_df, fields_to_display): demisto.debug("Entering calculate_campaign_details_table.") global RECIPIENTS_COLUMNS n_incidents = len(incidents_df) similarities = incidents_df["similarity"].dropna().to_list() max_similarity = max(similarities) min_similarity = min(similarities) headers = [] contents = [] headers.append("Details") contents.append(f"Found possible campaign of {n_incidents} similar emails") if max_similarity > min_similarity + 10**-3: headers.append("Similarity range") contents.append(f"{min_similarity * 100:.1f}%-{max_similarity * 100:.1f}%") else: headers.append("Similarity") contents.append(f"{max_similarity * 100:.1f}%") incidents_df["created_dt"] = incidents_df["created"].apply(lambda x: dateutil.parser.parse(x)) # type: ignore datetimes = incidents_df["created_dt"].dropna() # type: ignore min_datetime, max_datetime = min(datetimes), max(datetimes) if (max_datetime - min_datetime).days == 0: headers.append("Date") contents.append(max_datetime.strftime("%B %d, %Y")) else: headers.append("Date range") contents.append("{} - {}".format(min_datetime.strftime("%B %d, %Y"), max_datetime.strftime("%B %d, %Y"))) senders = incidents_df[FROM_FIELD].dropna().replace("", np.nan).tolist() senders_counter = Counter(senders).most_common() # type: ignore senders_domain = incidents_df[FROM_DOMAIN_FIELD].replace("", np.nan).dropna().tolist() domains_counter = Counter(senders_domain).most_common() # type: ignore for column in RECIPIENTS_COLUMNS: incidents_df = standardize_recipients_column(incidents_df, column) recipients = [] for column in RECIPIENTS_COLUMNS: for incidents_recipient in incidents_df[column]: recipients += incidents_recipient recipients_counter = Counter(recipients).most_common() # type: ignore if len(senders_counter) == 1: domain_header = "Sender domain" sender_header = "Sender address" elif len(senders_counter) > 1 and len(domains_counter) == 1: domain_header = "Senders domain" sender_header = "Senders addresses" else: domain_header = "Senders domains" sender_header = "Senders addresses" top_n = 3 domain_value = get_str_representation_top_n_values(senders_domain, domains_counter, top_n) sender_value = get_str_representation_top_n_values(senders, senders_counter, top_n) recipients_value = get_str_representation_top_n_values(recipients, recipients_counter, len(recipients_counter)) headers.append(domain_header) contents.append(domain_value) headers.append(sender_header) contents.append(sender_value) headers.append("Recipients") contents.append(recipients_value) for field in fields_to_display: if field in incidents_df.columns: field_values = get_non_na_empty_values(incidents_df, field) if len(field_values) > 0: if field in RECIPIENTS_COLUMNS: field_values = [item for sublist in field_values for item in sublist] elif any(isinstance(field_value, list) for field_value in field_values): flattened_list = [] for item in field_values: if isinstance(item, list): flattened_list.extend(item) else: flattened_list.append(item) field_values = flattened_list field_values_counter = Counter(field_values).most_common() # type: ignore field_value_str = get_str_representation_top_n_values(field_values, field_values_counter, top_n) headers.append(field) contents.append(field_value_str) hr = tableToMarkdown("Possible Campaign Detected", dict(zip(headers, contents)), headers=headers) demisto.info("Successfully calculated campaign details table (Human Readable).") return hr def get_non_na_empty_values(incidents_df, field): field_values = incidents_df[field].replace("", None).dropna().tolist() field_values = [x for x in field_values if len(str(x).strip()) > 0] return field_values def cosine_sim(a, b): return dot(a, b) / (norm(a) * norm(b)) def summarize_email_body(body, subject, nb_sentences=3, subject_weight=1.5, keywords_weight=1.5): demisto.debug("Entering summarize_email_body.") corpus: list[str] = sent_tokenize(body) cv = CountVectorizer(stop_words=list(stopwords.words("english"))) body_arr = cv.fit_transform(corpus).toarray() subject_arr = cv.transform(sent_tokenize(subject)).toarray() word_list = cv.get_feature_names_out() count_list = body_arr.sum(axis=0) + subject_arr.sum(axis=0) * subject_weight duplicate_sentences = [ i for i, arr in enumerate(body_arr) if any(cosine_sim(arr, arr2) > DUPLICATE_SENTENCE_THRESHOLD for arr2 in body_arr[:i]) ] word_frequency = dict(zip(word_list, count_list)) val = sorted(word_frequency.values()) max_frequency = val[-1] for word in word_frequency: word_frequency[word] = word_frequency[word] / max_frequency for word in KEYWORDS: if word in word_frequency: word_frequency[word] *= keywords_weight sentence_rank = [0] * len(corpus) for i, sent in enumerate(corpus): if i in duplicate_sentences: continue for word in word_tokenize(sent): if word.lower() in word_frequency: sentence_rank[i] += word_frequency[word.lower()] sentence_rank[i] = sentence_rank[i] / len(word_tokenize(sent)) # type: ignore top_sentences_indices: np.ndarray = np.argsort(sentence_rank)[::-1][:nb_sentences].tolist() # type: ignore[assignment] summary = [] for sent_i in sorted(top_sentences_indices): # type: ignore sent = corpus[sent_i].strip().replace("\n", " ") if sent_i == 0 and sent_i + 1 not in top_sentences_indices: sent = sent + " ..." elif sent_i + 1 == len(corpus) and sent_i - 1 not in top_sentences_indices: sent = "... " + sent elif sent_i - 1 not in top_sentences_indices and sent_i + 1 not in top_sentences_indices: sent = "... " + sent + " ..." summary.append(sent) demisto.info("Successfully summarized email body.") return "\n".join(summary) def create_email_summary_hr(incidents_df, fields_to_display): demisto.debug("Entering create_email_summary_hr.") clean_email_subject = incidents_df.iloc[0][PREPROCESSED_EMAIL_SUBJECT] email_summary = "*Subject*: " + clean_email_subject.replace("\n", "") + " |" clean_email_body = incidents_df.iloc[0][PREPROCESSED_EMAIL_BODY] email_summary += "\n*Body*: \n" + summarize_email_body(clean_email_body, clean_email_subject) + " |" for word in KEYWORDS: for cased_word in [word.lower(), word.title(), word.upper()]: email_summary = re.sub(rf"(?<!\w)({cased_word})(?!\w)", f"**{cased_word}**", email_summary) hr_email_summary = "\n" + email_summary context = add_context_key( create_context_for_campaign_details( campaign_found=True, incidents_df=incidents_df, additional_context_fields=fields_to_display ) ) demisto.info("Successfully created email summary (Human Readable).") return context, hr_email_summary def horizontal_to_vertical_md_table(horizontal_md_table: str) -> str: """ convert the output of tableToMarkdown to be vertical. Args: horizontal_md_table: original tableToMarkdown output Returns: md string with rotated table """ demisto.debug("Entering horizontal_to_vertical_md_table.") lines = horizontal_md_table.split("\n") headers_list = lines[1][1:-1].split("|") # To allow pipes in the values, verify that there is a space before or after the pipe, before splitting. regex = rf"(?<=\s){re.escape('|')}|{re.escape('|')}(?=\s)" content_list = re.split(regex, lines[3][1:-1]) new_table = "\n| | |" new_table += "\n|---|---|" for header, content in zip(headers_list, content_list): new_table += f"\n|**{header}**|{content}|" return new_table def return_campaign_details_entry(incidents_df, fields_to_display): demisto.debug("Entering return_campaign_details_entry.") hr_campaign_details = calculate_campaign_details_table(incidents_df, fields_to_display) context, hr_email_summary = create_email_summary_hr(incidents_df, fields_to_display) hr = "\n".join([hr_campaign_details, hr_email_summary]) vertical_hr_campaign_details = horizontal_to_vertical_md_table(hr_campaign_details) demisto.executeCommand( "setIncident", {"emailcampaignsummary": f"{vertical_hr_campaign_details}", "emailcampaignsnippets": hr_email_summary} ) demisto.info("Successfully set incident fields for campaign summary.") return return_outputs_custom(hr, context, tag="campaign_details") def return_no_mututal_indicators_found_entry(): demisto.debug("Entering return_no_mututal_indicators_found_entry.") hr = "No mutual indicators were found." demisto.executeCommand("setIncident", {"emailcampaignmutualindicators": hr}) return_outputs_custom(hr, add_context_key(create_context_for_indicators()), tag="indicators") demisto.info("No mutual indicators found.") def return_indicator_entry(incidents_df): demisto.debug("Entering return_indicator_entry.") indicators_query = "investigationIDs:({})".format(" ".join(f'"{id_}"' for id_ in incidents_df["id"])) fields = ["id", "indicator_type", "investigationIDs", "investigationsCount", "score", "value"] demisto.debug(f"Querying indicators with: {indicators_query}") search_indicators = IndicatorsSearcher(query=indicators_query, limit=150, size=500, filter_fields=",".join(fields)) indicators = [] for res in search_indicators: indicators.extend(res.get("iocs", [])) indicators_df = pd.DataFrame(data=indicators) if len(indicators_df) == 0: demisto.debug("No indicators found after initial search.") return_no_mututal_indicators_found_entry() return indicators_df indicators_df = indicators_df[indicators_df["relatedIncCount"] < 150] indicators_df["Involved Incidents Count"] = indicators_df["investigationIDs"].apply( lambda x: sum(id_ in x for id_ in incidents_df["id"]) ) indicators_df = indicators_df[indicators_df["Involved Incidents Count"] > 1] if len(indicators_df) == 0: demisto.debug("No indicators found with involved count > 1.") return_no_mututal_indicators_found_entry() return indicators_df indicators_df["Id"] = indicators_df["id"].apply(lambda x: f"[{x}](#/indicator/{x})") indicators_df = indicators_df.sort_values(["score", "Involved Incidents Count"], ascending=False) indicators_df["Reputation"] = indicators_df["score"].apply(scoreToReputation) indicators_df = indicators_df.rename({"value": "Value", "indicator_type": "Type"}, axis=1) indicators_headers = ["Id", "Value", "Type", "Reputation", "Involved Incidents Count"] hr = tableToMarkdown("Mutual Indicators", indicators_df.to_dict(orient="records"), headers=indicators_headers) hr_no_title = "\n".join(hr.split("\n")[1:]) demisto.executeCommand("setIncident", {"emailcampaignmutualindicators": hr_no_title}) # without title demisto.info(f"Found {len(indicators_df)} mutual indicators.") return_outputs_custom(hr, add_context_key(create_context_for_indicators(indicators_df)), tag="indicators") return indicators_df def get_comma_sep_list(value): res = [x.strip() for x in value.split(",")] return [x for x in res if x != ""] def get_reputation(id_, indicators_df): if len(indicators_df) == 0: max_reputation = 0 else: relevant_indicators_df = indicators_df[indicators_df["investigationIDs"].apply(lambda x: id_ in x)] if len(relevant_indicators_df) > 0: max_reputation = max(relevant_indicators_df["score"]) else: max_reputation = 0 return scoreToReputation(max_reputation) def return_involved_incidents_entry(incidents_df, indicators_df, fields_to_display): demisto.debug("Entering return_involved_incidents_entry.") incidents_df["Id"] = incidents_df["id"].apply(lambda x: f"[{x}](#/Details/{x})") incidents_df = incidents_df.sort_values("created", ascending=False).reset_index(drop=True) incidents_df["created_dt"] = incidents_df["created"].apply(lambda x: dateutil.parser.parse(x)) # type: ignore incidents_df["Created"] = incidents_df["created_dt"].apply(lambda x: x.strftime("%B %d, %Y")) incidents_df["similarity"] = incidents_df["similarity"].fillna(1) incidents_df["similarity"] = incidents_df["similarity"].apply(lambda x: f"{x * 100:.1f}%") current_incident_id = demisto.incident()["id"] incidents_df["DBot Score"] = incidents_df["id"].apply(lambda id_: get_reputation(id_, indicators_df)) # add a mark at current incident, at its similarity cell incidents_df["similarity"] = incidents_df.apply( lambda x: "{} (current)".format(x["similarity"]) if x["id"] == current_incident_id else x["similarity"], axis=1 ) incidents_df["status"] = incidents_df["status"].apply(lambda x: STATUS_DICT.get(x, "")) incidents_df = incidents_df.rename( {"name": "Name", FROM_FIELD: "Email From", "similarity": "Similarity to Current Incident", "status": "Status"}, axis=1 ) incidents_headers = ["Id", "Created", "Name", "Status", "Email From", "DBot Score", "Similarity to Current Incident"] if fields_to_display is not None: fields_to_display = [f for f in fields_to_display if f in incidents_df.columns] incidents_df[fields_to_display] = incidents_df[fields_to_display].fillna("") fields_to_display = [f for f in fields_to_display if len(get_non_na_empty_values(incidents_df, f)) > 0] incidents_headers += fields_to_display hr = "\n\n" + tableToMarkdown( "Involved Incidents", incidents_df[incidents_headers].to_dict(orient="records"), headers=incidents_headers ) demisto.info("Successfully created 'Involved Incidents' markdown table.") return_outputs_custom(hr, tag="incidents") def draw_canvas(incidents, indicators): demisto.debug(f"Entering draw_canvas for {len(incidents)} incidents and {len(indicators)} indicators.") incident_ids = {x["id"] for x in incidents} filtered_indicators = [] for indicator in indicators: investigations = indicator.get("investigationIDs", []) mutual_incidents_in_canvas = len(set(investigations).intersection(incident_ids)) if mutual_incidents_in_canvas >= 2: filtered_indicators.append(indicator) try: res = demisto.executeCommand( "DrawRelatedIncidentsCanvas", {"relatedIncidentsIDs": list(incident_ids), "indicators": filtered_indicators, "overrideUserCanvas": "true"}, ) if not is_error(res): demisto.info("Successfully generated canvas.") res[-1]["Tags"] = ["canvas"] else: demisto.debug(f"Error drawing canvas: {get_error(res)}") try: demisto.executeCommand("setIncident", {"emailcampaigncanvas": res[-1].get("HumanReadable", "").strip("#")}) except Exception as e: demisto.debug(f"Could not set emailcampaigncanvas incident field: {e}") demisto.results(res) except Exception as e: demisto.debug(f"Exception in draw_canvas: {e}") def analyze_incidents_campaign(incidents, fields_to_display): global TO_PLOT_CANVAS, MAX_INCIDENTS_FOR_CANVAS_PLOTTING, MAX_INDICATORS_FOR_CANVAS_PLOTTING demisto.debug(f"Entering analyze_incidents_campaign for {len(incidents)} incidents.") incidents_df = pd.DataFrame(incidents) return_campaign_details_entry(incidents_df, fields_to_display) indicators_df = return_indicator_entry(incidents_df) return_involved_incidents_entry(incidents_df, indicators_df, fields_to_display) if TO_PLOT_CANVAS and len(incidents_df) <= MAX_INCIDENTS_FOR_CANVAS_PLOTTING: demisto.debug("TO_PLOT_CANVAS is true and incident count is within limit. Drawing canvas.") draw_canvas(incidents, indicators_df.head(MAX_INDICATORS_FOR_CANVAS_PLOTTING).to_dict(orient="records")) else: demisto.debug( f"Skipping canvas plot. TO_PLOT_CANVAS: {TO_PLOT_CANVAS}, " f"Incidents: {len(incidents_df)} (Max: {MAX_INCIDENTS_FOR_CANVAS_PLOTTING})" ) demisto.info("Campaign analysis complete.") def split_non_content_entries(response: list) -> tuple[dict, list]: """ Args: response: A response list from executeCommand. Return: (dict: The last content entry, list: non content entries) """ demisto.debug("Entering split_non_content_entries.") content_entry = response[0] non_content_entries = [] for res_entry in response: if res_entry.get("Contents"): content_entry = res_entry else: non_content_entries.append(res_entry) demisto.debug(f"Found {len(non_content_entries)} non-content entries and 1 content entry.") return content_entry, non_content_entries def main(): global EMAIL_BODY_FIELD, EMAIL_SUBJECT_FIELD, EMAIL_HTML_FIELD, FROM_FIELD, SELF_IN_CONTEXT demisto.debug("Starting EmailCampaign script.") input_args = demisto.args() demisto.debug(f"Script arguments: {input_args}") EMAIL_BODY_FIELD = input_args.get("emailBody", EMAIL_BODY_FIELD) EMAIL_SUBJECT_FIELD = input_args.get("emailSubject", EMAIL_SUBJECT_FIELD) EMAIL_HTML_FIELD = input_args.get("emailBodyHTML", EMAIL_HTML_FIELD) FROM_FIELD = input_args.get("emailFrom", FROM_FIELD) fields_to_display = input_args.get("fieldsToDisplay") SELF_IN_CONTEXT = argToBoolean(input_args.get("includeSelf", "false")) if fields_to_display is not None: input_args["populateFields"] = fields_to_display fields_to_display = get_comma_sep_list(fields_to_display) else: fields_to_display = [] demisto.debug(f"fields_to_display: {fields_to_display}") demisto.debug("Executing FindDuplicateEmailIncidents command.") res = demisto.executeCommand("FindDuplicateEmailIncidents", input_args) if is_error(res): demisto.debug(f"Error from FindDuplicateEmailIncidents: {get_error(res)}") return_error(get_error(res)) content_entry, non_content_entries = split_non_content_entries(res) incidents = json.loads(content_entry["Contents"]) if incidents: demisto.info(f"FindDuplicateEmailIncidents returned {len(incidents)} incidents.") skip_analysis = is_number_of_incidents_too_low(content_entry, incidents) or is_number_of_unique_recipients_is_too_low( incidents ) if not skip_analysis: demisto.debug("Proceeding with campaign analysis.") analyze_incidents_campaign(incidents, fields_to_display) else: demisto.info("Skipping campaign analysis due to pre-check failures (low incidents or recipients).") else: demisto.info("FindDuplicateEmailIncidents returned no incidents.") if non_content_entries: demisto.debug(f"Returning {len(non_content_entries)} non-content entries.") return_results(non_content_entries) demisto.debug("EmailCampaign script finished.") if __name__ in ["__main__", "__builtin__", "builtins"]: main()
README
Find a campaign of emails based on their textual similarity.
This script can be executed upon each new incoming Phishing incident.
The script would search among past incidents whether past incidents with high text similarity to the current one exist. The script uses NLP techniques for calculating text similarity. The text similarity is calculated based on the email body and email subject fields of the phishing incident.
If such incidents were found, the script would aggregate details regarding them, such as their senders, recipients, dates, mutual indicators, snippets from the email, etc.
This script’s purpose is to provide you an immediate background for phishing incidents when similar incidents exist, and furthermore, help you to detect phishing campaigns more easily.
Script Data
| Name | Description |
|---|---|
| Script Type | python3 |
| Tags | ml, phishing |
| Cortex XSOAR Version | 5.0.0 |
Used In
This script is used in the following playbooks and scripts.
- Detect & Manage Phishing Campaigns
Inputs
| Argument Name | Description |
|---|---|
| incidentTypeFieldName | The name of the incident field in which the incident type is stored. Default is “type”. Change this argument only if you are using a custom field for specifying the incident type. |
| incidentTypes | A comma-separated list of incident types by which to filter. Specify “None” to search through all incident types. |
| existingIncidentsLookback | The date from which to search for similar incidents. Date format is the same as in the incidents query page. For example: “3 days ago”, “2019-01-01T00:00:00 +0200”. |
| query | Additional text by which to query incidents. |
| limit | The maximum number of incidents to fetch. |
| emailSubject | The name of the field that contains the email subject. |
| emailBody | The name of the field that contains the email body. |
| emailBodyHTML | The name of the field that contains the HTML version of the email body. |
| emailFrom | The name of the field that contains the email sender. |
| statusScope | Whether to compare the new incident to closed incidents, unclosed incidents, or all incidents. |
| threshold | Threshold by which to consider incidents as similar. The range of values is 0-1. |
| maxIncidentsToReturn | The maximum number of incidents to display as part of a campaign. If a campaign includes a higher number of incidents, the results will contain only this amount of incidents. |
| minIncidentsForCampaign | Minimum number of incidents to consider as a campaign. |
| minUniqueRecipients | Minimum number of unique recipients of similar email incidents to consider as a campaign. |
| fieldsToDisplay | A comma-seperated list of fields to display. An example is “emailclassification,closereason”. If a list of fields is provided, and a campaign is detected, these incidents fields will be displayed. |
| includeSelf | Include the current incident in EmailCampaign path in context. |
Outputs
| Path | Description | Type |
|---|---|---|
| EmailCampaign.isCampaignFound | Whether a campaign was found. | Boolean |
| EmailCampaign.involvedIncidentsCount | The number of incidents involved in the campaign. | Number |
| EmailCampaign.incidents.id | The IDs of the incidents involved in the campaign. | Unknown |
| EmailCampaign.incidents.similarity | The textual similarity of the related emails to the current incident. | Unknown |
| EmailCampaign.incidents.emailfrom | The senders of the emails involved in the campaign. | Unknown |
| EmailCampaign.incidents.emailfromdomain | The domains of the email senders involved in the campaign. | Unknown |
| EmailCampaign.incidents.recipients | A list of email addresses of recipients involved in the campaign. The list is comprised of the following fields, “Email To”, “Email CC”, “Email BCC”. | Unknown |
| EmailCampaign.incidents.recipientsdomain | A list of the domains of the email addresses of recipients involved in the campaign. The list is comprised of the following fields, “Email To”, “Email CC”, “Email BCC”. | Unknown |
| EmailCampaign.indicators.id | The IDs of the mututal indicators of the incidents involved in the campaign. | Unknown |
| EmailCampaign.indicators.value | The values of the mututal indicators of the incidents involved in the campaign. | Unknown |
| EmailCampaign.fieldsToDisplay | List of fields to display in the linked list table. | Unknown |
| EmailCampaign.firstIncidentDate | The occurrence date of the oldest incident in the campaign. | unknown |
| incident.emailcampaignsummary | Markdown table with email campaign summary. | string |
| incident.emailcampaignsnippets | Markdown table with email content summary. | string |
| incident.emailcampaignmutualindicators | Markdown table with relevant indicators. | string |
| incident.emailcampaigncanvas | Link to the campaign canvas. | string |