# @author Spencer Harman
# @file vulnerabilityScanner.py
#   This program scans an inputted url for vulnerabilities.
#
#   Usage:
#       pip install httpx beautifulsoup4
#   Then you can run the program like this:
#
#       SCAN REGULAR:
#           phidippus -u <some url starting with http/https>
#
#       SCAN WITH VERBOSE:
#           phidippus -u <some url starting with http/https> --verbose
#
#       SCAN AN AUTHENTICATED AREA (if you have credentials)
#           phidippus -u <some url starting with http/https> \
#           --login-url <url for login> \
#           --username <username> \
#           --password <password> \
#           --login-data "user={username}&password={password}&login=submit"
#       (you can also use verbose for this by just adding --verbose at the end)
#
#
#                       ONLY RUN ON SITES YOU HAVE PERMISSION FOR!!!!!!!


import asyncio
import httpx
import argparse
from urllib.parse import urlparse, urlencode, parse_qs, urljoin
import re
import sys
from bs4 import BeautifulSoup
import time
import ssl
import socket
from .nvdRequests import scanNVD

# Making a dictionary that has regular expressions that indicate common SQL errors.
SQL_ERROR_PATTERNS = {
    "MySQL": [
        r"SQL syntax.*MySQL", r"Warning.*mysql_connect\(\)", r"You have an error in your SQL syntax;",
        r"supplied argument is not a valid MySQL result resource", r"Unknown column '[^']+' in 'field list'",
        r"mysql_fetch_array\(\)", r"mysql_num_rows\(\)"
    ],
    "PostgreSQL": [
        r"PostgreSQL.*ERROR", r"Warning.*pg_connect\(\)", r"valid PostgreSQL result",
        r"Npgsql\.PostgresException", r"ERROR: parser: parse error", r"PG::SyntaxError", r"pg_query\(\)"
    ],
    "Microsoft SQL Server": [
        r"Microsoft SQL Server Native Client", r"SQLSTATE\[\d{5}\]: Syntax error or access violation",
        r"Unclosed quotation mark after the character string", r"System\.Data\.SqlClient\.SqlException",
        r"\[SQLSTATE \d{5}\]", r"Incorrect syntax near", r"SQL Server"
    ],
    "Oracle": [
        r"ORA-\d{5}", r"System\.Data\.OracleClient\.OracleException", r"quoted string not properly terminated",
        r"SQL command not properly ended"
    ],
    "SQLite": [
        r"SQLite error", r"near \"\w+\": syntax error", r"unrecognized token", r"SQLITE_ERROR"
    ]
}

# Dictionary of common XSS payloads
XSS_PAYLOADS = [
    "<script>alert('XSS')</script>",
    "\"><script>alert('XSS')</script>",
    "'><img src=x onerror=alert('XSS')>",
    "<svg onload=alert('XSS')>",
    "';alert(1)//",
    "<body onload=alert('XSS')>",
    "<iframe srcdoc=\"<script>alert('XSS')</script>\">",
    "<img src=x onerror=alert('XSS') style=display:none>"
]

# SQL Payload Dictionary
SQLI_PAYLOADS = {
    "error_based": [
        "'", '"', "`",
        "\\", "%27", "%22", "%60", # URL quotes/backticks
        "')", '")', '`")',
        " ORDER BY 9999--", # find number of columns (fails if 9999 columns don't exist)
        "' OR 1=1--", "' OR '1'='1'--", "' OR 1=1#" # true condition
    ],
    "boolean_based_true": [ "' AND 1=1--", "' AND '1'='1'--", " AND 'a'='a'" ],
    "boolean_based_false": [ "' AND 1=2--", "' AND '1'='2'--", " AND 'a'='b'" ],
    "time_based_mysql": [ "' AND SLEEP(5)--", "' UNION SELECT SLEEP(5)--", " OR SLEEP(5)" ],
    "time_based_postgresql": [ "' AND pg_sleep(5)--", "' UNION SELECT pg_sleep(5)--", " OR pg_sleep(5)" ],
    "time_based_mssql": [ "' WAITFOR DELAY '0:0:5'--", "' WAITFOR DELAY '0:0:5' --", " OR WAITFOR DELAY '0:0:5'" ]
}

# List of common file/directory names that often hold sensitive information
SENSITIVE_PATHS = [
    ".env", ".git/config", ".git/HEAD", "phpinfo.php", "config.php",
    "backup.zip", "data.sql", "admin/", "test/", "dev/",
    "wp-config.php.bak", "web.config.old", "index.php.bak", "index.html.bak",
    "robots.txt.bak", "sitemap.xml.bak",
    "server-status",
    "nginx-status",
    "admin/login.php", "login.php", "wp-admin/",
    "phpmyadmin/", "adminer/"
]


DEFAULT_TIMEOUT = 10
RATE_LIMIT_DELAY = 0.1 # seconds between requests
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Scanner/1.0"
TIME_BASED_THRESHOLD = 4.0 # seconds for time based SQLi detection (should def be less than sleep time)


