DBotPredictURLPhishing

Predict phishing URLs using a pre-trained model.

python · Phishing URL

Source

import demistomock as demisto
from CommonServerPython import *
from CommonServerUserPython import *
import urllib
import pandas as pd
import base64
import dill
import copy
from tldextract import TLDExtract
from bs4 import BeautifulSoup
from typing import Literal

dill.settings["recurse"] = True


# Site-specific allowlist for safe pickle loading — extends the shared base with classes this site needs.
_ALLOWED_CLASSES: set[tuple[str, str]] = BASE_PICKLE_ALLOWED_CLASSES | {
    # The Model class (defined in this script)
    ("__main__", "Model"),
    # Scikit-learn pipeline and estimators
    ("sklearn.pipeline", "Pipeline"),
    ("sklearn.linear_model._logistic", "LogisticRegression"),
    ("sklearn.feature_extraction.text", "TfidfVectorizer"),
    ("sklearn.feature_extraction.text", "CountVectorizer"),
    ("sklearn.compose._column_transformer", "ColumnTransformer"),
    ("sklearn.preprocessing._data", "StandardScaler"),
    ("sklearn.preprocessing._label", "LabelEncoder"),
    ("sklearn.base", "BaseEstimator"),
    ("sklearn.base", "TransformerMixin"),
    ("sklearn.utils.deprecation", "DeprecationDict"),
    # Dill internals (for legacy dill-serialized models)
    ("dill._dill", "_create_function"),
    ("dill._dill", "_create_code"),
    ("dill._dill", "_load_type"),
}

# Safe top-level modules whose internal submodules are all data-science code.
_SAFE_MODULE_PREFIXES = {"sklearn", "numpy", "pandas", "scipy"}


no_fetch_extract = TLDExtract(suffix_list_urls=None, cache_dir=False)  # type: ignore

KEY_IMAGE_RASTERIZE = "image_b64"
KEY_IMAGE_HTML = "html"

MSG_SOMETHING_WRONG_IN_RASTERIZE = "Something went wrong with rasterize"
MSG_ENABLE_WHOIS = "Please enable whois integration for more accurate prediction"
MSG_MODEL_VERSION_IN_DEMISTO = "Model version in demisto: {}.{}"
MSG_NO_MODEL_IN_DEMISTO = "There is no existing model version in demisto"
MSG_FAILED_RASTERIZE = "Rasterize error: ERR_NAME_NOT_RESOLVED"
MSG_FAILED_RASTERIZE_TIMEOUT = "Timeout rasterize"
MSG_IMPOSSIBLE_CONNECTION = "Failed to establish a new connection - Name or service not known"
MSG_UPDATE_MODEL = "Update demisto model from docker model version {}.{}"
MSG_UPDATE_LOGO = "Update demisto model from docker model version {}.{} and transfering logos from demisto version {}.{}"
MSG_WRONG_CONFIG_MODEL = "Wrong configuration of the model"
MSG_NO_ACTION_ON_MODEL = "Use current model"
MSG_WHITE_LIST = "White List"
MSG_NEED_TO_UPDATE_RASTERIZE = "Please install and/or update rasterize pack"
URL_PHISHING_MODEL_NAME = "url_phishing_model"
OUT_OF_THE_BOX_MODEL_PATH = "/model/model_docker.pkl"
UNKNOWN_MODEL_TYPE = "UNKNOWN_MODEL_TYPE"
THRESHOLD_NEW_DOMAIN_MONTHS = 6
DOMAIN_AGE_KEY = f"New domain (less than {THRESHOLD_NEW_DOMAIN_MONTHS} months)"

MALICIOUS_VERDICT = "Malicious"
BENIGN_VERDICT = "Benign"
SUSPICIOUS_VERDICT = "Suspicious"
BENIGN_VERDICT_WHITELIST = "Benign - Top domains from Majestic"
UNKNOWN = "Unknown"

BENIGN_THRESHOLD = 0.5
SUSPICIOUS_THRESHOLD = 0.7

SCORE_INVALID_URL = -1.0
SCORE_BENIGN = 0.0  # type: float

GREEN_COLOR = RED_COLOR = VERDICT_MALICIOUS_COLOR = VERDICT_SUSPICIOUS_COLOR = VERDICT_BENIGN_COLOR = VERDICT_ERROR_COLOR = (
    "**{}**"
)
MAPPING_VERDICT_COLOR = {
    MALICIOUS_VERDICT: VERDICT_MALICIOUS_COLOR,
    BENIGN_VERDICT: VERDICT_BENIGN_COLOR,
    SUSPICIOUS_VERDICT: VERDICT_SUSPICIOUS_COLOR,
    BENIGN_VERDICT_WHITELIST: VERDICT_BENIGN_COLOR,
}

