Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New Module: Nmap XML Output #1971

Open
wants to merge 12 commits into
base: dev
Choose a base branch
from
14 changes: 12 additions & 2 deletions bbot/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ async def _main():
return

# if we're listing modules or their options
if options.list_modules or options.list_module_options:
if options.list_modules or options.list_output_modules or options.list_module_options:
# if no modules or flags are specified, enable everything
if not (options.modules or options.output_modules or options.flags):
for module, preloaded in preset.module_loader.preloaded().items():
Expand All @@ -96,7 +96,17 @@ async def _main():
print("")
print("### MODULES ###")
print("")
for row in preset.module_loader.modules_table(preset.modules).splitlines():
modules = sorted(set(preset.scan_modules + preset.internal_modules))
for row in preset.module_loader.modules_table(modules).splitlines():
print(row)
return

# --list-output-modules
if options.list_output_modules:
print("")
print("### OUTPUT MODULES ###")
print("")
for row in preset.module_loader.modules_table(preset.output_modules).splitlines():
print(row)
return

Expand Down
1 change: 1 addition & 0 deletions bbot/core/helpers/names_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@
"alyssa",
"amanda",
"amber",
"amir",
"amy",
"andrea",
"andrew",
Expand Down
2 changes: 1 addition & 1 deletion bbot/modules/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ class BaseModule:

target_only (bool): Accept only the initial target event(s). Default is False.

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

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

Expand Down
6 changes: 5 additions & 1 deletion bbot/modules/internal/cloudcheck.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@

class CloudCheck(BaseInterceptModule):
watched_events = ["*"]
meta = {"description": "Tag events by cloud provider, identify cloud resources like storage buckets"}
meta = {
"description": "Tag events by cloud provider, identify cloud resources like storage buckets",
"created_date": "2024-07-07",
"author": "@TheTechromancer",
}
scope_distance_modifier = 1
_priority = 3

Expand Down
11 changes: 9 additions & 2 deletions bbot/modules/internal/dnsresolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

class DNSResolve(BaseInterceptModule):
watched_events = ["*"]
produced_events = ["DNS_NAME", "IP_ADDRESS", "RAW_DNS_RECORD"]
meta = {"description": "Perform DNS resolution", "created_date": "2022-04-08", "author": "@TheTechromancer"}
_priority = 1
scope_distance_modifier = None

