FindEmailCampaign

Find a campaign of emails based on their textual similarity.

python · Phishing Campaign

Details

IDFindEmailCampaign
Languagepython
From Version5.0.0
Docker Imagedemisto/sklearn:1.0.0.12545527
Tagsml phishing

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
# from CommonServerPython import *
import json
from datetime import datetime
from email.utils import parseaddr

import pandas as pd
import pytest
import tldextract
from FindEmailCampaign import *

no_fetch_extract = tldextract.TLDExtract(suffix_list_urls=None, cache_dir=False)


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}"


EXISTING_INCIDENTS = []

RESULTS = None
EXISTING_INCIDENT_ID = DUP_INCIDENT_ID = None

IDS_COUNTER = 57878

text = (
    "Imagine there's no countries It isn't hard to do Nothing to kill or die for And no religion too "
    "Imagine all the people Living life in peace"
)
text2 = "Love of my life, you've hurt me You've broken my heart and now you leave me Love of my life, can't you see?\
      Bring it back, bring it back Don't take it away from me, because you don't know What it means to me"

INCIDENTS_CONTEXT_KEY = "EmailCampaign." + INCIDENTS_CONTEXT_TD


def create_incident(
    subject=None,
    body=None,
    html=None,
    emailfrom=None,
    created=None,
    id_=None,
    similarity=0,
    sender="a@phishing.com",
    emailto="a@paloaltonetwork.com",
    emailcc="",
    emailbcc="",
    status=1,
    severity=1,
):
    global IDS_COUNTER
    dt_format = "%Y-%m-%d %H:%M:%S.%f %z"
    incident = {
        "id": id_ if id_ is not None else str(IDS_COUNTER),
        "name": " ".join(str(x) for x in [subject, body, html, emailfrom]),
        "created": created.strftime(dt_format) if created is not None else datetime.now().strftime(dt_format),
        "type": "Phishing",
        "similarity": similarity,
        "emailfrom": sender,
        PREPROCESSED_EMAIL_BODY: body,
        "emailbodyhtml": html,
        PREPROCESSED_EMAIL_SUBJECT: subject,
        "fromdomain": extract_domain(sender),
        "emailto": emailto,
        "emailcc": emailcc,
        "emailbcc": emailbcc,
        "status": status,
        "severity": severity,
    }
    return incident


def set_existing_incidents_list(incidents_list):
    global EXISTING_INCIDENTS
    EXISTING_INCIDENTS = incidents_list


def executeCommand(command, args=None):
    global EXISTING_INCIDENTS, EXISTING_INCIDENT_ID, DUP_INCIDENT_ID
    if command == "FindDuplicateEmailIncidents":
        incidents_str = json.dumps(EXISTING_INCIDENTS)
        return [{"Contents": incidents_str, "Type": "not error"}]
    if command == "CloseInvestigationAsDuplicate":
        EXISTING_INCIDENT_ID = args["duplicateId"]
        return None
    return None


def results(arg):
    global RESULTS
    RESULTS.append(arg)


def mock_summarize_email_body(body, subject, nb_sentences=3, subject_weight=1.5, keywords_weight=1.5):
    return f"{subject}\n{body}"


def test_return_campaign_details_entry(mocker):
    global RESULTS
    RESULTS = []
    mocker.patch.object(demisto, "results", side_effect=results)
    mocker.patch("FindEmailCampaign.summarize_email_body", mock_summarize_email_body)
    inciddent1 = create_incident(subject="subject", body="email body")
    incidents_list = [inciddent1]
    data = pd.DataFrame(incidents_list)
    return_campaign_details_entry(data, fields_to_display=[])
    res = RESULTS[0]
    context = res["EntryContext"]
    assert context["EmailCampaign.isCampaignFound"]
    assert context["EmailCampaign.involvedIncidentsCount"] == len(data)
    for original_incident, context_incident in zip(incidents_list, context[INCIDENTS_CONTEXT_KEY]):
        for k in ["id", "similarity", "emailfrom"]:
            assert original_incident[k] == context_incident[k]
        assert original_incident["emailto"] in context_incident["recipients"]
        assert original_incident["fromdomain"] == context_incident["emailfromdomain"]
        assert extract_domain(original_incident["emailto"]) in context_incident["recipientsdomain"]


def test_return_campaign_details_entry_comma_seperated_recipients(mocker):
    global RESULTS
    RESULTS = []
    mocker.patch.object(demisto, "results", side_effect=results)
    mocker.patch("FindEmailCampaign.summarize_email_body", mock_summarize_email_body)
    inciddent1 = create_incident(subject="subject", body="email body", emailto="a@a.com, b@a.com")
    incidents_list = [inciddent1]
    data = pd.DataFrame(incidents_list)
    return_campaign_details_entry(data, fields_to_display=[])
    res = RESULTS[0]
    context = res["EntryContext"]
    assert context["EmailCampaign.isCampaignFound"]
    assert context["EmailCampaign.involvedIncidentsCount"] == len(data)
    for original_incident, context_incident in zip(incidents_list, context[INCIDENTS_CONTEXT_KEY]):
        for k in ["id", "similarity", "emailfrom"]:
            assert original_incident[k] == context_incident[k]
        for recipient in original_incident["emailto"].split(","):
            assert recipient.strip() in context_incident["recipients"]
            assert extract_domain(recipient) in context_incident["recipientsdomain"]
        assert original_incident["fromdomain"] == context_incident["emailfromdomain"]