SCORE_THRESHOLD = 0.6  # type: float

MODEL_KEY_URL_SCORE = "url_score"
MODEL_KEY_LOGO_FOUND = "logo_found"
MODEL_KEY_SEO = "seo"
MODEL_KEY_LOGO_IMAGE_BYTES = "image_bytes"
MODEL_KEY_LOGIN_FORM = "login_form"

KEY_CONTENT_DOMAIN = "Domain"
KEY_CONTENT_URL = "URL"
KEY_CONTENT_LOGO = "UseOfSuspiciousLogo"
KEY_CONTENT_LOGIN = "HasLoginForm"
KEY_CONTENT_URL_SCORE = "URLStaticScore"
KEY_CONTENT_SEO = "BadSEOQuality"
KEY_CONTENT_AGE = "NewDomain"
KEY_CONTENT_VERDICT = "FinalVerdict"
KEY_CONTENT_IS_WHITELISTED = "TopMajesticDomain"
KEY_CONTENT_DBOT_SCORE = "DBotScore"

KEY_HR_DOMAIN = "Domain"
KEY_HR_URL = "Url"
KEY_HR_SEO = "Search engine optimization"
KEY_HR_LOGIN = "Is there a Login form?"
KEY_HR_LOGO = "Suspicious use of company logo"
KEY_HR_URL_SCORE = "URL severity score (from 0 to 1)"

KEY_CONTENT_SUMMARY_URL = "URL"
KEY_CONTENT_SUMMARY_FINAL_VERDICT = "FinalVerdict"

KEY_FINAL_VERDICT = "Final Verdict"

WEIGHT_HEURISTIC = {DOMAIN_AGE_KEY: 3, MODEL_KEY_LOGIN_FORM: 1, MODEL_KEY_SEO: 1, MODEL_KEY_URL_SCORE: 2, MODEL_KEY_LOGO_FOUND: 1}

MAPPING_VERDICT_TO_DISPLAY_VERDICT = {
    MODEL_KEY_SEO: {True: RED_COLOR.format("Bad"), False: GREEN_COLOR.format("Good")},
    MODEL_KEY_LOGO_FOUND: {True: RED_COLOR.format("Suspicious"), False: GREEN_COLOR.format("Not Suspicious")},
    MODEL_KEY_LOGIN_FORM: {True: RED_COLOR.format("Yes"), False: GREEN_COLOR.format("No")},
    DOMAIN_AGE_KEY: {
        True: RED_COLOR.format("Less than 6 months ago"),
        False: GREEN_COLOR.format("More than 6 months ago"),
        None: None,
    },
}  # type: Dict

VERDICT_TO_INT = {MALICIOUS_VERDICT: 3, BENIGN_VERDICT: 1, BENIGN_VERDICT_WHITELIST: 1, SUSPICIOUS_VERDICT: 2}

WAIT_TIME_RASTERIZE = 5
TIMEOUT_RASTERIZE = 120


class Model:
    """Abstract class that represents the class of the built-in phishing model."""

    clf: Any  # sklearn.pipeline.Pipeline
    df_voc: dict
    top_domains: dict
    logos_dict: dict
    custom_logo_associated_domain: dict

    def predict(self, x_pred: pd.DataFrame) -> dict:  # type: ignore[empty-body]
        ...

    def update_model(
        self,
        top_domains: dict,
        logos_dict: dict,
        custom_logo_associated_domain: dict,
    ): ...


class ModelData(dict[Literal["top_domains", "logos_dict", "custom_logo_associated_domain"], dict]):
    """Abstract class that represents the format of the data stored in the server."""


def delete_model():
    res = demisto.executeCommand("deleteMLModel", {"modelName": URL_PHISHING_MODEL_NAME})
    demisto.debug(f"Deleted model. server response: {res}")


def save_model_data(model_data: ModelData):
    """
    Load and save model from the model in the docker
    :return: None
    """
    res = demisto.executeCommand(
        "createMLModel",
        {
            "modelData": b64encode_string(json.dumps(model_data)),
            "modelName": URL_PHISHING_MODEL_NAME,
            "modelLabels": [MALICIOUS_VERDICT, BENIGN_VERDICT, SUSPICIOUS_VERDICT],
            "modelOverride": "true",
            "modelHidden": True,
            "modelType": "url_phishing",
        },
    )
    if is_error(res):
        raise DemistoException(get_error(res))


