Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
# if one python version fails, let the others finish
fail-fast: false
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v6
- name: Set up Python
Expand Down
2 changes: 1 addition & 1 deletion bbot/modules/internal/excavate.py
Original file line number Diff line number Diff line change
Expand Up @@ -1271,7 +1271,7 @@ async def handle_event(self, event, **kwargs):
content_type = headers["content-type"][0]

# skip PDF responses -- running YARA/regex on raw PDF bytes produces false positives and wastes time.
# PDFs are still processed correctly via the filedownload → extractous → RAW_TEXT pipeline,
# PDFs are still processed correctly via the filedownload → kreuzberg → RAW_TEXT pipeline,
# which extracts readable text and feeds it back to excavate as a RAW_TEXT event (handled separately below).
# TODO: remove this in favor of a proper categorization system for text vs non-text (i.e. to-be-extracted) content
if content_type and "application/pdf" in content_type.lower():
Expand Down
64 changes: 33 additions & 31 deletions bbot/modules/extractous.py → bbot/modules/kreuzberg.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
from extractous import Extractor
import pypdfium2
from kreuzberg import extract_file

from bbot.modules.base import BaseModule


class extractous(BaseModule):
class kreuzberg(BaseModule):
watched_events = ["FILESYSTEM"]
produced_events = ["RAW_TEXT"]
flags = ["passive", "safe"]
Expand Down Expand Up @@ -65,7 +66,7 @@ class extractous(BaseModule):
"extensions": "File extensions to parse",
}

deps_pip = ["extractous~=0.3.0"]
deps_pip = ["kreuzberg~=4.3", "pypdfium2~=5.0"]
scope_distance_modifier = 1

async def setup(self):
Expand All @@ -82,11 +83,17 @@ async def filter_event(self, event):

async def handle_event(self, event):
file_path = event.data["path"]
content = await self.scan.helpers.run_in_executor_mp(extract_text, file_path)
if isinstance(content, tuple):
error, traceback = content
self.error(f"Error extracting text from {file_path}: {error}")
self.trace(traceback)
try:
if file_path.lower().endswith(".pdf"):
content = await self.helpers.run_in_executor_mp(self.extract_pdf, file_path)
else:
result = await extract_file(file_path)
content = result.content.strip()
except Exception as e:
import traceback

self.error(f"Error extracting text from {file_path}: {e}")
self.trace(traceback.format_exc())
return

if content:
Expand All @@ -98,27 +105,22 @@ async def handle_event(self, event):
)
await self.emit_event(raw_text_event)

@staticmethod
def extract_pdf(file_path):
"""Extract text from PDF using pypdfium2 directly instead of kreuzberg.

def extract_text(file_path):
"""
extract_text Extracts plaintext from a document path using extractous.

:param file_path: The path of the file to extract text from.
:return: ASCII-encoded plaintext extracted from the document.
"""

try:
extractor = Extractor()
reader, metadata = extractor.extract_file(str(file_path))

result = ""
buffer = reader.read(4096)
while len(buffer) > 0:
result += buffer.decode("utf-8", errors="ignore")
buffer = reader.read(4096)

return result.strip()
except Exception as e:
import traceback

return (str(e), traceback.format_exc())
kreuzberg's bundled pdfium extracts text spatially via get_text_bounded(), which
clips long unbroken strings (JWTs, base64, URLs) that extend beyond the page width.
pypdfium2's get_text_range() extracts by character index, returning complete text
regardless of spatial position.
"""
document = pypdfium2.PdfDocument(file_path)
try:
pages = []
for page in document:
textpage = page.get_textpage()
text = textpage.get_text_range()
pages.append(text)
return " ".join("\n".join(pages).strip().split())
finally:
document.close()
20 changes: 6 additions & 14 deletions bbot/test/test_step_2/module_tests/test_module_excavate.py
Original file line number Diff line number Diff line change
Expand Up @@ -1322,7 +1322,7 @@ def check(self, module_test, events):