# Makes an asynchronous HTTP request using httpx
# It first sets the User Agent to identify the scanner to use. Then, it runs a retry mechanism with an exponental backoff
# for certain errors, making the scanner stronger against temporary network issues, and records the duration of the request.
# If the error is a 4-- or 5-- error, it raises an HTTPStatusError to simplify how its handled.
async def makeRequest( httpClient, url, method="GET", params=None, data=None, headers=None, allowRedirects=True, timeout=DEFAULT_TIMEOUT, verbose=False, maxRetries=3, retryDelay=1 ):
    if headers is None:
        headers = { "User-Agent": USER_AGENT }
    else:
        headers[ "User-Agent" ] = headers.get( "User-Agent", USER_AGENT )

    for attempt in range( maxRetries ):
        try:
            if verbose:
                print( f"    [verbose] Making {method} request (Attempt {attempt+1}/{maxRetries}) to: {url}" )
                if params: print( f"    [verbose] Params: {params}" )
                if data: print( f"    [verbose] Data: {data}" )
                if headers: print( f"    [verbose] Headers: {headers}" )

            startTime = time.time()
            if method.upper() == "GET":
                response = await httpClient.get( url, params=params, headers=headers, follow_redirects=allowRedirects, timeout=timeout )
            elif method.upper() == "POST":
                response = await httpClient.post( url, data=data, headers=headers, follow_redirects=allowRedirects, timeout=timeout )
            else:
                if verbose: print( f"[-] Unsupported HTTP method: {method}" )
                return None, 0

            duration = time.time() - startTime
            response.raise_for_status() # bad responses (anything 400 - 599)
            return response, duration
        except httpx.HTTPStatusError as e:
            if verbose: print( f"[-] HTTP error for {url}: {e.response.status_code} - {e.response.text[:100]}..." )
            if e.response.status_code in [ 401, 403, 404 ]: # Don't retry these errors
                return None, 0
            if attempt < maxRetries - 1:
                await asyncio.sleep( retryDelay * ( 2 ** attempt ) ) # BACK OFF!!
            else:
                return None, 0
        except httpx.RequestError as e:
            if verbose: print( f"[-] Request error for {url}: {e}" )
            if attempt < maxRetries - 1:
                await asyncio.sleep( retryDelay * ( 2 ** attempt ) ) # BACK OFF!!!
            else:
                return None, 0
        except Exception as e:
            if verbose: print( f"[-] Unexpected error for {url}: {e}" )
            return None, 0
    return None, 0 # If every retry fails

# Adds a finding to 'findings'
# Each finding contains a type, severity, description, url, and evidence, and is presented once the program
# finished scanning.
def addFinding( findings, findingType, severity, description, url, evidence ):
    findings.append( {
        "type": findingType,
        "severity": severity,
        "description": description,
        "url": url,
        "evidence": evidence
        # Add remediations
    } )

# Identifies HTML forms on the web page given from the user by:
#   Fetching the HTML content of the url using the makeRequest function above
#   Uses BeautifulSoup to parse the HTML and find all <form> tags
#   For each form, the action url and method is extracted 
#   Then, all <input>, <textarea>, and <select> fields within the form are identified
#   A default value is assigned to form each field
#   And returns a list of dictionaries where each dictionary represents a form from the page
async def findForms( httpClient, url, verbose=False ):
    forms = []
    response, _ = await makeRequest( httpClient, url, verbose=verbose )
    if response and response.status_code == 200:
        soup = BeautifulSoup( response.text, 'html.parser' )
        for formTag in soup.find_all( 'form' ):
            formAction = formTag.get( 'action', url ) # do to current url if no action
            # Resolve stuff
            formAction = urljoin( url, formAction )
            formMethod = formTag.get( 'method', 'GET' ).upper()
            formFields = {}
            for inputTag in formTag.find_all( [ 'input', 'textarea', 'select' ] ):
                name = inputTag.get( 'name' )
                if name:
                    # set a default value for testing
                    if inputTag.get( 'type' ) == 'password':
                        formFields[ name ] = 'testpassword123'
                    elif inputTag.get( 'type' ) == 'email':
                        formFields[ name ] = 'test@example.com'
                    else:
                        formFields[ name ] = 'testvalue'
            forms.append( {
                'action': formAction,
                'method': formMethod,
                'fields': formFields
            } )
    return forms


