<?php

/**
 * Code related to the sitecheck.lib.php interface.
 *
 * PHP version 5
 *
 * @category   Library
 * @package    Sucuri
 * @subpackage SucuriScanner
 * @author     Daniel Cid <dcid@sucuri.net>
 * @copyright  2010-2018 Sucuri Inc.
 * @license    https://www.gnu.org/licenses/gpl-2.0.txt GPL2
 * @link       https://wordpress.org/plugins/sucuri-scanner
 */

if (!defined('SUCURISCAN_INIT') || SUCURISCAN_INIT !== true) {
    if (!headers_sent()) {
        /* Report invalid access if possible. */
        header('HTTP/1.1 403 Forbidden');
    }
    exit(1);
}

/**
 * Controls the execution of the SiteCheck scanner.
 *
 * SiteCheck is a web application scanner that reads the source code of a
 * website to determine if it is serving malicious code, it scans the home page
 * and linked sub-pages, then compares the results with a list of signatures as
 * well as a list of blocklist services to see if other malware scanners have
 * flagged the website before. This operation may take a couple of seconds,
 * around twenty seconds in most cases; be sure to set enough timeout for the
 * operation to finish, otherwise the scanner will return innacurate
 * information.
 *
 * @category   Library
 * @package    Sucuri
 * @subpackage SucuriScanner
 * @author     Daniel Cid <dcid@sucuri.net>
 * @copyright  2010-2018 Sucuri Inc.
 * @license    https://www.gnu.org/licenses/gpl-2.0.txt GPL2
 * @link       https://wordpress.org/plugins/sucuri-scanner
 * @see        https://sitecheck.sucuri.net/
 */
class SucuriScanSiteCheck extends SucuriScanAPI
{
    /**
     * Returns the URL that will be scanned by SiteCheck.
     *
     * @return string URL to be scanned.
     */
    private static function targetURL()
    {
        /* allow to set a custom URL for the scans */
        $custom = SucuriScanOption::getOption(':sitecheck_target');

        if ($custom) {
            return $custom;
        }

        return SucuriScan::getDomain();
    }

    /**
     * Executes a malware scan against the specified website.
     *
     * @see https://sitecheck.sucuri.net/
     *
     * @param  bool $clear Request the results from a fresh scan or not.
     * @return array|bool  JSON encoded website scan results.
     */
    public static function runMalwareScan($clear = false)
    {
        $params = array();
        $params['json'] = 1;
        $params['fromwp'] = 2;
        $params['scan'] = self::targetURL();

        /* force clear scan */
        if ($clear === true) {
            $params['clear'] = 1;
        }

        $args = array('assoc' => true, 'timeout' => 60);

        return self::apiCall('https://sitecheck.sucuri.net/', 'GET', $params, $args);
    }

    /**
     * Scans a website for malware using SiteCheck.
     *
     * This method will first check if the scan results have been cached by a
     * previous scan. The lifetime of the cache is defined in the global script
     * but usually it should not be higher than fifteen minutes. If the cache
     * exists it will be used to display the information in the dashboard.
     *
     * If the cache does not exists or has already expired, it will send a HTTP
     * request to the SiteCheck API service to execute a fresh scan, this takes
     * around twenty seconds, it will decode and process the response and render
     * the results in the dashboard.
     *
     * If the user sends a GET parameter named "s" with a valid domain name, it
     * will be used instead of the one of the current website. This is useful if
     * you want to test the functionality of the scanner in a different website
     * without access to its domain, which is basically the same thing that you
     * can do in the official SiteCheck website. This parameter also bypasses
     * the cache.
     *
     * @return array|bool SiteCheck scan results.
     */
    public static function scanAndCollectData()
    {
        $cache = new SucuriScanCache('sitecheck');

        if (SucuriScanRequest::post(':sitecheck_refresh') === 'true') {
            /* user requested to reset the sitecheck cache */
            $cache->delete('scan_results');
        }

        $results = $cache->get('scan_results', SUCURISCAN_SITECHECK_LIFETIME, 'array');

        /* return cached malware scan results. */
        if ($results && !empty($results)) {
            return $results;
        }

        /* delete expired cache */
        $cache->delete('scan_results');

        /* send HTTP request to SiteCheck's API service. */
        $results = self::runMalwareScan(true);

        /* check for error in the request's response. */
        if (is_string($results) || isset($results['SYSTEM']['ERROR'])) {
            if (isset($results['SYSTEM']['ERROR'])) {
                $results = implode("\x20", $results['SYSTEM']['ERROR']);
            }

            return SucuriScanInterface::error('SiteCheck error: ' . $results);
        }

        /* cache the results for some time. */
        $cache->add('scan_results', $results);

        return $results;
    }