Expand Down Expand Up @@ -83,9 +85,14 @@ async def handle_event(self, event, **kwargs):
event_data_changed = await self.handle_wildcard_event(main_host_event)
if event_data_changed:
# since data has changed, we check again whether it's a duplicate
if event.type == "DNS_NAME" and self.scan.ingress_module.is_incoming_duplicate(event, add=True):
if event.type == "DNS_NAME" and self.scan.ingress_module.is_incoming_duplicate(
event, add=True
):
if not event._graph_important:
return False, "it's a DNS wildcard, and its module already emitted a similar wildcard event"
return (
False,
"it's a DNS wildcard, and its module already emitted a similar wildcard event",
)
else:
self.debug(
f"Event {event} was already emitted by its module, but it's graph-important so it gets a pass"
Expand Down
2 changes: 2 additions & 0 deletions bbot/modules/internal/excavate.py
Original file line number Diff line number Diff line change
Expand Up @@ -656,8 +656,10 @@ async def process(self, yara_results, event, yara_rule_settings, discovery_conte
continue
if parsed_url.scheme in ["http", "https"]:
continue

def abort_if(e):
return e.scope_distance > 0

finding_data = {"host": str(host), "description": f"Non-HTTP URI: {parsed_url.geturl()}"}
await self.report(finding_data, event, yara_rule_settings, discovery_context, abort_if=abort_if)
protocol_data = {"protocol": parsed_url.scheme, "host": str(host)}
Expand Down
2 changes: 1 addition & 1 deletion bbot/modules/internal/speculate.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ async def handle_event(self, event):
# don't act on unresolved DNS_NAMEs
usable_dns = False
if event.type == "DNS_NAME":
if self.dns_disable or ("a-record" in event.tags or "aaaa-record" in event.tags):
if self.dns_disable or event.resolved_hosts:
usable_dns = True

if event.type == "IP_ADDRESS" or usable_dns:
Expand Down
2 changes: 1 addition & 1 deletion bbot/modules/output/mysql.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

class MySQL(SQLTemplate):
watched_events = ["*"]
meta = {"description": "Output scan data to a MySQL database"}
meta = {"description": "Output scan data to a MySQL database", "created_date": "2024-11-13", "author": "@TheTechromancer"}
options = {
"username": "root",
"password": "bbotislife",
Expand Down
171 changes: 171 additions & 0 deletions bbot/modules/output/nmap_xml.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import sys
from xml.dom import minidom
from datetime import datetime
from xml.etree.ElementTree import Element, SubElement, tostring

from bbot import __version__
from bbot.modules.output.base import BaseOutputModule


class NmapHost:
__slots__ = ["hostnames", "open_ports"]

def __init__(self):
self.hostnames = set()
# a dict of {port: {protocol: banner}}
self.open_ports = dict()


class Nmap_XML(BaseOutputModule):
watched_events = ["OPEN_TCP_PORT", "DNS_NAME", "IP_ADDRESS", "PROTOCOL", "HTTP_RESPONSE"]
meta = {"description": "Output to Nmap XML", "created_date": "2024-11-16", "author": "@TheTechromancer"}
output_filename = "output.nmap.xml"
in_scope_only = True

async def setup(self):
self.hosts = {}
self._prep_output_dir(self.output_filename)
return True

async def handle_event(self, event):
event_host = event.host

# we always record by IP
ips = []
for ip in event.resolved_hosts:
try:
ips.append(self.helpers.make_ip_type(ip))
except ValueError:
continue
if not ips and self.helpers.is_ip(event_host):
ips = [event_host]

for ip in ips:
try:
nmap_host = self.hosts[ip]
except KeyError:
nmap_host = NmapHost()
self.hosts[ip] = nmap_host

event_port = getattr(event, "port", None)
if event.type == "OPEN_TCP_PORT":
if event_port not in nmap_host.open_ports:
nmap_host.open_ports[event.port] = {}
elif event.type in ("PROTOCOL", "HTTP_RESPONSE"):
if event_port is not None:
try:
existing_services = nmap_host.open_ports[event.port]
except KeyError:
existing_services = {}
nmap_host.open_ports[event.port] = existing_services
if event.type == "PROTOCOL":
protocol = event.data["protocol"].lower()
banner = event.data.get("banner", None)
elif event.type == "HTTP_RESPONSE":
protocol = event.parsed_url.scheme.lower()
banner = event.http_title
if protocol not in existing_services:
existing_services[protocol] = banner

if self.helpers.is_ip(event_host):
if str(event.module) == "PTR":
nmap_host.hostnames.add(event.parent.data)
else:
nmap_host.hostnames.add(event_host)

async def report(self):
scan_start_time = str(int(self.scan.start_time.timestamp()))
scan_start_time_str = self.scan.start_time.strftime("%a %b %d %H:%M:%S %Y")
scan_end_time = datetime.now()
scan_end_time_str = scan_end_time.strftime("%a %b %d %H:%M:%S %Y")
scan_end_time_timestamp = str(scan_end_time.timestamp())
scan_duration = scan_end_time - self.scan.start_time
num_hosts_up = len(self.hosts)

# Create the root element
nmaprun = Element(
"nmaprun",
{
"scanner": "bbot",
"args": " ".join(sys.argv),
"start": scan_start_time,
"startstr": scan_start_time_str,
"version": str(__version__),
"xmloutputversion": "1.05",
},
)

ports_scanned = []
speculate_module = self.scan.modules.get("speculate", None)
if speculate_module is not None:
ports_scanned = speculate_module.ports
portscan_module = self.scan.modules.get("portscan", None)
if portscan_module is not None:
ports_scanned = self.helpers.parse_port_string(str(portscan_module.ports))
num_ports_scanned = len(sorted(ports_scanned))
ports_scanned = ",".join(str(x) for x in sorted(ports_scanned))

# Add scaninfo
SubElement(
nmaprun,
"scaninfo",
{"type": "syn", "protocol": "tcp", "numservices": str(num_ports_scanned), "services": ports_scanned},
)

# Add host information
for ip, nmap_host in self.hosts.items():
hostnames = sorted(nmap_host.hostnames)
ports = sorted(nmap_host.open_ports)

host_elem = SubElement(nmaprun, "host")
SubElement(host_elem, "status", {"state": "up", "reason": "user-set", "reason_ttl": "0"})
SubElement(host_elem, "address", {"addr": str(ip), "addrtype": f"ipv{ip.version}"})

if hostnames:
hostnames_elem = SubElement(host_elem, "hostnames")
for hostname in hostnames:
SubElement(hostnames_elem, "hostname", {"name": hostname, "type": "user"})

ports = SubElement(host_elem, "ports")
for port, protocols in nmap_host.open_ports.items():
port_elem = SubElement(ports, "port", {"protocol": "tcp", "portid": str(port)})
SubElement(port_elem, "state", {"state": "open", "reason": "syn-ack", "reason_ttl": "0"})
# <port protocol="tcp" portid="443"><state state="open" reason="syn-ack" reason_ttl="53"/><service name="http" product="AkamaiGHost" extrainfo="Akamai&apos;s HTTP Acceleration/Mirror service" tunnel="ssl" method="probed" conf="10"/></port>
for protocol, banner in protocols.items():
attrs = {"name": protocol, "method": "probed", "conf": "10"}
if banner is not None:
attrs["product"] = banner
attrs["extrainfo"] = banner
SubElement(port_elem, "service", attrs)

# Add runstats
runstats = SubElement(nmaprun, "runstats")
SubElement(
runstats,
"finished",
{
"time": scan_end_time_timestamp,
"timestr": scan_end_time_str,
"summary": f"BBOT done at {scan_end_time_str}; {num_hosts_up} scanned in {scan_duration} seconds",
"elapsed": str(scan_duration.total_seconds()),
"exit": "success",
},
)
SubElement(runstats, "hosts", {"up": str(num_hosts_up), "down": "0", "total": str(num_hosts_up)})

# make backup of the file
self.helpers.backup_file(self.output_file)

# Pretty-format the XML
rough_string = tostring(nmaprun, encoding="utf-8")
reparsed = minidom.parseString(rough_string)

# Create a new document with the doctype
doctype = minidom.DocumentType("nmaprun")
reparsed.insertBefore(doctype, reparsed.documentElement)

pretty_xml = reparsed.toprettyxml(indent=" ")

with open(self.output_file, "w") as f:
f.write(pretty_xml)
self.info(f"Saved Nmap XML output to {self.output_file}")
6 changes: 5 additions & 1 deletion bbot/modules/output/postgres.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@

class Postgres(SQLTemplate):
watched_events = ["*"]
meta = {"description": "Output scan data to a SQLite database"}
meta = {
"description": "Output scan data to a SQLite database",
"created_date": "2024-11-08",
"author": "@TheTechromancer",
}
options = {
"username": "postgres",
"password": "bbotislife",
Expand Down
6 changes: 5 additions & 1 deletion bbot/modules/output/sqlite.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@

class SQLite(SQLTemplate):
watched_events = ["*"]
meta = {"description": "Output scan data to a SQLite database"}
meta = {
"description": "Output scan data to a SQLite database",
"created_date": "2024-11-07",
"author": "@TheTechromancer",
}
options = {
"database": "",
}
Expand Down
2 changes: 1 addition & 1 deletion bbot/modules/output/stdout.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

class Stdout(BaseOutputModule):
watched_events = ["*"]
meta = {"description": "Output to text"}
meta = {"description": "Output to text", "created_date": "2024-04-03", "author": "@TheTechromancer"}
options = {"format": "text", "event_types": [], "event_fields": [], "in_scope_only": False, "accept_dupes": True}
options_desc = {
"format": "Which text format to display, choices: text,json",
Expand Down
2 changes: 1 addition & 1 deletion bbot/modules/output/txt.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

class TXT(BaseOutputModule):
watched_events = ["*"]
meta = {"description": "Output to text"}
meta = {"description": "Output to text", "created_date": "2024-04-03", "author": "@TheTechromancer"}
options = {"output_file": ""}
options_desc = {"output_file": "Output to file"}

Expand Down
18 changes: 12 additions & 6 deletions bbot/scanner/preset/args.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ class BBOTArgs:
"",
"bbot -l",
),
(
"List output modules",
"",
"bbot -lo",
),
(
"List presets",
"",
Expand Down Expand Up @@ -290,12 +295,6 @@ def create_parser(self, *args, **kwargs):
)

output = p.add_argument_group(title="Output")
output.add_argument(
"-o",
"--output-dir",
help="Directory to output scan results",
metavar="DIR",
)
output.add_argument(
"-om",
"--output-modules",
Expand All @@ -304,6 +303,13 @@ def create_parser(self, *args, **kwargs):
help=f'Output module(s). Choices: {",".join(sorted(self.preset.module_loader.output_module_choices))}',
metavar="MODULE",
)
output.add_argument("-lo", "--list-output-modules", action="store_true", help="List available output modules")
output.add_argument(
"-o",
"--output-dir",
help="Directory to output scan results",
metavar="DIR",
)
output.add_argument("--json", "-j", action="store_true", help="Output scan data in JSON format")
output.add_argument("--brief", "-br", action="store_true", help="Output only the data itself")
output.add_argument("--event-types", nargs="+", default=[], help="Choose which event types to display")
Expand Down
16 changes: 14 additions & 2 deletions bbot/test/test_step_1/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,11 +150,23 @@ async def test_cli_args(monkeypatch, caplog, capsys, clean_default_config):
out, err = capsys.readouterr()
# internal modules
assert "| excavate " in out
# output modules
assert "| csv " in out
# no output modules
assert not "| csv " in out
# scan modules
assert "| wayback " in out

# list output modules
monkeypatch.setattr("sys.argv", ["bbot", "--list-output-modules"])
result = await cli._main()
assert result == None
out, err = capsys.readouterr()
# no internal modules
assert not "| excavate " in out
# output modules
assert "| csv " in out
# no scan modules
assert not "| wayback " in out

# output dir and scan name
output_dir = bbot_test_dir / "bbot_cli_args_output"
scan_name = "bbot_cli_args_scan_name"
Expand Down
Loading
Loading