# Gets information about the target url
#   Checks HTTPS headers and response statuses to try and disclose information about the server and technology
#   it uses
#   Checks for the presence of security related headers, which can indicate vulnerabilities
#   like XSS or SSL stripping if absent. 
#   Examines Set-Cookie headers for missing flags, which are important for cookie security
#   Checks for robots.txt and sitemap.xml, since they can sometimes accidentally reveal sensitive paths or directories
async def scanInfoGathering( httpClient, targetUrl, findings, verbose=False, detected_server_banners=None ):
    if detected_server_banners is None:
        detected_server_banners = [] # Initialize if not provided

    print( f"\n[+] Running Information Gathering for: {targetUrl}" )
    await asyncio.sleep( RATE_LIMIT_DELAY )
    response, _ = await makeRequest( httpClient, targetUrl, verbose=verbose )

    if response:
        # initial status (keep your existing logic for findings)
        if response.status_code == 200:
            addFinding( findings, "Connectivity", "Informational",
                        f"Target URL is reachable and returned HTTP 200 OK.", targetUrl,
                        f"HTTP Status: {response.status_code}" )
        else:
            addFinding( findings, "Connectivity", "Low",
                        f"Target URL returned non-200 status code: {response.status_code}", targetUrl,
                        f"HTTP Status: {response.status_code}" )

        # Headers
        if verbose: print( "    [+] Analyzing HTTP Headers..." )
        for header, value in response.headers.items():
            if verbose: print( f"        {header}: {value}" )
            if header.lower() == 'server':
                addFinding( findings, "Information Disclosure", "Informational",
                            f"Server banner disclosed: {value}", targetUrl,
                            f"Header: Server: {value}" )
                # Extract server name and version for NVD scan
                server_name_match = re.match(r"(\w+)(?:/([\d.]+))?", value)
                if server_name_match:
                    name = server_name_match.group(1).lower()
                    version = server_name_match.group(2) if server_name_match.group(2) else ""
                    detected_server_banners.append({"name": name, "version": version, "url": targetUrl})
            elif header.lower() == 'x-powered-by':
                addFinding( findings, "Information Disclosure", "Informational",
                            f"X-Powered-By header disclosed: {value}", targetUrl,
                            f"Header: X-Powered-By: {value}" )
                # For X-Powered-By, assume the value is the product, version might be embedded or absent
                name = value.split('/')[0].strip().lower()
                version_match = re.search(r'[\d.]+', value)
                version = version_match.group(0) if version_match else ""
                detected_server_banners.append({"name": name, "version": version, "url": targetUrl})
            elif header.lower() == 'content-security-policy':
                addFinding( findings, "Security Header", "Informational",
                            f"Content-Security-Policy (CSP) header found: {value}", targetUrl,
                            f"Header: CSP: {value}" )
            elif header.lower() == 'x-frame-options':
                addFinding( findings, "Security Header", "Informational",
                            f"X-Frame-Options header found: {value}", targetUrl,
                            f"Header: X-Frame-Options: {value}" )
            elif header.lower() == 'strict-transport-security':
                addFinding( findings, "Security Header", "Informational",
                            f"Strict-Transport-Security (HSTS) header found: {value}", targetUrl,
                            f"Header: Strict-Transport-Security: {value}" )
            elif header.lower() == 'set-cookie':
                if not re.search( r'httponly', value, re.IGNORECASE ):
                    addFinding( findings, "Cookie Security", "Low",
                                "Cookie missing HttpOnly flag. Could be vulnerable to XSS cookie theft.",
                                targetUrl, f"Cookie: {value}" )
                if not re.search( r'secure', value, re.IGNORECASE ) and targetUrl.startswith( 'https' ):
                    addFinding( findings, "Cookie Security", "Low",
                                "Cookie missing Secure flag on HTTPS. Could be sent over HTTP.",
                                targetUrl, f"Cookie: {value}" )

        if 'Content-Security-Policy' not in response.headers:
            addFinding( findings, "Security Header Missing", "Low",
                        "Content-Security-Policy (CSP) header not found. This can increase XSS risk.",
                        targetUrl, "Missing CSP header." )
        if 'X-Frame-Options' not in response.headers:
            addFinding( findings, "Security Header Missing", "Medium",
                        "X-Frame-Options header not found. Site may be vulnerable to Clickjacking.",
                        targetUrl, "Missing X-Frame-Options header." )
        if 'Strict-Transport-Security' not in response.headers and targetUrl.startswith( 'https' ):
            addFinding( findings, "Security Header Missing", "Medium",
                        "Strict-Transport-Security (HSTS) header not found. Could allow SSL stripping attacks.",
                        targetUrl, "Missing HSTS header on HTTPS site." )

        # Check for robots.txt and sitemap.xml (keep your existing logic)
        robotsTxtUrl = urljoin( targetUrl, "/robots.txt" )
        await asyncio.sleep( RATE_LIMIT_DELAY )
        robotsResponse, _ = await makeRequest( httpClient, robotsTxtUrl, verbose=verbose )
        if robotsResponse and robotsResponse.status_code == 200:
            robotsText = robotsResponse.text
            if "Disallow:" in robotsText or "Allow:" in robotsText:
                addFinding( findings, "Information Disclosure", "Informational",
                            "robots.txt found with entries. Review for sensitive path disclosures.",
                            robotsTxtUrl, f"robots.txt content (first 200 chars):\n{robotsText[:200]}..." )
            else:
                addFinding( findings, "Information Disclosure", "Informational",
                            "robots.txt found, but no explicit 'Disallow' or 'Allow' entries.",
                            robotsTxtUrl, f"robots.txt content (first 200 chars):\n{robotsText[:200]}..." )
            if verbose: print( f"    [+] robots.txt found: {robotsTxtUrl}" )
        else:
            if verbose: print( f"    [-] robots.txt not found or inaccessible: {robotsTxtUrl}" )

        sitemapXmlUrl = urljoin( targetUrl, "/sitemap.xml" )
        await asyncio.sleep( RATE_LIMIT_DELAY )
        sitemapResponse, _ = await makeRequest( httpClient, sitemapXmlUrl, verbose=verbose )
        if sitemapResponse and sitemapResponse.status_code == 200 and '<?xml' in sitemapResponse.text:
            addFinding( findings, "Information Disclosure", "Informational",
                        "sitemap.xml found. Review for potential directory/file disclosures.",
                        sitemapXmlUrl, f"sitemap.xml content (first 200 chars):\n{sitemapResponse.text[:200]}..." )
            if verbose: print( f"    [+] sitemap.xml found: {sitemapXmlUrl}" )
        else:
            if verbose: print( f"    [-] sitemap.xml not found or inaccessible: {sitemapXmlUrl}" )

    else:
        addFinding( findings, "Network Error", "High",
                        f"Could not connect to target URL: {targetUrl}. Check URL or network connectivity.",
                        targetUrl, "Failed to get HTTP response for main URL." )
    return detected_server_banners # Return the collected server banners