def extract_and_save_old_model_data(model_data: str, minor_version: int) -> Optional[ModelData]:  # pragma: no cover
    """Update the model to the new version. This will be eventually deleted."""
    delete_model()
    if minor_version == 0:  # no changes were made to the model by the user
        demisto.debug("Old version is unchanged")
        return None

    import warnings

    warnings.filterwarnings("ignore", module="sklearn")

    try:
        raw_bytes = base64_to_bytes(model_data)
        model = cast(Model, safe_pickle_loads(raw_bytes, _ALLOWED_CLASSES, _SAFE_MODULE_PREFIXES))
    except UnsafePickleError as e:
        demisto.error(f"Security: blocked unsafe model payload: {e}")
        return None
    except Exception as e:
        demisto.debug(f"Unable to load model data: {e}")
        return None

    model_data = cast(
        ModelData,
        {
            "top_domains": model.top_domains,
            "logos_dict": model.logos_dict,
            "custom_logo_associated_domain": model.custom_logo_associated_domain,
        },
    )
    save_model_data(model_data)
    return model_data


def get_model_data() -> Optional[ModelData]:
    res = demisto.executeCommand("getMLModel", {"modelName": URL_PHISHING_MODEL_NAME})[0]
    if is_error(res):
        demisto.debug(f"Model not found: {get_error(res)}")
        return None

    extra_data = dict_safe_get(res, ("Contents", "model", "extra"))
    model_data = dict_safe_get(res, ("Contents", "modelData"))

    if isinstance(extra_data, dict) and "minor" in extra_data:  # this means the old model exists as a pickled object
        demisto.debug(f"Old model found. {extra_data=}")
        return extract_and_save_old_model_data(model_data, extra_data["minor"])
    return cast(ModelData, json.loads(b64decode_string(model_data)))


def load_model_from_docker(path: str = OUT_OF_THE_BOX_MODEL_PATH) -> Model:
    with open(path, "rb") as f:
        return cast(Model, dill.load(f))  # guardrails-disable-line


def load_model() -> Model:
    model = load_model_from_docker()
    model_data = get_model_data()
    if model_data:
        model.update_model(**model_data)  # type: ignore[misc]
    return model


def b64encode_string(string: str) -> str:
    return base64.b64encode(string.encode()).decode()


def b64decode_string(string: str) -> str:
    return base64_to_bytes(string).decode()


def base64_to_bytes(base64_string: str) -> bytes:
    """
    Transform a base64 string into bytes
    :param base64_string:
    :return:
    """
    return base64.b64decode(base64_string.encode())


def extract_domainv2(url: str) -> str:
    ext = no_fetch_extract(url)
    return ext.domain + "." + ext.suffix


def in_white_list(model: Model, url: str) -> bool:
    """
    Check if url belongs to the Model whitelist
    :param model: model which contains top_domains attribute
    :param url: url to check
    :return:
    """
    return extract_domainv2(url) in model.top_domains


def get_colored_pred_json(pred_json: dict) -> dict:
    """
    Create copy and color json values according to their values.
    :param pred_json: json to color
    :return: json
    """
    return copy.deepcopy(pred_json) | {
        MODEL_KEY_SEO: MAPPING_VERDICT_TO_DISPLAY_VERDICT[MODEL_KEY_SEO][pred_json[MODEL_KEY_SEO]],
        MODEL_KEY_LOGO_FOUND: MAPPING_VERDICT_TO_DISPLAY_VERDICT[MODEL_KEY_LOGO_FOUND][pred_json[MODEL_KEY_LOGO_FOUND]],
        MODEL_KEY_LOGIN_FORM: MAPPING_VERDICT_TO_DISPLAY_VERDICT[MODEL_KEY_LOGIN_FORM][pred_json[MODEL_KEY_LOGIN_FORM]],
        DOMAIN_AGE_KEY: MAPPING_VERDICT_TO_DISPLAY_VERDICT[DOMAIN_AGE_KEY][pred_json[DOMAIN_AGE_KEY]],
    }


def create_x_pred(output_rasterize: dict, url: str) -> pd.DataFrame:
    """
    Create dataframe to predict from the rasterize output
    :param output_rasterize: Dict from the output of rasterize command
    :param url: url to examine
    :return: pd.DataFrame
    """
    website64 = output_rasterize.get(KEY_IMAGE_RASTERIZE, None)
    html = output_rasterize.get(KEY_IMAGE_HTML, None)
    X_pred = pd.DataFrame(columns=["name", "image", "html"])
    X_pred.loc[0] = [url, website64, html]
    return X_pred


