<?php

ob_start();
require_once('XmlValidator.inc.php');
require_once('FileInfo.inc.php');
require_once('Writer.inc.php');
require_once('Quarantiner.inc.php');
ob_end_clean();

class MalwareDetector
{
    function __construct()
    {
        global $projectRootDir, $projectTmpDir;

        if (!function_exists('escapedHexToHex')  || !function_exists('escapedOctDec')) {
            die('escapedHexToHex or escapedOctDec is missing');
        }

        $this->SIGNATURE_FILENAME = $projectRootDir . '/static/signatures/malware_db.xml';

        $this->QUEUE_FILENAME = $projectTmpDir . '/scan_queue.manul.tmp.txt';
        $this->QUEUE_OFFSET_FILENAME = $projectTmpDir . '/queue_offset.manul.tmp.txt';
        $this->MALWARE_LOG_FILENAME = $projectTmpDir . '/malware_log.manul.tmp.txt';
        $this->MALWARE_QUARANTINE_FILENAME = $projectTmpDir . '/malware_quarantine.manul.tmp.txt';
        $this->MALWARE_QUARANTINE_FILEPATH_FILEPATH = $projectTmpDir . '/malware_quarantine_filepath.tmp.txt';

        $this->XML_LOG_FILENAME = $projectTmpDir . '/scan_log.xml';

        $this->SCRIPT_START = time();

        $this->MAX_FILESIZE = 1 * 1024 * 1024; // 1MB
        $this->MAX_PREVIEW_LENGTH = 80; // characters
        $this->MAX_EXECUTION_DURATION = 20;

        $validator = new XmlValidator();
        if (!$validator->validate(implode('', file($this->SIGNATURE_FILENAME)), $projectRootDir . '/static/xsd/malware_db.xsd')) {
            die(basename(__FILE__) . PS_ERR_MALWARE_DB_BROKEN);
        }

        $this->signatures = new DOMDocument();
        $this->signatures->load($this->SIGNATURE_FILENAME);

    }

    function setRequestDelay($delay)
    {
        $this->MAX_EXECUTION_DURATION = $delay;
    }

    function throwTimeout($filePath)
    {
        echo $this->AJAX_HEADER_ERROR . "\n";
        echo basename(__FILE__) . ': timeout while scanning ' . $filePath . "\nTry to increase an interval in settings.\n";
        exit;
    }

    function normalizeContent($content)
    {
        $content = preg_replace_callback('/\\\\x([a-fA-F0-9]{1,2})/i', 'escapedHexToHex', $content); // strip hex ascii notation
        $content = preg_replace_callback('/\\\\([0-9]{1,3})/i', 'escapedOctDec', $content); // strip dec ascii notation
        $content = preg_replace('/[\'"]\s*?\.\s*?[\'"]/smi', '', $content); // concat fragmented strings
        $content = preg_replace('|/\*.*?\*/|smi', '', $content); // remove comments to detect fragmented pieces of malware

        return $content;
    }

    function getFragment($content, $pos)
    {
        $maxChars = $this->MAX_PREVIEW_LENGTH;
        $maxLen = strlen($content);
        $rightPos = min($pos + $maxChars, $maxLen);
        $minPos = max(0, $pos - $maxChars);

        $start = substr($content, 0, $pos);
        $start = str_replace('\r', '', $start);
        $lineNo = strlen($start) - strlen(str_replace("\n", '', $start)) + 1;

        $res = 'L' . $lineNo . ': ' . substr($content, $minPos, $pos - $minPos) .
            '@_MARKER_@' .
            substr($content, $pos, $rightPos - $pos - 1);

        return htmlspecialchars($res);
    }

    function queueQuarantine($filename)
    {
        if ($fh = fopen($this->MALWARE_QUARANTINE_FILENAME, 'a')) {
            fputs($fh, $filename . "\n");
            fclose($fh);
        }
    }

    function checkForValidPhp($content)
    {
        $len = strlen($content);
        $start = 0;
        $valid = false;

        while (($start = strpos($content, '<?', $start)) !== false) {
            $valid = true;
            $start++;
            $end = strpos($content, '?>', $start + 1);
            if ($end === false) {
                $end = $len;
            }

            while (++$start < $end) {
                $c = ord($content[$start]);
                if ($c < 9 || ($c >= 14 && $c <= 31) || $c == 11 || $c == 12) {
                    return false;
                }
            }
        }

        return $valid;
    }

