<?php

namespace CleantalkSP\SpbctWP\Firewall;

use CleantalkSP\Common\Helpers\HTTP;
use CleantalkSP\SpbctWP\Helpers\Helper;
use CleantalkSP\Variables\Server;
use CleantalkSP\Security\Firewall\Result;

class WAF extends FirewallModule
{
    public $module_name = 'WAF';

    protected $waf__suspicious_check = true;
    protected $waf__xss_check = false;
    protected $waf__sql_check = false;
    protected $waf__exploit_check = false;

    private $waf_sql_signatures = array();
    private $waf_exploit_signatures = array();
    private $waf_xss_signatures = array();
    private $waf_suspicious_signatures = array();

    /**
     * @var WafBlocker
     */
    protected $waf_blocker;

    /**
     * @psalm-suppress PossiblyUnusedProperty
     */
    protected $api_key = false;

    /**
     * FireWall_module constructor.
     * Use this method to prepare any data for the module working.
     *
     * @param array $params
     */
    public function __construct($params = array())
    {
        parent::__construct($params);
    }

    /**
     * @inheritDoc
     */
    public function check()
    {
        $results = array();

        // Get signatures from DB
        $signatures = $this->db->fetchAll(
            'SELECT * FROM ' . SPBC_TBL_SCAN_SIGNATURES . ' WHERE type = "WAF_RULE";',
            ARRAY_A
        );

        if ( $signatures ) {
            foreach ( $signatures as $signature ) {
                switch ( $signature['attack_type'] ) {
                    case 'SQL_INJECTION':
                        $this->waf_sql_signatures[] = $signature;
                        break;
                    case 'XSS':
                        $this->waf_xss_signatures[] = $signature;
                        break;
                    case 'EXPLOIT':
                        $this->waf_exploit_signatures[] = $signature;
                        break;
                    case 'SUSPICIOUS':
                        $this->waf_suspicious_signatures[] = $signature;
                        break;
                }
            }
        }

        $data_array = array();
        $query_str = '';

        if (Server::get('QUERY_STRING', null, 'url')) {
            $query_str = urldecode(Server::get('QUERY_STRING', null, 'url'));
        } else if (@file_get_contents('php://input')) {
            $query_str = file_get_contents('php://input');
        }

        if ( $_POST || $_GET || $_COOKIE ) {
            $data_array = array($_POST, $_GET, $_COOKIE);
        } else if (@file_get_contents('php://input')) {
            $data_flow_row['data_flow_row'] = file_get_contents('php://input');
            $data_array = array($data_flow_row);
        }

        $results[] = $this->waf__suspicious_check
            ? $this->wafSuspiciousCheck($data_array)
            : false;
        $results[] = $this->waf__xss_check
            ? $this->wafXssCheck($data_array)
            : false;
        $results[] = $this->waf__sql_check
            ? $this->wafSqlCheck($data_array)
            : false;
        $results[] = $this->waf__exploit_check
            ? $this->wafExploitCheck($query_str)
            : false;

        // Adding common parameters to results
        foreach ( $results as $key => &$result ) {
            // Cleaning from "false" values
            if ( $result === false ) {
                unset($results[$key]);
                continue;
            }
        }
        unset($result);

        // Set a PASS  result if no results from DB
        if ( empty($results) ) {
            $results[] = new Result(
                array(
                    'module' => $this->module_name,
                    'ip'     => end($this->ip_array),
                    'status' => 'PASS',
                )
            );
            if ( $this->waf_blocker ) {
                // Try to clear old data by the clean visitors
                $this->waf_blocker->clearTable();
            }
        } elseif ( $this->waf_blocker ) {
            // Update TC log only on WAF-triggered request
            $this->waf_blocker->updateLogs();
        }

        return $results;
    }

