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
4 changes: 2 additions & 2 deletions bbot/core/helpers/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
log = logging.getLogger("bbot.core.helpers.files")


def tempfile(self, content, pipe=True):
def tempfile(self, content, pipe=True, extension=None):
"""
Creates a temporary file or named pipe and populates it with content.

Expand All @@ -29,7 +29,7 @@ def tempfile(self, content, pipe=True):
>>> tempfile(["Another", "temp", "file"], pipe=False)
'/home/user/.bbot/temp/someotherfile'
"""
filename = self.temp_filename()
filename = self.temp_filename(extension)
rm_at_exit(filename)
try:
if type(content) not in (set, list, tuple):
Expand Down
6 changes: 4 additions & 2 deletions bbot/defaults.yml
Original file line number Diff line number Diff line change
Expand Up @@ -187,8 +187,10 @@ url_extension_blacklist:
- mov
- flv
- webm
# Distribute URLs with these extensions only to httpx (these are omitted from output)
url_extension_httpx_only:

# URLs with these extensions are not distributed to modules unless the module opts in via `accept_url_special = True`
# They are also excluded from output. If you want to see them in output, remove them from this list.
url_extension_special:
- js

# These url extensions are almost always static, so we exclude them from modules that fuzz things
Expand Down
16 changes: 11 additions & 5 deletions bbot/modules/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ class BaseModule:

in_scope_only (bool): Accept only explicitly in-scope events, regardless of the scan's search distance. Default is False.

accept_url_special (bool): Accept "special" URLs not typically distributed to web modules, e.g. JS URLs. Default is False.

options (Dict): Customizable options for the module, e.g., {"api_key": ""}. Empty dict by default.

options_desc (Dict): Descriptions for options, e.g., {"api_key": "API Key"}. Empty dict by default.
Expand Down Expand Up @@ -97,7 +99,7 @@ class BaseModule:
scope_distance_modifier = 0
target_only = False
in_scope_only = False

accept_url_special = False
_module_threads = 1
_batch_size = 1

Expand Down Expand Up @@ -785,10 +787,14 @@ def _event_precheck(self, event):
if "target" not in event.tags:
return False, "it did not meet target_only filter criteria"

# exclude certain URLs (e.g. javascript):
# TODO: revisit this after httpx rework
if event.type.startswith("URL") and self.name != "httpx" and "httpx-only" in event.tags:
return False, "its extension was listed in url_extension_httpx_only"
# limit js URLs to modules that opt in to receive them
if (not self.accept_url_special) and event.type.startswith("URL"):
extension = getattr(event, "url_extension", "")
if extension in self.scan.url_extension_special:
return (
False,
f"it is a special URL (extension {extension}) but the module does not opt in to receive special URLs",
)

return True, "precheck succeeded"

Expand Down
2 changes: 2 additions & 0 deletions bbot/modules/httpx.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ class httpx(BaseModule):
_shuffle_incoming_queue = False
_batch_size = 500
_priority = 2
# accept Javascript URLs
accept_url_special = True

async def setup(self):
self.threads = self.config.get("threads", 50)
Expand Down
5 changes: 0 additions & 5 deletions bbot/modules/output/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,6 @@ def _event_precheck(self, event):
if self._is_graph_important(event):
return True, "event is critical to the graph"

# exclude certain URLs (e.g. javascript):
# TODO: revisit this after httpx rework
if event.type.startswith("URL") and self.name != "httpx" and "httpx-only" in event.tags:
return False, (f"Omitting {event} from output because it's marked as httpx-only")

# omit certain event types
if event._omit:
if "target" in event.tags:
Expand Down
238 changes: 238 additions & 0 deletions bbot/modules/retirejs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
import json
from enum import IntEnum
from bbot.modules.base import BaseModule


class RetireJSSeverity(IntEnum):
NONE = 0
LOW = 1
MEDIUM = 2
HIGH = 3
CRITICAL = 4

@classmethod
def from_string(cls, severity_str):
try:
return cls[severity_str.upper()]
except (KeyError, AttributeError):
return cls.NONE


class retirejs(BaseModule):
watched_events = ["URL_UNVERIFIED"]
produced_events = ["FINDING"]
flags = ["active", "safe", "web-thorough"]
meta = {
"description": "Detect vulnerable/out-of-date JavaScript libraries",
"created_date": "2025-08-19",
"author": "@liquidsec",
}
options = {
"version": "5.3.0",
"node_version": "18.19.1",
"severity": "medium",
}
options_desc = {
"version": "retire.js version",
"node_version": "Node.js version to install locally",
"severity": "Minimum severity level to report (none, low, medium, high, critical)",
}