    function detectMalware($filePath, &$foundFragment, &$pos, $startTime, $timeout, $ext)
    {

        if (filesize($filePath) > $this->MAX_FILESIZE) {
            return 'skipped';
        }

        if (!is_file($filePath)) {
            return 'no_read';
        }

        $needToScan = false;
        $extensions = array('ph' /* php, php3, phtml */, 'htm' /* htm, html */, 'txt', 'js', 'pl', 'cgi', 'py', 'bash', 'sh', 'xml', 'ssi', 'inc', 'pm', 'tpl');

        // do scan for all kind of scripts 
        foreach ($extensions as $scanExt) {
            if (strpos($ext, $scanExt) !== false) {
                $needToScan = true;
            }
        }

        $content = implode('', file($filePath));

        $fileToBeScannedSignatureList = array(
            '<?php',
            '<?=',
            '#!/usr/',
            '#!/bin/',
            '#!/local/',
            'eval(',
            'assert(',
            'base64_decode('
        );

        if (!$needToScan) {
            foreach ($fileToBeScannedSignatureList as $scanSig) {
                if (strpos($content, $scanSig) !== false) {
                    $needToScan = true;
                }
            }
        }

        if (!$needToScan && $this->checkForValidPhp($content)) {
            $needToScan = true;
        }

        if (!$needToScan) {
            return 'no_need_to_check';
        }

        $normalized = $this->normalizeContent($content);

        $db = $this->signatures->getElementsByTagName('signature');
        $detected = false;
        foreach ($db as $sig) {
            if ($detected) break;

            $currentTime = time();
            if ($currentTime - $startTime > $timeout) {
                return 'timeout';
            }

            $pos = -1;
            $sigContent = $sig->nodeValue;
            $attr = $sig->attributes;
            $attrId = $attr->getNamedItem('id')->nodeValue;
            $attrFormat = $attr->getNamedItem('format')->nodeValue;
            $attrChildId = $attr->getNamedItem('child_id')->nodeValue;
            $attrSeverity = $attr->getNamedItem('sever')->nodeValue;

            switch ($attrFormat) {

                case 're':
                    if ((preg_match('#(' . $sigContent . ')#smi', $content, $found, PREG_OFFSET_CAPTURE)) ||
                        (preg_match('#(' . $sigContent . ')#smi', $normalized, $found, PREG_OFFSET_CAPTURE))
                    ) {
                        $detected = true;
                        $pos = $found[0][1];
                        continue;
                    }

                    break;
                case 'const':
                    if ((($pos = strpos($content, $sigContent)) !== FALSE) ||
                        (($pos = strpos($normalized, $sigContent)) !== FALSE)
                    ) {
                        $detected = true;
                        continue;
                    }

                    break;
            }
        }

        if ($detected) {
            $foundFragment = $this->getFragment($content, $pos);
            return $attrSeverity;
        }
    }


    function parseXml($xmlFilename)
    {
        $dom = null;
        try {
            $dom = new DOMDocument('1.0', 'utf-8');
            $dom->formatOutput = true;
            $dom->load($xmlFilename);
        } catch (Exception $e) {
            die('An exception has occured: ' . $e->getMessage() . "\n");
        }
        if (!$dom) {
            die('An exception has occured: ');
        }
        return $dom;
    }

    function repackXMLLog()
    {
        global $projectRootDir;

        $xmlLogFilename = $this->XML_LOG_FILENAME;
        $xml = $this->parseXml($xmlLogFilename);
        $xpath = new DOMXpath($xml);

        if (!is_file($this->MALWARE_LOG_FILENAME)) {
            die(basename(__FILE__) . ': cannot open ' . $this->MALWARE_LOG_FILENAME . ' during repacking');
        }

        $lines = file($this->MALWARE_LOG_FILENAME);

        foreach ($lines as $lineNum => $line) {
            #Example /home/www/badcode.tk/web_root/robots.txt.dist;detected=;pos=-1;snippet=
            $data = explode(';', $line);
            $filePath = $data[0];
            $detected = substr($data[1], strlen('detected='));
            if ($detected) {
                $pos = substr($data[2], strlen('pos='));
                $snippet = trim(substr($data[3], strlen('snippet=')));

                #Getting current element in DOM
                $relativePath = str_replace($_SERVER['DOCUMENT_ROOT'], '.', $filePath);
                $filePathNode = $xpath->query('/website_info/files/file/path[text()="' . $relativePath . '"]')->item(0);
                $fileinfoNode = $filePathNode->parentNode;

                if (!$fileinfoNode) {
                    die("XML path error filePath={$filePath} relativePath={$relativePath} projectRootDir={$projectRootDir} docRoot={$_SERVER['DOCUMENT_ROOT']}");
                }

                #Adding detection info to DOM
                if ($fileinfoNode) {
                    $fileinfoNode->setAttribute('detected', $detected);
                    $fileinfoNode->setAttribute('snippet', $snippet);
                    $fileinfoNode->setAttribute('pos', $pos);
                }
            }
        }

        $xml->save($xmlLogFilename);

        return $xml->saveXML();
    }