# Analyzes both the GET and POST parameters for XSS
#   GET:
#       Parses the target url to extract existing GET parameters
#       For every parameter, it injects different XSS payloads one by one
#       Constructs a new url with the injected payload
#       Makes a GET request to the new url
#       Then it checks if the payload is reflected at all in the HTTP response body using BeautifulSoup
#       to see if it's reflected as plain text, within an HTML attribute, within a <script> block, or
#       as a direct HTML tag (something like <script>payload</script>)
#
#   POST:
#       Calls the findForms function for a list of all the forms
#       For each form and each input within it, it injects XSS payloads into the fields value
#       Makes POST data with the payload
#       Makes a POST request to the forms action url
#       Then it checks if the payload is reflected in the POST response body and its context (like the GET one)
async def scanXSS( httpClient, targetUrl, findings, verbose=False ):

    print( f"\n[+] Running XSS Scan for: {targetUrl}" )
    parsedUrl = urlparse( targetUrl )
    queryParams = parse_qs( parsedUrl.query )
    tasks = []

    # Test GET
    if queryParams:
        for param, values in queryParams.items():
            originalValue = values[ 0 ]
            for payload in XSS_PAYLOADS:
                async def _checkGetXSS( p, ov, pl ):
                    testParams = queryParams.copy()
                    testParams[ p ] = pl
                    encodedParams = urlencode( testParams, doseq=True )
                    testUrl = parsedUrl._replace( query=encodedParams ).geturl()

                    await asyncio.sleep( RATE_LIMIT_DELAY )
                    response, _ = await makeRequest( httpClient, testUrl, verbose=verbose )
                    if response and response.status_code == 200:
                        soup = BeautifulSoup( response.text, 'html.parser' )
                        # check for reflection
                        if pl in response.text:
                            if soup.find( string=pl ):
                                addFinding( findings, "Reflected XSS", "High",
                                            f"Potential Reflected XSS found. Payload '{pl}' reflected as plain text.",
                                            testUrl, f"Parameter: {p}\nPayload: {pl}\nReflected in body." )
                            elif soup.find( lambda tag: any( pl in str( val ) for val in tag.attrs.values() if isinstance( val, ( str, list ) ) ) ):
                                addFinding( findings, "Reflected XSS", "High",
                                            f"Potential Reflected XSS found. Payload '{pl}' reflected in an HTML attribute.",
                                            testUrl, f"Parameter: {p}\nPayload: {pl}\nReflected in attribute." )
                            elif soup.find( 'script', string=re.compile( re.escape( pl ) ) ): # script tag check
                                addFinding( findings, "Reflected XSS", "High",
                                            f"Potential Reflected XSS found. Payload '{pl}' reflected in a script block.",
                                            testUrl, f"Parameter: {p}\nPayload: {pl}\nReflected in script." )
                            elif f"<script>{pl}</script>" in response.text or f"<{pl}>" in response.text:
                                addFinding( findings, "Reflected XSS", "High",
                                            f"Potential Reflected XSS found. Payload '{pl}' reflected as a direct HTML/script tag.",
                                            testUrl, f"Parameter: {p}\nPayload: {pl}\nReflected as HTML/script." )
                            else:
                                addFinding( findings, "Reflected XSS (Possible)", "Medium",
                                            f"XSS payload '{pl}' reflected in response, but specific context uncertain. Manual review recommended.",
                                            testUrl, f"Parameter: {p}\nPayload: {pl}\nRaw reflection detected." )
                            if verbose: print( f"    [!] XSS detected (GET) in {p}: {testUrl}" )
                tasks.append( _checkGetXSS( param, originalValue, payload ) )
    else:
        if verbose: print( "    [-] No GET parameters found for XSS scan." )

    # Test POST
    forms = await findForms( httpClient, targetUrl, verbose=verbose )
    if forms:
        for form in forms:
            for fieldName, defaultValue in form[ 'fields' ].items():
                for payload in XSS_PAYLOADS:
                    async def _checkPostXSS( f, fn, dv, pl ):
                        testData = f[ 'fields' ].copy()
                        testData[ fn ] = pl # inject the payload
                        await asyncio.sleep( RATE_LIMIT_DELAY )
                        response, _ = await makeRequest( httpClient, f[ 'action' ], method=f[ 'method' ], data=testData, verbose=verbose )
                        if response and response.status_code == 200:
                            soup = BeautifulSoup( response.text, 'html.parser' )
                            if pl in response.text:
                                if soup.find( string=pl ):
                                    addFinding( findings, "Reflected XSS (POST)", "High",
                                                f"Potential Reflected XSS found. Payload '{pl}' reflected as plain text in POST parameter '{fn}'.",
                                                f[ 'action' ], f"Form Action: {f[ 'action' ]}\nParameter: {fn}\nPayload: {pl}\nReflected in body." )
                                elif soup.find( lambda tag: any( pl in str( val ) for val in tag.attrs.values() if isinstance( val, ( str, list ) ) ) ):
                                    addFinding( findings, "Reflected XSS (POST)", "High",
                                                f"Potential Reflected XSS found. Payload '{pl}' reflected in an HTML attribute in POST parameter '{fn}'.",
                                                f[ 'action' ], f"Form Action: {f[ 'action' ]}\nParameter: {fn}\nPayload: {pl}\nReflected in attribute." )
                                elif soup.find( 'script', string=re.compile( re.escape( pl ) ) ):
                                    addFinding( findings, "Reflected XSS (POST)", "High",
                                                f"Potential Reflected XSS found. Payload '{pl}' reflected in a script block in POST parameter '{fn}'.",
                                                f[ 'action' ], f"Form Action: {f[ 'action' ]}\nParameter: {fn}\nPayload: {pl}\nReflected in script." )
                                elif f"<script>{pl}</script>" in response.text or f"<{pl}>" in response.text:
                                    addFinding( findings, "Reflected XSS (POST)", "High",
                                                f"Potential Reflected XSS found. Payload '{pl}' reflected as a direct HTML/script tag in POST parameter '{fn}'.",
                                                f[ 'action' ], f"Form Action: {f[ 'action' ]}\nParameter: {fn}\nPayload: {pl}\nReflected as HTML/script." )
                                else:
                                    addFinding( findings, "Reflected XSS (POST - Possible)", "Medium",
                                                f"XSS payload '{pl}' reflected in response for POST parameter '{fn}', but context uncertain. Manual review recommended.",
                                                f[ 'action' ], f"Form Action: {f[ 'action' ]}\nParameter: {fn}\nPayload: {pl}\nRaw reflection detected." )
                                if verbose: print( f"    [!] XSS detected (POST) in {fn}: {f[ 'action' ]}" )
                    tasks.append( _checkPostXSS( form, fieldName, defaultValue, payload ) )
    else:
        if verbose: print( "    [-] No forms found for POST XSS scan." )

    await asyncio.gather( *tasks )


