<?php

/*
 * Copyright (c) 2016 Gabor Gyorvari
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *  http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

class MalwareScanner
{
    //Pretty Colors
    private $ANSI_GREEN = "\033[32m";
    private $ANSI_RED = "\033[31m";
    private $ANSI_YELLOW = "\033[33m";
    private $ANSI_BLUE = "\033[36m";
    private $ANSI_OFF = "\033[0m";

    private $dir = '';
    private $extension = array('.php');
    protected $flagBase64 = false;
    private $flagChecksum = false;
    private $flagComments = false;
    private $flagHideOk = false;
    private $flagHideErr = false;
    private $flagHideWhitelist = false;
    private $flagNoStop = false;
    private $flagPattern = false;
    private $flagTime = false;
    protected $flagExtraCheck = false;
    private $flagFollowSymlink = false;
    private $flagLineNumber = false;
    private $flagScanEverything = false;
    private $flagCombinedWhitelist = false;
    private $flagDisableStats = false;
    private $flagEntropy = false;
    private $entropyThreshold = 5.3;
    private $flagAst = false;
    private $customWhitelist = array();
    private $outputFormat = '';
    protected $whitelist = array();
    protected $ignore = array();
    protected $stat = array(
        'directories' => 0,
        'files_scanned' => 0,
        'files_infected' => 0,
    );

    //Pattern File Attributes
    protected $patterns_raw = array();
    protected $patterns_iraw = array();
    protected $patterns_re = array();
    protected $patterns_b64functions = array();
    protected $patterns_b64keywords = array();
    protected $combined_whitelist = array();
    protected $combined_whitelist_count = 0;

    /**
     * MalwareScanner constructor.
     *
     * @param bool $cli defines its calling from commandline or using as a library, default is true
     */
    public function __construct($cli = true)
    {
        if ($cli === true) {
            //Read Run Options
            $this->parseArgs();

            $dirs = array();
            if (is_array($this->dir)) {
                // allow multiple directory aka. array
                foreach ($this->dir as $path) {
                    $dirs[] = realpath($path);
                }
            } elseif ($bpos = strpos($this->dir, '{')) {
                // Check path has a "brace", expand it to subdirectories
                foreach (glob($this->dir, GLOB_BRACE) as $path) {
                    $dirs[] = realpath($path);
                }
            } else {
                // only one directory specified
                $dirs = array (realpath($this->dir));
            }

            //Make sure a directory was specified.
            if (empty($dirs)) {
                $this->error('No directory specified or directory doesn\'t exist');
                exit(-1);
            }

            //Initiate Scan
            if (!$this->run($dirs)) {
                exit(-1);
            }
        }
    }

    //Allows the -n/--no-color flag to easily remove color characters.
    private function disableColor()
    {
        $this->ANSI_GREEN = '';
        $this->ANSI_RED = '';
        $this->ANSI_YELLOW = '';
        $this->ANSI_BLUE = '';
        $this->ANSI_OFF = '';
    }

    //Prints the passed 'string' in red text, calls showHelp().
    //Exits
    private function error($msg)
    {
        echo $this->ANSI_RED . 'Error: ' . $msg . $this->ANSI_OFF . PHP_EOL;
        $this->showHelp();
        echo PHP_EOL . $this->ANSI_RED . 'Quiting' . $this->ANSI_OFF . PHP_EOL;
    }

    //Handles pattern loading and saving to the class object
    public function initializePatterns()
    {
        $dir = dirname(__FILE__);
        //Loads either the primary scanning patterns or the base64 patterns depending on -b/--base64 flag
        if (!$this->flagBase64) {
            $this->patterns_raw = $this->loadPatterns($dir . '/definitions/patterns_raw.txt');
            $this->patterns_iraw = $this->loadPatterns($dir . '/definitions/patterns_iraw.txt');
            $this->patterns_re = $this->loadPatterns($dir . '/definitions/patterns_re.txt');
        } else {
            $this->patterns_b64functions = $this->loadPatterns($dir . '/base64_patterns/php_functions.txt');
            $this->patterns_b64keywords = $this->loadPatterns($dir . '/base64_patterns/php_keywords.txt');
        }

        //Adds additional checks to patterns_raw
        //This may be something to move into a pattern file rather than leave hardcoded.
        if ($this->flagExtraCheck) {
            $this->patterns_raw['googleBot'] = '# ';
            $this->patterns_raw['htaccess'] = '# ';
        }
    }

    //Check if the md5 checksum exists in the whitelist and returns true if it does.
    protected function inWhitelist($hash)
    {
        if ($this->flagCombinedWhitelist) {
            if ($this->binarySearch($hash, $this->combined_whitelist, $this->combined_whitelist_count) > -1) {
                return true;
            }
        }
        return in_array($hash, $this->whitelist);
    }

    //Check if -i/--ignore flag listed this path to be omitted.
    protected function isIgnored($pathname)
    {
        foreach ($this->ignore as $pattern) {
            $match = $this->pathMatches($pathname, $pattern);
            if ($match) {
                return true;
            }
        }
        return false;
    }

    //Loads individual pattern files
    //Skips blank linese
    //Stores most recent comment with the pattern in the list[] array
    //Returns an array of patterns:comments in key:value pairs
    protected function loadPatterns($file)
    {
        $last_comment = '';
        $list = array();
        if (is_readable($file)) {
            foreach (file($file) as $pattern) {
                //Check if the line is only whitespace and skips.
                if (strlen(trim($pattern)) == 0) {
                    continue;
                }
                //Check if first char in pattern is a '#' which indicates a comment and skips.
                //Stores the comment to be stored with the pattern in the list as key:value pairs.
                //The pattern is the key and the comment is the value.
                if ($pattern[0] === '#') {
                    $last_comment = $pattern;
                    continue;
                }
                $list[trim($pattern)] = trim($last_comment);
            }
        }
        return $list;
    }

    /**
     * Loads the whitelist files
     */
    public function loadWhitelists()
    {
        $a = array_merge([__DIR__ . '/whitelist.txt'], $this->customWhitelist);
        foreach ($a as $file) {
            if (is_file($file)) {
                $fp = fopen($file, 'r');
                while (!feof($fp)) {
                    $line = fgets($fp);
                    $hash = substr($line, 0, 32);
                    if (strlen($hash) === 32) {
                        $this->whitelist[] = $hash;
                    }
                }
                fclose($fp);
            }
        }
    }

    public function addWordpressChecksums($wp_version)
    {
        if (!preg_match('/^\d+\.\d+(\.\d+)?$/', $wp_version)) {
            $this->error('Invalid WordPress version format: ' . $wp_version);
            exit(-1);
        }

        $apiurl = 'https://api.wordpress.org/core/checksums/1.0/?version=' . $wp_version;
        $raw = file_get_contents($apiurl);
        if ($raw === false) {
            $this->error('Cannot load wordpress checksums from: ' . $apiurl);
            exit(-1);
        }

        $json = json_decode($raw);
        if ($json === null || !isset($json->checksums)) {
            $this->error('Invalid response from WordPress checksums API');
            exit(-1);
        }

        $checksums = $json->checksums;
        if ($checksums === false || empty((array)$checksums)) {
            $this->error('No checksums returned for WordPress version: ' . $wp_version);
            exit(-1);
        }

        $entries = isset($checksums->$wp_version) ? $checksums->$wp_version : $checksums;
        foreach ($entries as $file => $checksum) {
            $this->whitelist[] = $checksum;
        }
    }

    //Handles the getopt() function call, sets attributes according to flags.
    //All flag handling stuff should be setup here.
    private function parseArgs()
    {
        $options = getopt(
            'd:e:i:o:abmcxlhkrwnsptLj:E',
            array(
                'directory:',
                'extension:',
                'ignore:',
                'all-output',
                'base',
                'checksum',
                'comment',
                'extra-check',
                'follow-link',
                'help',
                'hide-ok',
                'hide-err',
                'hide-whitelist',
                'no-color',
                'no-stop',
                'pattern',
                'time',
                'line-number',
                'output-format:',
                'wordpress-version:',
                'scan-everything',
                'combined-whitelist',
                'custom-whitelist:',
                'disable-stats',
                'entropy',
                'entropy-threshold:',
                'ast',
            )
        );

        //Help Option should be first
        if (isset($options['help']) || isset($options['h'])) {
            $this->showHelp();
            exit;
        }

        //Options that Require Additional Parameters
        if (isset($options['directory']) || isset($options['d'])) {
            $this->dir = isset($options['directory']) ? $options['directory'] : $options['d'];
        }
        if (isset($options['extension']) || isset($options['e'])) {
            $a = isset($options['extension']) ? $options['extension'] : $options['e'];
            if (!is_array($a)) {
                $a = array($a);
            }
            $this->setExtensions($a);
        }
        if (isset($options['ignore']) || isset($options['i'])) {
            $tmp = isset($options['ignore']) ? $options['ignore'] : $options['i'];
            $this->setIgnore(is_array($tmp) ? $tmp : array($tmp));
        }

        //Simple Flag Options
        if (isset($options['all-output']) || isset($options['a'])) {
            $this->setFlagChecksum(true);
            $this->setFlagComments(true);
            $this->setFlagPattern(true);
            $this->setFlagTime(true);
        }
        if (isset($options['base64']) || isset($options['b'])) {
            $this->setFlagBase64(true);
        }
        if (isset($options['checksum']) || isset($options['m'])) {
            $this->setFlagChecksum(true);
        }
        if (isset($options['comment']) || isset($options['c'])) {
            $this->setFlagComments(true);
        }
        if (isset($options['extra-check']) || isset($options['x'])) {
            $this->setFlagExtraCheck(true);
        }
        if (isset($options['follow-symlink']) || isset($options['l'])) {
            $this->setFlagFollowSymlink(true);
        }
        if (isset($options['hide-ok']) || isset($options['k'])) {
            $this->setFlagHideOk(true);
        }
        if (isset($options['hide-err']) || isset($options['r'])) {
            $this->setFlagHideErr(true);
        }
        if (isset($options['hide-whitelist']) || isset($options['w'])) {
            $this->setFlagHideWhitelist(true);
        }
        if (isset($options['no-color']) || isset($options['n'])) {
            $this->disableColor();
        }
        if (isset($options['no-stop']) || isset($options['s'])) {
            $this->setFlagNoStop(true);
        }
        if (isset($options['pattern']) || isset($options['p'])) {
            $this->setFlagPattern(true);
        }
        if (isset($options['time']) || isset($options['t'])) {
            $this->setFlagTime(true);
        }
        if (isset($options['line-number']) || isset($options['L'])) {
            $this->setFlagLineNumber(true);
        }
        if (isset($options['output-format']) || isset($options['o'])) {
            $tmp = isset($options['output-format']) ? $options['output-format'] : $options['o'];
            $this->setOutputFormat(is_array($tmp) ? $tmp : array($tmp));
        }
        if (isset($options['wordpress-version']) || isset($options['j'])) {
            $tmp = isset($options['wordpress-version']) ? $options['wordpress-version'] : $options['j'];
            $this->addWordpressChecksums($tmp);
        }
        if (isset($options['scan-everything']) || isset($options['E'])) {
            $this->setFlagScanEverything(true);
        }
        if (isset($options['combined-whitelist'])) {
            $this->setFlagCombinedWhitelist(true);
        }
        if (isset($options['custom-whitelist'])) {
            $a = $options['custom-whitelist'];
            if (!is_array($a)) {
                $a = array($a);
            }
            $this->setCustomWhitelist(array_unique($a));
        }
        if (isset($options['disable-stats'])) {
            $this->setFlagDisableStats(true);
        }
        if (isset($options['entropy'])) {
            $this->setFlagEntropy(true);
        }
        if (isset($options['entropy-threshold'])) {
            $this->setEntropyThreshold((float)$options['entropy-threshold']);
        }
        if (isset($options['ast'])) {
            $this->setFlagAst(true);
        }
    }

    public function setExtensions(array $a)
    {
        $this->extension = array();
        foreach ($a as $ext) {
            if ($ext[0] != '.') {
                $ext = '.' . $ext;
            }
            $this->extension[] = strtolower($ext);
        }
    }

    public function setIgnore(array $a)
    {
        $this->ignore = $a;
    }

    public function setFlagChecksum($b)
    {
        $this->flagChecksum = $b;
    }

    public function setFlagComments($b)
    {
        $this->flagComments = $b;
    }

    public function setFlagPattern($b)
    {
        $this->flagPattern = $b;
    }

    public function setFlagTime($b)
    {
        $this->flagTime = $b;
    }

    public function setFlagLineNumber($b)
    {
        $this->flagLineNumber = $b;
    }

    public function setFlagBase64($b)
    {
        $this->flagBase64 = $b;
    }

    public function setFlagExtraCheck($b)
    {
        $this->flagExtraCheck = $b;
    }

    public function setFlagFollowSymlink($b)
    {
        $this->flagFollowSymlink = $b;
    }

    public function setFlagHideOk($b)
    {
        $this->flagHideOk = $b;
    }

    public function setFlagHideErr($b)
    {
        $this->flagHideErr = $b;
    }

    public function setFlagHideWhitelist($b)
    {
        $this->flagHideWhitelist = $b;
    }

    public function setFlagNoStop($b)
    {
        $this->flagNoStop = $b;
    }

    public function setOutputFormat(array $format)
    {
        $this->outputFormat = array_shift($format);
    }

    public function setFlagScanEverything($b)
    {
        $this->flagScanEverything = $b;
    }

    public function setFlagCombinedWhitelist($b)
    {
        $this->flagCombinedWhitelist = $b;
    }

    public function setFlagDisableStats($b)
    {
        $this->flagDisableStats = $b;
    }

    public function setFlagEntropy($b)
    {
        $this->flagEntropy = $b;
    }

    public function setEntropyThreshold($v)
    {
        $this->entropyThreshold = $v;
    }

    public function setFlagAst($b)
    {
        $this->flagAst = $b;
    }

    public function setCustomWhitelist($a)
    {
        $this->customWhitelist = $a;
    }

    // @see http://stackoverflow.com/a/13914119
    protected function pathMatches($path, $pattern, $ignoreCase = false)
    {
        $expr = preg_replace_callback(
            '/[\\\\^$.[\\]|()?*+{}\\-\\/]/',
            function ($matches) {
                switch ($matches[0]) {
                    case '*':
                        return '.*';
                    case '?':
                        return '.';
                    default:
                        return '\\' . $matches[0];
                }
            },
            $pattern
        );

        $expr = '/' . $expr . '/';
        if ($ignoreCase) {
            $expr .= 'i';
        }

        return (bool)preg_match($expr, $path);
    }

    /**
     * Formats and prints the scan result output line by line.
     *
     * Depending on specified options, it will print:
     * - Status code
     * - Last Modified Time
     * - MD5 Hash
     * - File Path
     * - Pattern Matched
     * - The last comment to appear in the pattern file before this pattern
     * - Matching line number
     *
     * @param $found
     * @param $path
     * @param $pattern
     * @param $comment
     * @param $hash
     * @param $lineNumber
     * @param bool $inWhitelist
     */
    protected function printPath($found, $path, $pattern, $comment, $hash, $lineNumber, $inWhitelist = false)
    {
        $default_format = '%S ';

        if (!$found && !$inWhitelist) {
            if ($this->flagHideOk) {
                return;
            }
            $state = 'OK';
            $hash = '                                ';
            $state_color = $this->ANSI_GREEN;
        } elseif ($inWhitelist) {
            if ($this->flagHideWhitelist) {
                return;
            }
            $state = 'WL';
            $state_color = $this->ANSI_YELLOW;
        } else {
            if ($this->flagHideErr) {
                return;
            }
            $state = 'ER';
            $state_color = $this->ANSI_RED;
        }

        //Include cTime
        if ($this->flagTime) {
            $changed_time = filectime($path);
            $ctime = date('H:i d-m-Y', $changed_time);
            $default_format .= '%T';
        } else {
            $ctime = '';
        }

        //Include Checksum/Hash
        if ($this->flagChecksum) {
            $default_format .= '%M ';
        }

        // '#' and {} included to prevent accidental script execution attempts
        // in the event that script output is pasted into a root terminal
        $default_format .= '# {%F} ';

        //'#' added again as code snippets have the potential to be valid shell commands
        if ($found) {
            if ($this->flagPattern) {
                $default_format .= '%P ';
            }
            if ($this->flagComments) {
                $default_format .= '%C ';
            }
            if ($this->flagLineNumber) {
                $default_format .= '# %L';
            }
        }

        if ($this->outputFormat) {
            $map = array(
                '%S' => $state,
                '%T' => $ctime,
                '%M' => $hash,
                '%F' => $path,
                '%P' => $pattern,
                '%C' => $comment,
                '%L' => $lineNumber,
            );
        } else {
            $map = array(
                '%S' => $state_color . '# ' . $state . $this->ANSI_OFF,
                '%T' => $this->ANSI_BLUE . $ctime . $this->ANSI_OFF,
                '%M' => $this->ANSI_BLUE . $hash . $this->ANSI_OFF,
                '%F' => $path,
                '%P' => $state_color . '#' . $pattern . $this->ANSI_OFF,
                '%C' => $this->ANSI_BLUE . $comment . $this->ANSI_OFF,
                '%L' => $lineNumber,
            );
        }

        if ($this->outputFormat) {
            $format = $this->outputFormat;
        } else {
            $format = trim($default_format);
        }

        echo str_replace(array_keys($map), array_values($map), $format) . PHP_EOL;
    }

    //Recursively scales the file system.
    //Calls the scan() function for each file found.
    protected function process($dir)
    {
        $dh = opendir($dir);
        if (!$dh) {
            return;
        }
        $this->stat['directories']++;
        while (($file = readdir($dh)) !== false) {
            if ($file == '.' || $file == '..') {
                continue;
            }
            if ($this->isIgnored($dir . $file)) {
                continue;
            }
            if (!$this->flagFollowSymlink && is_link($dir . $file)) {
                continue;
            }
            if (is_dir($dir . $file)) {
                $this->process($dir . $file . '/');
            } elseif (is_file($dir . $file)) {
                $ext = strtolower(substr($file, strrpos($file, '.')));
                if ($this->flagScanEverything || in_array($ext, $this->extension)) {
                    $this->scan($dir . $file);
                }
            }
        }
        closedir($dh);
    }

    //Prints stats on the run.
    private function report($start, $dir)
    {
        $end = time();
        echo 'Start time: ' . date('Y-m-d H:i:s', $start) . PHP_EOL;
        echo 'End time: ' . date('Y-m-d H:i:s', $end) . PHP_EOL;
        echo 'Total execution time: ' . ($end - $start) . PHP_EOL;
        echo 'Base directory: ' . $dir . PHP_EOL;
        echo 'Total directories scanned: ' . $this->stat['directories'] . PHP_EOL;
        echo 'Total files scanned: ' . $this->stat['files_scanned'] . PHP_EOL;
        echo 'Total malware identified: ' . $this->stat['files_infected'] . PHP_EOL;
    }

    /**
     * Validates the input directory
     *
     * - Calls the load pattern and load whitelist functions
     * - Fetch and load combined whitelist
     * - Calls the process and report functions.
     *
     * @param string|array $dir A directory path or a list of paths in array
     * @return bool
     */
    public function run($dir)
    {
        $this->initializePatterns();

        $this->loadWhitelists();

        if ($this->flagCombinedWhitelist && !$this->updateCombinedWhitelist()) {
            return false;
        }

        $start = time();

        if (!is_array($dir)) {
            $dir = array ($dir);
        }

        foreach ($dir as $path) {
            // Make sure the input is a valid directory path.
            $path = rtrim($path, '/');
            if (!is_dir($path)) {
                $this->error('Specified path is not a directory: ' . $path);
                return false;
            }
            $this->process($path . '/');
        }

        if (!$this->flagDisableStats) {
            $this->report($start, implode(', ', $dir));
        }
        return true;
    }

    //Loads target file contents for scanning
    //Initiates the multiple scan types by calling the scanLoop function
    public function scan($path)
    {
        $this->stat['files_scanned']++;
        $fileContent = file_get_contents($path);
        if ($fileContent === false) {
            if (!$this->flagHideErr) {
                echo $this->ANSI_RED . '# ER' . $this->ANSI_OFF . ' # {' . $path . '} (unreadable)' . PHP_EOL;
            }
            return false;
        }
        $found = false;
        $inWhitelist = false;
        $hash = md5($fileContent);
        $toSearch = '';
        $comment = '';

        if ($this->inWhitelist($hash)) {
            $inWhitelist = true;
        } elseif (!$this->flagBase64) {
            $this->scanLoop('scanFunc_STR', $fileContent, $this->patterns_raw, $path, $found, $hash);
            $this->scanLoop('scanFunc_STRI', $fileContent, $this->patterns_iraw, $path, $found, $hash);
            $this->scanLoop('scanFunc_RE', $fileContent, $this->patterns_re, $path, $found, $hash);
        } else {
            $this->scanLoop('scanFunc_STR', $fileContent, $this->patterns_b64functions, $path, $found, $hash);
            $this->scanLoop('scanFunc_STR', $fileContent, $this->patterns_b64keywords, $path, $found, $hash);
        }

        if ($this->flagEntropy && !$inWhitelist && (!$found || $this->flagNoStop)) {
            $entropyHit = $this->scanFunc_ENTROPY($fileContent);
            if ($entropyHit !== false) {
                $found = true;
                $this->printPath(true, $path, '[entropy:' . $entropyHit['score'] . ']',
                    '# High-entropy string literal — possible obfuscation/encoding', $hash, $entropyHit['line']);
            }
        }

        if ($this->flagAst && !$inWhitelist && (!$found || $this->flagNoStop)) {
            foreach ($this->scanFunc_AST($fileContent) as $hit) {
                if (!$found || $this->flagNoStop) {
                    $found = true;
                    $this->printPath(true, $path, '[ast:' . $hit['type'] . ']',
                        '# ' . $hit['detail'], $hash, $hit['line']);
                }
            }
        }

        if (!$found) {
            $this->printPath($found, $path, $toSearch, $comment, $hash, 0, $inWhitelist);
            return false;
        }

        $this->stat['files_infected']++;
        return true;
    }

    //Performs raw string, case sensitive matching.
    //Returns true if the raw string exists in the file contents.
    protected function scanFunc_STR(&$pattern, &$content)
    {
        return strpos($content, (string)$pattern);
    }

    //Performs raw string, case insensitive matching.
    //Returns true if the raw string exists in the file contents, ignoring case.
    protected function scanFunc_STRI(&$pattern, &$content)
    {
        return stripos($content, (string)$pattern);
    }

    //Performs regular expression matching.
    //Returns true if the Regular Expression matches something in the file.
    //Patterns will match multiple lines, though you can use ^$ to match the beginning and end of a line.
    protected function scanFunc_RE(&$pattern, &$content)
    {
        $ret = @preg_match('/' . $pattern . '/im', $content, $match, PREG_OFFSET_CAPTURE);
        if ($ret === false) {
            return false;
        }
        if ($ret) {
            return $match[0][1];
        }
        return false;
    }

    private function shannonEntropy($str)
    {
        $len = strlen($str);
        if ($len === 0) {
            return 0.0;
        }
        $counts = array();
        for ($i = 0; $i < $len; $i++) {
            $c = $str[$i];
            $counts[$c] = isset($counts[$c]) ? $counts[$c] + 1 : 1;
        }
        $entropy = 0.0;
        foreach ($counts as $count) {
            $p = $count / $len;
            $entropy -= $p * log($p, 2);
        }
        return $entropy;
    }

    // Scans file content for high-entropy string literals (> $entropyThreshold bits/char).
    // Targets quoted strings >= 40 chars — the typical carrier for obfuscated/encrypted payloads.
    // Returns array('score' => float, 'line' => int) on first hit, or false if clean.
    private function scanFunc_ENTROPY(&$content)
    {
        $lines = explode("\n", $content);
        foreach ($lines as $lineIdx => $line) {
            if (!preg_match_all('/["\']([^"\'\\s]{40,})["\']/', $line, $matches)) {
                continue;
            }
            foreach ($matches[1] as $str) {
                if ($this->shannonEntropy($str) >= $this->entropyThreshold) {
                    return array('score' => round($this->shannonEntropy($str), 2), 'line' => $lineIdx + 1);
                }
            }
        }
        return false;
    }

    // Returns false when php-parser is unavailable; emits a one-time stderr notice.
    private function isPhpParserAvailable()
    {
        static $checked = false, $available = false;
        if (!$checked) {
            $checked = true;
            $autoload = __DIR__ . '/vendor/autoload.php';
            if (is_readable($autoload)) {
                require_once $autoload;
                $available = class_exists('PhpParser\ParserFactory');
            }
            if (!$available) {
                fwrite(STDERR, 'Note: --ast requires nikic/php-parser (run composer require nikic/php-parser)' . PHP_EOL);
            }
        }
        return $available;
    }

    // Parses $content as PHP via nikic/php-parser and returns all suspicious AST findings.
    // Returns an empty array when nothing is found or php-parser is unavailable.
    private function scanFunc_AST(&$content)
    {
        if (!$this->isPhpParserAvailable()) {
            return array();
        }

        $visitorFile = __DIR__ . '/ast_visitor.php';
        if (!is_readable($visitorFile)) {
            return array();
        }
        require_once $visitorFile;

        try {
            $factory = new \PhpParser\ParserFactory();
            if (method_exists($factory, 'createForNewestSupportedVersion')) {
                $parser = $factory->createForNewestSupportedVersion(); // v5
            } elseif (defined('PhpParser\ParserFactory::PREFER_PHP7')) {
                $parser = $factory->create(\PhpParser\ParserFactory::PREFER_PHP7); // v3/v4
            } else {
                return array();
            }

            $stmts = @$parser->parse($content);
            if ($stmts === null) {
                return array();
            }

            $traverser = new \PhpParser\NodeTraverser();
            $visitor   = new MalwareScannerAstVisitor();
            $traverser->addVisitor($visitor);
            $traverser->traverse($stmts);

            return $visitor->getFindings();
        } catch (\Exception $e) {
            return array();
        }
    }

    //First parameter '$scanFunction' is a defined function name passed as a string.
    //This function should accept a pattern string and a content string.
    //This function will return true if the pattern exists in the content.
    //See 'scanFunc_STR', 'scanFunc_STRI', 'scanFUNC_RE' above as examples.

    //Loops through all patterns in a file using the passed function name to determine a match.
    //Variables passed by reference for performance and modification access.
    protected function scanLoop($scanFunction, &$fileContent, &$patterns, &$path, &$found, $hash)
    {
        if (!$found || $this->flagNoStop) {
            foreach ($patterns as $pattern => $comment) {
                //Call the function that is named in $scanFunction
                //This allows multiple search/match functions to be used without duplicating the loop code.
                $position = $this->$scanFunction($pattern, $fileContent);
                if ($position !== false) {
                    $found = true;
                    $lineNumber = 0;
                    if ($this->flagLineNumber) {
                        if ($pos = strrpos(substr($fileContent, 0, $position), "\n")) {
                            $lineNumber = substr_count(substr($fileContent, 0, $pos + 1), "\n") + 1;
                        }
                    }
                    $this->printPath($found, $path, $pattern, $comment, $hash, $lineNumber);
                    if (!$this->flagNoStop) {
                        return;
                    }
                }
            }
        }
    }

    // @see https://www.mkwd.net/binary-search-algorithm-in-php/
    protected function binarySearch($needle, array $haystack, $high, $low = 0)
    {
        $key = false;
        // Whilst we have a range. If not, then that match was not found.
        while ($high >= $low) {
            // Find the middle of the range.
            $mid = (int)floor(($high + $low) / 2);
            // Compare the middle of the range with the needle. This should return <0 if it's in the first part of the range,
            // or >0 if it's in the second part of the range. It will return 0 if there is a match.
            $cmp = strcmp($needle, $haystack[$mid]);
            // Adjust the range based on the above logic, so the next loop iteration will use the narrowed range
            if ($cmp < 0) {
                $high = $mid - 1;
            } elseif ($cmp > 0) {
                $low = $mid + 1;
            } else {
                $key = $mid;
                break;
            }
        }

        return $key;
    }

    private function updateCombinedWhitelist($url = 'https://scr34m.github.io/php-malware-scanner')
    {
        $ctx = stream_context_create(array('http' => array('timeout' => 30)));

        $latest_hash = file_get_contents($url . '/database/compressed.sha256', false, $ctx);
        if ($latest_hash === false) {
            $this->error('Unable to download database checksum');
            return false;
        }
        $latest_hash = trim($latest_hash);

        $file = __DIR__ . '/whitelist.dat';
        if (is_readable($file)) {
            $hash = hash_file('sha256', $file);
            if ($hash != $latest_hash) {
                $download = true;
            } else {
                $download = false;
            }
        } else {
            $download = true;
        }

        if ($download) {
            $data = file_get_contents($url . '/database/compressed.dat', false, $ctx);
            if ($data === false) {
                $this->error('Unable to download database');
                return false;
            }

            file_put_contents($file, $data);
            $hash = hash_file('sha256', $file);
            if ($hash != $latest_hash) {
                $this->error('Downloaded database hash mismatch');
                return false;
            }
        }

        $content = gzdecode(file_get_contents($file));
        $this->combined_whitelist = array();
        $this->combined_whitelist_count = 0;
        foreach (explode("\n", $content) as $line) { // faster than strtok, but needs more memory
            if ($line) {
                $this->combined_whitelist[] = $line;
                $this->combined_whitelist_count++;
            }
        }
        $this->combined_whitelist_count -= 1; // -1 because we use indexes in binary search
        echo 'Combined whitelist records count: ' . ($this->combined_whitelist_count + 1) . PHP_EOL;
        return true;
    }

    //Prints out the usage menu options.
    private function showHelp()
    {
        echo 'Usage: php scan.php -d <directory>' . PHP_EOL;
        echo '    -h                   --help               Show this help message' . PHP_EOL;
        echo '    -d <directory>       --directory          Directory for searching' . PHP_EOL;
        echo '    -e <file extension>  --extension          File Extension to Scan, can be used multiple times' . PHP_EOL;
        echo '    -E                   --scan-everything    Scan all files, with or without extensions' . PHP_EOL;
        echo '    -i <directory|file>  --ignore             Directory of file to ignore' . PHP_EOL;
        echo '    -a                   --all-output         Enables --checksum,--comment,--pattern,--time' . PHP_EOL;
        echo '    -b                   --base64             Scan for base64 encoded PHP keywords' . PHP_EOL;
        echo '    -m                   --checksum           Display MD5 Hash/Checksum of file' . PHP_EOL;
        echo '    -c                   --comment            Display comments for matched patterns' . PHP_EOL;
        echo '    -x                   --extra-check        Adds GoogleBot and htaccess to Scan List' . PHP_EOL;
        echo '    -l                   --follow-symlink     Follow symlinked directories' . PHP_EOL;
        echo '    -k                   --hide-ok            Hide results with \'OK\' status' . PHP_EOL;
        echo '    -r                   --hide-err           Hide results with \'ER\' status' . PHP_EOL;
        echo '    -w                   --hide-whitelist     Hide results with \'WL\' status' . PHP_EOL;
        echo '    -n                   --no-color           Disable color mode' . PHP_EOL;
        echo '    -s                   --no-stop            Continue scanning file after first hit' . PHP_EOL;
        echo '    -p                   --pattern            Show Patterns next to the file name' . PHP_EOL;
        echo '    -t                   --time               Show time of last file change' . PHP_EOL;
        echo '    -L                   --line-number        Display matching pattern line number in file' . PHP_EOL;
        echo '    -o                   --output-format      Custom defined output format' . PHP_EOL;
        echo '    -j <version>         --wordpress-version  Version of wordpress to get md5 signatures' . PHP_EOL;
        echo '                         --combined-whitelist Combined whitelist' . PHP_EOL;
        echo '                         --disable-stats      Disable statistics output' . PHP_EOL;
        echo '                         --entropy            Flag files with high-entropy string literals (obfuscation detection)' . PHP_EOL;
        echo '                         --entropy-threshold  Entropy threshold in bits/char (default: 5.3)' . PHP_EOL;
        echo '                         --ast                AST-based detection of dynamic eval/create_function/assert (requires nikic/php-parser)' . PHP_EOL;

    }

}

// script it's self called and not included
if (isset($argv[0]) && realpath($argv[0]) == realpath(__FILE__)) {
    new MalwareScanner();
}
