ReadPDFFileV2
Load a PDF file's content and metadata into context. Supports extraction of hashes, urls, and emails when available.
python · Common Scripts
Source
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. | String |
| File.Extension | The file’s extension. | String |
| Account.Email | The email address of the account. | String |
| Hashes.type | The hash type extracted from the PDF file. | String |
| Hashes.value | The hash value extracted from the PDF file. | String |