FindDuplicateEmailIncidents
Can be used to find duplicate emails for incidents of type phishing, including malicious, spam, and legitimate emails.
python · Phishing
Source
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. | Unknown |