    /**
     * Returns the amount of time left before the SiteCheck cache expires.
     *
     * @return string Time left before the SiteCheck cache expires.
     */
    private static function cacheLifetime()
    {
        $current = time();
        $cache = new SucuriScanCache('sitecheck');
        $timeDiff = $current - $cache->updatedAt();
        $timeLeft = SUCURISCAN_SITECHECK_LIFETIME - $timeDiff;

        return self::humanTime($current + $timeLeft);
    }

    /**
     * Generates the HTML section for the SiteCheck details.
     *
     * @return string HTML code to render the details section.
     */
    public static function details()
    {
        $params = array();
        $data = self::scanAndCollectData();
        $data['details'] = array();

        $params['SiteCheck.Metadata'] = '';
        $params['SiteCheck.Lifetime'] = self::cacheLifetime();

        $data['details'][] = sprintf(__('PHP Version: %s', 'sucuri-scanner'), phpversion());
        $data['details'][] = sprintf(__('Version: %s', 'sucuri-scanner'), SucuriScan::siteVersion());

        if (isset($data['SCAN']['SITE'])) {
            $params['SiteCheck.Website'] = $data['SCAN']['SITE'][0];
        }

        if (isset($data['SCAN']['IP'])) {
            $params['SiteCheck.ServerAddress'] = $data['SCAN']['IP'][0];
        }

        if (isset($data['SCAN']['HOSTING'])) {
            $data['details'][] = sprintf(__('Hosting: %s', 'sucuri-scanner'), apply_filters( 'sucuriscan_sitecheck_details_hosting', $data['SCAN']['HOSTING'][0] ));
        }

        if (isset($data['SCAN']['CMS'])) {
            $data['details'][] = sprintf(__('CMS: %s', 'sucuri-scanner'), $data['SCAN']['CMS'][0]);
        }

        if (isset($data['SYSTEM']['NOTICE'])) {
            $data['details'] = array_merge(
                $data['details'],
                $data['SYSTEM']['NOTICE']
            );
        }

        if (isset($data['SYSTEM']['INFO'])) {
            $data['details'] = array_merge(
                $data['details'],
                $data['SYSTEM']['INFO']
            );
        }

        if (isset($data['WEBAPP']['VERSION'])) {
            $data['details'] = array_merge(
                $data['details'],
                $data['WEBAPP']['VERSION']
            );
        }

        if (isset($data['WEBAPP']['WARN'])) {
            $data['details'] = array_merge(
                $data['details'],
                $data['WEBAPP']['WARN']
            );
        }

        if (isset($data['OUTDATEDSCAN'])) {
            foreach ($data['OUTDATEDSCAN'] as $outdated) {
                if (isset($outdated[0]) && isset($outdated[2])) {
                    $data['details'][] = $outdated[0] . ':' . $outdated[2];
                }
            }
        }

        foreach ($data['details'] as $text) {
            $parts = explode(':', $text, 2);

            if (count($parts) === 2) {
                /* prefer local version number over SiteCheck's */
                if (strpos($parts[0], 'WordPress version') !== false) {
                    continue;
                }

                /* redundant; we already know the CMS is WordPress */
                if (strpos($parts[0], 'CMS') !== false) {
                    continue;
                }

                $params['SiteCheck.Metadata'] .= SucuriScanTemplate::getSnippet(
                    'sitecheck-details',
                    array(
                        'SiteCheck.Title' => trim($parts[0]),
                        'SiteCheck.Value' => trim($parts[1]),
                    )
                );
            }
        }

        return SucuriScanTemplate::getSection('sitecheck-details', $params);
    }

    /**
     * Generates the HTML section for the SiteCheck malware.
     *
     * @return string HTML code to render the malware section.
     */
    public static function malware()
    {
        $params = array();
        $data = self::scanAndCollectData();

        $params['Malware.Content'] = '';
        $params['Malware.Color'] = 'green';
        $params['Malware.Title'] = __('Site is Clean', 'sucuri-scanner');
        $params['Malware.CleanVisibility'] = 'visible';
        $params['Malware.InfectedVisibility'] = 'hidden';

        if (isset($data['MALWARE']['WARN']) && !empty($data['MALWARE']['WARN'])) {
            $params['Malware.Color'] = 'red';
            $params['Malware.Title'] = __('Site is not Clean', 'sucuri-scanner');
            $params['Malware.CleanVisibility'] = 'hidden';
            $params['Malware.InfectedVisibility'] = 'visible';

            foreach ($data['MALWARE']['WARN'] as $mal) {
                $info = self::malwareDetails($mal);

                if ($info) {
                    $params['Malware.Content'] .= SucuriScanTemplate::getSnippet(
                        'sitecheck-malware',
                        array(
                            'Malware.InfectedURL' => $info['infected_url'],
                            'Malware.MalwareType' => $info['malware_type'],
                            'Malware.MalwareDocs' => $info['malware_docs'],
                            'Malware.AlertMessage' => $info['alert_message'],
                            'Malware.MalwarePayload' => $info['malware_payload'],
                        )
                    );
                }
            }
        }

        return SucuriScanTemplate::getSection('sitecheck-malware', $params);
    }

