import contextlib
import glob
import html
import io
import json
import os
import re
import shutil
import stat
import subprocess
import demistomock as demisto
import PyPDF2
from CommonServerPython import *
from pikepdf import PasswordError, Pdf
URL_EXTRACTION_REGEX = (
r"(?:(?:https?|ftp|hxxps?):\/\/|www\[?\.\]?|ftp\[?\.\]?)(?:[-\w\d]+\[?\.\]?)+"
r"[-\w\d]+(?::\d+)?(?:(?:\/|\?)[-\w\d+&@#\/%=~_$?!\-:,.\(\);]*[\w\d+&@#\/%=~_$\(\);])?"
)
INTEGRATION_NAME = "ReadPDFFileV2"
DEFAULT_NUM_IMAGES = 20
class PdfPermissionsException(Exception):
"""
Every exception class that is in charge of catching errors that occur when trying to
extract data from the PDF must inherit this class
"""
class PdfCopyingProtectedException(PdfPermissionsException):
"""
This class is in charge of catching errors that occur when we try to extract data from
a `copy-protected` file (Copy-protected files are files that prevent us from copy its content)
This is relevant since we run a command that copies the content of the pdf file into a text file.
"""
class PdfInvalidCredentialsException(PdfPermissionsException):
"""
This class is in charge of catching errors that occur when we try to decrypt an encrypted
pdf file with the wrong password.
"""
# Error class for shell errors
class ShellException(Exception):
pass
try:
ROOT_PATH = os.getcwd()
except OSError:
return_error(
"The script failed to access the current working directory. This might happen if your docker isn't "
"set up correctly. Please contact customer support"
)
EMAIL_REGXEX = "[a-zA-Z0-9-_.]+@[a-zA-Z0-9-_.]+"
# Documentation claims png is enough for pdftohtml, but through testing we found jpg can be generated as well
IMG_FORMATS = ["jpg", "jpeg", "png", "gif"]
def handle_error_read_only(fun, path, exp) -> None:
"""
Handling errors that can be encountered in `shutil.rmtree()` execution.
"""
demisto.debug(exp)
# Checking if the file is Read-Only
if not os.access(path, os.W_OK):
demisto.debug(f"The {path} file is read-only")
# Change the file permission to the writing
try:
os.chmod(path, stat.S_IWUSR)
fun(path)
except Exception as e:
raise ValueError(str(e))
else:
raise ValueError(str(exp))
def create_file_instance(entry_id: str, path: str, file_name: str, score: int | None) -> Common.File:
dbot_score = Common.DBotScore(
indicator=entry_id,
indicator_type=DBotScoreType.FILE,
integration_name="PDFx",
score=score,
)
file = Common.File(
dbot_score=dbot_score,
extension="pdf",
entry_id=entry_id,
name=file_name,
path=path,
)
return file
def mark_suspicious(suspicious_reason: str, entry_id: str, path: str, file_name: str) -> None:
"""Missing EOF, file may be corrupted or suspicious file"""
human_readable = f"{suspicious_reason}\nFile marked as suspicious for entry id: {entry_id}"
file_instance = create_file_instance(
entry_id=entry_id,
path=path,
file_name=file_name,
score=Common.DBotScore.SUSPICIOUS,
)
return_warning(message=human_readable, outputs=file_instance.to_context())
def run_shell_command(command: str, *args) -> bytes:
"""Runs shell command and returns the result if not encountered an error"""
cmd = [command] + list(args)
demisto.debug(f"Running the shell command {cmd=}")
completed_process = subprocess.run( # noqa: UP022
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
exit_codes = completed_process.returncode
error_string = completed_process.stderr.decode("utf-8")
demisto.debug(f"Got the following error: {exit_codes=}, {error_string=}")
if exit_codes != 0:
if "PDF file is damaged" in error_string or "Couldn't read xref table" in error_string:
raise ShellException("PDf file is damaged/corrupted.")
elif "Incorrect password" in error_string:
raise PdfInvalidCredentialsException("Incorrect password. Please provide the correct password.")
elif "Copying of text from this document is not allowed" in error_string:
raise PdfCopyingProtectedException("Copying is not permitted")
raise ShellException(f"Failed with the following error code: {exit_codes}.\n Error: {error_string}")
elif error_string:
demisto.debug(f"ReadPDFFilev2: exec of [{cmd}] completed with warnings: {error_string}")
return completed_process.stdout
def get_files_names_in_path(path: str, name_of_file: str, full_path: bool = False) -> list:
"""Returns a list[str] of file names in path, will return full path if given full_path=True"""
os.chdir(ROOT_PATH)
os.chdir(path)
res = []
for file_path in glob.glob(name_of_file):
if full_path:
file_path = f"{path}/{file_path}"
res.append(file_path)
return res
def get_images_paths_in_path(path: str) -> list[str]:
"""Gets images paths from path"""
res: list[str] = []
for img_type in IMG_FORMATS:
img_format = f"*.{img_type}"
res.extend(get_files_names_in_path(path, img_format, True))
return res
def get_pdf_metadata(file_path: str, user_or_owner_password: str | None = None) -> dict:
"""Gets the metadata from the pdf as a dictionary"""
if user_or_owner_password:
try:
demisto.debug("Trying password as user password, using the [upw] flag")
metadata_txt = run_shell_command("pdfinfo", "-upw", user_or_owner_password, file_path)
except PdfInvalidCredentialsException:
demisto.debug("Trying password as owner password, using the [opw] flag")
metadata_txt = run_shell_command("pdfinfo", "-opw", user_or_owner_password, file_path)
demisto.debug("PDF file has been successfully opened. Metadata has been retrieved.")
else:
metadata_txt = run_shell_command("pdfinfo", "-enc", "UTF-8", file_path)
metadata = {}
metadata_str = metadata_txt.decode("utf8", "replace")
for line in metadata_str.split("\n"):
# split to [key, value...]
line_arr = line.split(":")
if len(line_arr) > 1:
key = line_arr[0]
# camelize key
if " " in key:
if "PDF" in key:
key = key.title().replace("Pdf", "PDF").replace(" ", "")
else:
key = key.title().replace(" ", "")
# Handle values with and without ':'
value = ""
for i in range(1, len(line_arr)):
value += line_arr[i].strip() + ":"
# remove redundant ':'
value = value[:-1]
metadata[key] = value
return metadata
def bypass_copy_protected_limitations(pdf_file: str) -> None:
"""
This function is in charge of handling the situation when a pdf is `copy-protected`.
Copy protected files prevent us from extracting content from the file, therefore we need a way to bypass this limitation.
"""
with Pdf.open(pdf_file, allow_overwriting_input=True) as pdf:
pdf.save(pdf_file)
def get_pdf_text(file_path: str, pdf_text_output_path: str) -> str:
"""Creates a txt file from the pdf in the pdf_text_output_path and returns the content of the txt file"""
try:
run_shell_command("pdftotext", file_path, pdf_text_output_path)
except PdfCopyingProtectedException:
bypass_copy_protected_limitations(pdf_file=file_path)
run_shell_command("pdftotext", file_path, pdf_text_output_path)
text = ""
with open(pdf_text_output_path, "rb") as f:
for line in f:
text += line.decode("utf-8")
return text
def get_pdf_htmls_content(pdf_path: str, output_folder: str, unescape_url: bool = True) -> str:
"""Creates an html file and images from the pdf in output_folder and returns the text content of the html files"""
pdf_html_output_path = f"{output_folder}/PDF_html"
try:
run_shell_command("pdftohtml", pdf_path, pdf_html_output_path)
except PdfCopyingProtectedException:
bypass_copy_protected_limitations(pdf_file=pdf_path)
run_shell_command("pdftohtml", pdf_path, pdf_html_output_path)
html_file_names = get_files_names_in_path(output_folder, "*.html")
html_content = ""
for file_name in html_file_names:
with open(file_name, "rb") as f:
for line in f:
html_content += html.unescape(str(line)) if unescape_url else str(line)
return html_content
def build_readpdf_entry_object(
entry_id: str,
metadata: dict,
text: str,
urls: list,
emails: list,
images: list[str],
max_images: int,
hash_contexts: list[dict[str, Any]] | None = None,
) -> list[dict[str, Any]]:
"""Builds an entry object for the main script flow"""
pdf_file = {"EntryID": entry_id}
# Add Text to file entity
pdf_file["Text"] = text
# Add Metadata to file entity
for k in metadata:
pdf_file[k] = metadata[k]
md = "### Metadata\n"
md += "* " if metadata else ""
md += "\n* ".join([f"{k}: {v}" for k, v in metadata.items()])
md += "\n### URLs\n"
md += "* " if urls else ""
md += "\n* ".join([f'{k["Data"]!s}' for k in urls])
md += "\n### Text"
md += f"\n{text}"
results = [
{
"Type": entryTypes["note"],
"ContentsFormat": formats["markdown"],
"Contents": md,
"HumanReadable": md,
"EntryContext": {"File(val.EntryID == obj.EntryID)": pdf_file, "URL": urls},
}
]
if images:
results[0]["HumanReadable"] = f"{results[0]['HumanReadable']}\n### Images"
os.chdir(ROOT_PATH)
for i, img in enumerate(images):
if i >= max_images:
break
file = file_result_existing_file(img)
results.append(file)
all_pdf_data = ""
if metadata:
for _, v in metadata.items():
all_pdf_data += str(v)
if text:
all_pdf_data += text
if urls:
for u in urls:
u = u["Data"] + " "
all_pdf_data += u
# Extract indicators (omitting context output, letting auto-extract work)
try:
indicators_map = demisto.executeCommand("extractIndicators", {"text": all_pdf_data})[0]["Contents"]
indicators_map = json.loads(indicators_map)
if emails:
indicators_map["Email"] = emails
if hash_contexts:
indicators_map["Hashes"] = hash_contexts
except json.JSONDecodeError:
pass
ec = build_readpdf_entry_context(indicators_map)
results.append(
{
"Type": entryTypes["note"],
"ContentsFormat": formats["json"],
"Contents": indicators_map,
"HumanReadable": indicators_map,
"EntryContext": ec,
}
)
return results
def build_readpdf_entry_context(indicators_map: Any) -> dict:
ec = {}
if isinstance(indicators_map, dict):
if "URL" in indicators_map:
ec_url = []
for url in indicators_map["URL"]:
ec_url.append({"Data": url})
ec["URL"] = ec_url
if "Email" in indicators_map:
ec_email = []
for email in indicators_map["Email"]:
ec_email.append({"Email": email})
ec["Account"] = ec_email
if "Hashes" in indicators_map:
ec["Hashes"] = indicators_map["Hashes"]
return ec
def get_urls_from_binary_file(file_path: str) -> set:
"""Reading from the binary pdf in the pdf_text_output_path and returns a list of the urls in the file"""
with open(file_path, "rb") as file:
# the urls usually appear in the form: '/URI (url)'
urls = re.findall(r"/URI ?\((.*?)\)", str(file.read()))
binary_file_urls = set()
# make sure the urls match the url regex
for url in urls:
mached_url = re.findall(URL_EXTRACTION_REGEX, url)
if len(mached_url) != 0:
binary_file_urls.add(mached_url[0])
return binary_file_urls
def get_urls_and_emails_from_pdf_html_content(
cpy_file_path: str, output_folder: str, unescape_url: bool = True
) -> tuple[set, set]:
"""
Extract the URLs and emails from the pdf html content.
Args:
cpy_file_path (str): the path of the PDF file.
output_folder (str): the folder output to get the HTML files from.
Returns:
tuple[set, set]: The URLs and emails that were found.
"""
pdf_html_content = get_pdf_htmls_content(cpy_file_path, output_folder, unescape_url)
return set(re.findall(URL_EXTRACTION_REGEX, pdf_html_content)), set(re.findall(EMAIL_REGXEX, pdf_html_content))
def extract_url_from_annot_object(annot_object: Any):
"""
Extracts the URLs from the Annot object (under key: '/A').
Args:
annot_object (PyPDF2.generic.DictionaryObject): An object contains annotations of a PDF.
Returns:
(PyPDF2.generic.TextStringObject): The extracted url if exists, else - None.
"""
# Extracts the URLs from the Annot object (under key: '/A'):
if a := annot_object.get("/A"):
if isinstance(a, PyPDF2.generic.IndirectObject):
a = a.get_object()
if url := a.get("/URI"):
if isinstance(url, PyPDF2.generic.IndirectObject):
url = url.get_object()
return url
return None
return None
def extract_url(extracted_object: Any):
"""
Extracts URL (if exists) from the extracted object, according to the URL_EXTRACTION_REGEX.
Args:
extracted_object (PyPDF2.generic.TextStringObject): A TextStringObject object contains a url or an email.
Returns:
(str): The extracted url.
"""
match = ""
matched_url = re.findall(URL_EXTRACTION_REGEX, extracted_object)
if len(matched_url) != 0:
match = matched_url[0]
return match
def extract_email(extracted_object: Any):
"""
Extracts Email (if exists) from the extracted object, according to the EMAIL_REGXEX.
Args:
extracted_object (PyPDF2.generic.TextStringObject): A TextStringObject object contains a url or an email.
Returns:
(str): The extracted email.
"""
match = ""
matched_email = re.findall(EMAIL_REGXEX, extracted_object)
if len(matched_email) != 0:
match = matched_email[0]
return match
def extract_urls_and_emails_from_annot_objects(annot_objects: list | Any):
"""
Extracts URLs and Emails from the Annot objects, and separate them into two different sets.
Args:
annot_objects (List): A list of objects that contain annotations of a PDF.
Returns:
Tuple[set, set]: A set includes the extracted urls, A set includes the extracted emails.
"""
urls = set()
emails = set()
for annot_object in annot_objects:
if isinstance(annot_object, PyPDF2.generic.IndirectObject):
try:
annot_object = annot_object.get_object()
except Exception as e:
if "Could not find object" in str(e):
demisto.error(f"annot.get_object() encountered an error: {e}.\n Skipping without failure.")
continue
else:
demisto.error(f"annot.get_object() encountered an error: {e}.")
extracted_object = extract_url_from_annot_object(annot_object)
# Separates URLs and Emails:
if extracted_object:
if isinstance(extracted_object, bytes):
extracted_object = extracted_object.decode()
if url := extract_url(extracted_object):
urls.add(url)
if email := extract_email(extracted_object):
emails.add(email)
return urls, emails
def get_urls_and_emails_from_pdf_annots(file_path: str) -> tuple[set, set]:
"""
Extracts the URLs and Emails from the pdf's Annots (Annotations and Commenting) using PyPDF2 package.
Args:
file_path (str): The path of the PDF file.
Returns:
Tuple[set, set]: A set includes the URLs that were found, A set includes the Emails that were found.
"""
all_urls: set[str] = set()
all_emails: set[str] = set()
output_capture = io.StringIO()
with open(file_path, "rb") as pdf_file:
# The following context manager was added so we could redirect error messages to the server logs since
# PyPDF2 would sometimes return warnings on some files (warnings and not errors because strict=False), and these warnings
# would be flushed to stderr, and therefore they would be returned as an error message to the user instead of being
# flushed to the server logs.
with contextlib.redirect_stderr(output_capture):
pdf = PyPDF2.PdfReader(pdf_file, strict=False)
pages_len = len(pdf.pages)
# Goes over the PDF, page by page, and extracts urls and emails:
for page in range(pages_len):
page_sliced = pdf.pages[page]
page_object = page_sliced.get_object()
# Extracts the PDF's Annots (Annotations and Commenting):
if annots := page_object.get("/Annots"): # type: ignore[union-attr]
if not isinstance(annots, PyPDF2.generic.ArrayObject):
annots = [annots]
for annot in annots:
annot_objects = annot.get_object()
if not isinstance(annot_objects, PyPDF2.generic.ArrayObject):
annot_objects = [annot_objects]
# Extracts URLs and Emails:
urls_set, emails_set = extract_urls_and_emails_from_annot_objects(annot_objects)
all_urls = all_urls.union(urls_set)
all_emails = all_emails.union(emails_set)
demisto.debug(output_capture.getvalue())
# Logging:
if len(all_urls) == 0:
demisto.debug("No URLs were extracted from the PDF.")
if len(all_emails) == 0:
demisto.debug("No Emails were extracted from the PDF.")
return all_urls, all_emails
def extract_urls_and_emails_from_pdf_file(file_path: str, output_folder: str, unescape_url: bool = True) -> tuple[list, list]:
"""
Extract URLs and Emails from the PDF file.
Args:
file_path (str): The path of the PDF file.
output_folder (str): The output folder for html files.
Returns:
tuple[set, set]: A set including the URLs and emails that were found, A set including only emails that were
extracted from the html content.
"""
# Get urls from the binary file:
binary_file_urls = get_urls_from_binary_file(file_path)
# Get URLS + emails:
annots_urls, annots_emails = get_urls_and_emails_from_pdf_annots(file_path)
html_urls, html_emails = get_urls_and_emails_from_pdf_html_content(file_path, output_folder, unescape_url)
# This url might be generated with the pdf html file, if so, we remove it
html_urls.discard("http://www.w3.org/1999/xhtml")
# Unify urls:
urls_set = annots_urls.union(html_urls, binary_file_urls)
emails_set = annots_emails.union(html_emails)
urls_ec = []
emails_ec = []
for url in urls_set:
urls_ec.append({"Data": url})
for email in emails_set:
emails_ec.append(email)
return urls_ec, emails_ec
def extract_hash_contexts_from_pdf_file(file_text: str) -> list[dict[str, Any]]:
"""Extracts the hashes from the file's text, and converts them to hash contexts.
Args:
file_text (str): The text extracted from the PDF.
Returns:
list[dict[str, Any]]: A list of hash contexts.
"""
hash_contexts: list[dict[str, Any]] = []
hashes_in_file = get_hashes_from_file(file_text)
for hash_type, hashes in hashes_in_file.items():
if hashes:
hash_contexts.extend(convert_hash_to_context(hash_type, hashes))
return hash_contexts
def convert_hash_to_context(hash_type: str, hashes: set[Any]) -> list[dict[str, Any]]:
"""Converts the given hashes to hash contexts
Args:
hash_type (str): The hash type of the given hashes.
hashes (set[Any]): The set of hashes.
Returns:
list[dict[str, Any]]: A list of hash contexts that have the same hash type.
"""
hash_context: list[dict[str, Any]] = [{"type": hash_type, "value": hash} for hash in hashes]
return hash_context
def get_hashes_from_file(file_text: str) -> dict[str, set[Any]]:
"""Extracts all the hashes found in the file's text.
Args:
file_text (str): The file's text.
Returns:
dict[str, set[Any]]: A dictionary that holds the hash types as keys, and each key
holds the set of hashes corresponding to that hash type.
"""
demisto.debug("Extracting hashes from file")
hashes: dict[str, set[Any]] = {}
hashes["SHA1"] = set(re.findall(sha1Regex, file_text))
hashes["SHA256"] = set(re.findall(sha256Regex, file_text))
hashes["SHA512"] = set(re.findall(sha512Regex, file_text))
hashes["MD5"] = set(re.findall(md5Regex, file_text))
return hashes
def handling_pdf_credentials(cpy_file_path: str, dec_file_path: str, encrypted: str = "", user_password: str = "") -> str:
"""
This function decrypts the pdf if needed.
"""
try:
if user_password or "yes" in encrypted:
with Pdf.open(cpy_file_path, allow_overwriting_input=True, password=user_password) as pdf:
pdf.save(dec_file_path)
return dec_file_path
except PasswordError:
raise PdfInvalidCredentialsException("Incorrect password. Please provide the correct password.")
return cpy_file_path
def extract_data_from_pdf(
path: str, user_password: str, entry_id: str, max_images: int | None, working_dir: str, unescape_url: bool = True
) -> None:
max_images = max_images if max_images else DEFAULT_NUM_IMAGES
if path:
cpy_file_path = f"{working_dir}/WorkingReadPDF.pdf"
shutil.copy(path, cpy_file_path)
metadata = get_pdf_metadata(path, user_password)
encrypted = metadata.get("Encrypted", "")
cpy_file_path = handling_pdf_credentials(
cpy_file_path=cpy_file_path,
dec_file_path=f"{working_dir}/DecWorkingReadPDF.pdf",
encrypted=encrypted,
user_password=user_password,
)
# Get text:
pdf_text_output_path = f"{working_dir}/PDFText.txt"
text = get_pdf_text(cpy_file_path, pdf_text_output_path)
# Get hash contexts
hash_contexts = extract_hash_contexts_from_pdf_file(text)
# Get URLS + emails:
urls_ec, emails_ec = extract_urls_and_emails_from_pdf_file(cpy_file_path, working_dir, unescape_url)
# Get images:
images = get_images_paths_in_path(working_dir)
readpdf_entry_object = build_readpdf_entry_object(
entry_id, metadata, text, urls_ec, emails_ec, images, max_images=max_images, hash_contexts=hash_contexts
)
return_results(readpdf_entry_object)
else:
raise Exception(f"EntryID {entry_id} path could not be found")
def main(): # pragma: no cover
args = demisto.args()
unescape_url: bool = argToBoolean(args.get("unescape_url", "true"))
working_dir = "ReadPDFTemp"
try:
if not os.path.exists(working_dir):
""" Check if the working directory does not exist and create it """
os.makedirs(working_dir)
entry_id = args.get("entryID")
user_password = str(args.get("userPassword", ""))
max_images = arg_to_number(args.get("maxImages", None))
path = demisto.getFilePath(entry_id).get("path")
extract_data_from_pdf(
path=path,
user_password=user_password,
entry_id=entry_id,
max_images=max_images,
working_dir=working_dir,
unescape_url=unescape_url,
)
except PdfPermissionsException as e:
return_warning(str(e))
except ShellException as e:
file_name = demisto.getFilePath(entry_id).get("name")
mark_suspicious(
suspicious_reason=f"The script {INTEGRATION_NAME} failed due to an error\n{e!s}",
entry_id=entry_id,
path=path,
file_name=file_name,
)
except Exception as e:
demisto.error(traceback.format_exc())
return_error(str(e))
finally:
os.chdir(ROOT_PATH)
shutil.rmtree(working_dir, onerror=handle_error_read_only)
if __name__ in ["__main__", "__builtin__", "builtins"]:
main()
README
Load a PDF file’s content and metadata into context. Supports extraction of hashes, urls, and emails when available.
Script Data
Name
Description
Script Type
python3
Tags
Utility, ingestion
Cortex XSOAR Version
4.1.0+
Inputs
Argument Name
Description
entryID
The War Room entryID of the file to read.
userPassword
The password for the file, if encrypted.
maxImages
The maximum number of images to extract from the PDF file.
unescape_url
To unescape URLs that have been escaped as part of the URLs extraction. Invalid characters will be ignored. Default is true.
Outputs
Path
Description
Type
URL.Data
The list of URLs that were extracted from the PDF file.
String
File.Text
The text that was extracted from the PDF file.
String
File.Producer
The producer of the PDF file.
String
File.Title
The title of the PDF file.
String
File.Author
The author of the PDF file.
String
File.ModDate
The ModDate of the PDF file.
Date
File.CreationDate
The CreationDate of the PDF file.
Date
File.Pages
The number of pages in the PDF file.
String
File.Size
The file size in bytes.
Number
File.Form
The PDF form type.
String
File.Encrypted
Whether the file is encrypted.
String
File.FileSize
The file size in bytes.
String
File.SHA1
The SHA1 file hash of the file.
String
File.PageRot
The page rotation of the PDF file.
String
File.Optimized
Whether the page has been optimized.
String
File.SHA256
The SHA256 file hash of the file.
String
File.PDFVersion
The PDF version.
String
File.Name
The name of the PDF file.
String
File.Creator
The creator of the PDF file.
String
File.Tagged
Whether the file has tagged meta-information.
String
File.SSDeep
The SSDeep hash of the file.
String
File.EntryID
The Entry ID of the file.
String
File.JavaScript
Whether the file is in JavaScript.
String
File.Info
The additional information about the file.
String
File.PageSize
The PDF file page size.
String
File.Type
The file type.
String
File.Suspects
Indicates the presence of tag suspects.
String
File.MD5
The MD5 file hash of the file.
String
File.UserProperties
Indicates the presence of the structure elements that contain user properties attributes.