# Scans for SQL vulnerabilities in GET and POST
# Error Based:
#       Injects commmon payloads that raise errors
#       Checks the response body against the SQL error patterns to identify the error message. Could
#       indicate a vulnerable parameter and potentially the database type.
# Boolean Based:
#       Establishes a response length for the original request
#       Sends 2 requests-- one with a payload that SHOULD result in a true SQL condition, and one with
#       a false SQL condition. Responses should differ.
#       Then it compares the response length. A significant difference for the false condition and the baseline
#       and true condition could mean a boolean based blind SQLi
# Time Based:
#       Makes a baseline response time for original request
#       Injects payloads that casue a time delay in the database when executed (like a sleep command)
#       Measures the test duration of the payload request
#       If the test duration is significantly longer than the baseline, there might be a time based blind SQLi
async def scanSQLI( httpClient, targetUrl, findings, verbose=False ):

    print( f"\n[+] Running SQL Injection Scan for: {targetUrl}" )
    parsedUrl = urlparse( targetUrl )
    queryParams = parse_qs( parsedUrl.query )
    tasks = []

    # GET
    async def _testGetParamSQLI( param, originalValue ):
        # error based
        for payload in SQLI_PAYLOADS[ "error_based" ]:
            testParams = queryParams.copy()
            testParams[ param ] = f"{originalValue}{payload}"
            encodedParams = urlencode( testParams, doseq=True )
            testUrl = parsedUrl._replace( query=encodedParams ).geturl()

            await asyncio.sleep( RATE_LIMIT_DELAY )
            response, _ = await makeRequest( httpClient, testUrl, verbose=verbose )
            if response:
                for dbType, patterns in SQL_ERROR_PATTERNS.items():
                    for pattern in patterns:
                        if re.search( pattern, response.text, re.IGNORECASE ):
                            addFinding( findings, f"SQL Injection (Error-Based - {dbType})", "High",
                                        f"Potential SQL Injection detected in GET parameter '{param}'. Detected {dbType} error.",
                                        testUrl, f"Payload: '{payload}'\nError pattern: '{pattern}' found in response." )
                            if verbose: print( f"    [!] SQLi (Error) found in GET {param} ({dbType}): {testUrl}" )
                            return

        # Boolean Based 
        baselineParams = queryParams.copy()
        baselineParams[ param ] = originalValue
        baselineUrl = parsedUrl._replace( query=urlencode( baselineParams, doseq=True ) ).geturl()
        await asyncio.sleep( RATE_LIMIT_DELAY )
        baselineResponse, _ = await makeRequest( httpClient, baselineUrl, verbose=verbose )
        if baselineResponse:
            baselineLen = len( baselineResponse.text )

            # true
            testTrueParams = queryParams.copy()
            testTrueParams[ param ] = f"{originalValue}{SQLI_PAYLOADS[ 'boolean_based_true' ][ 0 ]}"
            testTrueUrl = parsedUrl._replace( query=urlencode( testTrueParams, doseq=True ) ).geturl()
            await asyncio.sleep( RATE_LIMIT_DELAY )
            trueResponse, _ = await makeRequest( httpClient, testTrueUrl, verbose=verbose )

            # false
            testFalseParams = queryParams.copy()
            testFalseParams[ param ] = f"{originalValue}{SQLI_PAYLOADS[ 'boolean_based_false' ][ 0 ]}"
            testFalseUrl = parsedUrl._replace( query=urlencode( testFalseParams, doseq=True ) ).geturl()
            await asyncio.sleep( RATE_LIMIT_DELAY )
            falseResponse, _ = await makeRequest( httpClient, testFalseUrl, verbose=verbose )

            if trueResponse and falseResponse:
                trueLen = len( trueResponse.text )
                falseLen = len( falseResponse.text )

                if ( abs( trueLen - baselineLen ) < 0.05 * baselineLen and
                    abs( falseLen - baselineLen ) > 0.05 * baselineLen ):
                    addFinding( findings, "SQL Injection (Boolean-Based Blind)", "High",
                                f"Potential Boolean-Based Blind SQLi in GET parameter '{param}'. "
                                "Response length changed significantly for false condition.",
                                targetUrl, f"Parameter: {param}\nBaseline Length: {baselineLen}\nTrue Payload Length: {trueLen}\nFalse Payload Length: {falseLen}" )
                    if verbose: print( f"    [!] SQLi (Boolean) found in GET {param}: {targetUrl}" )
                    return

        # Time Based
        baselineResponseTime = 0
        _, baselineResponseTime = await makeRequest( httpClient, baselineUrl, verbose=verbose )

        if baselineResponseTime > 0:
            for dbTypeKey, timePayloads in SQLI_PAYLOADS.items():
                if "time_based" in dbTypeKey:
                    for payload in timePayloads:
                        testTimeParams = queryParams.copy()
                        testTimeParams[ param ] = f"{originalValue}{payload}"
                        testTimeUrl = parsedUrl._replace( query=urlencode( testTimeParams, doseq=True ) ).geturl()

                        await asyncio.sleep( RATE_LIMIT_DELAY )
                        _, testDuration = await makeRequest( httpClient, testTimeUrl, verbose=verbose )

                        if testDuration >= baselineResponseTime + TIME_BASED_THRESHOLD:
                            addFinding( findings, f"SQL Injection (Time-Based Blind - {dbTypeKey.split('_')[-1].upper()})", "High",
                                        f"Potential Time-Based Blind SQLi detected in GET parameter '{param}'. "
                                        f"Response time significantly increased for {dbTypeKey.split('_')[-1].upper()} payload.",
                                        testTimeUrl, f"Parameter: {param}\nPayload: '{payload}'\nBaseline Time: {baselineResponseTime:.2f}s\nTest Time: {testDuration:.2f}s" )
                            if verbose: print( f"    [!] SQLi (Time) found in GET {param} ({dbTypeKey.split('_')[-1].upper()}): {testTimeUrl}" )
                            return 

    if queryParams:
        for param, values in queryParams.items():
            tasks.append( _testGetParamSQLI( param, values[ 0 ] ) )
    else:
        if verbose: print( "    [-] No GET parameters found for SQLi scan." )

    # POST
    forms = await findForms( httpClient, targetUrl, verbose=verbose )
    if forms:
        for form in forms:
            for fieldName, defaultValue in form[ 'fields' ].items():
                async def _testPostParamSQLI( f, fn, dv ):
                    # Error based
                    for payload in SQLI_PAYLOADS[ "error_based" ]:
                        testData = f[ 'fields' ].copy()
                        testData[ fn ] = f"{dv}{payload}"
                        await asyncio.sleep( RATE_LIMIT_DELAY )
                        response, _ = await makeRequest( httpClient, f[ 'action' ], method=f[ 'method' ], data=testData, verbose=verbose )
                        if response:
                            for dbType, patterns in SQL_ERROR_PATTERNS.items():
                                for pattern in patterns:
                                    if re.search( pattern, response.text, re.IGNORECASE ):
                                        addFinding( findings, f"SQL Injection (Error-Based - {dbType})", "High",
                                                    f"Potential SQL Injection detected in POST parameter '{fn}'. Detected {dbType} error.",
                                                    f[ 'action' ], f"Form Action: {f[ 'action' ]}\nParameter: {fn}\nPayload: '{payload}'\nError pattern: '{pattern}' found." )
                                        if verbose: print( f"    [!] SQLi (Error) found in POST {fn} ({dbType}): {f[ 'action' ]}" )
                                        return

                    # Boolean Based
                    baselineData = f[ 'fields' ].copy()
                    baselineData[ fn ] = defaultValue
                    await asyncio.sleep( RATE_LIMIT_DELAY )
                    baselineResponse, _ = await makeRequest( httpClient, f[ 'action' ], method=f[ 'method' ], data=baselineData, verbose=verbose )
                    if baselineResponse:
                        baselineLen = len( baselineResponse.text )

                        testTrueData = f[ 'fields' ].copy()
                        testTrueData[ fn ] = f"{defaultValue}{SQLI_PAYLOADS[ 'boolean_based_true' ][ 0 ]}"
                        await asyncio.sleep( RATE_LIMIT_DELAY )
                        trueResponse, _ = await makeRequest( httpClient, f[ 'action' ], method=f[ 'method' ], data=testTrueData, verbose=verbose )

                        testFalseData = f[ 'fields' ].copy()
                        testFalseData[ fn ] = f"{defaultValue}{SQLI_PAYLOADS[ 'boolean_based_false' ][ 0 ]}"
                        await asyncio.sleep( RATE_LIMIT_DELAY )
                        falseResponse, _ = await makeRequest( httpClient, f[ 'action' ], method=f[ 'method' ], data=testFalseData, verbose=verbose )

                        if trueResponse and falseResponse:
                            trueLen = len( trueResponse.text )
                            falseLen = len( falseResponse.text )

                            if ( abs( trueLen - baselineLen ) < 0.05 * baselineLen and
                                abs( falseLen - baselineLen ) > 0.05 * baselineLen ):
                                addFinding( findings, "SQL Injection (Boolean-Based Blind - POST)", "High",
                                            f"Potential Boolean-Based Blind SQLi in POST parameter '{fn}'. "
                                            "Response length changed significantly for false condition.",
                                            f[ 'action' ], f"Form Action: {f[ 'action' ]}\nParameter: {fn}\nBaseline Length: {baselineLen}\nTrue Payload Length: {trueLen}\nFalse Payload Length: {falseLen}" )
                                if verbose: print( f"    [!] SQLi (Boolean) found in POST {fn}: {f[ 'action' ]}" )
                                return

                    # Time Based
                    baselinePostResponseTime = 0
                    _, baselinePostResponseTime = await makeRequest( httpClient, f[ 'action' ], method=f[ 'method' ], data=baselineData, verbose=verbose )

                    if baselinePostResponseTime > 0:
                        for dbTypeKey, timePayloads in SQLI_PAYLOADS.items():
                            if "time_based" in dbTypeKey:
                                for payload in timePayloads:
                                    testTimeData = f[ 'fields' ].copy()
                                    testTimeData[ fn ] = f"{defaultValue}{payload}"
                                    await asyncio.sleep( RATE_LIMIT_DELAY )
                                    _, testDuration = await makeRequest( httpClient, f[ 'action' ], method=f[ 'method' ], data=testTimeData, verbose=verbose )

                                    if testDuration >= baselinePostResponseTime + TIME_BASED_THRESHOLD:
                                        addFinding( findings, f"SQL Injection (Time-Based Blind - {dbTypeKey.split('_')[-1].upper()} - POST)", "High",
                                                    f"Potential Time-Based Blind SQLi detected in POST parameter '{fn}'. "
                                                    f"Response time significantly increased for {dbTypeKey.split('_')[-1].upper()} payload.",
                                                    f[ 'action' ], f"Form Action: {f[ 'action' ]}\nParameter: {fn}\nPayload: '{payload}'\nBaseline Time: {baselinePostResponseTime:.2f}s\nTest Time: {testDuration:.2f}s" )
                                        if verbose: print( f"    [!] SQLi (Time) found in POST {fn} ({dbTypeKey.split('_')[-1].upper()}): {f[ 'action' ]}" )
                                        return

                tasks.append( _testPostParamSQLI( form, fieldName, defaultValue ) )
    else:
        if verbose: print( "    [-] No forms found for POST SQLi scan." )

    await asyncio.gather( *tasks )