    function buildQuarantineArchive()
    {
        if (file_exists($this->MALWARE_QUARANTINE_FILENAME)) {
            $list = file($this->MALWARE_QUARANTINE_FILENAME);

            if (count($list) > 0) {
                $quarantiner = new Quarantiner();
                foreach ($list as $filename) {
                    $quarantiner->add(trim($filename));
                }

                return $quarantiner->getArchive();
            }
        }

        return null;
    }

    function finishMalwareScan()
    {
        global $php_errormsg;

        $xml = $this->repackXMLLog();

        if (file_exists($this->MALWARE_LOG_FILENAME)) {
            unlink($this->MALWARE_LOG_FILENAME);
        }
        if (file_exists($this->QUEUE_OFFSET_FILENAME)) {
            unlink($this->QUEUE_OFFSET_FILENAME);
        }
        if (file_exists($this->QUEUE_FILENAME)) {
            @unlink($this->QUEUE_FILENAME);
        }

        $quarantineFilepath = $this->buildQuarantineArchive();
        if ($quarantineFilepath) {
            file_put_contents2($this->MALWARE_QUARANTINE_FILEPATH_FILEPATH, $quarantineFilepath);
        }

        if (file_exists($this->MALWARE_QUARANTINE_FILENAME)) {
            @unlink($this->MALWARE_QUARANTINE_FILENAME);
        }

        $result = json_encode(array('type' => 'getSignatureScanResult', 'status' => 'finished', 'phpError' => $php_errormsg));
        return $result;
    }

    function malwareScanRound()
    {

        global $php_errormsg;

        $startTime = time();

        if (!is_file($this->QUEUE_FILENAME)) {
            die(basename(__FILE__) . ': cannot open ' . $this->QUEUE_FILENAME . ' on scan round');
        }

        $fh = fopen($this->QUEUE_FILENAME, 'r');
        $offset = 0;

        if (file_exists($this->QUEUE_OFFSET_FILENAME)) {
            $offset = (int)file_get_contents($this->QUEUE_OFFSET_FILENAME);
            fseek($fh, $offset);
        }

        if (filesize($this->QUEUE_FILENAME) - $offset <= 0) {
            fclose($fh);
            return $this->finishMalwareScan();
        }

        $queueText = fread($fh, filesize($this->QUEUE_FILENAME) - $offset);
        $queueLines = explode("\n", $queueText);

        $numFilesScanned = 0;

        if (count($queueLines) < 1) {
            fclose($fh);
            return $this->finishMalwareScan();
        }

        foreach ($queueLines as $line) {
            $executionTime = time() - $startTime;
            if ($executionTime >= round($this->MAX_EXECUTION_DURATION * 0.8)) {
                break;
            } else if (empty($line)) {
                continue;
            }

            $offset += strlen($line) + 1;

            $fileinfo = explode(' ', $line);

            $filePath = $fileinfo[0];
            $fileHash = $fileinfo[1];

            $snippet = '';
            $fileExtension = pathinfo(basename($filePath), PATHINFO_EXTENSION);

            $res = $this->detectMalware($filePath, $snippet, $pos, $this->SCRIPT_START, $this->MAX_EXECUTION_DURATION, $fileExtension);
            switch ($res) {
                case 'no_need_to_check':
                    break;
                case 'no_read':
                    break;
                case 'skipped':
                    break;
                case 'timeout':
                    file_put_contents2($this->QUEUE_OFFSET_FILENAME, $offset);
                    $this->throwTimeout($filePath);
                    break;
                default:
                    $numFilesScanned++;
                    $content = $filePath . ';detected=' . $res . ';pos=' . $pos . ';snippet=' . base64_encode($snippet) . PHP_EOL;
                    file_put_contents2($this->MALWARE_LOG_FILENAME, $content, 'a');

                    if ($res) {
                        $this->queueQuarantine($filePath);
                    }
            }
        }

        file_put_contents2($this->QUEUE_OFFSET_FILENAME, $offset);
        fclose($fh);

        if (count($queueLines) <= 1) {
            return $this->finishMalwareScan();
        }

        $data = array('filesScannedThisTime' => $numFilesScanned, 'filesLeft' => count($queueLines), 'lastFile' => $filePath);
        $result = json_encode(array('type' => 'getSignatureScanResult', 'status' => 'inProcess', 'data' => $data, 'phpError' => $php_errormsg));
        return $result;

    }


} // End of class