def prepend_protocol(url: str, protocol: str, www: bool = True) -> str:
    """forceModel
    Append a protocol name (usually http or https) and www to a url
    :param url: url
    :param protocol: protocol we want to add (usually http or https)
    :return: str
    """
    p = urllib.parse.urlparse(url, protocol)  # type: ignore
    netloc = p.netloc or p.path
    path = p.path if p.netloc else ""
    if not netloc.startswith("www.") and www:
        netloc = "www." + netloc
    p = urllib.parse.ParseResult(protocol, netloc, path, *p[3:])  # type: ignore
    return p.geturl()


def return_entry_summary(
    pred_json: dict,
    url: str,
    is_white_listed: bool,
    output_rasterize: dict,
    verdict: str,
    reliability: str = DBotScoreReliability.A_PLUS,
    **_,
) -> Optional[dict[str, Any]]:
    """
    Return entry to demisto
    :param pred_json: json with output of the model
    :param url: url
    :param whitelist: if url belongs to whitelist of the model
    :param reliability: reliability of the source providing the intelligence data.
    :return: entry to demisto
    """
    if is_white_listed:
        return None
    if verdict == BENIGN_VERDICT_WHITELIST:
        verdict = BENIGN_VERDICT
    if not pred_json:
        url_score = SCORE_BENIGN
        url_score_colored = (GREEN_COLOR if url_score < SCORE_THRESHOLD else RED_COLOR).format(url_score)
    else:
        url_score = round(pred_json[MODEL_KEY_URL_SCORE], 2)
        url_score_colored = (GREEN_COLOR if url_score < SCORE_THRESHOLD else RED_COLOR).format(url_score)
    pred_json_colored = get_colored_pred_json(pred_json) if pred_json else {}
    domain = extract_domainv2(url)
    explain = {
        KEY_CONTENT_DOMAIN: domain,
        KEY_CONTENT_URL: url,
        KEY_CONTENT_LOGO: str(pred_json.get(MODEL_KEY_LOGO_FOUND, UNKNOWN)),
        KEY_CONTENT_LOGIN: str(pred_json.get(MODEL_KEY_LOGIN_FORM, UNKNOWN)),
        KEY_CONTENT_URL_SCORE: url_score,
        KEY_CONTENT_SEO: str(pred_json.get(MODEL_KEY_SEO, UNKNOWN)),
        KEY_CONTENT_VERDICT: verdict,
        KEY_CONTENT_IS_WHITELISTED: str(is_white_listed),
    }
    dbot_score = Common.DBotScore(
        indicator=url,
        indicator_type=DBotScoreType.URL,
        integration_name="DBotPhishingURL",
        score=VERDICT_TO_INT.get(verdict),
        reliability=reliability,
    )
    context_DBot_score = dbot_score.to_context().get(dbot_score.get_context_path())

    if pred_json and pred_json[DOMAIN_AGE_KEY] is not None:
        explain[KEY_CONTENT_AGE] = str(pred_json[DOMAIN_AGE_KEY])
    explain_hr = {
        KEY_HR_URL: url,
        KEY_HR_SEO: str(pred_json_colored.get(MODEL_KEY_SEO, UNKNOWN)),
        KEY_HR_LOGIN: str(pred_json_colored.get(MODEL_KEY_LOGIN_FORM, UNKNOWN)),
        KEY_HR_LOGO: str(pred_json_colored.get(MODEL_KEY_LOGO_FOUND, UNKNOWN)),
        KEY_HR_URL_SCORE: url_score_colored,
    }
    if pred_json and pred_json[DOMAIN_AGE_KEY] is not None:
        explain_hr[DOMAIN_AGE_KEY] = str(pred_json_colored[DOMAIN_AGE_KEY])
    if verdict == BENIGN_VERDICT:
        return_entry = {
            "Type": entryTypes["note"],
            "ContentsFormat": formats["json"],
            "HumanReadable": tableToMarkdown(f"Phishing prediction evidence | {domain}", explain_hr),
            "Contents": explain,
            "EntryContext": {"DBotPredictURLPhishing": explain},
        }
    else:
        return_entry = {
            "Type": entryTypes["note"],
            "ContentsFormat": formats["json"],
            "HumanReadable": tableToMarkdown(f"Phishing prediction evidence | {domain}", explain_hr),
            "Contents": explain,
            "EntryContext": {"DBotPredictURLPhishing": explain, KEY_CONTENT_DBOT_SCORE: context_DBot_score},
            "Tags": ["DBOT_URL_PHISHING_MALICIOUS"],
        }
    return_results(return_entry)

    if pred_json:
        image = pred_json[MODEL_KEY_LOGO_IMAGE_BYTES]
        if not image:
            image = base64_to_bytes(output_rasterize.get(KEY_IMAGE_RASTERIZE))  # type: ignore[arg-type]
        res = fileResult(filename="Logo detection engine", data=image)
        res["Type"] = entryTypes["image"]
        if pred_json[MODEL_KEY_LOGO_FOUND]:
            res["Tags"] = ["DBOT_URL_PHISHING_MALICIOUS"]
        return_results(res)
    return explain