    /**
     * Generates the HTML section for the SiteCheck blocklist.
     *
     * @return string HTML code to render the blocklist section.
     */
    public static function blocklist()
    {
        $params = array();
        $data = self::scanAndCollectData();

        if (!isset($data['BLACKLIST']) || !is_array($data['BLACKLIST'])) {
            return ''; /* there is not enough information to render */
        }

        $params['Blocklist.Title'] = __('Not blocklisted', 'sucuri-scanner');
        $params['Blocklist.Color'] = 'green';
        $params['Blocklist.Content'] = '';

        foreach ($data['BLACKLIST'] as $type => $proof) {
            foreach ($proof as $info) {
                $url = $info[1];
                $title = @preg_replace(
                    '/Domain (clean|blacklisted) (on|by) (the )?/',
                    '' /* remove unnecessary text from the output */,
                    substr($info[0], 0, strrpos($info[0], ':'))
                );

                $params['Blocklist.Content'] .= SucuriScanTemplate::getSnippet(
                    'sitecheck-blocklist',
                    array(
                        'Blocklist.URL' => $url,
                        'Blocklist.Status' => $type,
                        'Blocklist.Service' => $title,
                    )
                );
            }
        }

        if (isset($data['BLACKLIST']['WARN'])) {
            $params['Blocklist.Title'] = __('In the blocklist', 'sucuri-scanner');
            $params['Blocklist.Color'] = 'red';
        }

        return SucuriScanTemplate::getSection('sitecheck-blocklist', $params);
    }

    /**
     * Generates the HTML section for the SiteCheck recommendations.
     *
     * @return string HTML code to render the recommendations section.
     */
    public static function recommendations()
    {
        $data = self::scanAndCollectData();
  
        if (!isset($data['RECOMMENDATIONS'])) {
            return;
        }

        $params = array();
        $params['Recommendations.Content'] = '';
        $params['Recommendations.Color'] = 'blue';

        foreach ($data['RECOMMENDATIONS'] as $recommendation) {
            if (count($recommendation) < 3) {
                continue;
            }

            $params['Recommendations.Content'] .= SucuriScanTemplate::getSnippet(
                'sitecheck-recommendations',
                array(
                    'Recommendations.Title' => $recommendation[0],
                    'Recommendations.Value' => $recommendation[1],
                    'Recommendations.URL' => $recommendation[2],
                )
            );
        }
        
        return SucuriScanTemplate::getSection('sitecheck-recommendations', $params);
    }

    /**
     * Returns the title for the iFrames section.
     *
     * @return string Title for the iFrames section.
     */
    public static function iFramesTitle()
    {
        $data = self::scanAndCollectData();
        $niframes = isset($data['LINKS']['IFRAME']) && is_array($data['LINKS']['IFRAME'])
            ? count($data['LINKS']['IFRAME'])
            : 0;

        return sprintf(__('iFrames: %d', 'sucuri-scanner'), $niframes);
    }

    /**
     * Returns the title for the links section.
     *
     * @return string Title for the links section.
     */
    public static function linksTitle()
    {
        $data = self::scanAndCollectData();
        $nlinks = isset($data['LINKS']['URL']) && is_array($data['LINKS']['URL'])
            ? count($data['LINKS']['URL'])
            : 0;

        return sprintf(__('Links: %d', 'sucuri-scanner'), $nlinks);
    }

    /**
     * Returns the title for the scripts section.
     *
     * @return string Title for the scripts section.
     */
    public static function scriptsTitle()
    {
        $data = self::scanAndCollectData();
        $total = 0; /* all type of scripts */

        if (isset($data['LINKS']['JSLOCAL'])) {
            $total += count($data['LINKS']['JSLOCAL']);
        }

        if (isset($data['LINKS']['JSEXTERNAL'])) {
            $total += count($data['LINKS']['JSEXTERNAL']);
        }

        return sprintf(__('Scripts: %d', 'sucuri-scanner'), $total);
    }