def test_return_campaign_details_entry_list_dumped_recipients(mocker):
    global RESULTS
    RESULTS = []
    mocker.patch.object(demisto, "results", side_effect=results)
    mocker.patch("FindEmailCampaign.summarize_email_body", mock_summarize_email_body)
    inciddent1 = create_incident(subject="subject", body="email body", emailto='["a@a.com", "b@a.com"]')
    incidents_list = [inciddent1]
    data = pd.DataFrame(incidents_list)
    return_campaign_details_entry(data, fields_to_display=[])
    res = RESULTS[0]
    context = res["EntryContext"]
    assert context["EmailCampaign.isCampaignFound"]
    assert context["EmailCampaign.involvedIncidentsCount"] == len(data)
    for original_incident, context_incident in zip(incidents_list, context[INCIDENTS_CONTEXT_KEY]):
        for k in ["id", "similarity", "emailfrom"]:
            assert original_incident[k] == context_incident[k]
        for recipient in json.loads(original_incident["emailto"]):
            assert recipient.strip() in context_incident["recipients"]
            assert extract_domain(recipient) in context_incident["recipientsdomain"]
        assert original_incident["fromdomain"] == context_incident["emailfromdomain"]


def test_return_campaign_details_entry_list_dumped_recipients_cc(mocker):
    global RESULTS
    RESULTS = []
    mocker.patch.object(demisto, "results", side_effect=results)
    mocker.patch("FindEmailCampaign.summarize_email_body", mock_summarize_email_body)
    inciddent1 = create_incident(subject="subject", body="email body", emailcc='["a@a.com", "b@a.com"]')
    incidents_list = [inciddent1]
    data = pd.DataFrame(incidents_list)
    return_campaign_details_entry(data, fields_to_display=[])
    res = RESULTS[0]
    context = res["EntryContext"]
    assert context["EmailCampaign.isCampaignFound"]
    assert context["EmailCampaign.involvedIncidentsCount"] == len(data)
    for original_incident, context_incident in zip(incidents_list, context[INCIDENTS_CONTEXT_KEY]):
        for k in ["id", "similarity", "emailfrom"]:
            assert original_incident[k] == context_incident[k]
        for recipient in json.loads(original_incident["emailcc"]):
            assert recipient.strip() in context_incident["recipients"]
            assert extract_domain(recipient) in context_incident["recipientsdomain"]
        assert original_incident["fromdomain"] == context_incident["emailfromdomain"]


ADDITIONAL_CONTEXT_KEYS_PARAMETRIZE = [
    (["name", "emailfrom", "emailto", "severity", "status", "created"]),
    (["name", "emailfrom", "emailto"]),
]


def prepare_additional_context_fields_test(mocker):
    global RESULTS
    RESULTS = []
    # prepare
    mocker.patch.object(demisto, "results", side_effect=results)
    mocker.patch("FindEmailCampaign.summarize_email_body", mock_summarize_email_body)
    incident = create_incident(
        subject="subject", body="email body", emailfrom="a@a.com", emailto="a@a.com, b@a.com", emailcc='["a@a.com", "b@a.com"]'
    )
    incidents_list = [incident]
    data = pd.DataFrame(incidents_list)
    return data


@pytest.mark.parametrize("fields_to_store_in_context", ADDITIONAL_CONTEXT_KEYS_PARAMETRIZE)
def test_context_populated_with_requested_fields_happy_path(mocker, fields_to_store_in_context):
    """

    Given:
        - List of valid fields for the command argument fieldsToDisplay, expected to be stored in the context

    When:
        - Get the campaign details entry

    Then:
        - Assert that the user requested fields are stored in the context

    """
    # prepare

    data = prepare_additional_context_fields_test(mocker)

    # run
    return_campaign_details_entry(data, fields_to_display=fields_to_store_in_context)
    res = RESULTS[0]
    context = res["EntryContext"]

    # assert
    for context_incident in context[INCIDENTS_CONTEXT_KEY]:
        for field in fields_to_store_in_context:
            assert field in context_incident, f'the field "{field}" is expected to be stored in context'


def test_context_not_populated_with_invalid_fields(mocker):
    """

    Given:
        - List of invalid fields for the command argument fieldsToDisplay, expected not to be stored in the context

    When:
        - Get the campaign details entry

    Then:
        - Assert that the invalid fields aren't stored in the context and there is Warning entry

    """
    invalid_fields = ["name_", "email_from", "emailTo", "Severity", "statuses", "create"]
    data = prepare_additional_context_fields_test(mocker)

    # run
    return_campaign_details_entry(data, fields_to_display=invalid_fields)

    # assert RESULTS have 2 entries, Warning entry about the invalid fields and context entry
    assert len(RESULTS) == 2
    assert "Warning: " in RESULTS[0]["Contents"]

    # assert that invalid keys aren't in the context
    context = RESULTS[1]["EntryContext"]
    for context_incident in context[INCIDENTS_CONTEXT_KEY]:
        for field in invalid_fields:
            assert field not in context_incident, f'the field "{field}" should not be stored in context'