def return_entry_white_list(url: str):
    """
    Create syntethci entry when url belongs to whitelist
    :param url: url
    :return:
    """
    explain = {
        KEY_CONTENT_DOMAIN: extract_domainv2(url),
        KEY_CONTENT_URL: url,
        KEY_CONTENT_AGE: MSG_WHITE_LIST,
        KEY_CONTENT_LOGO: MSG_WHITE_LIST,
        KEY_CONTENT_LOGIN: MSG_WHITE_LIST,
        KEY_CONTENT_URL_SCORE: MSG_WHITE_LIST,
        KEY_CONTENT_SEO: MSG_WHITE_LIST,
    }
    explain_hr = {
        KEY_HR_URL: url,
        KEY_HR_SEO: MSG_WHITE_LIST,
        DOMAIN_AGE_KEY: MSG_WHITE_LIST,
        KEY_HR_LOGIN: MSG_WHITE_LIST,
        KEY_HR_LOGO: MSG_WHITE_LIST,
        KEY_HR_URL_SCORE: MSG_WHITE_LIST,
    }
    verdict_hr = {"Verdict": BENIGN_VERDICT, "URL": url}
    return_entry = {
        "Type": entryTypes["note"],
        "ContentsFormat": formats["json"],
        "HumanReadable": tableToMarkdown("Verdict", verdict_hr) + tableToMarkdown("Report", explain_hr),
        "Contents": explain,
        "EntryContext": {"DBotPredictURLPhishing": explain},
    }
    return_results(return_entry)


def get_score(pred_json: dict) -> int:
    use_age = False
    use_logo = False
    if pred_json[DOMAIN_AGE_KEY]:
        use_age = True
    if pred_json[MODEL_KEY_LOGO_FOUND]:
        use_logo = True
    domain_age_key = 0 if pred_json[DOMAIN_AGE_KEY] is None else pred_json[DOMAIN_AGE_KEY]
    total_weight_used = (
        WEIGHT_HEURISTIC[DOMAIN_AGE_KEY] * use_age
        + WEIGHT_HEURISTIC[MODEL_KEY_LOGIN_FORM]
        + WEIGHT_HEURISTIC[MODEL_KEY_SEO]
        + WEIGHT_HEURISTIC[MODEL_KEY_URL_SCORE]
        + WEIGHT_HEURISTIC[MODEL_KEY_LOGO_FOUND] * use_logo
    )
    score = (
        use_age * WEIGHT_HEURISTIC[DOMAIN_AGE_KEY] * domain_age_key
        + WEIGHT_HEURISTIC[MODEL_KEY_LOGIN_FORM] * pred_json[MODEL_KEY_LOGIN_FORM]
        + WEIGHT_HEURISTIC[MODEL_KEY_SEO] * pred_json[MODEL_KEY_SEO]
        + WEIGHT_HEURISTIC[MODEL_KEY_URL_SCORE] * pred_json[MODEL_KEY_URL_SCORE]
        + use_logo * WEIGHT_HEURISTIC[MODEL_KEY_LOGO_FOUND] * pred_json[MODEL_KEY_LOGO_FOUND]
    ) / total_weight_used
    return score


def get_verdict(pred_json: dict, is_white_listed: bool) -> tuple[float, str]:
    """
    Return verdict of the url based on the output of the model
    :param pred_json: output from the model
    :return:
    """
    if is_white_listed:
        return SCORE_BENIGN, BENIGN_VERDICT
    score = get_score(pred_json)
    if pred_json[MODEL_KEY_LOGO_FOUND]:
        return score, MALICIOUS_VERDICT
    if score < BENIGN_THRESHOLD:
        return score, BENIGN_VERDICT
    if score < SUSPICIOUS_THRESHOLD:
        return score, SUSPICIOUS_VERDICT
    return score, MALICIOUS_VERDICT


def create_dict_context(url, verdict, pred_json, score, is_white_listed, output_rasterize) -> dict:
    return {
        "url_redirect": url,
        "url": url,
        "verdict": verdict,
        "pred_json": pred_json,
        "score": score,
        "is_white_listed": is_white_listed,
        "output_rasterize": output_rasterize,
    }