deps_ansible = [
# Download Node.js binary (Linux x64)
{
"name": "Download Node.js binary (Linux x64)",
"get_url": {
"url": "https://nodejs.org/dist/v#{BBOT_MODULES_RETIREJS_NODE_VERSION}/node-v#{BBOT_MODULES_RETIREJS_NODE_VERSION}-linux-x64.tar.xz",
"dest": "#{BBOT_TEMP}/node-v#{BBOT_MODULES_RETIREJS_NODE_VERSION}-linux-x64.tar.xz",
"mode": "0644",
},
},
# Extract Node.js binary (x64)
{
"name": "Extract Node.js binary (x64)",
"unarchive": {
"src": "#{BBOT_TEMP}/node-v#{BBOT_MODULES_RETIREJS_NODE_VERSION}-linux-x64.tar.xz",
"dest": "#{BBOT_TOOLS}",
"remote_src": True,
},
},
# Remove existing node directory if it exists
{
"name": "Remove existing node directory",
"file": {"path": "#{BBOT_TOOLS}/node", "state": "absent"},
},
# Rename extracted directory to 'node' (x64)
{
"name": "Rename Node.js directory (x64)",
"command": "mv #{BBOT_TOOLS}/node-v#{BBOT_MODULES_RETIREJS_NODE_VERSION}-linux-x64 #{BBOT_TOOLS}/node",
},
# Make Node.js binary executable
{
"name": "Make Node.js binary executable",
"file": {"path": "#{BBOT_TOOLS}/node/bin/node", "mode": "0755"},
},
# Make npm executable
{
"name": "Make npm executable",
"file": {"path": "#{BBOT_TOOLS}/node/bin/npm", "mode": "0755"},
},
# Remove existing retirejs directory if it exists
{
"name": "Remove existing retirejs directory",
"file": {"path": "#{BBOT_TOOLS}/retirejs", "state": "absent"},
},
# Create retire.js local directory
{
"name": "Create retire.js directory in BBOT_TOOLS",
"file": {"path": "#{BBOT_TOOLS}/retirejs", "state": "directory", "mode": "0755"},
},
# Install retire.js locally using local Node.js
{
"name": "Install retire.js locally",
"shell": "cd #{BBOT_TOOLS}/retirejs && #{BBOT_TOOLS}/node/bin/node #{BBOT_TOOLS}/node/lib/node_modules/npm/bin/npm-cli.js install retire@#{BBOT_MODULES_RETIREJS_VERSION} --no-fund --no-audit --silent --no-optional",
"args": {"creates": "#{BBOT_TOOLS}/retirejs/node_modules/.bin/retire"},
"timeout": 600,
"ignore_errors": False,
},
# Fix retire script shebang to use our local node binary
{
"name": "Fix retire script shebang",
"shell": "sed -i '1s|#!/usr/bin/env node|#!#{BBOT_TOOLS}/node/bin/node|' #{BBOT_TOOLS}/retirejs/node_modules/.bin/retire",
},
# Make retire script executable
{
"name": "Make retire script executable",
"file": {"path": "#{BBOT_TOOLS}/retirejs/node_modules/.bin/retire", "mode": "0755"},
},
# Create retire cache directory
{
"name": "Create retire cache directory",
"file": {"path": "#{BBOT_CACHE}/retire_cache", "state": "directory", "mode": "0755"},
},
]

accept_url_special = True
scope_distance_modifier = 1
_module_threads = 4

async def setup(self):
excavate_enabled = self.scan.config.get("excavate")
if not excavate_enabled:
return None, "retirejs will not function without excavate enabled"

# Validate severity level
valid_severities = ["none", "low", "medium", "high", "critical"]
configured_severity = self.config.get("severity", "medium").lower()
if configured_severity not in valid_severities:
return (
False,
f"Invalid severity level '{configured_severity}'. Valid options are: {', '.join(valid_severities)}",
)

self.repofile = await self.helpers.download(
"https://raw.githubusercontent.com/RetireJS/retire.js/master/repository/jsrepository-v4.json", cache_hrs=24
)
if not self.repofile:
return False, "failed to download retire.js repository file"
return True

async def handle_event(self, event):
js_file = await self.helpers.request(event.data)
if js_file:
js_file_body = js_file.text
if js_file_body:
js_file_body_saved = self.helpers.tempfile(js_file_body, pipe=False, extension="js")
results = await self.execute_retirejs(js_file_body_saved)
if not results:
self.warning("no output from retire.js")
return
results_json = json.loads(results)
if results_json.get("data"):
for file_result in results_json["data"]:
for component_result in file_result.get("results", []):
component = component_result.get("component", "unknown")
version = component_result.get("version", "unknown")
vulnerabilities = component_result.get("vulnerabilities", [])
for vuln in vulnerabilities:
severity = vuln.get("severity", "unknown")

# Filter by minimum severity level
min_severity = RetireJSSeverity.from_string(self.config.get("severity", "medium"))
vuln_severity = RetireJSSeverity.from_string(severity)
if vuln_severity < min_severity:
self.debug(
f"Skipping vulnerability with severity '{severity}' (below minimum '{min_severity.name.lower()}')"
)
continue