    /**
     * Checks an array for suspicious signatures entry
     *
     * @param array $arr
     *
     * @return false|Result
     */
    private function wafSuspiciousCheck($arr)
    {
        foreach ( $arr as $_name => $param ) {
            // Recursion
            if ( is_array($param) ) {
                $result = $this->wafSuspiciousCheck($param);
                if ( $result !== false ) {
                    return $result;
                }
                continue;
            }

            //Check
            foreach ( $this->waf_suspicious_signatures as $signature ) {
                if ( self::hasSignature($param, $signature) ) {
                    return new Result(
                        array(
                            'module'        => 'WAF',
                            'ip'            => end($this->ip_array),
                            'status'        => 'PASS',
                            'pattern'       => array('suspicious' => $signature['body']),
                            'triggered_for' => $param,
                            'waf_action'    => 'LOG',
                            'signature_id'  => $signature['id'],
                        )
                    );
                }
            }
        }

        return false;
    }

    /**
     * Checks array for XSS-attack patterns
     *
     * @param $arr
     *
     * @return false|Result
     */
    private function wafXssCheck($arr)
    {
        foreach ( $arr as $_name => $param ) {
            // Recursion
            if ( is_array($param) ) {
                $result = $this->wafXssCheck($param);
                if ( $result !== false ) {
                    return $result;
                }
                continue;
            }

            //Check
            foreach ( $this->waf_xss_signatures as $signature ) {
                if ( self::hasSignature($param, $signature) ) {
                    return new Result(
                        array(
                            'module'        => 'WAF',
                            'ip'            => end($this->ip_array),
                            'status'        => 'DENY_BY_WAF_XSS',
                            'pattern'       => array('critical' => $signature['body']),
                            'triggered_for' => $param,
                            'waf_action'    => 'DENY',
                            'signature_id'  => $signature['id'],
                        )
                    );
                }
            }
        }

        return false;
    }

    /**
     * Checks array for SQL injections
     *
     * @param $arr
     *
     * @return false|Result
     */
    private function wafSqlCheck($arr)
    {
        foreach ( $arr as $_name => $param ) {
            if ( is_array($param) ) {
                $result = $this->wafSqlCheck($param);
                if ( $result !== false ) {
                    return $result;
                }
                continue;
            }

            foreach ( $this->waf_sql_signatures as $signature ) {
                if ( self::hasSignature($param, $signature) ) {
                    return new Result(
                        array(
                            'module'        => 'WAF',
                            'ip'            => end($this->ip_array),
                            'status'        => 'DENY_BY_WAF_SQL',
                            'pattern'       => array('critical' => $signature['body']),
                            'triggered_for' => $param,
                            'waf_action'    => 'DENY',
                            'signature_id'  => $signature['id'],
                        )
                    );
                }
            }
        }

        return false;
    }

    /**
     * Checks given string for exploits
     *
     * @param string $string
     *
     * @return false|Result
     */
    private function wafExploitCheck($string)
    {
        foreach ( $this->waf_exploit_signatures as $signature ) {
            if ( self::hasSignature($string, $signature) ) {
                return new Result(
                    array(
                        'module'        => 'WAF',
                        'ip'            => end($this->ip_array),
                        'status'        => 'DENY_BY_WAF_EXPLOIT',
                        'pattern'       => array('critical' => $signature['body']),
                        'triggered_for' => 'query_string',
                        'waf_action'    => 'DENY',
                        'signature_id'  => $signature['id'],
                    )
                );
            }
        }

        return false;
    }

    public function middleAction(Result $waf_result)
    {
        if ( $waf_result->waf_action === 'LOG' ) {
            $this->log($waf_result);
        }
    }