def extract_created_date(entry: dict) -> Union[bool, None]:
    """
    Check if domain age is younger than THRESHOLD_NEW_DOMAIN_YEAR year
    :param entry_list: output of the whois command
    :return: bool
    """
    if not is_error(entry):
        date_str = dict_safe_get(entry, ("EntryContext", "Domain(val.Name && val.Name == obj.Name)", "WHOIS", "CreationDate"))
        if date_str:
            date = datetime.strptime(date_str, "%d-%m-%Y")
            threshold_date = datetime.now() - timedelta(days=THRESHOLD_NEW_DOMAIN_MONTHS * 30)
            return date > threshold_date
    return None


def return_and_remove_additional_results(results: list, from_index: int):
    """Return and remove the extra unneeded results returned from a command call.
    In XSOAR 8 log results are usually returned with sub-commands if debug-mode=true"""
    if results[from_index:]:
        return_results(results[from_index:])
        del results[from_index:]
        demisto.debug(f"removed and returned {from_index} outputs")


def weed_rasterize_errors(urls: list[str], res_rasterize: list[Union[dict, str]]):
    """Remove the URLs that failed rasterization and return them."""
    error_idx = [i for (i, res) in enumerate(res_rasterize) if not isinstance(res, dict)][
        ::-1
    ]  # reverse the list as it will be used to remove elements.
    if error_idx:
        return_results(
            CommandResults(
                readable_output=tableToMarkdown(
                    "The following URLs failed rasterize and were skipped:",
                    [{"URL": urls.pop(i), "Message": res_rasterize.pop(i)} for i in error_idx],
                    ["URL", "Message"],
                )
            )
        )


def rasterize_command(urls: Union[list[str], str], rasterize_timeout: int) -> list[Union[dict, str]]:
    res_rasterize: list[dict] = demisto.executeCommand(  # type: ignore
        "rasterize", {"type": "json", "url": urls, "wait_time": WAIT_TIME_RASTERIZE, "execution-timeout": rasterize_timeout}
    )
    demisto.debug(f"Rasterize Data: {res_rasterize}")
    return_and_remove_additional_results(res_rasterize, len(urls) if isinstance(urls, list) else 1)
    return [res["Contents"] or res["HumanReadable"] for res in res_rasterize]


def rasterize_urls(urls: list[str], rasterize_timeout: int) -> list[dict]:
    urls = [url.removeprefix("http://") for url in urls]
    res_rasterize = rasterize_command(urls, rasterize_timeout)
    if len(res_rasterize) < len(urls):  # check for errors in the response
        demisto.info(f"Rasterize response is too short, running command for each URL\n{res_rasterize=}\n{urls=}")
        rasterize_runs = map(rasterize_command, urls, [rasterize_timeout] * len(urls))
        res_rasterize = sum(rasterize_runs, [])
    weed_rasterize_errors(urls, res_rasterize)
    return cast(list[dict], res_rasterize)


def get_whois_verdict(domains: list[str]) -> list:
    """Check domain age from WHOIS command"""
    default = [None] * len(domains)
    if isCommandAvailable("whois"):
        try:
            res = demisto.executeCommand("whois", {"query": domains, "execution-timeout": 5})
            return_and_remove_additional_results(res, len(domains))
            return res or default
        except Exception as e:
            demisto.debug(str(e))
    else:
        return_results(MSG_ENABLE_WHOIS)
    return default


def get_predictions_for_urls(
    model: Model,
    urls: list[str],
    requested_urls: list[str],
    force_model: bool,
    debug: bool,
    rasterize_timeout: int,
    protocol: str,
) -> Optional[list[dict]]:
    """Generate predictions for the given URL list

    :param model: Prediction model to use.
    :param urls: List of URLs to generate predictions for.
    :param requested_urls: List of URLs requested to be checked, in their original form.
    :param force_model: When set, the model will be run even if the URL is whitelisted.
    :param debug: Enable debug output messages.
    :param rasterize_timeout: Timeout for the rasterize call.
    :param protocol: Protocol override to be used in the URLs (e.g. "http", "https")

    :return: A list of dictionaries containing the prediction results for each URL, or None if no results were generated.
    """

    domains = list(map(extract_domainv2, urls))

    rasterize_outputs = rasterize_urls(urls, rasterize_timeout)

    if not rasterize_outputs:
        return_results("All URLs failed to be rasterized. Skipping prediction.")
        return None

    whois_results = get_whois_verdict(domains)

    results = []
    for url, requested_url, res_whois, output_rasterize in zip(urls, requested_urls, whois_results, rasterize_outputs):
        # Check is domain in white list -  If yes we don't run the model
        if in_white_list(model, url):
            is_white_listed = True
            if not force_model:
                results.append(
                    create_dict_context(requested_url, BENIGN_VERDICT_WHITELIST, {}, SCORE_BENIGN, is_white_listed, {})
                )
                continue
        else:
            is_white_listed = False

        x_pred = create_x_pred(output_rasterize, prepend_protocol(url, protocol))

        pred_json = model.predict(x_pred)
        if debug:
            return_results(pred_json["debug_top_words"])
            return_results(pred_json["debug_found_domains_list"])
            return_results(pred_json["seo"])
            return_results(pred_json["debug_image"])

        pred_json[DOMAIN_AGE_KEY] = extract_created_date(res_whois)

        score, verdict = get_verdict(pred_json, is_white_listed)
        results.append(create_dict_context(requested_url, verdict, pred_json, score, is_white_listed, output_rasterize))
    return results