# Scans for common misconfigurations like exposed files or directories 
# It iterates through the list of sensitive paths, and constructs a url for them and makes a GET request
# If the response is HTTP 200 OK, it checks for:
#   Keywords like "Index of /" or "Directory listing for /" in response title or body. This could reveal the
#   file structure of some critical files
#   Existence of sensitive files. Getting a 200 OK for things like /.env or /admin could be bad
async def scanSecurityMisconfiguration( httpClient, targetUrl, findings, verbose=False ):

    print( f"\n[+] Running Security Misconfiguration Scan for: {targetUrl}" )
    tasks = []

    async def _checkPath( pathSuffix ):
        fullUrl = urljoin( targetUrl, pathSuffix )
        await asyncio.sleep( RATE_LIMIT_DELAY )
        response, _ = await makeRequest( httpClient, fullUrl, verbose=verbose )
        if response and response.status_code == 200:
            if "Index of /" in response.text or "<title>Directory listing for /</title>" in response.text:
                addFinding( findings, "Security Misconfiguration", "High",
                            f"Directory listing enabled for: {pathSuffix}. This can expose sensitive files.",
                            fullUrl, f"HTTP Status: {response.status_code}\nContent: Directory listing detected." )
                if verbose: print( f"    [!] Directory listing found: {fullUrl}" )
            else:
                addFinding( findings, "Security Misconfiguration", "Medium",
                            f"Potentially sensitive file/directory found: {pathSuffix}. Review for sensitive information.",
                            fullUrl, f"HTTP Status: {response.status_code}\nFirst 200 chars: {response.text[:200]}..." )
                if verbose: print( f"    [!] Sensitive path found: {fullUrl}" )
        else:
            if verbose: print( f"    [.] No sensitive content at {fullUrl} (Status: {response.status_code if response else 'N/A'})" )

    for path in SENSITIVE_PATHS:
        tasks.append( _checkPath( path ) )

    await asyncio.gather( *tasks )

