1<?php
2/**
3 * e107 website system
4 *
5 * Copyright (C) 2008-2020 e107 Inc (e107.org)
6 * Released under the terms and conditions of the
7 * GNU General Public License (http://www.gnu.org/licenses/gpl.txt)
8 */
9
10require_once("e_file_inspector_interface.php");
11
12/**
13 * File Inspector
14 *
15 * Tool to validate application files for consistency by comparing hashes of files with those in a database
16 */
17abstract class e_file_inspector implements e_file_inspector_interface
18{
19    protected $database;
20    protected $currentVersion;
21    private $validatedBitmask;
22
23    protected $defaultDirsCache;
24    protected $customDirsCache;
25    private $undeterminable = array();
26
27    // FIXME: Better place for the insecure file list
28    public $insecureFiles = [
29        e_ADMIN . "ad_links.php",
30        e_PLUGIN . "tinymce4/e_meta.php",
31        e_THEME . "bootstrap3/css/bootstrap_dark.css",
32        e_PLUGIN . "search_menu/languages/English.php",
33        e_LANGUAGEDIR . e_LANGUAGE . "/lan_parser_functions.php",
34        e_LANGUAGEDIR . e_LANGUAGE . "/admin/help/theme.php",
35        e_HANDLER . "np_class.php",
36        e_CORE . "shortcodes/single/user_extended.sc",
37        e_ADMIN . "download.php",
38        e_PLUGIN . "banner/config.php",
39        e_PLUGIN . "forum/newforumposts_menu_config.php",
40        e_PLUGIN . "forum/e_latest.php",
41        e_PLUGIN . "forum/e_status.php",
42        e_PLUGIN . "forum/forum_post_shortcodes.php",
43        e_PLUGIN . "forum/forum_shortcodes.php",
44        e_PLUGIN . "forum/forum_update_check.php",
45        e_PLUGIN . "online_extended_menu/online_extended_menu.php",
46        e_PLUGIN . "online_extended_menu/images/user.png",
47        e_PLUGIN . "online_extended_menu/languages/English.php",
48        e_PLUGIN . "pm/sendpm.sc",
49        e_PLUGIN . "pm/shortcodes/",
50        e_PLUGIN . "social/e_header.php",
51    ];
52
53    private $existingInsecureFiles = array();
54    private $existingInsecureDirectories = array();
55
56    /**
57     * e_file_inspector constructor
58     * @param string $database The database from which integrity data may be read or to which integrity data may be
59     *                         written.  This should be an URL or absolute file path for most implementations.
60     */
61    public function __construct($database)
62    {
63        $this->database = $database;
64
65        $this->checkDeprecatedFilesLog();
66
67        $appRoot = e107::getInstance()->file_path;
68        $this->undeterminable = array_map(function ($path)
69        {
70            return realpath($path) ? realpath($path) : $path;
71        }, [
72                $appRoot . "e107_config.php",
73                $appRoot . e107::getFolder('system_base') . "core_image.phar",
74            ]
75        );
76        $this->existingInsecureFiles = array_filter($this->insecureFiles, function ($path)
77        {
78            return is_file($path);
79        });
80        $this->existingInsecureFiles = array_map('realpath', $this->existingInsecureFiles);
81        $this->existingInsecureDirectories = array_filter($this->insecureFiles, function ($path)
82        {
83            return is_dir($path);
84        });
85        $this->existingInsecureDirectories = array_map('realpath', $this->existingInsecureDirectories);
86    }
87
88    /**
89     * Populate insecureFiles list if deprecatedFiles.log found.
90     * @return void
91     */
92    private function checkDeprecatedFilesLog()
93    {
94        $log = e_LOG.'fileinspector/deprecatedFiles.log';
95
96        if(!file_exists($log))
97        {
98            return;
99        }
100
101        $content = file_get_contents($log);
102
103        if(empty($content))
104        {
105            return;
106        }
107
108       $tmp = explode("\n", $content);
109       $this->insecureFiles = [];
110       foreach($tmp as $line)
111       {
112            if(empty($line))
113            {
114                continue;
115            }
116
117            $this->insecureFiles[] = e_BASE.$line;
118       }
119
120
121    }
122
123    /**
124     * Convert validation code to string.
125     * @param $validationCode
126     * @return string
127     */
128    public static function getStatusForValidationCode($validationCode)
129    {
130        $status = 'unknown';
131        if ($validationCode & self::VALIDATED)
132            $status = 'check';
133        elseif (!($validationCode & self::VALIDATED_FILE_EXISTS))
134            $status = 'missing';
135        elseif (!($validationCode & self::VALIDATED_FILE_SECURITY))
136            $status = 'warning';
137        elseif (!($validationCode & self::VALIDATED_PATH_KNOWN))
138            $status = 'unknown';
139        elseif (!($validationCode & self::VALIDATED_PATH_VERSION))
140            $status = 'old';
141        elseif (!($validationCode & self::VALIDATED_HASH_CALCULABLE))
142            $status = 'uncalc';
143        elseif (!($validationCode & self::VALIDATED_HASH_CURRENT))
144            if ($validationCode & self::VALIDATED_HASH_EXISTS)
145                $status = 'old';
146            else
147                $status = 'fail';
148        return $status;
149    }
150
151    /**
152     * Prepare the provided database for reading or writing
153     *
154     * Should tolerate a non-existent database and try to create it if a write operation is executed.
155     *
156     * @return void
157     */
158    abstract public function loadDatabase();
159
160    /**
161     * Check the integrity of the provided path
162     *
163     * @param $path string Relative path of the file to look up
164     * @param $version string The desired software release to match.
165     *                        Leave blank for the current version.
166     *                        Do not prepend the version number with "v".
167     * @return int Validation code (see the constants of this class)
168     */
169    public function validate($path, $version = null)
170    {
171        if ($version === null) $version = $this->getCurrentVersion();
172
173        $bits = 0x0;
174        $absolutePath = $this->relativePathToAbsolutePath($path);
175        $dbChecksums = $this->getChecksums($path);
176        $dbChecksum = $this->getChecksum($path, $version);
177        $actualChecksum = !empty($dbChecksums) ? $this->checksumPath($absolutePath) : null;
178
179        if (!empty($dbChecksums)) $bits |= self::VALIDATED_PATH_KNOWN;
180        if ($dbChecksum !== false) $bits |= self::VALIDATED_PATH_VERSION;
181        if (file_exists($absolutePath)) $bits |= self::VALIDATED_FILE_EXISTS;
182        if (!$this->isInsecure($path)) $bits |= self::VALIDATED_FILE_SECURITY;
183        if ($this->isDeterminable($absolutePath)) $bits |= self::VALIDATED_HASH_CALCULABLE;
184        if ($actualChecksum === $dbChecksum) $bits |= self::VALIDATED_HASH_CURRENT;
185
186        foreach ($dbChecksums as $dbChecksum)
187        {
188            if ($dbChecksum === $actualChecksum) $bits |= self::VALIDATED_HASH_EXISTS;
189        }
190
191        if ($bits + self::VALIDATED === $this->getValidatedBitmask()) $bits |= self::VALIDATED;
192
193        $this->log($path, $bits);
194
195        return $bits;
196    }
197
198     /**
199     * Log old file paths. (may be expanded to other types in future)
200     *
201     * @param string $relativePath
202     * @param int $status
203     * @return null
204     */
205    private function log($relativePath, $status)
206    {
207        if(empty($relativePath) || self::getStatusForValidationCode($status) !== 'old') // deprecated-file status
208        {
209            return null;
210        }
211
212        $message = $relativePath."\n";
213
214        $logPath = e_LOG."fileinspector/";
215
216        if(!is_dir($logPath))
217        {
218            mkdir($logPath, 0775);
219        }
220
221        file_put_contents($logPath."deprecatedFiles.log", $message, FILE_APPEND);
222
223        return null;
224    }
225
226    /**
227     * Get the file integrity hash for the provided path and version
228     *
229     * @param $path string Relative path of the file to look up
230     * @param $version string The software release version corresponding to the file hash.
231     *                        Leave blank for the current version.
232     *                        Do not prepend the version number with "v".
233     * @return string|bool The database hash for the path and version specified. FALSE if the record does not exist.
234     */
235    public function getChecksum($path, $version = null)
236    {
237        if ($version === null) $version = $this->getCurrentVersion();
238        $checksums = $this->getChecksums($path);
239        return isset($checksums[$version]) ? $checksums[$version] : false;
240    }
241
242    /**
243     * Calculate the hash of a path to compare with the hash database
244     *
245     * @param $absolutePath string Absolute path of the file to hash
246     * @return string|bool The actual hash for the path. FALSE if the hash was incalculable.
247     */
248    public function checksumPath($absolutePath)
249    {
250        if (!$this->isDeterminable($absolutePath)) return false;
251
252        return $this->checksum(file_get_contents($absolutePath));
253    }
254
255    /**
256     * Calculate the hash of a string, which would be used to compare with the hash database
257     *
258     * @param $content string Full content to hash
259     * @return string
260     */
261    public function checksum($content)
262    {
263        return md5(str_replace(array(chr(13), chr(10)), "", $content));
264    }
265
266    /**
267     * @inheritDoc
268     */
269    public function getVersions($path)
270    {
271        return array_keys($this->getChecksums($path));
272    }
273
274    /**
275     * @inheritDoc
276     */
277    public function getCurrentVersion()
278    {
279        if ($this->currentVersion) return $this->currentVersion;
280
281        $checksums = $this->getChecksums("index.php");
282        $versions = array_keys($checksums);
283        usort($versions, 'version_compare');
284        return $this->currentVersion = array_pop($versions);
285    }
286
287    /**
288     * Get the matching version of the provided path
289     *
290     * Useful for looking up the versions of old files that no longer exist in the latest image
291     *
292     * @param $path string Relative path of the file to look up
293     * @return string|bool PHP-standardized version of the file. FALSE if there is no match.
294     */
295    public function getVersion($path)
296    {
297        $actualChecksum = $this->checksumPath($path);
298        foreach ($this->getChecksums($path) as $dbVersion => $dbChecksum)
299        {
300            if ($actualChecksum === $dbChecksum) return $dbVersion;
301        }
302        return false;
303    }
304
305    /**
306     * @inheritDoc
307     */
308    public function isInsecure($path)
309    {
310        $absolutePath = $this->relativePathToAbsolutePath($path);
311        if (in_array($absolutePath, $this->existingInsecureFiles)) return true;
312        foreach ($this->existingInsecureDirectories as $existingInsecureDirectory)
313        {
314            $existingInsecureDirectory .= '/';
315            if (substr($absolutePath, 0, strlen($existingInsecureDirectory)) === $existingInsecureDirectory) return true;
316        }
317        return false;
318    }
319
320    /**
321     * Convert a custom site path to a default path
322     * @param string $path Custom path
323     * @return string
324     */
325    public function customPathToDefaultPath($path)
326    {
327        if (!is_array($this->customDirsCache)) $this->populateDirsCache();
328        foreach ($this->customDirsCache as $dirType => $customDir)
329        {
330            if (!isset($this->defaultDirsCache[$dirType])) continue;
331
332            $defaultDir = $this->defaultDirsCache[$dirType];
333            if ($customDir === $defaultDir) continue;
334
335            if (substr($path, 0, strlen($customDir)) === $customDir)
336                $path = $defaultDir . substr($path, strlen($customDir));
337        }
338        return $path;
339    }
340
341    public function defaultPathToCustomPath($path)
342    {
343        if (!is_array($this->customDirsCache)) $this->populateDirsCache();
344        foreach ($this->customDirsCache as $dirType => $customDir)
345        {
346            if (!isset($this->defaultDirsCache[$dirType])) continue;
347
348            $defaultDir = $this->defaultDirsCache[$dirType];
349            if ($customDir === $defaultDir) continue;
350
351            if (substr($path, 0, strlen($defaultDir)) === $defaultDir)
352                $path = $customDir . substr($path, strlen($defaultDir));
353        }
354        return $path;
355    }
356
357    private function getValidatedBitmask()
358    {
359        if ($this->validatedBitmask !== null) return $this->validatedBitmask;
360        $constants = (new ReflectionClass(self::class))->getConstants();
361        $validated_constants = array_filter($constants, function ($key)
362        {
363            $str = 'VALIDATED_';
364            return substr($key, 0, strlen($str)) === $str;
365        }, ARRAY_FILTER_USE_KEY);
366
367        $this->validatedBitmask = (max($validated_constants) << 0x1) - 0x1;
368        return $this->validatedBitmask;
369    }
370
371    /**
372     * @param $absolutePath
373     * @return bool
374     */
375    private function isDeterminable($absolutePath)
376    {
377        return is_file($absolutePath) && is_readable($absolutePath) && !in_array($absolutePath, $this->undeterminable);
378    }
379
380    protected function populateDirsCache()
381    {
382        $this->defaultDirsCache = e107::getInstance()->defaultDirs();
383        $customDirs = e107::getInstance()->e107_dirs ? e107::getInstance()->e107_dirs : [];
384        $this->customDirsCache = array_diff_assoc($customDirs, $this->defaultDirsCache);
385    }
386
387    /**
388     * @param $path
389     * @return false|string
390     */
391    private function relativePathToAbsolutePath($path)
392    {
393        return realpath(e_BASE . $path);
394    }
395}