Files
recon-pipeline/recon-pipeline.py
epi052 7a24d85db4 Add scan tests (#12) - tests of current codebase complete
* recon.targets tests added

* restructured tests logically

* fixed yaml error

* fixed job names

* recon.__init__ tests added

* recon.config tests added

* recon.amass.ParseAmassScan tests added

* fixed test destined to fail on CI pipeline

* testing amass partially complete

* Changed the dir layout (#6) and fixed paths (#8)

this commit closes #6 and #8
updated existing tests to utilize new paths

* tests of current codebase complete

* added is_kali check to searchsploit test

* added test_web action to pipeline
2020-02-04 06:33:00 -06:00

274 lines
10 KiB
Python
Executable File

#!/usr/bin/env python
# stdlib imports
import os
import sys
import shlex
import pickle
import selectors
import threading
import subprocess
from pathlib import Path
# fix up the PYTHONPATH so we can simply execute the shell from wherever in the filesystem
os.environ["PYTHONPATH"] = f"{os.environ.get('PYTHONPATH')}:{str(Path(__file__).parent.resolve())}"
# suppress "You should consider upgrading via the 'pip install --upgrade pip' command." warning
os.environ["PIP_DISABLE_PIP_VERSION_CHECK"] = "1"
# third party imports
import cmd2 # noqa: E402
from cmd2.ansi import style # noqa: E402
# project's module imports
from recon import get_scans, tools, scan_parser, install_parser # noqa: F401,E402
# select loop, handles async stdout/stderr processing of subprocesses
selector = selectors.DefaultSelector()
class SelectorThread(threading.Thread):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._stop_event = threading.Event()
def stop(self):
""" Helper to set the SelectorThread's Event and cleanup the selector's fds """
self._stop_event.set()
# close any fds that were registered and still haven't been unregistered
for key in selector.get_map():
selector.get_key(key).fileobj.close()
def stopped(self):
""" Helper to determine whether the SelectorThread's Event is set or not. """
return self._stop_event.is_set()
def run(self):
""" Run thread that executes a select loop; handles async stdout/stderr processing of subprocesses. """
while not self.stopped():
for k, mask in selector.select():
callback = k.data
callback(k.fileobj)
class ReconShell(cmd2.Cmd):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.sentry = False
self.prompt = "recon-pipeline> "
self.selectorloop = None
# register hooks to handle selector loop start and cleanup
self.register_preloop_hook(self._preloop_hook)
self.register_postloop_hook(self._postloop_hook)
def _preloop_hook(self) -> None:
""" Hook function that runs prior to the cmdloop function starting; starts the selector loop. """
self.selectorloop = SelectorThread(daemon=True)
self.selectorloop.start()
def _postloop_hook(self) -> None:
""" Hook function that runs after the cmdloop function stops; stops the selector loop. """
if self.selectorloop.is_alive():
self.selectorloop.stop()
selector.close()
def _install_error_reporter(self, stderr):
""" Helper to print errors that crop up during any tool installation commands. """
output = stderr.readline()
if not output:
return
output = output.decode().strip()
self.async_alert(style(f"[!] {output}", fg="bright_red"))
def _luigi_pretty_printer(self, stderr):
""" Helper to clean up the VERY verbose luigi log messages that are normally spewed to the terminal. """
output = stderr.readline()
if not output:
return
output = output.decode()
if "===== Luigi Execution Summary =====" in output:
# header that begins the summary of all luigi tasks that have executed/failed
self.async_alert("")
self.sentry = True
# block below used for printing status messages
if self.sentry:
# only set once the Luigi Execution Summary is seen
self.async_alert(style(output.strip(), fg="bright_blue"))
elif output.startswith("INFO: Informed") and output.strip().endswith("PENDING"):
# luigi Task has been queued for execution
words = output.split()
self.async_alert(style(f"[-] {words[5].split('_')[0]} queued", fg="bright_white"))
elif output.startswith("INFO: ") and "running" in output:
# luigi Task is currently running
words = output.split()
# output looks similar to , pid=3938074) running MasscanScan(
# want to grab the index of the luigi task running and use it to find the name of the scan (i.e. MassScan)
scantypeidx = words.index("running") + 1
scantype = words[scantypeidx].split("(", 1)[0]
self.async_alert(style(f"[*] {scantype} running...", fg="bright_yellow"))
elif output.startswith("INFO: Informed") and output.strip().endswith("DONE"):
# luigi Task has completed
words = output.split()
self.async_alert(style(f"[+] {words[5].split('_')[0]} complete!", fg="bright_green"))
@cmd2.with_argparser(scan_parser)
def do_scan(self, args):
""" Scan something.
Possible scans include
AmassScan CORScannerScan GobusterScan SearchsploitScan
ThreadedNmapScan WebanalyzeScan AquatoneScan FullScan
MasscanScan SubjackScan TKOSubsScan HTBScan
"""
self.async_alert(
style(
"If anything goes wrong, rerun your command with --verbose to enable debug statements.",
fg="cyan",
dim=True,
)
)
# get_scans() returns mapping of {classname: [modulename, ...]} in the recon module
# each classname corresponds to a potential recon-pipeline command, i.e. AmassScan, CORScannerScan ...
scans = get_scans()
# command is a list that will end up looking something like what's below
# luigi --module recon.web.webanalyze WebanalyzeScan --target-file tesla --top-ports 1000 --interface eth0
command = ["luigi", "--module", scans.get(args.scantype)[0]]
command.extend(args.__statement__.arg_list)
if args.verbose:
# verbose is not a luigi option, need to remove it
command.pop(command.index("--verbose"))
subprocess.run(command)
else:
# suppress luigi messages in favor of less verbose/cleaner output
proc = subprocess.Popen(command, stderr=subprocess.PIPE, stdout=subprocess.PIPE)
# add stderr to the selector loop for processing when there's something to read from the fd
selector.register(proc.stderr, selectors.EVENT_READ, self._luigi_pretty_printer)
@cmd2.with_argparser(install_parser)
def do_install(self, args):
""" Install any/all of the libraries/tools necessary to make the recon-pipeline function. """
# imported tools variable is in global scope, and we reassign over it later
global tools
# create .cache dir in the home directory, on the off chance it doesn't exist
cachedir = Path.home() / ".cache/"
cachedir.mkdir(parents=True, exist_ok=True)
persistent_tool_dict = cachedir / ".tool-dict.pkl"
if args.tool == "all":
# show all tools have been queued for installation
[
self.async_alert(style(f"[-] {x} queued", fg="bright_white"))
for x in tools.keys()
if not tools.get(x).get("installed")
]
for tool in tools.keys():
self.do_install(tool)
return
if persistent_tool_dict.exists():
tools = pickle.loads(persistent_tool_dict.read_bytes())
if tools.get(args.tool).get("dependencies"):
# get all of the requested tools dependencies
for dependency in tools.get(args.tool).get("dependencies"):
if tools.get(dependency).get("installed"):
# already installed, skip it
continue
self.async_alert(
style(f"[!] {args.tool} has an unmet dependency; installing {dependency}", fg="yellow", bold=True,)
)
# install the dependency before continuing with installation
self.do_install(dependency)
if tools.get(args.tool).get("installed"):
return self.async_alert(style(f"[!] {args.tool} is already installed.", fg="yellow"))
else:
# list of return values from commands run during each tool installation
# used to determine whether the tool installed correctly or not
retvals = list()
self.async_alert(style(f"[*] Installing {args.tool}...", fg="bright_yellow"))
for command in tools.get(args.tool).get("commands"):
# run all commands required to install the tool
# print each command being run
self.async_alert(style(f"[=] {command}", fg="cyan"))
if tools.get(args.tool).get("shell"):
# go tools use subshells (cmd1 && cmd2 && cmd3 ...) during install, so need shell=True
proc = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE,)
else:
# "normal" command, split up the string as usual and run it
proc = subprocess.Popen(shlex.split(command), stdout=subprocess.PIPE, stderr=subprocess.PIPE,)
out, err = proc.communicate()
if err:
self.poutput(style(f"[!] {err.decode().strip()}", fg="bright_red"))
retvals.append(proc.returncode)
if all(x == 0 for x in retvals):
# all return values in retvals are 0, i.e. all exec'd successfully; tool has been installed
self.async_alert(style(f"[+] {args.tool} installed!", fg="bright_green"))
tools[args.tool]["installed"] = True
else:
# unsuccessful tool install
tools[args.tool]["installed"] = False
self.async_alert(
style(
f"[!!] one (or more) of {args.tool}'s commands failed and may have not installed properly; check output from the offending command above...",
fg="bright_red",
bold=True,
)
)
# store any tool installs/failures (back) to disk
pickle.dump(tools, persistent_tool_dict.open("wb"))
if __name__ == "__main__":
rs = ReconShell(persistent_history_file="~/.reconshell_history", persistent_history_length=10000)
sys.exit(rs.cmdloop())