import multiprocessing
# set omp
import os
import demistomock as demisto # noqa: F401
from CommonServerPython import * # noqa: F401
from CommonServerUserPython import *
os.environ["OMP_NUM_THREADS"] = str(multiprocessing.cpu_count()) # noqa
demisto.debug(f"Set OMP_NUM_THREADS to {os.environ['OMP_NUM_THREADS']}")
import re
from email.utils import parseaddr
from urllib.parse import urlparse
import dateutil # type: ignore
import pandas as pd
import tldextract
from bs4 import BeautifulSoup
from FormatURLApiModule import * # noqa: E402
from numpy import dot
from numpy.linalg import norm
from sklearn.feature_extraction.text import CountVectorizer
no_fetch_extract = tldextract.TLDExtract(suffix_list_urls=[], cache_dir=None)
pd.options.mode.chained_assignment = None # default='warn'
SIMILARITY_THRESHOLD = float(demisto.args().get("threshold", 0.97))
CLOSE_TO_SIMILAR_DISTANCE = 0.2
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"
MIN_TEXT_LENGTH = 50
DEFAULT_ARGS = {
"limit": "1000",
"incidentTypes": "Phishing",
"existingIncidentsLookback": "100 days ago",
}
FROM_POLICY_TEXT_ONLY = "TextOnly"
FROM_POLICY_EXACT = "Exact"
FROM_POLICY_DOMAIN = "Domain"
FROM_POLICY = FROM_POLICY_TEXT_ONLY
URL_REGEX = (
r"(?:(?:https?|ftp|hxxps?):\/\/|www\[?\.\]?|ftp\[?\.\]?)(?:[-\w\d]+\[?\.\]?)+[-\w\d]+(?::\d+)?"
r"(?:(?:\/|\?)[-\w\d+&@#\/%=~_$?!\-:,.\(\);]*[\w\d+&@#\/%=~_$\(\);])?"
)
IGNORE_INCIDENT_TYPE_VALUE = "None"
def get_existing_incidents(input_args, current_incident_type):
global DEFAULT_ARGS
demisto.debug("Entering get_existing_incidents")
get_incidents_args = {}
get_incidents_args["limit"] = input_args.get("limit", DEFAULT_ARGS["limit"])
if "existingIncidentsLookback" in input_args:
get_incidents_args["fromDate"] = input_args["existingIncidentsLookback"]
elif "existingIncidentsLookback" in DEFAULT_ARGS:
get_incidents_args["fromDate"] = DEFAULT_ARGS["existingIncidentsLookback"]
status_scope = input_args.get("statusScope", "All")
query_components = []
if input_args.get("query"):
query_components.append(input_args["query"])
if status_scope == "ClosedOnly":
query_components.append("status:closed")
elif status_scope == "NonClosedOnly":
query_components.append("-status:closed")
elif status_scope == "All":
pass
else:
return_error(f"Unsupported statusScope: {status_scope}")
type_values = input_args.get("incidentTypes", current_incident_type)
if type_values != IGNORE_INCIDENT_TYPE_VALUE:
type_field = input_args.get("incidentTypeFieldName", "type")
type_query = generate_incident_type_query_component(type_field, type_values)
query_components.append(type_query)
if len(query_components) > 0:
get_incidents_args["query"] = " and ".join(f"({c})" for c in query_components)
fields = [
EMAIL_BODY_FIELD,
EMAIL_SUBJECT_FIELD,
EMAIL_HTML_FIELD,
FROM_FIELD,
FROM_DOMAIN_FIELD,
"created",
"id",
"name",
"status",
"emailto",
"emailcc",
"emailbcc",
"removedfromcampaigns",
]
if "populateFields" in input_args and input_args["populateFields"] is not None:
get_incidents_args["populateFields"] = ",".join([",".join(fields), input_args["populateFields"]])
else:
get_incidents_args["populateFields"] = ",".join(fields)
demisto.debug(f"Calling GetIncidentsByQuery with {get_incidents_args=}")
incidents_query_res = demisto.executeCommand("GetIncidentsByQuery", get_incidents_args)
if is_error(incidents_query_res):
return_error(get_error(incidents_query_res))
incidents_query_contents = "{}"
for res in incidents_query_res:
if res["Contents"]:
incidents_query_contents = res["Contents"]
incidents = json.loads(incidents_query_contents)
demisto.info(f"Retrieved {len(incidents)} existing incidents for comparison.")
return incidents
def generate_incident_type_query_component(type_field_arg, type_values_arg):
demisto.debug(f"Generating incident type query for field '{type_field_arg}' with values '{type_values_arg}'")
type_field = type_field_arg.strip()
type_values = [x.strip() for x in type_values_arg.split(",")]
types_unions = " ".join(f'"{t}"' for t in type_values)
return f"{type_field}:({types_unions})"
def extract_domain(address):
global no_fetch_extract
if address == "":
return ""
demisto.debug(f"Going to extract domain from {address=}")
email_address = parseaddr(address)[1]
ext = no_fetch_extract(email_address)
return f"{ext.domain}.{ext.suffix}"
def get_text_from_html(html):
demisto.debug("Entering get_text_from_html")
soup = BeautifulSoup(html, features="html.parser")
# kill all script and style elements
for script in soup(["script", "style"]):
script.extract() # rip it out
# get text
text = soup.get_text()
# break into lines and remove leading and trailing space on each
lines = (line.strip() for line in text.splitlines())
# break multi-headlines into a line each
chunks = (phrase.strip() for line in lines for phrase in line.split(" "))
# drop blank lines
text = "\n".join(chunk for chunk in chunks if chunk)
demisto.debug("Successfully extracted text from HTML.")
return text
def eliminate_urls_extensions(text):
demisto.debug("Entering eliminate_urls_extensions")
urls_list = re.findall(URL_REGEX, text)
if len(urls_list) == 0:
demisto.debug("No URLs found to eliminate.")
return text
demisto.debug(f"Found {len(urls_list)} URLs to neutralize.")
formatted_urls_list = format_urls(urls_list)
for url, formatted_url in zip(urls_list, formatted_urls_list):
parsed_uri = urlparse(formatted_url)
url_with_no_path = f"{parsed_uri.scheme}://{parsed_uri.netloc}/"
text = text.replace(url, url_with_no_path)
return text
def preprocess_email_body(incident):
demisto.debug(f"Entering preprocess_email_body for incident {incident.get('id')}")
email_body = email_html = ""
if EMAIL_BODY_FIELD in incident:
email_body = incident[EMAIL_BODY_FIELD]
if EMAIL_HTML_FIELD in incident:
email_html = incident[EMAIL_HTML_FIELD]
if isinstance(email_html, float):
email_html = ""
if email_body is None or isinstance(email_body, float) or email_body.strip() == "":
demisto.debug("Email body is empty or None, falling back to HTML extraction.")
email_body = get_text_from_html(email_html)
return eliminate_urls_extensions(email_body)
def preprocess_email_subject(incident):
demisto.debug(f"Entering preprocess_email_subject for incident {incident.get('id')}")
email_subject = ""
if EMAIL_SUBJECT_FIELD in incident:
email_subject = incident[EMAIL_SUBJECT_FIELD]
if isinstance(email_subject, float):
email_subject = ""
return eliminate_urls_extensions(email_subject)
def concatenate_subject_body(row):
return f"{row[PREPROCESSED_EMAIL_SUBJECT]}\n{row[PREPROCESSED_EMAIL_BODY]}"
def preprocess_incidents_df(existing_incidents):
global MERGED_TEXT_FIELD, FROM_FIELD, FROM_DOMAIN_FIELD
demisto.debug(f"Entering preprocess_incidents_df for {len(existing_incidents)} incidents.")
incidents_df = pd.DataFrame(existing_incidents)
if "CustomFields" in incidents_df.columns:
demisto.debug("Found 'CustomFields', expanding them into DataFrame columns.")
incidents_df["CustomFields"] = incidents_df["CustomFields"].fillna(value={})
custom_fields_df = incidents_df["CustomFields"].apply(pd.Series)
unique_keys = [k for k in custom_fields_df if k not in incidents_df]
custom_fields_df = custom_fields_df[unique_keys]
incidents_df = pd.concat([incidents_df.drop("CustomFields", axis=1), custom_fields_df], axis=1).reset_index()
incidents_df[PREPROCESSED_EMAIL_SUBJECT] = incidents_df.apply(lambda x: preprocess_email_subject(x), axis=1)
incidents_df[PREPROCESSED_EMAIL_BODY] = incidents_df.apply(lambda x: preprocess_email_body(x), axis=1)
incidents_df[MERGED_TEXT_FIELD] = incidents_df.apply(concatenate_subject_body, axis=1)
incidents_df = incidents_df[incidents_df[MERGED_TEXT_FIELD].str.len() >= MIN_TEXT_LENGTH]
demisto.debug(f"Preprocessing complete. {len(incidents_df)} incidents remain after filtering by text length.")
incidents_df = incidents_df.reset_index()
if FROM_FIELD in incidents_df:
incidents_df[FROM_FIELD] = incidents_df[FROM_FIELD].fillna(value="")
else:
incidents_df[FROM_FIELD] = ""
incidents_df[FROM_FIELD] = incidents_df[FROM_FIELD].apply(lambda x: x.strip())
incidents_df[FROM_DOMAIN_FIELD] = incidents_df[FROM_FIELD].apply(lambda address: extract_domain(address))
incidents_df["created"] = incidents_df["created"].apply(lambda x: dateutil.parser.parse(x)) # type: ignore
return incidents_df
def incident_has_text_fields(incident):
demisto.debug(f"Checking for text fields in incident {incident.get('id')}")
text_fields = [EMAIL_SUBJECT_FIELD, EMAIL_HTML_FIELD, EMAIL_BODY_FIELD]
custom_fields = incident.get("CustomFields", []) or []
if any(field in incident for field in text_fields):
return True
elif "CustomFields" in incident and any(field in custom_fields for field in text_fields):
return True
return False
def filter_out_same_incident(existing_incidents_df, new_incident):
demisto.debug(f"Filtering out current incident {new_incident['id']} from DataFrame.")
same_id_mask = existing_incidents_df["id"] == new_incident["id"]
existing_incidents_df = existing_incidents_df[~same_id_mask]
return existing_incidents_df
def filter_newer_incidents(existing_incidents_df, new_incident):
demisto.debug("Filtering out incidents newer than the current incident.")
new_incident_datetime = dateutil.parser.parse(new_incident["created"]) # type: ignore
earlier_incidents_mask = existing_incidents_df["created"] < new_incident_datetime
filtered_df = existing_incidents_df[earlier_incidents_mask]
demisto.debug(f"{len(filtered_df)} incidents remain after filtering newer incidents.")
return filtered_df
def vectorize(text, vectorizer):
return vectorizer.transform([text]).toarray()[0]
def cosine_sim(a, b):
return dot(a, b) / (norm(a) * norm(b))
def find_duplicate_incidents(new_incident, existing_incidents_df, max_incidents_to_return):
global MERGED_TEXT_FIELD, FROM_POLICY
demisto.debug(f"Entering find_duplicate_incidents. Comparing 1 new incident against {len(existing_incidents_df)} existing.")
new_incident_text = new_incident[MERGED_TEXT_FIELD]
text = [new_incident_text] + existing_incidents_df[MERGED_TEXT_FIELD].tolist()
demisto.debug("Vectorizing text corpus...")
vectorizer = CountVectorizer(token_pattern=r"(?u)\b\w\w+\b|!|\?|\"|\'").fit(text)
new_incident_vector = vectorize(new_incident_text, vectorizer)
existing_incidents_df["vector"] = existing_incidents_df[MERGED_TEXT_FIELD].apply(lambda x: vectorize(x, vectorizer))
existing_incidents_df["similarity"] = existing_incidents_df["vector"].apply(lambda x: cosine_sim(x, new_incident_vector))
if FROM_POLICY == FROM_POLICY_DOMAIN:
demisto.debug(f"Applying FROM_POLICY_DOMAIN. Filtering by domain: {new_incident[FROM_DOMAIN_FIELD]}")
mask = (existing_incidents_df[FROM_DOMAIN_FIELD] != "") & (
existing_incidents_df[FROM_DOMAIN_FIELD] == new_incident[FROM_DOMAIN_FIELD]
)
existing_incidents_df = existing_incidents_df[mask]
elif FROM_POLICY == FROM_POLICY_EXACT:
demisto.debug(f"Applying FROM_POLICY_EXACT. Filtering by sender: {new_incident[FROM_FIELD]}")
mask = (existing_incidents_df[FROM_FIELD] != "") & (existing_incidents_df[FROM_FIELD] == new_incident[FROM_FIELD])
existing_incidents_df = existing_incidents_df[mask]
else:
demisto.debug("Applying FROM_POLICY_TEXT_ONLY. No sender/domain filtering.")
existing_incidents_df["distance"] = existing_incidents_df["similarity"].apply(lambda x: 1 - x)
tie_breaker_col = "id"
try:
existing_incidents_df["int_id"] = existing_incidents_df["id"].astype(int)
tie_breaker_col = "int_id"
except Exception:
pass
existing_incidents_df = existing_incidents_df.sort_values(by=["distance", "created", tie_breaker_col])
demisto.debug(f"Found {len(existing_incidents_df)} potential duplicates after filtering and sorting.")
return existing_incidents_df.head(max_incidents_to_return)
def return_entry(message, duplicate_incidents_df=None, new_incident=None):
demisto.debug("Entering return_entry to format and return results.")
if duplicate_incidents_df is None:
demisto.debug("No duplicate incidents DataFrame provided.")
duplicate_incident = {}
all_duplicate_incidents = []
full_incidents = []
else:
demisto.debug(f"Formatting {len(duplicate_incidents_df)} duplicate incidents for output.")
most_similar_incident = duplicate_incidents_df.iloc[0]
duplicate_incident = format_incident_context(most_similar_incident)
all_duplicate_incidents = [format_incident_context(row) for _, row in duplicate_incidents_df.iterrows()]
new_incident["created"] = new_incident["created"].astype(str)
duplicate_incidents_df["created"] = duplicate_incidents_df["created"].astype(str)
duplicate_incidents_df = duplicate_incidents_df.drop("vector", axis=1)
full_incidents = new_incident.to_dict(orient="records") + duplicate_incidents_df.to_dict(orient="records")
outputs = {
"duplicateIncident": duplicate_incident,
"isDuplicateIncidentFound": duplicate_incidents_df is not None,
"allDuplicateIncidents": all_duplicate_incidents,
}
demisto.info(f"Setting isDuplicateIncidentFound: {duplicate_incidents_df is not None}")
return_outputs(message, outputs, raw_response=json.dumps(full_incidents))
def format_incident_context(df_row):
duplicate_incident = {
"rawId": df_row["id"],
"id": df_row["id"],
"name": df_row.get("name"),
"similarity": df_row.get("similarity"),
}
return duplicate_incident
def close_new_incident_and_link_to_existing(new_incident, duplicate_incidents_df):
demisto.info(f"Found {len(duplicate_incidents_df)} duplicates above threshold. Closing and linking.")
mask = duplicate_incidents_df["similarity"] >= SIMILARITY_THRESHOLD
duplicate_incidents_df = duplicate_incidents_df[mask]
most_similar_incident = duplicate_incidents_df.iloc[0]
max_similarity = duplicate_incidents_df.iloc[0]["similarity"]
min_similarity = duplicate_incidents_df.iloc[-1]["similarity"]
formatted_incident, headers = format_incident_hr(duplicate_incidents_df)
incident = "incidents" if len(duplicate_incidents_df) > 1 else "incident"
if max_similarity > min_similarity:
title = f"Duplicate {incident} found with similarity {min_similarity * 100:.1f}%-{max_similarity * 100:.1f}%"
else:
title = f"Duplicate {incident} found with similarity {max_similarity * 100:.1f}%"
message = tableToMarkdown(title, formatted_incident, headers)
if demisto.args().get("closeAsDuplicate", "true") == "true":
demisto.debug("closeAsDuplicate is 'true'. Executing CloseInvestigationAsDuplicate.")
res = demisto.executeCommand("CloseInvestigationAsDuplicate", {"duplicateId": most_similar_incident["id"]})
if is_error(res):
return_error(res)
message += "This incident (#{}) will be closed and linked to #{}.".format(
new_incident.iloc[0]["id"], most_similar_incident["id"]
)
else:
demisto.debug("closeAsDuplicate is 'false'. Will not close incident, just linking.")
return_entry(message, duplicate_incidents_df, new_incident)
def create_new_incident():
demisto.info("No duplicates found. This is a new incident.")
return_entry("This incident is not a duplicate of an existing incident.")
def format_incident_hr(duplicate_incidents_df):
demisto.debug(f"Entering format_incident_hr for {len(duplicate_incidents_df)} incidents.")
incidents_list = duplicate_incidents_df.to_dict("records")
json_lists = []
status_map = {"0": "Pending", "1": "Active", "2": "Closed", "3": "Archive"}
for incident in incidents_list:
json_lists.append(
{
"Id": "[{}](#/Details/{})".format(incident["id"], incident["id"]),
"Name": incident["name"],
"Status": status_map[str(incident.get("status"))],
"Time": str(incident["created"]),
"Email From": incident.get(demisto.args().get(FROM_FIELD)),
"Text Similarity": "{:.1f}%".format(incident["similarity"] * 100),
}
)
headers = ["Id", "Name", "Status", "Time", "Email From", "Text Similarity"]
return json_lists, headers
def create_new_incident_low_similarity(duplicate_incidents_df):
demisto.info("No duplicates found above similarity threshold.")
message = "## This incident is not a duplicate of an existing incident.\n"
similarity = duplicate_incidents_df.iloc[0]["similarity"]
if similarity > SIMILARITY_THRESHOLD - CLOSE_TO_SIMILAR_DISTANCE:
demisto.debug(f"Found {len(duplicate_incidents_df)} incidents with similarity close to threshold. Will report them.")
mask = duplicate_incidents_df["similarity"] >= SIMILARITY_THRESHOLD - CLOSE_TO_SIMILAR_DISTANCE
duplicate_incidents_df = duplicate_incidents_df[mask]
formatted_incident, headers = format_incident_hr(duplicate_incidents_df)
message += tableToMarkdown("Most similar incidents found", formatted_incident, headers=headers)
message += (
f"The threshold for considering 2 incidents as duplicate is a similarity of {SIMILARITY_THRESHOLD * 100:.1f}%.\n"
)
message += (
"Therefore these 2 incidents will not be considered as duplicate and the current incident will remain active.\n"
)
else:
demisto.debug("No incidents were close to the similarity threshold.")
return_entry(message)
def create_new_incident_no_text_fields():
demisto.info("Incident has no text fields to compare. Treating as new.")
text_fields = [EMAIL_BODY_FIELD, EMAIL_HTML_FIELD, EMAIL_SUBJECT_FIELD]
message = "No text fields were found within this incident: {}.\n".format(",".join(text_fields))
message += "Incident will remain active."
return_entry(message)
def create_new_incident_too_short():
demisto.info(f"Incident text is too short (less than {MIN_TEXT_LENGTH} chars) after preprocessing. Treating as new.")
return_entry("Incident text after preprocessing is too short for deduplication. Incident will remain active.")
def main():
global EMAIL_BODY_FIELD, EMAIL_SUBJECT_FIELD, EMAIL_HTML_FIELD, FROM_FIELD, MIN_TEXT_LENGTH, FROM_POLICY
demisto.debug("Starting FindDuplicateEmailIncidents 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)
FROM_POLICY = input_args.get("fromPolicy", FROM_POLICY)
max_incidents_to_return = input_args.get("maxIncidentsToReturn", "20")
try:
max_incidents_to_return = int(max_incidents_to_return)
except Exception:
return_error(f'Illegal value of arguement "maxIncidentsToReturn": {max_incidents_to_return}. Value should be an integer')
new_incident = demisto.incidents()[0]
demisto.debug(f"Current incident ID: {new_incident.get('id')}")
type_field = input_args.get("incidentTypeFieldName", "type")
existing_incidents = get_existing_incidents(input_args, new_incident.get(type_field, IGNORE_INCIDENT_TYPE_VALUE))
demisto.debug(f"found {len(existing_incidents)} incidents by query")
if len(existing_incidents) == 0:
demisto.debug("No existing incidents found matching query. Calling create_new_incident.")
create_new_incident()
return None
if not incident_has_text_fields(new_incident):
demisto.debug("Current incident has no text fields. Calling create_new_incident_no_text_fields.")
create_new_incident_no_text_fields()
return None
demisto.debug("Preprocessing new incident.")
new_incident_df = preprocess_incidents_df([new_incident])
if len(new_incident_df) == 0: # len(new_incident_df)==0 means new incident is too short
demisto.debug("New incident is too short after preprocessing. Calling create_new_incident_too_short.")
create_new_incident_too_short()
return None
demisto.debug("Preprocessing existing incidents.")
existing_incidents_df = preprocess_incidents_df(existing_incidents)
demisto.debug("Filtering same incident ID.")
existing_incidents_df = filter_out_same_incident(existing_incidents_df, new_incident)
demisto.debug("Filtering newer incidents.")
existing_incidents_df = filter_newer_incidents(existing_incidents_df, new_incident)
if len(existing_incidents_df) == 0:
demisto.debug("No existing incidents left after filtering. Calling create_new_incident.")
create_new_incident()
return None
new_incident_preprocessed = new_incident_df.iloc[0].to_dict()
demisto.debug("Finding duplicate incidents...")
duplicate_incidents_df = find_duplicate_incidents(new_incident_preprocessed, existing_incidents_df, max_incidents_to_return)
if len(duplicate_incidents_df) == 0:
demisto.debug("No duplicates found by find_duplicate_incidents. Calling create_new_incident.")
create_new_incident()
return None
if duplicate_incidents_df.iloc[0]["similarity"] < SIMILARITY_THRESHOLD:
demisto.debug("Highest similarity is below threshold. Calling create_new_incident_low_similarity.")
create_new_incident_low_similarity(duplicate_incidents_df)
return None
else:
demisto.debug("Duplicates found above threshold. Calling close_new_incident_and_link_to_existing.")
return close_new_incident_and_link_to_existing(new_incident_df, duplicate_incidents_df)
if __name__ in ["__main__", "__builtin__", "builtins"]:
main()
README
Can be used to find duplicate emails for incidents of type phishing, including malicious, spam, and legitimate emails.
Script Data
Name
Description
Script Type
python3
Tags
ml, phishing
Cortex XSOAR Version
5.0.0
Inputs
Argument Name
Description
incidentTypeFieldName
The name of the incident field where its type is stored. Default is “type”. Change this argument only in case you use a custom field for specifying incident type.
incidentTypes
A comma-separated list of incident types by which to filter. The default is the current incident type. Specify “None” to ignore incident type from deduplication logic.
existingIncidentsLookback
The start date by which to search for duplicated existing 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
Subject of the email.
emailBody
Body of the email.
emailBodyHTML
HTML body of the email.
emailFrom
Incident fields contains the email from value.
fromPolicy
Whether to take into account the email from field for deduplication. “TextOnly” - incidents will be considered as duplicated based on test similarity only, ignoring the sender’s address. “Exact” - incidents will be considered as duplicated if their text is similar and their sender is the same. “Domain” - incidents will be considered as duplicated if their text is similar and their senders’ address has the same domain. Default is “Domain”.
statusScope
Whether to compare the new incident to past closed or non closed incidents only.
closeAsDuplicate
Whether to close the current incident if a duplicate incident is found. Only supported in Cortex XSOAR.
threshold
Threshold to consider incident as duplication, number between 0-1
maxIncidentsToReturn
Maximum number of duplicate incidents IDs to return.
populateFields
A comma-separated list of incident fields to populate.
exsitingIncidentsLookback
Deprecated. Use the *existingIncidentsLookback* argument instead.
Outputs
Path
Description
Type
duplicateIncident
The oldest duplicate incident found with the highest similarity to the current incident.
unknown
duplicateIncident.id
Duplicate incident ID.
string
duplicateIncident.rawId
Duplicate incident ID.
Unknown
duplicateIncident.name
Duplicate incident name.
Unknown
duplicateIncident.similarity
Number in range 0-1 which describe the similarity between the existing incident and the new incident.
Unknown
isDuplicateIncidentFound
Whether a duplicate incident was found (“true” or “false”).
boolean
allDuplicateIncidents
All duplicate incidents found where their similarity with the new incident exceeds the threshold.
Unknown
allDuplicateIncidents.id
A list of all duplicate incidents IDs found.
Unknown
allDuplicateIncidents.rawId
A list of all duplicate incidents IDs found.
Unknown
allDuplicateIncidents.name
A list of all duplicate incidents names found.
Unknown
allDuplicateIncidents.similarity
A list of the similarity between duplicate incidents and new the incident of all duplicate incidents names found.