@pytest.mark.parametrize("include_self", [True, False])
def test_include_self_flag_on(mocker, include_self):
    """

    Given:
        - include_self flag either True or false

    When:
        - Get the campaign details entry

    Then:
        - Assert that the appearance of the current incident is in the context (INCIDENTS_CONTEXT_KEY) according to the
          given flag

    """
    import FindEmailCampaign

    global RESULTS
    RESULTS = []
    FindEmailCampaign.SELF_IN_CONTEXT = include_self
    mocker.patch.object(demisto, "results", side_effect=results)
    mocker.patch("FindEmailCampaign.summarize_email_body", mock_summarize_email_body)
    incident = create_incident(subject="subject", body="email body")
    mocker.patch.object(demisto, "incident", return_value=incident)
    incidents_list = [incident]
    data = pd.DataFrame(incidents_list)
    return_campaign_details_entry(data, fields_to_display=[])
    res = RESULTS[0]
    context = res["EntryContext"]
    result = incident["id"] in [context_incident["id"] for context_incident in context[INCIDENTS_CONTEXT_KEY]]
    # if include_self is true result should be true
    # if include_self is false result should be false
    assert (include_self and result) or (not include_self and not result)


def test_return_indicator_entry(mocker):
    import FindEmailCampaign

    # create dataframe with one incident
    incidents = pd.DataFrame([{"id": 2}, {"id": 1}])
    mocker.patch.object(
        FindEmailCampaign.demisto,
        "searchIndicators",
        return_value={
            "iocs": [{"id": "1", "value": "1", "score": 1, "investigationIDs": [1, 2], "relatedIncCount": 1}],
            "total": 1,
        },
    )
    mocker.patch.object(FindEmailCampaign.demisto, "executeCommand")
    indicator = FindEmailCampaign.return_indicator_entry(incidents)
    assert indicator["id"].values[0] == "1"
    assert indicator["relatedIncCount"].values[0] == 1


CONTENT_ENTRY = {"Contents": "[]", "Type": 1}
NON_CONTENT_ENTRY1 = {"Contents": "", "Type": 16}
NON_CONTENT_ENTRY2 = {"NotContents": "", "Type": 16}


def test_return_non_content_entries(mocker):
    """
    Given: a content entry and non-content entries as a response to the executeCommand.
    When: Running the scipt (usually happens with debug-mode=true)
    Then: assert the non-content entries are returned.
    """
    import FindEmailCampaign

    mocker.patch.object(FindEmailCampaign.demisto, "args", return_value={})
    mocker.patch.object(
        FindEmailCampaign.demisto, "executeCommand", return_value=[CONTENT_ENTRY, NON_CONTENT_ENTRY1, NON_CONTENT_ENTRY2]
    )

    return_results_mock = mocker.patch("FindEmailCampaign.return_results")
    FindEmailCampaign.main()
    return_results_mock.assert_called_with([NON_CONTENT_ENTRY1, NON_CONTENT_ENTRY2])


def test_horizontal_to_vertical_md_table():
    """
    Given: a horizontal markdown table with pipes in the values.
    When: Running the scipt.
    Then: assert that fields that include pipes without spaces will not be split.
        For example, for the markdown:
        "### Possible Campaign Detected\n|field1|name|field3|\n|--|-|--|\n| value_field1 | Phishing:X|hello@test.com|Subject
        Details (1) | value_field3 |\n"
        The outcome of the fields and values will be:
        "field1": "value_field1"
        "name": "Phishing:X|hello@test.com|Subject Details (1)"
        "field3": "value_field1"
    """
    horizontal_md_table = (
        "### Possible Campaign Detected\n"
        "|field1|name|field3|\n"
        "|--|-|--|\n"
        "| value_field1 | Phishing:X|hello@test.com|Subject Details (1) | value_field3 |\n"
    )
    expected_value = (
        "\n| | |\n|---|---|\n|**field1**| value_field1 |\n|**name**| Phishing:X|hello@test.com|Subject Details (1) "
        "|\n|**field3**| value_field3 |"
    )
    result = horizontal_to_vertical_md_table(horizontal_md_table)
    assert expected_value == result


def test_horizontal_to_vertical_md_table_no_pipe():
    """
    Given: a horizontal markdown table without pipes in the values.
    When: Running the scipt.
    Then: assert that fields are extracted as expected.
    """
    horizontal_md_table = (
        "### Possible Campaign Detected\n"
        "|field1|name|field3|\n"
        "|--|-|--|\n"
        "| value_field1 | value_field2:text | value_field3 |\n"
    )
    expected_value = (
        "\n| | |\n|---|---|\n|**field1**| value_field1 |\n|**name**| value_field2:text |\n|**field3**| value_field3 |"
    )
    result = horizontal_to_vertical_md_table(horizontal_md_table)
    assert expected_value == result