identifiers = vuln.get("identifiers", {})
summary = identifiers.get("summary", "Unknown vulnerability")
cves = identifiers.get("CVE", [])
description_parts = [
f"Vulnerable JavaScript library detected: {component} v{version}",
f"Severity: {severity.upper()}",
f"Summary: {summary}",
f"JavaScript URL: {event.data}",
]
if cves:
description_parts.append(f"CVE(s): {', '.join(cves)}")

below_version = vuln.get("below", "")
at_or_above = vuln.get("atOrAbove", "")
if at_or_above and below_version:
description_parts.append(f"Affected versions: [{at_or_above} to {below_version})")
elif below_version:
description_parts.append(f"Affected versions: [< {below_version}]")
elif at_or_above:
description_parts.append(f"Affected versions: [>= {at_or_above}]")
description = " ".join(description_parts)
data = {
"description": description,
"severity": severity,
"component": component,
"url": event.parent.data["url"],
}
await self.emit_event(
data,
"FINDING",
parent=event,
context=f"{{module}} identified vulnerable JavaScript library {component} v{version} ({severity} severity)",
)

async def filter_event(self, event):
url_extension = getattr(event, "url_extension", "")
if url_extension != "js":
return False, f"it is a {url_extension} URL but retirejs only accepts js URLs"
return True

async def execute_retirejs(self, js_file):
cache_dir = self.helpers.cache_dir / "retire_cache"
retire_dir = self.scan.helpers.tools_dir / "retirejs"

# Use the retire CLI script directly with our local node binary
local_node_dir = self.scan.helpers.tools_dir / "node"
retire_cli_script = retire_dir / "node_modules" / "retire" / "lib" / "cli.js"

command = [
str(local_node_dir / "bin" / "node"),
str(retire_cli_script),
"--outputformat",
"json",
"--cachedir",
str(cache_dir),
"--path",
js_file,
"--jsrepo",
str(self.repofile),
]

proxy = self.scan.web_config.get("http_proxy")
if proxy:
command.extend(["--proxy", proxy])

self.verbose(f"Running retire.js on {js_file}")
self.verbose(f"retire.js command: {command}")

result = await self.run_process(command)
return result.stdout
11 changes: 7 additions & 4 deletions bbot/scanner/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,6 @@ async def handle_event(self, event, **kwargs):
# special handling of URL extensions
url_extension = getattr(event, "url_extension", None)
if url_extension is not None:
if url_extension in self.scan.url_extension_httpx_only:
event.add_tag("httpx-only")
event._omit = True

# blacklist by extension
if url_extension in self.scan.url_extension_blacklist:
self.debug(
Expand Down Expand Up @@ -209,6 +205,13 @@ async def handle_event(self, event, **kwargs):
)
event.internal = True

# mark special URLs (e.g. Javascript) as internal so they don't get output except when they're critical to the graph
if event.type.startswith("URL"):
extension = getattr(event, "url_extension", "")
if extension in self.scan.url_extension_special:
event.internal = True
self.debug(f"Making {event} internal because it is a special URL (extension {extension})")

if event.type in self.scan.omitted_event_types:
self.debug(f"Omitting {event} because its type is omitted in the config")
event._omit = True
Expand Down
2 changes: 1 addition & 1 deletion bbot/scanner/scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,8 +230,8 @@ def __init__(
)

# url file extensions
self.url_extension_special = {e.lower() for e in self.config.get("url_extension_special", [])}
self.url_extension_blacklist = {e.lower() for e in self.config.get("url_extension_blacklist", [])}
self.url_extension_httpx_only = {e.lower() for e in self.config.get("url_extension_httpx_only", [])}

# url querystring behavior
self.url_querystring_remove = self.config.get("url_querystring_remove", True)
Expand Down
4 changes: 2 additions & 2 deletions bbot/test/test_step_1/test_bbot_fastapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ def test_bbot_multiprocess(bbot_httpserver):
queue = multiprocessing.Queue()
events_process = multiprocessing.Process(target=run_bbot_multiprocess, args=(queue,))
events_process.start()
events_process.join()
events = queue.get()
events_process.join(timeout=300)
events = queue.get(timeout=10)
assert len(events) >= 3
scan_events = [e for e in events if e["type"] == "SCAN"]
assert len(scan_events) == 2
Expand Down
1 change: 0 additions & 1 deletion bbot/test/test_step_1/test_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,6 @@ async def test_events(events, helpers):
javascript_event = scan.make_event("http://evilcorp.com/asdf/a.js?b=c#d", "URL_UNVERIFIED", parent=scan.root_event)
assert "extension-js" in javascript_event.tags
await scan.ingress_module.handle_event(javascript_event)
assert "httpx-only" in javascript_event.tags

# scope distance
event1 = scan.make_event("1.2.3.4", dummy=True)
Expand Down
Loading