    public function log(Result $waf_result)
    {
        //single quote escaping
        foreach ( $waf_result->pattern as &$pattern ) {
            $pattern = str_replace(array("'", '"'), array("ESC_S_QUOTE", "ESC_D_QUOTE"), $pattern);
        }
        unset($pattern);

        // Truncate before escaping — addslashes-then-substr left a dangling \ at the cut (SQLi).
        $pattern         = ! empty($waf_result->pattern)
            ? json_encode($waf_result->pattern)
            : '';
        $triggered_for   = ! empty($waf_result->triggered_for)
            ? substr($waf_result->triggered_for, 0, 100)
            : '';
        $page_url        = substr(
            (Server::getString('HTTPS') !== 'off' ? 'https://' : 'http://')
            . Server::getString('HTTP_HOST')
            . Server::get('REQUEST_URI', null, 'url'),
            0,
            4096
        );
        $http_user_agent = Server::getString('HTTP_USER_AGENT')
            ? htmlspecialchars(substr(Server::getString('HTTP_USER_AGENT'), 0, 300))
            : 'unknown';
        $ip              = (string) $waf_result->ip;
        $time            = time();
        $status          = (string) $waf_result->status;
        $request_method  = substr(Server::getString('REQUEST_METHOD'), 0, 5);
        $x_forwarded_for = htmlspecialchars(substr(Server::getString('HTTP_X_FORWARDED_FOR'), 0, 15));
        $network         = ($waf_result->network === '' || $waf_result->network === null)
            ? ''
            : (string) $waf_result->network;
        $mask            = ($waf_result->mask === '' || $waf_result->mask === null)
            ? ''
            : (string) $waf_result->mask;
        $is_personal     = (int) $waf_result->is_personal;
        $country_code    = (string) $waf_result->country_code;
        $id              = md5($waf_result->ip . $http_user_agent . $waf_result->status . $waf_result->waf_action);
        $signature_id    = (int) $waf_result->signature_id;

        $query = "INSERT INTO " . SPBC_TBL_FIREWALL_LOG
                 . " SET
                entry_id        = %s,
				ip_entry        = %s,
				entry_timestamp = %d,
				status          = %s,
				pattern         = NULLIF(%s, ''),
				signature_id    = NULLIF(%d, 0),
				triggered_for   = NULLIF(%s, ''),
				requests        = 1,
				page_url        = %s,
				http_user_agent = %s,
				request_method  = %s,
				x_forwarded_for = NULLIF(%s, ''),
				network         = NULLIF(%s, ''),
				mask            = NULLIF(%s, ''),
				country_code    = NULLIF(%s, ''),
				is_personal     = %d
			ON DUPLICATE KEY UPDATE
				ip_entry        = ip_entry,
				entry_timestamp = %d,
				status          = %s,
				pattern         = NULLIF(%s, ''),
				signature_id    = NULLIF(%d, 0),
				triggered_for   = NULLIF(%s, ''),
				requests        = requests + 1,
				page_url        = %s,
				http_user_agent = http_user_agent,
				request_method  = %s,
				x_forwarded_for = NULLIF(%s, ''),
				network         = NULLIF(%s, ''),
				mask            = NULLIF(%s, ''),
				country_code    = NULLIF(%s, ''),
				is_personal     = %d";

        $this->db->prepare(
            $query,
            array(
                $id,
                $ip,
                $time,
                $status,
                $pattern,
                $signature_id,
                $triggered_for,
                $page_url,
                $http_user_agent,
                $request_method,
                $x_forwarded_for,
                $network,
                $mask,
                $country_code,
                $is_personal,
                $time,
                $status,
                $pattern,
                $signature_id,
                $triggered_for,
                $page_url,
                $request_method,
                $x_forwarded_for,
                $network,
                $mask,
                $country_code,
                $is_personal,
            )
        )->execute();
    }

