diff --git a/README.md b/README.md index 7565dc8..8594f84 100644 --- a/README.md +++ b/README.md @@ -7,17 +7,67 @@ use it to monitor your enterprise's ports over time
+[About](#about) • [Installation](#installation) • -[Examples](#examples) • +[Example](#example) • [Contributing](#contributing) • [License](#license) +## About + +*bronzeburner* words + ## Installation -TBD +This project was authored for use with Pypy3.10 for performance reasons, but will likely run fine with any full Python implementation. +Unfortunately, this means several useful libraries are yet incompatible (e.g., uvloop). -## Examples +### Requirements & Recommendations + +- [RustScan](https://github.com/RustScan/RustScan) (required, in $PATH) +- [InfluxDB](https://github.com/influxdata/influxdb) (required) +- [Grafana](https://github.com/grafana/grafana) (optional, recommended) +- Docker (recommended) + +### Instructions + +These instructions assume you're running a Linux or macOS system. If you aren't, the instructions can easily be adapted. +If you don't already use [pyenv](https://github.com/pyenv/pyenv), look into using it to manage your Python versions. Use it to install +Pypy3.10 or install it manually. For macOS users, Pypy3.10 can be installed with `brew install pypy3.10`. + +Clone this repository with `git clone ...`. Browse to the newly created project directory with `cd bronzeburner`. Create a new virtual +Python environment with `pypy3.10 -m venv venv` and activate it with `source venv/bin/activate`. Install bronzeburner and its dependencies +with `pip install .`. + +Install Docker if you don't already use it. Create a persistent directory to store your data (i.e., `/opt/influxdb`). To run an InfluxDB instance, +run `docker run -v /opt/influxdb:/var/lib/influxdb2 -p 8086:8086 influxdb:2.7.1-alpine`. Browse to [http://127.0.0.1:8086/](http://127.0.0.1:8086/) and +set up your instance. Create a new API key with write access to your new org's new bucket and note it down. + +You're ready to run bronzeburner. + +```bash +❯ bronzeburner -h +usage: bronzeburner [-h] -a ADDRESS -u URL -o ORG -b BUCKET -t TOKEN + +A humble network scanner + +options: + -h, --help show this help message and exit + -a ADDRESS, --address ADDRESS + IP address or CIDR range to scan + -u URL, --url URL InfluxDB server URL + -o ORG, --org ORG InfluxDB organization + -b BUCKET, --bucket BUCKET + InfluxDB bucket + -t TOKEN, --token TOKEN + InfluxDB token +``` + +Decide on a target. bronzeburner accepts IPv4 addresses and CIDR ranges as address targets but can be extended to include additional options. See the +example execution below. + +## Example TBD diff --git a/bronzeburner.png b/bronzeburner.png index 3de026e..ca1c62e 100644 Binary files a/bronzeburner.png and b/bronzeburner.png differ diff --git a/bronzeburner/cli.py b/bronzeburner/cli.py index d8854bd..c7e88a4 100644 --- a/bronzeburner/cli.py +++ b/bronzeburner/cli.py @@ -1,16 +1,58 @@ import argparse +import asyncio + +from .logs import logger +from .scan import run_rustscan +from .validation import validate_cidr_or_ipv4 +from bronzeburner.influx import InfluxDB -def main(): - pass +async def main(args: argparse.Namespace) -> None: + """ + Invoked after parsing args. Checks InfluxDB state and initiates network scan. + + Args: + args (argparse.Namespace): All the arguments passed to the command line + + Returns: + int: Exit code from invoking rustscan + + Raises: + SystemExit: When there is an error connecting to InfluxDB + """ + if not await args.db.check_settings(): + logger.error("There was an error connecting to InfluxDB. Check your settings!") + exit(1) + return await run_rustscan(args) -def run(): +def run() -> None: + """ + This function creates and configures an ArgumentParser object for command line input, + instantiates the InfluxDB class with the provided arguments, and asynchronously runs the main function. + + Command Line Arguments: + -a, --address: IP address or CIDR range to scan. Required. + -u, --url: InfluxDB server URL. Required. + -o, --org: InfluxDB organization. Required. + -b, --bucket: InfluxDB bucket. Required. + -t, --token: InfluxDB token. Required. + + Raises: + argparse.ArgumentError: If any of the required arguments are not provided + """ parser = argparse.ArgumentParser(description="A humble network scanner") - + parser.add_argument( + "-a", "--address", help="IP address or CIDR range to scan", type=validate_cidr_or_ipv4, required=True + ) + parser.add_argument("-u", "--url", help="InfluxDB server URL", type=str, required=True) + parser.add_argument("-o", "--org", help="InfluxDB organization", required=True) + parser.add_argument("-b", "--bucket", help="InfluxDB bucket", required=True) + parser.add_argument("-t", "--token", help="InfluxDB token", required=True) args = parser.parse_args() - main(args) + args.db = InfluxDB(args.url, args.org, args.bucket, args.token) + asyncio.run(main(args)) if __name__ == "__main__": diff --git a/bronzeburner/influx.py b/bronzeburner/influx.py new file mode 100644 index 0000000..841675d --- /dev/null +++ b/bronzeburner/influx.py @@ -0,0 +1,36 @@ +from collections import namedtuple + +import aiohttp + +from .logs import logger + + +class InfluxDB: + def __init__(self, url: str, org: str, bucket: str, token: str) -> "InfluxDB": + self.url = url + self.org = org + self.bucket = bucket + self.headers = {"Authorization": f"Token {token}", "Content-Type": "text/plain"} + + async def check_settings(self) -> bool: + async with aiohttp.ClientSession() as session: + async with session.get(f"{self.url}/api/v2/orgs", headers=self.headers) as response: + return response.status == 200 + + async def insert(self, host: str, ports: namedtuple) -> None: + payload = "\n".join( + f'port_scan,host={host},port={p.port} state="{p.state}",protocol="{p.protocol}",service="{p.service}"' + for p in ports + ) + + async with aiohttp.ClientSession() as session: + async with session.post( + f"{self.url}/api/v2/write?org={self.org}&bucket={self.bucket}&precision=s", + headers=self.headers, + data=payload, + ) as response: + if response.status != 204: + logger.error(f"Failed write to InfluxDB (HTTP {response.status}): {await response.text()}") + return False + logger.info(f"Received HTTP {response.status} from InfluxDB") + return True diff --git a/bronzeburner/logs.py b/bronzeburner/logs.py new file mode 100644 index 0000000..088de0d --- /dev/null +++ b/bronzeburner/logs.py @@ -0,0 +1,34 @@ +from loguru import logger + +logger.add( + "app.log", + format="{time:YYYY-MM-DD HH:mm:ss} | {message}", + level="INFO", + rotation="1 day", + retention="30 days", +) + +logger.add( + "errors.log", + format="ℹ️ {time:YYYY-MM-DD HH:mm:ss} | {message}", + level="WARNING", + rotation="1 day", + retention="30 days", +) + +logger.add( + "error.log", + format="⛔️ {time:YYYY-MM-DD HH:mm:ss} | {message}", + level="ERROR", + rotation="1 day", + retention="30 days", +) + + +logger.add( + "critical.log", + format="🚨 {time:YYYY-MM-DD HH:mm:ss} | {message}", + level="CRITICAL", + rotation="1 day", + retention="30 days", +) diff --git a/bronzeburner/scan.py b/bronzeburner/scan.py new file mode 100644 index 0000000..e35c3cc --- /dev/null +++ b/bronzeburner/scan.py @@ -0,0 +1,76 @@ +import argparse +import asyncio +import re +from collections import namedtuple + +from .influx import InfluxDB +from .logs import logger + +PORTS_LINE_RE = re.compile(r"Host: (\d+\.\d+\.\d+\.\d+).*Ports: (.+)$") +PORT_ENTRY_RE = re.compile(r"(\d+)/([^/]*)/([^/]*)/([^/]*)/([^/]*)/([^/]*)/([^/]*)/") +PortEntry = namedtuple("PortEntry", "port state protocol owner service rpc_info version") + + +async def parse_output_line(db: InfluxDB, line: str) -> None: + """ + This function parses a line of output from RustScan and stores port information in an InfluxDB database. + + Args: + db (InfluxDB): The InfluxDB instance where the data will be stored. + line (str): A single line of output from RustScan. + + Returns: + None + """ + if match := PORTS_LINE_RE.match(line): + host_ip, port_findings = match.groups() + port_entries = PORT_ENTRY_RE.findall(port_findings) + parsed_ports = [PortEntry(*p) for p in port_entries] + + logger.info(f"Found {len(port_entries)} ports for {host_ip}: {','.join(str(p.port) for p in parsed_ports)}") + + if await db.insert(host_ip, parsed_ports): + logger.info(f"Successfully wrote {len(parsed_ports)} ports to InfluxDB") + else: + logger.error(f"Failed to write {len(parsed_ports)} ports to InfluxDB") + + +async def run_rustscan(args: argparse.Namespace) -> int: + """ + Runs rustscan with specified arguments and processes its output line by line using parse_output_line(). + + Args: + args (argparse.Namespace): Parsed command line arguments containing the address to scan + + Returns: + int: The exit code of the rustscan process + + Raises: + May raise various exceptions depending on the rustscan output and parsing process + """ + rustscan_args = [ + "rustscan", + "-t", + "500", + "-b", + "1500", + "--ulimit", + "5500", + "-a", + str(args.address), + "--", + "-oG", + "-", + ] + logger.info(f"Invoking rustscan: {' '.join(rustscan_args)}") + + process = await asyncio.create_subprocess_exec(*rustscan_args, stdout=asyncio.subprocess.PIPE) + + async for line in process.stdout: + await parse_output_line(args.db, line.decode().strip()) + + returncode = await process.wait() + if returncode != 0: + logger.critical(f"rustscan exited with code {returncode}") + + return returncode diff --git a/bronzeburner/validation.py b/bronzeburner/validation.py new file mode 100644 index 0000000..1b527a7 --- /dev/null +++ b/bronzeburner/validation.py @@ -0,0 +1,23 @@ +import argparse +from ipaddress import AddressValueError +from ipaddress import IPv4Network + + +def validate_cidr_or_ipv4(value: str) -> IPv4Network: + """ + Validates whether a given value is a valid CIDR or IPv4 address + by attempting return a newly constructed IPv4Network object. + + Args: + value (str): The string value to be validated as CIDR or IPv4 address + + Returns: + IPv4Network: An IPv4Network instance constructed with the input value + + Raises: + argparse.ArgumentTypeError: If the input value is not a valid CIDR or IPv4 address + """ + try: + return IPv4Network(value) + except AddressValueError as err: + raise argparse.ArgumentTypeError(str(err)) diff --git a/pyproject.toml b/pyproject.toml index a10883e..f50c81d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ authors = [{ name = "Darryl Nixon", email = "git@nixon.mozmail.com" }] description = "A humble network scanner" requires-python = ">=3.9" license = { text = "AGPL 3.0" } -dependencies = ["sh>=2.0.6", "rocketry>=2.5.1"] +dependencies = ["aiohttp>=3.8.5", "loguru>=0.7.1"] [project.scripts] bronzeburner = "bronzeburner.cli:run"