#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""
Helper functions for ODIN's custom libraries. These functions are used across different modules.
"""

import sys
import configparser

import click
from IPy import IP
from neo4j.v1 import GraphDatabase
from netaddr import IPNetwork,iter_iprange
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.desired_capabilities import DesiredCapabilities
from selenium.common.exceptions import TimeoutException,NoSuchElementException,WebDriverException


try:
    CONFIG_PARSER = configparser.ConfigParser()
    CONFIG_PARSER.read("auth/keys.config")
except configparser.Error as error:
    click.secho("[!] Could not open keys.config file inside the auth directory -- make sure it \
exists and is readable.",fg="red")
    click.secho("L.. Details: {}".format(error),fg="red")

def config_section_map(section):
    """This function helps by reading a config file section and returning a dictionary object 
    that can be referenced for configuration settings.


    Parameters:
    section     The config section to be collected from the config file
    """
    try:
        section_dict = {}
        # Parse the config file's sections into options
        options = CONFIG_PARSER.options(section)
        # Loop through each option
        for option in options:
            # Get the section and option and add it to the dictionary
            section_dict[option] = CONFIG_PARSER.get(section,option)
            if section_dict[option] == -1:
                click.secho("[*] Skipping: {}".format(option),fg="yellow")
        # Return the dictionary of settings and values
        return section_dict
    except configparser.Error as error:
        click.secho("[!] There was an error with: {}".format(section),fg="red")
        click.secho("L.. Details: {}".format(error),fg="red")

def is_ip(value):
    """Checks if the provided string is an IP address or not. If the check fails, it will be 
    assumed the string is a domain in most cases.

    IPy is used to determine if a string is a valid IP address. A True or False is returned.

    Parameters:
    value       The string to be determined to be evaluated as an IP address name or not
    """
    try:
        IP(value)
    except ValueError:
        return False
    return True

def is_domain(value):
    """A very basic check to see if the provided string contains any letters. This is useful for
    determining if a string should be treated as an IP address range or a domain.

    The is_ip() function will recognize an individual IP address or a CIDR, but will not validate a
    range like 192.168.1.0-50. Ranges will never contain letters, so this serves to separate domain
    names with hyphens from IP address ranges with hyphens.

    Parameters:
    value       The string to be determined to be evaluated as a domain name or not
    """
    result = any(check.isalpha() for check in value)
    return result

def setup_gdatabase_conn():
    """Function to setup the database connection to the active Neo4j project meant to contain the
    ODIN data.
    """
    try:
        database_uri = config_section_map("GraphDatabase")["uri"]
        database_user = config_section_map("GraphDatabase")["username"]
        database_pass = config_section_map("GraphDatabase")["password"]
        click.secho("[*] Attempting to connect to your Neo4j project using {}:{} @ {}."
                    .format(database_user,database_pass,database_uri),fg="yellow")
        neo4j_driver = GraphDatabase.driver(database_uri,auth=(database_user,database_pass))
        click.secho("[+] Success!",fg="green")
        return neo4j_driver
    except Exception:
        neo4j_driver = None
        click.secho("[!] Could not create a database connection using the details provided in \
your database.config! Please check the URI, username, and password. Also, make sure your Neo4j \
project is running. Note that the bolt port can change.",fg="red")
        exit()

def execute_query(driver,query):
    """Execute the provided query using the provided Neo4j database connection and driver.

    Parameters:
    driver      A Neo4j bolt driver object
    query       A Cypher query to be executed against the Neo4j database
    """
    with driver.session() as session:
        results = session.run(query)
    return results

def setup_headless_chrome(unsafe=False):
    """Attempt to setup a Selenium webdriver using headless Chrome. If this fails, fallback to
    PhantomJS. PhantomJS is a last resort, but better than nothing for the time being.

    Parameters:
    unsafe      A flag to set Chrome's `--no-sandbox` option
    """
    try:
        chrome_driver_path = config_section_map("WebDriver")["driver_path"]
        # Try loading the driver as a test
        chrome_options = Options()
        chrome_options.add_argument("--headless")
        chrome_options.add_argument("--window-size=1920x1080")
        # Setup 'capabilities' to ignore expired/self-signed certs so a screenshot is captured
        chrome_capabilities = DesiredCapabilities.CHROME.copy()
        chrome_capabilities['acceptSslCerts'] = True
        chrome_capabilities['acceptInsecureCerts'] = True
        # For Kali users, Chrome will get angry if the root user is used and requires --no-sandbox
        if unsafe:
            chrome_options.add_argument("--no-sandbox")
        browser = webdriver.Chrome(chrome_options=chrome_options,executable_path=chrome_driver_path,
                                   desired_capabilities=chrome_capabilities)
        click.secho("[*] Headless Chrome browser test was successful!",fg="yellow")
    # Catch issues with the web driver or path
    except WebDriverException:
        click.secho("[!] There was a problem with the specified Chrome web driver in your \
keys.config! Please check it. For now ODIN will try to use PhantomJS.",fg="yellow")
        browser = setup_phantomjs()
    # Catch issues loading the value from the config file
    except Exception:
        click.secho("[*] Could not load a Chrome webdriver for Selenium, so we will try to use \
PantomJS, but PhantomJS is no longer actively developed and is less reliable.",fg="yellow")
        browser = setup_phantomjs()
    return browser

def setup_phantomjs():
    """Create and return a PhantomJS browser object."""
    try:
        # Setup capabilities for the PhantomJS browser
        phantomjs_capabilities = DesiredCapabilities.PHANTOMJS
        # Some basic creds to use against an HTTP Basic Auth prompt
        phantomjs_capabilities['phantomjs.page.settings.userName'] = 'none'
        phantomjs_capabilities['phantomjs.page.settings.password'] = 'none'
        # Flags to ignore SSL problems and get screenshots
        service_args = []
        service_args.append('--ignore-ssl-errors=true')
        service_args.append('--web-security=no')
        service_args.append('--ssl-protocol=any')
        # Create the PhantomJS browser and set the window size
        browser = webdriver.PhantomJS(desired_capabilities=phantomjs_capabilities,service_args=service_args)
        browser.set_window_size(1920,1080)
    except Exception as error:
        click.secho("[!] Bad news: PhantomJS failed to load (not installed?), so activities \
requiring a web browser will be skipped.",fg="red")
        click.secho("L.. Details: {}".format(error),fg="red")
        browser = None
    return browser

def generate_scope(scope_file):
    """Parse IP ranges inside the provided scope file to expand IP ranges. This supports ranges
    with hyphens, underscores, and CIDRs.

    Parameters:
    scope_file  A file containing domain names and IP addresses/ranges
    """
    scope = []
    try:
        with open(scope_file,"r") as scope_file:
            for target in scope_file:
                target = target.rstrip()
                # Record individual IPs and expand CIDRs
                if is_ip(target):
                    ip_list = list(IPNetwork(target))
                    for address in sorted(ip_list):
                        str_address = str(address)
                        scope.append(str_address)
                # Sort IP ranges from domain names and expand the ranges
                if not is_domain(target):
                    # Check for hyphenated ranges like those accepted by Nmap
                    # Ex: 192.168.1.1-50 will become 192.168.1.1 ... 192.168.1.50
                    if "-" in target:
                        target = target.rstrip()
                        parts = target.split("-")
                        startrange = parts[0]
                        b = parts[0]
                        dot_split = b.split(".")
                        temp = "."
                        # Join the values using a "." so it makes a valid IP
                        combine = dot_split[0],dot_split[1],dot_split[2],parts[1]
                        endrange = temp.join(combine)
                        # Calculate the IP range
                        ip_list = list(iter_iprange(startrange,endrange))
                        # Iterate through the range and remove ip_list
                        for x in ip_list:
                            temp = str(x)
                            scope.append(temp)
                    # Check if range has an underscore because underscores are fine, I guess?
                    # Ex: 192.168.1.2_192.168.1.155
                    elif "_" in target:
                        target = target.rstrip()
                        parts = target.split("_")
                        startrange = parts[0]
                        endrange = parts[1]
                        ip_list = list(iter_iprange(startrange,endrange))
                        for address in ip_list:
                            str_address = str(address)
                            scope.append(str_address)
                else:
                    scope.append(target.rstrip())
    except IOError as error:
        click.secho("[!] Parsing of scope file failed!",fg="red")
        click.secho("L.. Details: {}".format(error),fg="red")
    return scope