    /**
     * The method checks for the presence of a signature in data
     */
    private static function hasSignature($data, $signature)
    {
        $signature_body             = $signature['body'];
        $signature_waf_headers      = $signature['waf_headers']; // json
        $signature_waf_url          = $signature['waf_url']; // string
        $what_to_check_additionally = self::whatToCheckAdditionally($signature);
        $result_signature_body      = false;
        $result_additionally_params = false;

        // Skip catch-all regex without headers/URL (e.g. #.*# with empty waf_headers) — mass FP
        if ( self::isUnsafeCatchAllSignature($signature_body, $what_to_check_additionally) ) {
            return false;
        }

        $is_regexp = Helper::isRegexp($signature_body);

        if ( $is_regexp && preg_match($signature_body, $data) ) {
            $result_signature_body = true;
        }

        if ( ! $is_regexp && stripos($data, $signature_body) !== false ) {
            $result_signature_body = true;
        }

        // Result with bad $signature_body and empty additional params
        if ( $result_signature_body && $what_to_check_additionally === 'nothing' ) {
            return true;
        }

        // Checking additionally params
        if ( $result_signature_body && $what_to_check_additionally !== 'nothing' ) {
            switch ( $what_to_check_additionally ) {
                case 'all':
                    $result_check_signature_waf_headers = self::compareSignatureWithHeaders($signature_waf_headers);
                    $result_check_signature_waf_url     = self::compareSignatureWithUrl($signature_waf_url);
                    if ( $result_check_signature_waf_headers && $result_check_signature_waf_url ) {
                        $result_additionally_params = true;
                    }
                    break;
                case 'only_headers':
                    $result_check_signature_waf_headers = self::compareSignatureWithHeaders($signature_waf_headers);
                    if ( $result_check_signature_waf_headers ) {
                        $result_additionally_params = true;
                    }
                    break;
                case 'only_url':
                    $result_check_signature_waf_url = self::compareSignatureWithUrl($signature_waf_url);
                    if ( $result_check_signature_waf_url ) {
                        $result_additionally_params = true;
                    }
                    break;
            }

            if ( $result_additionally_params ) {
                return true;
            }
        }

        return false;
    }

    /**
     * Detect catch-all regex WAF rules that have no headers/URL constraints.
     * Such rules (e.g. body "#.*#" with empty waf_headers) match every request
     * and cause mass false positives.
     *
     * @param string $signature_body
     * @param string $what_to_check_additionally
     *
     * @return bool
     */
    private static function isUnsafeCatchAllSignature($signature_body, $what_to_check_additionally)
    {
        if ( $what_to_check_additionally !== 'nothing' ) {
            return false;
        }

        if ( ! is_string($signature_body) || ! Helper::isRegexp($signature_body) ) {
            return false;
        }

        // Matches empty string and an arbitrary probe → pattern accepts any input
        $probe = 'spbct_waf_probe_catchall_check';

        return @preg_match($signature_body, '') === 1
            && @preg_match($signature_body, $probe) === 1;
    }

    /**
     * Compare signature headers and request headers
     *
     * @param $signature_waf_headers
     *
     * @return bool
     */
    private static function compareSignatureWithHeaders($signature_waf_headers)
    {
        if ( empty($signature_waf_headers) || $signature_waf_headers === 'undefined' ) {
            return false;
        }

        // Additional params for checking
        $request_headers       = HTTP::getHTTPHeaders(); // array
        $signature_waf_headers = json_decode($signature_waf_headers, true);

        if ( ! is_array($signature_waf_headers) ) {
            return false;
        }

        $result_comparing_headers = array_intersect_assoc($request_headers, $signature_waf_headers);

        if ( ! empty($result_comparing_headers) ) {
            return true;
        }

        return false;
    }

    /**
     * Compare signature URL and request URL
     *
     * @param $signature_waf_url
     *
     * @return bool
     */
    private static function compareSignatureWithUrl($signature_waf_url)
    {
        if ( empty($signature_waf_url) || $signature_waf_url === 'undefined' ) {
            return false;
        }

        // Additional params for checking
        $request_url = Server::getURL(); // string

        return strpos($request_url, $signature_waf_url) !== false;
    }

    /**
     * @param $signature
     *
     * @return string
     */
    private static function whatToCheckAdditionally($signature)
    {
        $signature_waf_headers = ! empty($signature['waf_headers']) && $signature['waf_headers'] !== 'undefined' ? $signature['waf_headers'] : false;
        $signature_waf_url     = ! empty($signature['waf_url']) && $signature['waf_url'] !== 'undefined' ? $signature['waf_url'] : false;

        if ( $signature_waf_headers && $signature_waf_url ) {
            return 'all';
        }

        if ( $signature_waf_headers ) {
            return 'only_headers';
        }

        if ( $signature_waf_url ) {
            return 'only_url';
        }

        return 'nothing';
    }
}