def return_general_summary(results: list[dict], tag: str = "Summary") -> list[dict]:
    df_summary = pd.DataFrame()
    df_summary["URL"] = [x.get("url_redirect") for x in results]
    df_summary[KEY_FINAL_VERDICT] = [
        MAPPING_VERDICT_COLOR.get(x.get("verdict"), VERDICT_ERROR_COLOR).format(x.get("verdict"))  # type: ignore
        for x in results  # type: ignore
    ]
    summary_context = [
        {
            KEY_CONTENT_SUMMARY_URL: x.get("url_redirect"),
            KEY_CONTENT_SUMMARY_FINAL_VERDICT: BENIGN_VERDICT,
            KEY_CONTENT_IS_WHITELISTED: "True",
        }
        for x in results
        if x.get("is_white_listed")
    ]
    df_summary_json = df_summary.to_dict(orient="records")
    return_entry = {
        "Type": entryTypes["note"],
        "ContentsFormat": formats["json"],
        "HumanReadable": tableToMarkdown(
            "Phishing prediction summary for URLs", df_summary_json, headers=["URL", KEY_FINAL_VERDICT]
        ),
        "Contents": summary_context,
        "EntryContext": {"DBotPredictURLPhishing": summary_context},
    }
    if tag is not None:
        return_entry["Tags"] = [f"DBOT_URL_PHISHING_{tag}"]
    return_results(return_entry)
    return df_summary_json


def return_detailed_summary(results: list, reliability: str) -> list[dict[str, str]]:
    outputs = []
    results.sort(key=lambda x: x["score"])
    for result in results:
        if result.get("score") == SCORE_INVALID_URL:
            continue
        summary_json = return_entry_summary(**result, reliability=reliability)
        if summary_json:
            outputs.append(summary_json)
    return outputs


def extract_urls(text: str) -> list[str]:
    res = demisto.executeCommand("extractIndicators", {"text": text})
    if is_error(res):
        raise DemistoException(get_error(res))
    return list(set(json.loads(res[0]["Contents"]).get("URL", [])))


def get_final_urls(urls: list[str], max_urls: int, model: Model) -> list[str]:
    final_url = []
    seen = []
    low_priority_urls = []
    i = 0
    for url in urls:
        if i < max_urls:
            if extract_domainv2(url) in seen or extract_domainv2(url) in model.top_domains:
                low_priority_urls.append(url)
            else:
                final_url.append(url)
                seen.append(extract_domainv2(url))
                i += 1
    if len(final_url) < max_urls:
        final_url += low_priority_urls[: min(len(low_priority_urls), max_urls - len(final_url))]
    return final_url


def extract_embedded_urls_from_html(html: str) -> list[str]:
    return [a.get("href") for a in BeautifulSoup(html).find_all("a") if a.get("href")]  # type: ignore


def get_urls_to_run(
    email_body: str,
    email_html: str,
    urls_argument: Union[list, str],
    max_urls: int,
    model: Model,
    msg_list: list[str],
    debug: bool,
) -> tuple[list[str], list[str], list[str]]:
    if email_body:
        urls_email_body = extract_urls(email_body)
    else:
        urls_email_body = extract_urls(BeautifulSoup(email_html).get_text()) if email_html else []
    if email_html:
        urls_email_html = extract_embedded_urls_from_html(email_html)
    else:
        urls_email_html = []
    if isinstance(urls_argument, list):
        urls_only = urls_argument
    else:
        urls_only = urls_argument.split()
    urls = list(set(urls_email_body + urls_only + urls_email_html))

    mailto_urls = [url for url in urls if url.startswith("mailto:")]

    urls = [item for item in urls if item not in mailto_urls]

    if mailto_urls:
        return_results(
            CommandResults(readable_output=f'URLs that start with "mailto:" cannot be rasterized.\nURL: {mailto_urls}')
        )

    if not urls:
        return_results("No URLs for prediction.")
        return [], [], msg_list
    urls = get_final_urls(urls, max_urls, model)
    unescaped_urls = demisto.executeCommand("UnEscapeURLs", {"input": urls}) or []
    unescaped_urls = [res["Contents"] for res in unescaped_urls]  # type: ignore
    if debug:
        return_results(unescaped_urls)
    return unescaped_urls, urls, msg_list