# Scans for potentially vulnerable TLS and SSL versions.
# Iterates between each TLS versions 1.0-1.3 and tries a test handshake.
# Uses a raw socket and SSL context to enforce the requested TLS version.
async def scanTLS( targetUrl, findings, verbose=False ):
    print( f"\n[+] Running TLS/SSL Version Scan for: {targetUrl}" )

    parsed = urlparse( targetUrl )
    host = parsed.hostname
    if parsed.port:
        port = parsed.port
    elif parsed.scheme == "https":
        port = 443
    elif parsed.scheme == "http":
        port = 80
    else:
        port = 443

    protocols = [
        ( ssl.TLSVersion.TLSv1, "TLSv1.0", "High", "TLSv1.0 is deprecated and insecure. It should be disabled." ),
        ( ssl.TLSVersion.TLSv1_1, "TLSv1.1", "Medium", "TLSv1.1 is deprecated and insecure. It should be disabled." ),
        ( ssl.TLSVersion.TLSv1_2, "TLSv1.2", None, None ), 
        ( ssl.TLSVersion.TLSv1_3, "TLSv1.3", None, None ),
    ]

    for version, name, severity, description in protocols:
        try:
            context = ssl.SSLContext( ssl.PROTOCOL_TLS_CLIENT )
            context.minimum_version = version
            context.maximum_version = version
            context.check_hostname = False
            context.verify_mode = ssl.CERT_NONE

            with socket.create_connection( ( host, port ), timeout=5 ) as sock:
                with context.wrap_socket( sock, server_hostname=host ) as ssock:
                    negotiated = ssock.version()
                    if negotiated:
                        if negotiated == name:
                            if verbose:
                                print( f"    [+] {name} supported (negotiated: {negotiated})" )
                            if severity is not None and description is not None:
                                addFinding(
                                    findings,
                                    "Cryptographic Failure",
                                    severity,
                                    description,
                                    targetUrl,
                                    f"Server supports {name}."
                                )
                        else:
                            if verbose:
                                print( f"    [-] {name} not strictly supported (negotiated: {negotiated})" )
                    else:
                        if verbose:
                            print( f"    [-] {name} not supported (no TLS version negotiated)" )
        except Exception as e:
            if verbose:
                print( f"    [-] {name} not supported ({e})" )

# Tries to log into the target and maintain the session 
# Should be a string like "username={username}&password={password}&submit=Login"
# Formats the login data template with username and password
# Then it converts the login data string into a dictionary suitable for a POST request and
# makes a POST request to the login url.
# It uses a very basic heuristic to see if the login was successful. Checking for common words like
# "logout" or "welcome" or "dashboard" in the response.
# If it's successful the instance with maintain the session cookies so that the other scan modules can run 
# with this context.
async def performLogin( httpClient, loginUrl, username, password, loginDataTemplate, verbose=False ):

    print( f"\n[+] Attempting to log in to: {loginUrl}" )
    loginData = loginDataTemplate.format( username=username, password=password )
    loginDataDict = parse_qs( loginData )
    loginDataForPost = { k: v[ 0 ] for k, v in loginDataDict.items() }

    await asyncio.sleep( RATE_LIMIT_DELAY )
    response, _ = await makeRequest( httpClient, loginUrl, method="POST", data=loginDataForPost, allowRedirects=True, verbose=verbose )

    if response and response.status_code == 200:
        if "logout" in response.text.lower() or "welcome" in response.text.lower() or "dashboard" in response.url.lower():
            print( f"    [+] Login successful to {loginUrl}. Session will be used for subsequent scans." )
            return True
        else:
            print( f"    [-] Login to {loginUrl} failed. Response content doesn't indicate success." )
            if verbose: print( f"        Response text (first 200 chars):\n{response.text[:200]}..." )
    else:
        print( f"    [-] Login to {loginUrl} failed. HTTP Status: {response.status_code if response else 'N/A'}" )
    return False



