diff --git a/README.md b/README.md index 19f654f..428d7fa 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ * [Deployment Recommendations](#Deployment-Recommendation) * [Installation - Docker](#docker) * [Installation - Bare Metal](#server) + * [Multi-node installation](#Multi-Node-Installation) * [Security](#security) * [Usage](#usage) * [License](#license) @@ -127,6 +128,15 @@ In your browser, navigate to http://ip.add.re.ss:80 and login with the credentia In your browser, navigate to http://ip.add.re.ss:8080 and use the credentials printed in your terminal. + +# Multi Node Installation +If you want to install NERVE in a multi-node deployment, you can follow the normal bare metal installation process, afterwards: +1. Modify the config.py file on each node +2. Change the server address of Redis `RDS_HOST` to point to a central Redis server that all NERVE instances will report to. +3. Run `service nerve restart` or `systemctl restart nerve` to reload the configuration +4. Run `apt-get remove redis` / `yum remove redis` (Depending on the Linux Distribution) since you will no longer need each instance to report to itself. +Don't forget to allow port 3769 inbound on the Redis instance, so that the NERVE instances can communicate with it. + # Security There are a few security mechanisms implemented into NERVE you need to be aware of. diff --git a/bin/scanner.py b/bin/scanner.py index e31f8b7..bae5c1f 100644 --- a/bin/scanner.py +++ b/bin/scanner.py @@ -27,7 +27,8 @@ def scanner(): if hosts: conf = rds.get_scan_config() scan_data = scanner.scan(hosts, - ports = conf['config']['scan_opts']['max_ports'], + max_ports = conf['config']['scan_opts']['max_ports'], + custom_ports = conf['config']['scan_opts']['custom_ports'], interface = conf['config']['scan_opts']['interface']) if scan_data: diff --git a/config.py b/config.py index 8f68da5..9193b33 100644 --- a/config.py +++ b/config.py @@ -27,9 +27,10 @@ MAX_LOGIN_ATTEMPTS = 5 # Redis Configuration -# This should not be set to anything else except localhost +# This should not be set to anything else except localhost unless you want to do a multi-node deployment. RDS_HOST = '127.0.0.1' RDS_PORT = 6379 +RDS_PASSW = None # Scan Configuration USER_AGENT = 'NERVE' @@ -45,7 +46,7 @@ 'config':{ 'name':'Default', 'description':'My Default Scan', - 'engineer':'Default', + 'engineer':'John Doe', 'allow_aggressive':3, 'allow_dos':False, 'allow_bf':False, @@ -57,6 +58,7 @@ 'scan_opts':{ 'interface':None, 'max_ports':100, + 'custom_ports':[], 'parallel_scan':50, 'parallel_attack':30, }, diff --git a/core/parser.py b/core/parser.py index f1563a6..3838dad 100644 --- a/core/parser.py +++ b/core/parser.py @@ -42,11 +42,94 @@ def verify(self): dictionary_usernames = self.data['config']['dictionary']['usernames'] dictionary_passwords = self.data['config']['dictionary']['passwords'] max_ports = self.data['config']['scan_opts']['max_ports'] + custom_ports = self.data['config']['scan_opts']['custom_ports'] net_interface = self.data['config']['scan_opts']['interface'] parallel_scan = self.data['config']['scan_opts']['parallel_scan'] parallel_attack = self.data['config']['scan_opts']['parallel_attack'] frequency = self.data['config']['frequency'] + """ + Check Structure + """ + if not isinstance(name, str): + error = 'Option [ASSESSMENT_NAME] must be a String' + verified = False + + if not isinstance(networks, list): + error = 'Option [NETWORKS] must be an Array' + verified = False + + if not isinstance(excluded_networks, list): + error = 'Option [EXCLUDED_NETWORKS] must be an Array' + verified = False + + if not isinstance(domains, list): + error = 'Option [DOMAINS] must be an Array' + verified = False + + if not isinstance(intrusive_level, int): + error = 'Option [AGGRESSIVENESS_LEVEL] must be an Integer' + verified = False + + if not isinstance(allow_dos, bool): + error = 'Option [ALLOW_DENIAL_OF_SERVICE] must be a Boolean' + verified = False + + if not isinstance(allow_inet, bool): + error = 'Option [ALLOW_INTERNET_OUTBOUND] must be a Boolean' + verified = False + + if not isinstance(allow_bf, bool): + error = 'Option [ALLOW_BRUTE_FORCE] must be a Boolean' + verified = False + + if not isinstance(dictionary_usernames, list): + error = 'Option [DICTIONARY_USERNAMES] must be an Array' + verified = False + + if not isinstance(dictionary_passwords, list): + error = 'Option [DICTIONARY_PASSWORDS] must be an Array' + verified = False + + if not isinstance(net_interface, (str, type(None))): + error = 'Option [NET_INTERFACE] must be null or a String' + verified = False + + if not isinstance(max_ports, int): + error = 'Option [MAX_PORTS] must be an Integer' + verified = False + + if not isinstance(custom_ports, list): + error = 'Option [MAX_PORTS] must be an Array' + verified = False + + if not isinstance(custom_ports, list): + error = 'Option [CUSTOM_PORTS] must be an Array' + verified = False + + if not isinstance(parallel_scan, int): + error = 'Option [PARALLEL_SCAN] must be an Integer' + verified = False + + if not isinstance(parallel_attack, int): + error = 'Option [PARALLEL_ATTACK] must be an Integer' + verified = False + + if not isinstance(webhook, (str, type(None))): + error = 'Option [WEB_HOOK] must be null or a String' + verified = False + + if not isinstance(frequency, str): + error = 'Option [FREQUENCY] must be a String' + verified = False + + if not verified: + return (verified, error, self.data) + + """ + Check passed values + """ + if len(name) > 30 or not self.utils.is_string_safe(name): error = 'Option [ASSESSMENT_NAME] must not exceed 30 characters and must not have special characters.' verified = False @@ -63,42 +146,27 @@ def verify(self): if webhook: if not self.utils.is_string_url(webhook): - error = 'Option [WEBHOOK] must be a valid URL.' + error = 'Option [WEB_HOOK] must be a valid URL.' verified = False if frequency not in ('once', 'continuous'): error = 'Option [SCHEDULE] must be "once" or "continuous"' verified = False - - if allow_dos not in (True, False): - error = 'Option [ALLOW_DENIAL-OF-SERVICE] must be true or false.' - verified = False - - if allow_bf not in (True, False): - error = 'Option [ALLOW_BRUTEFORCE] must be true or false.' - verified = False - - if allow_inet not in (True, False): - error = 'Option [ALLOW_INTERNET-OUTBOUND] must be true or false.' + + if not 0 <= intrusive_level <= 3: + error = 'Option [AGGRESSIVE_LEVEL] must be between 0-3' verified = False - - try: - if intrusive_level < 0 or intrusive_level > 3: - error = 'Option [AGGRESSIVE_LEVEL] must be between 0-3' - verified = False - except TypeError: - error = 'Option [AGGRESSIVE_LEVEL] must be an Integer' - verified = False - - try: - if max_ports < 10 or max_ports > 65535: - error = 'Option [MAX_PORTS] must be between 10-7000' - verified = False - - except TypeError: - error = 'Option [MAX_PORTS] must be an Integer' - verified = False + if max_ports: + if not self.netutils.is_valid_port(max_ports): + error = 'Option [MAX_PORTS] must be a value between 0-65535' + verified = False + + if custom_ports: + for cport in custom_ports: + if not self.netutils.is_valid_port(cport): + error = 'Option [CUSTOM_PORTS] must be an array of values between 0-65535' + verified = False if not networks and not domains: error = 'Options [DOMAINS] or Options [NETWORKS] must not be empty' @@ -135,39 +203,22 @@ def verify(self): if net_interface: n = Network() if not net_interface in n.get_nics(): - error = 'Option [INTERFACE] must be valid' + error = 'Option [NET_INTERFACE] must be valid' verified = False else: self.data['config']['scan_opts']['interface'] = None - try: - if parallel_attack < 10 or parallel_attack > 100: - error = 'Option [ATTACK_THREADS] must be between 10-1000' - verified = False - except TypeError: - error = 'Option [ATTACK_THREADS] must be an Integer' - verified = False - - try: - if parallel_scan < 10 or parallel_scan > 100: - error = 'Option [SCAN_THREADS] must be between 10-1000' - verified = False - except TypeError: - error = 'Option [SCAN_THREADS] must be an Integer' + if not 1 <= parallel_attack <= 100: + error = 'Option [ATTACK_THREADS] must be between 1-100' verified = False - if not isinstance(dictionary_usernames, list): - error = 'Option [DICTIONARY_USERNAMES] must be an array' - verified = False - - if not isinstance(dictionary_passwords, list): - error = 'Option [DICTIONARY_PASSWORDS] must be an array' + if not 1 <= parallel_scan <= 100: + error = 'Option [SCAN_THREADS] must be between 1-100' verified = False - + except KeyError as e: - print(e) - error = 'One or more options are missing' + error = 'One or more options are missing: {}'.format(e) verified = False return (verified, error, self.data) @@ -247,6 +298,9 @@ def get_cfg_allow_bf(self): def get_cfg_max_ports(self): return self.values['config']['scan_opts']['max_ports'] + def get_cfg_custom_ports(self): + return self.values['config']['scan_opts']['custom_ports'] + def get_cfg_usernames(self): return self.values['config']['dictionary']['usernames'] diff --git a/core/port_scanner.py b/core/port_scanner.py index 73c03ca..7568284 100644 --- a/core/port_scanner.py +++ b/core/port_scanner.py @@ -20,13 +20,23 @@ def __init__(self): } self.utils = Utils() - def scan(self, hosts, ports=100, interface=None): + def scan(self, hosts, max_ports, custom_ports, interface=None): data = {} hosts = ' '.join(hosts.keys()) - ports = '--top-ports {}'.format(ports) extra_args = '' scan_cmdline = 'unpriv_scan' + ports = '' + if custom_ports: + ports = '-p {}'.format(','.join([str(p) for p in set(custom_ports)])) + + elif max_ports: + ports = '--top-ports {}'.format(max_ports) + + else: + ports = '--top-ports 100' + + if interface: extra_args += '-e {}'.format(interface) diff --git a/core/redis.py b/core/redis.py index 76c7dcc..5d13caa 100644 --- a/core/redis.py +++ b/core/redis.py @@ -3,6 +3,7 @@ import redis import threading import pickle + from core.logging import logger from core.utils import Utils @@ -11,7 +12,7 @@ def __init__(self): self.utils = Utils() self.r = None try: - self.conn_pool = redis.ConnectionPool(host=config.RDS_HOST, port=config.RDS_PORT, db=0) + self.conn_pool = redis.ConnectionPool(host=config.RDS_HOST, port=config.RDS_PORT, password=config.RDS_PASSW, db=0) self.r = redis.Redis(connection_pool=self.conn_pool) except TimeoutError: logger.error('Redis Connection Timed Out!') diff --git a/core/utils.py b/core/utils.py index 75e8003..9a89207 100644 --- a/core/utils.py +++ b/core/utils.py @@ -90,6 +90,15 @@ def is_dns(self, addr): if validators.domain(addr): return True return False + + def is_valid_port(self, port): + try: + if 0 <= port <= 65535: + return True + return False + except TypeError: + return False + def get_primary_ip(self): s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) diff --git a/static/css/nerve.css b/static/css/nerve.css index f87048e..ef9d6f3 100644 --- a/static/css/nerve.css +++ b/static/css/nerve.css @@ -58,10 +58,19 @@ span.banner_sm { .scan_type_indicator { font-size:10px; font-weight: bold; - color:red; + color:darkorange; + letter-spacing: 3px; +} + +.withlove { + font-size:7px; + font-weight: bold; + color:lightgrey; letter-spacing: 2px; + text-decoration: none; } + .logo-text { font-size:8px; letter-spacing: 2.5px; @@ -106,6 +115,10 @@ span.banner_sm { color:orange } +.c-darkorange { + color:darkorange +} + .c-red { color:red } diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000..0d8471b Binary files /dev/null and b/static/favicon.ico differ diff --git a/templates/alert.html b/templates/alert.html index 0c030fc..15eb817 100644 --- a/templates/alert.html +++ b/templates/alert.html @@ -5,6 +5,7 @@