def main():
    msg_list: list = []
    try:
        args = demisto.args()
        reset_model = args.get("resetModel") == "True"
        debug = args.get("debug") == "True"
        force_model = args.get("forceModel") == "True"
        email_body = args.get("emailBody", "")
        email_html = args.get("emailHTML", "")
        max_urls = cast(int, arg_to_number(args.get("maxNumberOfURL", 5), "maxNumberOfURL", required=True))
        urls_argument = args.get("urls", "")
        rasterize_timeout = arg_to_number(args.get("rasterize_timeout", TIMEOUT_RASTERIZE)) or 0
        reliability = DBotScoreReliability.get_dbot_score_reliability_from_str(
            args.get("reliability", DBotScoreReliability.A_PLUS)
        )
        protocol = demisto.args().get("defaultRequestProtocol", "HTTP").lower()

        if reset_model:
            delete_model()

        model = load_model()

        urls, requested_urls, msg_list = get_urls_to_run(email_body, email_html, urls_argument, max_urls, model, msg_list, debug)

        if urls:
            results = get_predictions_for_urls(model, urls, requested_urls, force_model, debug, rasterize_timeout, protocol)
            if results:
                general_summary = return_general_summary(results)
                detailed_summary = return_detailed_summary(results, reliability)
                if debug:
                    return_results(msg_list)
                return general_summary, detailed_summary, msg_list

    except Exception as e:
        return_error(f"Failed to execute URL Phishing script. Error: {e}")

    finally:
        demisto.debug(f"{msg_list=}")


if __name__ in ["__main__", "__builtin__", "builtins"]:
    main()

README

Predict phishing URLs using a pre-trained model.

Security Recommendations


This script uses the Rasterize integration. If this script is used to rasterize untrusted URLs, we strongly recommend following the security recommendations included at the Rasterize Documentation.

Script Data


Name Description
Script Type python3
Tags ml
Cortex XSOAR Version 6.0.0

Used In


This script is used in the following playbooks and scripts.

Phishing - Machine Learning Analysis

Inputs


Argument Name Description
urls Space-separated list of URLs.
emailBody Body of the email for URL extraction.
emailHTML HTML of the email for URL extraction.
maxNumberOfURL Maximum number of extracted URLs on which to run the model.
forceModel Whether to force the model to run if the URL belongs to the whitelist. If True, the model will run in every case. If False, the model will run only if the URL does not belong to the whitelist.
resetModel Whether to reset the model to the model existing in Docker.
defaultRequestProtocol The protocol to use when calling the URLs. This argument effects the calls sent by the model only and has no effect on the rasterize or whois commands.
debug Whether to enter debug mode.
reliability Reliability of the source providing the intelligence data.

Outputs


Path Description Type
DBotPredictURLPhishing.URL URL on which the model ran. String
DBotPredictURLPhishing.FinalVerdict Final verdict of the URL. String
DBotPredictURLPhishing.UseOfSuspiciousLogo Whether a logo (from our list of top most use company for phishing) has been fraudulently used. Our predefined list of logos is: Paypal, Instagram, Gmail, Outlook, Linkedin, Facebook, Ebay, amazon, Google, Microsoft. String
DBotPredictURLPhishing.HasLoginForm Whether there is a login form in the HTML. Usually phishing attacks aim to steal credentials from the victim and attackers using login forms to retrieve this information. String
DBotPredictURLPhishing.URLStaticScore Probability for the URL to be malicious based only on the URL syntax. Number
DBotPredictURLPhishing.BadSEOQuality Whether the domain has a good search engine optimization. Malicious domains tend to have a poor SEO. String
DBotPredictURLPhishing.NewDomain Whether the domain is younger than 6 months. New domains tend to be malicious. String
DBotPredictURLPhishing.TopMajesticDomain Whether the domain belongs to the top Majestic domain list. If it does, we will always consider this domain as benign. String
DBotScore.Score Severity score. Number

Script Examples

Example command

!DBotPredictURLPhishing urls="http://google.com"

Context Example

{
  "DBotPredictURLPhishing": [
    {
      "FinalVerdict": "Benign",
      "TopMajesticDomain": "True",
      "URL": "http://google.com"
    }
  ]
}

Human Readable Output

Phishing prediction summary for URLs

URL Final Verdict
http://google.com Benign - whitelisted