async def main():
    # parse the arguments
    parser = argparse.ArgumentParser(
        description="Web Vulnerability Scanner",
        formatter_class=argparse.RawTextHelpFormatter
    )
    parser.add_argument(
        "-u", "--url",
        help="Target URL to scan (e.g., https://example.com/index.php?id=1)",
        required=True
    )
    parser.add_argument(
        "-v", "--verbose",
        action="store_true",
        help="Enable verbose output for debugging."
    )
    parser.add_argument(
        "--login-url",
        help="URL of the login page (e.g., https://example.com/login.php)"
    )
    parser.add_argument(
        "--username",
        help="Username for authentication (if --login-url is provided)"
    )
    parser.add_argument(
        "--password",
        help="Password for authentication (if --login-url is provided)"
    )
    parser.add_argument(
        "--login-data",
        help="Template for POST data for login. Use {username} and {password} placeholders.\n"
             "Example: 'user={username}&pass={password}&submit=Login'"
    )

    args = parser.parse_args()
    targetUrl = args.url
    verbose = args.verbose

    # validate url format
    try:
        parsedUrl = urlparse( targetUrl )
        if not all( [ parsedUrl.scheme, parsedUrl.netloc ] ):
            raise ValueError( "Invalid URL format. Please include scheme (http:// or https://)." )
    except ValueError as e:
        print( f"Error: {e}" )
        sys.exit( 1 )

    print( f"\n{'-'*50}" )
    print( f"Starting Web Vulnerability Scan for: {targetUrl}" )
    print( f"Rate Limit Delay: {RATE_LIMIT_DELAY} seconds per request" )
    print( f"{'-'*50}\n" )

    findings = []
    detected_server_banners = []

    async with httpx.AsyncClient( timeout=DEFAULT_TIMEOUT ) as httpClient:
        # do login if credentials are given
        if args.login_url and args.username and args.password and args.login_data:
            loginSuccess = await performLogin( httpClient, args.login_url, args.username, args.password, args.login_data, verbose )
            if not loginSuccess:
                print( "Login failed. Proceeding with unauthenticated scan." )
        elif args.login_url or args.username or args.password or args.login_data:
            print( "To perform an authenticated scan, you must provide --login-url, --username, --password, AND --login-data." )
            print( "    Proceeding with unauthenticated scan." )

        # Run the scans
        detected_server_banners = await scanInfoGathering( httpClient, targetUrl, findings, verbose, detected_server_banners )

        # Call the NVD scanner with the collected server banner info
        await scanNVD( httpClient, targetUrl, detected_server_banners, findings, verbose )

        await scanTLS( targetUrl, findings, verbose )
        await scanXSS( httpClient, targetUrl, findings, verbose )
        await scanSQLI( httpClient, targetUrl, findings, verbose )
        await scanSecurityMisconfiguration( httpClient, targetUrl, findings, verbose )

    print( "\nScan finished." )
    if findings:
        print( f"\n[!!!!] Found {len( findings )} potential issues or information disclosures. [!!!!]" )
 
        # Separate CVE findings and site findings
        cve_findings = [f for f in findings if f.get('type') == 'Known Vulnerability (CVE)']
        other_findings = [f for f in findings if f.get('type') != 'Known Vulnerability (CVE)']

        severityOrder = { "Critical": 0, "High": 1, "Medium": 2, "Low": 3, "Informational": 4, "Unknown": 5 }

        if other_findings:
            print( "\n\n" + "="*20 + " Main Vulnerability Report " + "="*20 )
            # Sort findings
            other_findings_sorted = sorted( other_findings, key=lambda x: severityOrder.get( x['severity'].capitalize(), 99 ) )

            for i, finding in enumerate( other_findings_sorted ):
                print( f"\n--- Finding {i+1} ---" )
                print( f"Type: {finding.get( 'type', 'N/A' )}" )
                print( f"Severity: {finding.get( 'severity', 'N/A' )}" )
                print( f"Description: {finding.get( 'description', 'N/A' )}" )
                print( f"URL: {finding.get( 'url', 'N/A' )}" )
                print( f"Evidence:\n{finding.get( 'evidence', 'N/A' )}" )
                print( "-" * 30 )

        if cve_findings:
            print( "\n\n" + "="*20 + " Associated CVE Report " + "="*23 )
            # Sort CVEs 
            cve_findings_sorted = sorted(cve_findings, key=lambda x: severityOrder.get(x['severity'].capitalize(), 99))

            for i, finding in enumerate( cve_findings_sorted ):
                print( f"\n--- CVE Finding {i+1} ---" )
                print( f"Type: {finding.get( 'type', 'N/A' )}" )
                print( f"Severity: {finding.get( 'severity', 'N/A' )}" )
                print( f"Description: {finding.get( 'description', 'N/A' )}" )
                print( f"Technology URL: {finding.get( 'url', 'N/A' )}" )
                print( f"Evidence:\n{finding.get( 'evidence', 'N/A' )}" )
                print( "-" * 30 )
        
        print( "\n\n" + "="*27 + " End Report " + "="*28 )
    else:
        print( "\nNo significant vulnerabilities or information found." )