class TestExcavateRAWTEXT(ModuleTestBase):
targets = ["http://127.0.0.1:8888/", "test.notreal"]
modules_overrides = ["excavate", "httpx", "filedownload", "extractous"]
modules_overrides = ["excavate", "httpx", "filedownload", "kreuzberg"]
config_overrides = {
"scope": {"report_distance": 1},
"web": {"spider_distance": 2, "spider_depth": 2},
Expand Down Expand Up @@ -1400,15 +1400,7 @@ class TestExcavateRAWTEXT(ModuleTestBase):
startxref
1669
%%EOF"""
extractous_response = """This is an email example@blacklanternsecurity.notreal

An example JWT eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

A serialized DOTNET object AAEAAAD/////AQAAAAAAAAAMAgAAAFJTeXN0ZW0uQ29sbGVjdGlvbnMuR2VuZXJpYy5MaXN0YDFbW1N5c3RlbS5TdHJpbmddXSwgU3lzdGVtLCBWZXJzaW9uPTQuMC4wLjAsIEN1bHR1cmU9bmV1dHJhbCwgUHVibGljS2V5VG9rZW49YjAzZjVmN2YxMWQ1MGFlMwEAAAAIQ29tcGFyZXIQSXRlbUNvdW50AQMAAAAJAwAAAAlTeXN0ZW0uU3RyaW5nW10FAAAACQIAAAAJBAAAAAkFAAAACRcAAAAJCgAAAAkLAAAACQwAAAAJDQAAAAkOAAAACQ8AAAAJEAAAAAkRAAAACRIAAAAJEwAAAA==

A full url https://www.test.notreal/about

A href <a href='/donot_detect.js'>Click me</a>"""
kreuzberg_response = "This is an email example@blacklanternsecurity.notreal An example JWT eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c A serialized DOTNET object AAEAAAD/////AQAAAAAAAAAMAgAAAFJTeXN0ZW0uQ29sbGVjdGlvbnMuR2VuZXJpYy5MaXN0YDFbW1N5c3RlbS5TdHJpbmddXSwgU3lzdGVtLCBWZXJzaW9uPTQuMC4wLjAsIEN1bHR1cmU9bmV1dHJhbCwgUHVibGljS2V5VG9rZW49YjAzZjVmN2YxMWQ1MGFlMwEAAAAIQ29tcGFyZXIQSXRlbUNvdW50AQMAAAAJAwAAAAlTeXN0ZW0uU3RyaW5nW10FAAAACQIAAAAJBAAAAAkFAAAACRcAAAAJCgAAAAkLAAAACQwAAAAJDQAAAAkOAAAACQ8AAAAJEAAAAAkRAAAACRIAAAAJEwAAAA== A full url https://www.test.notreal/about A href <a href='/donot_detect.js'>Click me</a>"

async def setup_after_prep(self, module_test):
module_test.set_expect_requests(
Expand All @@ -1429,13 +1421,13 @@ def check(self, module_test, events):
assert open(file).read() == self.pdf_data, f"File at {file} does not contain the correct content"
raw_text_events = [e for e in events if e.type == "RAW_TEXT"]
assert 1 == len(raw_text_events), "Failed to emit RAW_TEXT event"
assert raw_text_events[0].data == self.extractous_response, (
assert raw_text_events[0].data == self.kreuzberg_response, (
f"Text extracted from PDF is incorrect, got {raw_text_events[0].data}"
)
email_events = [e for e in events if e.type == "EMAIL_ADDRESS"]
assert 1 == len(email_events), "Failed to emit EMAIL_ADDRESS event"
assert email_events[0].data == "example@blacklanternsecurity.notreal", (
f"Email extracted from extractous text is incorrect, got {email_events[0].data}"
f"Email extracted from kreuzberg text is incorrect, got {email_events[0].data}"
)
finding_events = [e for e in events if e.type == "FINDING"]
assert 2 == len(finding_events), "Failed to emit FINDING events"
Expand All @@ -1460,10 +1452,10 @@ def check(self, module_test, events):
assert finding_events[0].data["path"] == str(file), "File path not included in finding event"
url_events = [e.data for e in events if e.type == "URL_UNVERIFIED"]
assert "https://www.test.notreal/about" in url_events, (
f"URL extracted from extractous text is incorrect, got {url_events}"
f"URL extracted from kreuzberg text is incorrect, got {url_events}"
)
assert "/donot_detect.js" not in url_events, (
f"URL extracted from extractous text is incorrect, got {url_events}"
f"URL extracted from kreuzberg text is incorrect, got {url_events}"
)


Expand Down
66 changes: 0 additions & 66 deletions bbot/test/test_step_2/module_tests/test_module_extractous.py

This file was deleted.

89 changes: 89 additions & 0 deletions bbot/test/test_step_2/module_tests/test_module_kreuzberg.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import base64
from pathlib import Path
from .base import ModuleTestBase

from ...bbot_fixtures import *


class TestKreuzberg(ModuleTestBase):
targets = ["http://127.0.0.1:8888"]
modules_overrides = ["kreuzberg", "filedownload", "httpx", "excavate", "speculate"]
config_overrides = {
"web": {
"spider_distance": 2,
"spider_depth": 2,
},
"modules": {
"filedownload": {
"output_folder": bbot_test_dir / "filedownload",
},
},
}

pdf_data = base64.b64decode(
"JVBERi0xLjMKJe+/ve+/ve+/ve+/vSBSZXBvcnRMYWIgR2VuZXJhdGVkIFBERiBkb2N1bWVudCBodHRwOi8vd3d3LnJlcG9ydGxhYi5jb20KMSAwIG9iago8PAovRjEgMiAwIFIKPj4KZW5kb2JqCjIgMCBvYmoKPDwKL0Jhc2VGb250IC9IZWx2ZXRpY2EgL0VuY29kaW5nIC9XaW5BbnNpRW5jb2RpbmcgL05hbWUgL0YxIC9TdWJ0eXBlIC9UeXBlMSAvVHlwZSAvRm9udAo+PgplbmRvYmoKMyAwIG9iago8PAovQ29udGVudHMgNyAwIFIgL01lZGlhQm94IFsgMCAwIDU5NS4yNzU2IDg0MS44ODk4IF0gL1BhcmVudCA2IDAgUiAvUmVzb3VyY2VzIDw8Ci9Gb250IDEgMCBSIC9Qcm9jU2V0IFsgL1BERiAvVGV4dCAvSW1hZ2VCIC9JbWFnZUMgL0ltYWdlSSBdCj4+IC9Sb3RhdGUgMCAvVHJhbnMgPDwKCj4+IAogIC9UeXBlIC9QYWdlCj4+CmVuZG9iago0IDAgb2JqCjw8Ci9QYWdlTW9kZSAvVXNlTm9uZSAvUGFnZXMgNiAwIFIgL1R5cGUgL0NhdGFsb2cKPj4KZW5kb2JqCjUgMCBvYmoKPDwKL0F1dGhvciAoYW5vbnltb3VzKSAvQ3JlYXRpb25EYXRlIChEOjIwMjQwNjAzMTg1ODE2KzAwJzAwJykgL0NyZWF0b3IgKFJlcG9ydExhYiBQREYgTGlicmFyeSAtIHd3dy5yZXBvcnRsYWIuY29tKSAvS2V5d29yZHMgKCkgL01vZERhdGUgKEQ6MjAyNDA2MDMxODU4MTYrMDAnMDAnKSAvUHJvZHVjZXIgKFJlcG9ydExhYiBQREYgTGlicmFyeSAtIHd3dy5yZXBvcnRsYWIuY29tKSAKICAvU3ViamVjdCAodW5zcGVjaWZpZWQpIC9UaXRsZSAodW50aXRsZWQpIC9UcmFwcGVkIC9GYWxzZQo+PgplbmRvYmoKNiAwIG9iago8PAovQ291bnQgMSAvS2lkcyBbIDMgMCBSIF0gL1R5cGUgL1BhZ2VzCj4+CmVuZG9iago3IDAgb2JqCjw8Ci9GaWx0ZXIgWyAvQVNDSUk4NURlY29kZSAvRmxhdGVEZWNvZGUgXSAvTGVuZ3RoIDEwNwo+PgpzdHJlYW0KR2FwUWgwRT1GLDBVXEgzVFxwTllUXlFLaz90Yz5JUCw7VyNVMV4yM2loUEVNXz9DVzRLSVNpOTBNakdeMixGUyM8UkM1K2MsbilaOyRiSyRiIjVJWzwhXlREI2dpXSY9NVgsWzVAWUBWfj5lbmRzdHJlYW0KZW5kb2JqCnhyZWYKMCA4CjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAwMDA3MyAwMDAwMCBuIAowMDAwMDAwMTA0IDAwMDAwIG4gCjAwMDAwMDAyMTEgMDAwMDAgbiAKMDAwMDAwMDQxNCAwMDAwMCBuIAowMDAwMDAwNDgyIDAwMDAwIG4gCjAwMDAwMDA3NzggMDAwMDAgbiAKMDAwMDAwMDgzNyAwMDAwMCBuIAp0cmFpbGVyCjw8Ci9JRCAKWzw4MGQ5ZjViOTY0ZmM5OTI4NDUwMWRlYjdhNmE2MzdmNz48ODBkOWY1Yjk2NGZjOTkyODQ1MDFkZWI3YTZhNjM3Zjc+XQolIFJlcG9ydExhYiBnZW5lcmF0ZWQgUERGIGRvY3VtZW50IC0tIGRpZ2VzdCAoaHR0cDovL3d3dy5yZXBvcnRsYWIuY29tKQoKL0luZm8gNSAwIFIKL1Jvb3QgNCAwIFIKL1NpemUgOAo+PgpzdGFydHhyZWYKMTAzNAolJUVPRg=="
)

docx_data = base64.b64decode(
"UEsDBBQAAAAAAFitWVzXeYTquAEAALgBAAATAAAAW0NvbnRlbnRfVHlwZXNdLnhtbDw/eG1sIHZl"
"cnNpb249IjEuMCIgZW5jb2Rpbmc9IlVURi04IiBzdGFuZGFsb25lPSJ5ZXMiPz4KPFR5cGVzIHht"
"bG5zPSJodHRwOi8vc2NoZW1hcy5vcGVueG1sZm9ybWF0cy5vcmcvcGFja2FnZS8yMDA2L2NvbnRl"
"bnQtdHlwZXMiPgogIDxEZWZhdWx0IEV4dGVuc2lvbj0icmVscyIgQ29udGVudFR5cGU9ImFwcGxp"
"Y2F0aW9uL3ZuZC5vcGVueG1sZm9ybWF0cy1wYWNrYWdlLnJlbGF0aW9uc2hpcHMreG1sIi8+CiAg"
"PERlZmF1bHQgRXh0ZW5zaW9uPSJ4bWwiIENvbnRlbnRUeXBlPSJhcHBsaWNhdGlvbi94bWwiLz4K"
"ICA8T3ZlcnJpZGUgUGFydE5hbWU9Ii93b3JkL2RvY3VtZW50LnhtbCIgQ29udGVudFR5cGU9ImFw"
"cGxpY2F0aW9uL3ZuZC5vcGVueG1sZm9ybWF0cy1vZmZpY2Vkb2N1bWVudC53b3JkcHJvY2Vzc2lu"
"Z21sLmRvY3VtZW50Lm1haW4reG1sIi8+CjwvVHlwZXM+UEsDBBQAAAAAAFitWVwgG4bqLgEAAC4B"
"AAALAAAAX3JlbHMvLnJlbHM8P3htbCB2ZXJzaW9uPSIxLjAiIGVuY29kaW5nPSJVVEYtOCIgc3Rh"
"bmRhbG9uZT0ieWVzIj8+CjxSZWxhdGlvbnNoaXBzIHhtbG5zPSJodHRwOi8vc2NoZW1hcy5vcGVu"
"eG1sZm9ybWF0cy5vcmcvcGFja2FnZS8yMDA2L3JlbGF0aW9uc2hpcHMiPgogIDxSZWxhdGlvbnNo"
"aXAgSWQ9InJJZDEiIFR5cGU9Imh0dHA6Ly9zY2hlbWFzLm9wZW54bWxmb3JtYXRzLm9yZy9vZmZp"
"Y2VEb2N1bWVudC8yMDA2L3JlbGF0aW9uc2hpcHMvb2ZmaWNlRG9jdW1lbnQiIFRhcmdldD0id29y"
"ZC9kb2N1bWVudC54bWwiLz4KPC9SZWxhdGlvbnNoaXBzPlBLAwQUAAAAAABYrVlcNNIntwABAAAA"
"AQAAEQAAAHdvcmQvZG9jdW1lbnQueG1sPD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRG"
"LTgiIHN0YW5kYWxvbmU9InllcyI/Pgo8dzpkb2N1bWVudCB4bWxuczp3PSJodHRwOi8vc2NoZW1h"
"cy5vcGVueG1sZm9ybWF0cy5vcmcvd29yZHByb2Nlc3NpbmdtbC8yMDA2L21haW4iPgogIDx3OmJv"
"ZHk+CiAgICA8dzpwPgogICAgICA8dzpyPgogICAgICAgIDx3OnQ+SGVsbG8sIFdvcmxkISE8L3c6"
"dD4KICAgICAgPC93OnI+CiAgICA8L3c6cD4KICA8L3c6Ym9keT4KPC93OmRvY3VtZW50PlBLAQIU"
"AxQAAAAAAFitWVzXeYTquAEAALgBAAATAAAAAAAAAAAAAACAAQAAAABbQ29udGVudF9UeXBlc10u"
"eG1sUEsBAhQDFAAAAAAAWK1ZXCAbhuouAQAALgEAAAsAAAAAAAAAAAAAAIAB6QEAAF9yZWxzLy5y"
"ZWxzUEsBAhQDFAAAAAAAWK1ZXDTSJ7cAAQAAAAEAABEAAAAAAAAAAAAAAIABQAMAAHdvcmQvZG9j"
"dW1lbnQueG1sUEsFBgAAAAADAAMAuQAAAG8EAAAAAA=="
)

expected_result_pdf = "Hello, World!"
expected_result_docx = "Hello, World!!"

async def setup_after_prep(self, module_test):
module_test.set_expect_requests(
{"uri": "/"},
{"response_data": '<a href="/Test_PDF"/><a href="/Test_DOCX"/>'},
)
module_test.set_expect_requests(
{"uri": "/Test_PDF"},
{"response_data": self.pdf_data, "headers": {"Content-Type": "application/pdf"}},
)
module_test.set_expect_requests(
{"uri": "/Test_DOCX"},
{
"response_data": self.docx_data,
"headers": {"Content-Type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"},
},
)

def check(self, module_test, events):
filesystem_events = [e for e in events if e.type == "FILESYSTEM"]
assert 2 == len(filesystem_events), filesystem_events
for filesystem_event in filesystem_events:
file = Path(filesystem_event.data["path"])
assert file.is_file(), "Destination file doesn't exist"
assert open(file, "rb").read() == self.pdf_data or open(file, "rb").read() == self.docx_data, (
f"File at {file} does not contain the correct content"
)
raw_text_events = [e for e in events if e.type == "RAW_TEXT"]
assert 2 == len(raw_text_events), "Failed to emit RAW_TEXT event"
for raw_text_event in raw_text_events:
assert raw_text_event.data in [
self.expected_result_pdf,
self.expected_result_docx,
], f"Text extracted from {raw_text_event.data['path']} is incorrect, got {raw_text_event.data}"
Original file line number Diff line number Diff line change
Expand Up @@ -1309,7 +1309,7 @@ def check(self, module_test, events):

class TestTrufflehog_RAWText(ModuleTestBase):
targets = ["http://127.0.0.1:8888/test.pdf"]
modules_overrides = ["httpx", "trufflehog", "filedownload", "extractous"]
modules_overrides = ["httpx", "trufflehog", "filedownload", "kreuzberg"]

download_dir = bbot_test_dir / "test_trufflehog_rawtext"
config_overrides = {
Expand Down
2 changes: 1 addition & 1 deletion docs/data/chord_graph/entities.json
Original file line number Diff line number Diff line change
Expand Up @@ -1250,7 +1250,7 @@
},
{
"id": 75,
"name": "extractous",
"name": "kreuzberg",
"parent": 99999999,
"consumes": [
10
Expand Down
2 changes: 1 addition & 1 deletion docs/modules/list_of_modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@
| docker_pull | scan | No | Download images from a docker repository | code-enum, download, passive, safe, slow | CODE_REPOSITORY | FILESYSTEM | @domwhewell-sage | 2024-03-24 |
| dockerhub | scan | No | Search for docker repositories of discovered orgs/usernames | code-enum, passive, safe | ORG_STUB, SOCIAL | CODE_REPOSITORY, SOCIAL, URL_UNVERIFIED | @domwhewell-sage | 2024-03-12 |
| emailformat | scan | No | Query email-format.com for email addresses | email-enum, passive, safe | DNS_NAME | EMAIL_ADDRESS | @TheTechromancer | 2022-07-11 |
| extractous | scan | No | Module to extract data from files | passive, safe | FILESYSTEM | RAW_TEXT | @domwhewell-sage | 2024-06-03 |
| kreuzberg | scan | No | Module to extract data from files | passive, safe | FILESYSTEM | RAW_TEXT | @domwhewell-sage | 2024-06-03 |
| fullhunt | scan | Yes | Query the fullhunt.io API for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | @TheTechromancer | 2022-08-24 |
| git_clone | scan | No | Clone code github repositories | code-enum, download, passive, safe, slow | CODE_REPOSITORY | FILESYSTEM | @domwhewell-sage | 2024-03-08 |
| gitdumper | scan | No | Download a leaked .git folder recursively or by fuzzing common names | code-enum, download, passive, safe, slow | CODE_REPOSITORY | FILESYSTEM | @domwhewell-sage | 2025-02-11 |
Expand Down
Loading
Loading