    /**
     * Returns the content for the iFrames section.
     *
     * @return string Content for the iFrames section.
     */
    public static function iFramesContent()
    {
        $data = self::scanAndCollectData();
        return isset($data['LINKS']['IFRAME']) ? $data['LINKS']['IFRAME'] : array();
    }

    /**
     * Returns the content for the links section.
     *
     * @return string Content for the links section.
     */
    public static function linksContent()
    {
        $data = self::scanAndCollectData();
        return isset($data['LINKS']['URL']) ? $data['LINKS']['URL'] : array();
    }

    /**
     * Returns the content for the scripts section.
     *
     * @return array Content for the scripts section.
     */
    public static function scriptsContent()
    {
        $links = array();
        $data = self::scanAndCollectData();

        if (isset($data['LINKS']['JSLOCAL'])) {
            $links = array_merge($links, $data['LINKS']['JSLOCAL']);
        }

        if (isset($data['LINKS']['JSEXTERNAL'])) {
            $links = array_merge($links, $data['LINKS']['JSEXTERNAL']);
        }

        return $links;
    }

    /**
     * Extract detailed information from a SiteCheck malware payload.
     *
     * @param  array $malware Array with two entries with basic malware information.
     * @return array          Detailed information of the malware found by SiteCheck.
     */
    public static function malwareDetails($malware = array())
    {
        if (count($malware) < 2) {
            return array(/* empty details */);
        }

        $data = array(
            'alert_message' => '',
            'infected_url' => '',
            'malware_type' => '',
            'malware_docs' => '',
            'malware_payload' => '',
        );

        // Extract the information from the alert message.
        $alert_parts = explode(':', $malware[0], 2);

        if (isset($alert_parts[1])) {
            $data['alert_message'] = $alert_parts[0];
            $data['infected_url'] = trim($alert_parts[1]);
        }

        // Extract the information from the malware message.
        $malware_parts = explode("\n", $malware[1], 2);

        if (isset($malware_parts[1])) {
            $pattern = ".\x20Details:\x20";
            if (strpos($malware_parts[0], $pattern) !== false) {
                $offset = strpos($malware_parts[0], $pattern);
                $data['malware_type'] = substr($malware_parts[0], 0, $offset);
                $data['malware_docs'] = substr($malware_parts[0], $offset + 11);
            }

            $data['malware_payload'] = trim($malware_parts[1]);
        }

        return $data;
    }

    /**
     * Returns a JSON-encoded object with the malware scan results.
     *
     * @codeCoverageIgnore - Notice that there is a test case that covers this
     * code, but since the WP-Send-JSON method uses die() to stop any further
     * output it means that XDebug cannot cover the next line, leaving a report
     * with a missing line in the coverage. Since the test case takes care of
     * the functionality of this code we will assume that it is fully covered.
     *
     * @return void
     */
    public static function ajaxMalwareScan()
    {
        if (SucuriScanRequest::post('form_action') !== 'malware_scan') {
            return;
        }

        ob_start();

        $response = array();

        $response['malware'] = SucuriScanSiteCheck::malware();
        $response['blocklist'] = SucuriScanSiteCheck::blocklist();
        $response['recommendations'] = SucuriScanSiteCheck::recommendations();

        $response['iframes'] = array(
            'title' => SucuriScanSiteCheck::iFramesTitle(),
            'content' => SucuriScanSiteCheck::iFramesContent(),
        );
        $response['links'] = array(
            'title' => SucuriScanSiteCheck::linksTitle(),
            'content' => SucuriScanSiteCheck::linksContent(),
        );
        $response['scripts'] = array(
            'title' => SucuriScanSiteCheck::scriptsTitle(),
            'content' => SucuriScanSiteCheck::scriptsContent(),
        );

        $errors = ob_get_clean(); /* capture possible errors */

        if (!empty($errors)) {
            $response['malware'] = '';
            $response['blocklist'] = '';
            $response['recommendations'] = '';
        }

        wp_send_json($response, 200);
    }

    /**
     * Returns the HTML to configure the API SiteCheck service.
     *
     * @return string HTML for the API SiteCheck service option.
     */
    public static function targetURLOption()
    {
        $params = array();

        if (SucuriScanInterface::checkNonce()) {
            $custom = SucuriScanRequest::post(':sitecheck_target');
            if ($custom !== false) {
                SucuriScanOption::updateOption(':sitecheck_target', $custom);
            }
        }

        $params['SiteCheck.Target'] = self::targetURL();

        return SucuriScanTemplate::getSection('sitecheck-target', $params);
    }
}
