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