diff --git a/.gitignore b/.gitignore
index eff5a4137..8ba6b35f7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,4 +26,5 @@ psalm.xml
/lib/CleantalkSP/Common/FSWatcher/Storage/data/
/lib/CleantalkSP/Common/FSWatcher/logs/
/lib/CleantalkSP/Common/Helpers/composer.json
-/tests/.phpunit.result.cache
\ No newline at end of file
+/tests/.phpunit.result.cache
+/skills/
\ No newline at end of file
diff --git a/inc/spbc-admin.php b/inc/spbc-admin.php
index 1a431d382..3a7e49ee1 100644
--- a/inc/spbc-admin.php
+++ b/inc/spbc-admin.php
@@ -848,7 +848,7 @@ function spbc_get_authorized_admins($direct_call = false)
function spbc_action_shuffle_salts()
{
- spbc_check_ajax_referer('spbc_secret_nonce', 'security');
+ spbc_check_ajax_referer('spbc_secret_nonce', 'security', true, true, true);
global $spbc;
$salts_array = array(
diff --git a/lib/CleantalkSP/SpbctWP/Activator.php b/lib/CleantalkSP/SpbctWP/Activator.php
index e4d059bb4..25670ac56 100644
--- a/lib/CleantalkSP/SpbctWP/Activator.php
+++ b/lib/CleantalkSP/SpbctWP/Activator.php
@@ -22,7 +22,7 @@ public static function activation($network, $redirect = true)
CDNHeadersChecker::sendCDNCheckerRequest();
- if ( current_action() !== 'wp_insert_site' ) {
+ if ( current_action() !== 'wp_initialize_site' ) {
delete_option('spbc_deactivation_in_process');
}
@@ -178,7 +178,8 @@ public static function addActionForNetworkBlogLegacy($wp_version)
if ( version_compare($wp_version, '5.1.0', '<') ) {
add_action('wpmu_new_blog', array(static::class, 'actionNewBlogLegacyMode'), 10, 6);
} else {
- add_action('wp_insert_site', array(static::class, 'actionNewBlog'), 10, 1);
+ // Core creates the blog tables on wp_initialize_site with priority 10, so run after it
+ add_action('wp_initialize_site', array(static::class, 'actionNewBlog'), 11, 1);
}
}
@@ -215,7 +216,7 @@ public static function actionNewBlogLegacyMode($blog_id, $_user_id, $_domain, $_
* Wrapper for spbc_activation__new_blog()
*
* @param $new_site
- * @psalm-suppress UnusedMethod - this method called via hook wp_insert_site/wpmu_new_blog
+ * @psalm-suppress UnusedMethod - this method called via hook wp_initialize_site/wpmu_new_blog
*/
public static function actionNewBlog($new_site)
{
diff --git a/lib/CleantalkSP/SpbctWP/FSWatcher/Analyzer/Analyzer.php b/lib/CleantalkSP/SpbctWP/FSWatcher/Analyzer/Analyzer.php
index 530db2f71..5f3f66f86 100644
--- a/lib/CleantalkSP/SpbctWP/FSWatcher/Analyzer/Analyzer.php
+++ b/lib/CleantalkSP/SpbctWP/FSWatcher/Analyzer/Analyzer.php
@@ -4,7 +4,7 @@
use CleantalkSP\SpbctWP\FSWatcher\Controller;
use CleantalkSP\SpbctWP\FSWatcher\Logger;
-use CleantalkSP\SpbctWP\FSWatcher\Storage\FileStorage;
+use CleantalkSP\SpbctWP\Scanner\Helper;
class Analyzer
{
@@ -44,82 +44,88 @@ public static function getCompareResult()
*/
public static function getViewFile()
{
- $path = isset($_POST['fswatcher_file_path']) ? $_POST['fswatcher_file_path'] : false;
+ $path = isset($_POST['fswatcher_file_path']) ? wp_unslash($_POST['fswatcher_file_path']) : false;
+ $first = isset($_POST['fswatcher__first_date']) ? filter_var($_POST['fswatcher__first_date'], FILTER_VALIDATE_INT) : false;
+ $second = isset($_POST['fswatcher__second_date']) ? filter_var($_POST['fswatcher__second_date'], FILTER_VALIDATE_INT) : false;
- $journals_first = isset($_POST['fswatcher__first_date']) ? $_POST['fswatcher__first_date'] : false;
- $journals_second = isset($_POST['fswatcher__second_date']) ? $_POST['fswatcher__second_date'] : false;
- $journals = array($journals_first, $journals_second);
+ if (!$path || !is_string($path)) {
+ throw new \Exception('File path is incorrect.');
+ }
- if (!$path || !is_file($path)) {
+ $real_path = realpath($path);
+ if ($real_path === false || !is_file($real_path)) {
throw new \Exception('File path is incorrect.');
}
- if (!$journals[0] || !$journals[1]) {
+ if (self::isWpConfigFile($path, $real_path) && !Helper::canViewWpConfigContents()) {
+ throw new \Exception(Helper::getWpConfigViewRestrictedMessage());
+ }
+
+ if (!$first || !$second) {
throw new \Exception('Provided journals paths are incorrect.');
}
- $path_found_in_journal = false;
+ $compare_result = self::getCompareResult();
- foreach ($journals as $journal_id) {
- if (
- self::isFileOfFSWJournal($path, $journal_id) &&
- self::isFileOfFSWJournalAfterCompareJournals($path)
- ) {
- $path_found_in_journal = true;
- break;
- }
+ if (!is_array($compare_result) || !self::isPathInCompareResult($real_path, $compare_result)) {
+ throw new \Exception('The file is out of FSWatcher journals.');
}
- if (!$path_found_in_journal) {
- throw new \Exception('The file is out of FSWatcher journals.');
+ $contents = file_get_contents($real_path);
+ if ($contents === false) {
+ throw new \Exception('File path is incorrect.');
}
- return esc_html__(file_get_contents($path));
+ return esc_html__($contents);
}
/**
- * @param $path
- * @param $journal_id
+ * Whether the file is the WordPress wp-config.php (site root or one level above), symlinks resolved.
+ *
+ * @param string $path Path as requested
+ * @param string $real_path Resolved path
* @return bool
*/
- private static function isFileOfFSWJournal($path, $journal_id)
+ private static function isWpConfigFile($path, $real_path)
{
- $storage = new FileStorage();
- $journal_parsed = $storage->getJournal($journal_id);
- $analyzer = new Analyzer();
- $journal_parsed = $analyzer->uncompress($journal_parsed, true);
- if (
- strpos($journal_parsed, $path) !== false ||
- strpos($journal_parsed, stripslashes($path)) !== false
- ) {
+ if (Helper::isWpConfigPath($path) || Helper::isWpConfigPath($real_path)) {
return true;
}
+
+ foreach (array(ABSPATH . 'wp-config.php', ABSPATH . '../wp-config.php') as $config_file) {
+ $real_config_file = realpath($config_file);
+ if ($real_config_file !== false && $real_config_file === $real_path) {
+ return true;
+ }
+ }
+
return false;
}
/**
- * @param $path
+ * Exact match of the resolved path against the paths of the journals diff.
+ *
+ * @param string $real_path
+ * @param array $compare_result Result of self::compare()
* @return bool
*/
- private static function isFileOfFSWJournalAfterCompareJournals($path)
+ private static function isPathInCompareResult($real_path, array $compare_result)
{
- $journal_result_compare = self::getCompareResult();
- $journal_result_string = '';
- foreach ($journal_result_compare as $journal_result) {
- if (count($journal_result) > 0) {
- foreach ($journal_result as $value) {
- $journal_result_string .= implode(' , ', $value);
+ foreach ($compare_result as $entries) {
+ if (!is_array($entries)) {
+ continue;
+ }
+ foreach ($entries as $entry) {
+ if (!isset($entry[0])) {
+ continue;
+ }
+ $entry_real_path = realpath((string) $entry[0]);
+ if ($entry_real_path !== false && $entry_real_path === $real_path) {
+ return true;
}
}
}
- if (
- strpos($journal_result_string, $path) !== false ||
- strpos($journal_result_string, stripslashes($path)) !== false
- ) {
- return true;
- }
-
return false;
}
diff --git a/lib/CleantalkSP/SpbctWP/FSWatcher/Controller.php b/lib/CleantalkSP/SpbctWP/FSWatcher/Controller.php
index b611a6143..85e16473c 100644
--- a/lib/CleantalkSP/SpbctWP/FSWatcher/Controller.php
+++ b/lib/CleantalkSP/SpbctWP/FSWatcher/Controller.php
@@ -122,7 +122,7 @@ private function checkRateLimit()
public static function ajaxCompare()
{
- spbc_check_ajax_referer('spbc_secret_nonce', 'security');
+ spbc_check_ajax_referer('spbc_secret_nonce', 'security', true, true, true);
if (self::$debug) {
Logger::log('run compare file system logs');
}
@@ -141,7 +141,7 @@ public static function ajaxCompare()
public function ajaxViewFile()
{
- spbc_check_ajax_referer('spbc_secret_nonce', 'security');
+ spbc_check_ajax_referer('spbc_secret_nonce', 'security', true, true, true);
if (self::$debug) {
Logger::log('run view file method');
}
@@ -165,7 +165,7 @@ public function ajaxViewFile()
*/
public function ajaxCreateSnapshot()
{
- spbc_check_ajax_referer('spbc_secret_nonce', 'security');
+ spbc_check_ajax_referer('spbc_secret_nonce', 'security', true, true, true);
if (self::$debug) {
Logger::log('run scan file system');
}
diff --git a/lib/CleantalkSP/SpbctWP/ListTable.php b/lib/CleantalkSP/SpbctWP/ListTable.php
index bec47674b..cc97bbbe6 100644
--- a/lib/CleantalkSP/SpbctWP/ListTable.php
+++ b/lib/CleantalkSP/SpbctWP/ListTable.php
@@ -1080,6 +1080,26 @@ public static function ajaxRowActionHandler()
spbc_check_ajax_referer('spbc_secret_nonce', 'security');
$action = Post::getString('add_action');
+
+ // Actions that change files, OS crontab or DB triggers shared by the whole network are for Super Admins only on a multisite
+ $network_wide_actions = array(
+ 'delete',
+ 'delete_quarantine',
+ 'replace',
+ 'quarantine',
+ 'restore',
+ 'enable_oscron_task',
+ 'disable_oscron_task',
+ 'delete-trigger',
+ );
+ if ( in_array($action, $network_wide_actions, true) && ! \CleantalkSP\SpbctWP\Scanner\Helper::canCurrentUserDoNetworkWideActions() ) {
+ wp_send_json(array(
+ 'temp_html' => '
'
+ ));
+ }
+
$is_frontend_malware_action = $action === 'disapprove_page' || $action === 'approve_page';
if ( $action !== 'restore' && ! $is_frontend_malware_action &&
diff --git a/lib/CleantalkSP/SpbctWP/Scanner/Cure.php b/lib/CleantalkSP/SpbctWP/Scanner/Cure.php
index dbfe6b2c2..ba83aa010 100644
--- a/lib/CleantalkSP/SpbctWP/Scanner/Cure.php
+++ b/lib/CleantalkSP/SpbctWP/Scanner/Cure.php
@@ -331,7 +331,7 @@ private static function idsForBulkRequest($allowed_statuses)
*/
public static function cureSelectedAction()
{
- spbc_check_ajax_referer('spbc_secret_nonce', 'security');
+ spbc_check_ajax_referer('spbc_secret_nonce', 'security', true, true, true);
$ids = self::idsForBulkRequest(array('critical', 'cure_log'));
@@ -389,7 +389,7 @@ public static function cureSelectedAction()
public static function restoreSelectedAction()
{
- spbc_check_ajax_referer('spbc_secret_nonce', 'security');
+ spbc_check_ajax_referer('spbc_secret_nonce', 'security', true, true, true);
$ids = self::idsForBulkRequest(array('cure_log'));
diff --git a/lib/CleantalkSP/SpbctWP/Scanner/Helper.php b/lib/CleantalkSP/SpbctWP/Scanner/Helper.php
index bcb947c4e..86d0b5402 100644
--- a/lib/CleantalkSP/SpbctWP/Scanner/Helper.php
+++ b/lib/CleantalkSP/SpbctWP/Scanner/Helper.php
@@ -332,6 +332,17 @@ public static function canViewWpConfigContents()
return function_exists('is_super_admin') && is_super_admin();
}
+ /**
+ * Whether the current user may act on files shared by the whole network.
+ * On a multisite the file system is shared by all subsites, so only Super Admins may do it.
+ *
+ * @return bool
+ */
+ public static function canCurrentUserDoNetworkWideActions()
+ {
+ return ! is_multisite() || self::canViewWpConfigContents();
+ }
+
/**
* @return string
*/
diff --git a/lib/CleantalkSP/SpbctWP/Scanner/ScannerActions/BackupsActions.php b/lib/CleantalkSP/SpbctWP/Scanner/ScannerActions/BackupsActions.php
index ae57289f6..d293db2cc 100644
--- a/lib/CleantalkSP/SpbctWP/Scanner/ScannerActions/BackupsActions.php
+++ b/lib/CleantalkSP/SpbctWP/Scanner/ScannerActions/BackupsActions.php
@@ -15,7 +15,7 @@ class BackupsActions
*/
protected static function checkAjaxReferer($action = 'spbc_secret_nonce', $query_arg = 'security')
{
- spbc_check_ajax_referer($action, $query_arg);
+ spbc_check_ajax_referer($action, $query_arg, true, true, true);
}
/**
diff --git a/lib/CleantalkSP/SpbctWP/Scanner/ScannerAjaxEndpoints.php b/lib/CleantalkSP/SpbctWP/Scanner/ScannerAjaxEndpoints.php
index 588296764..a0e96764a 100644
--- a/lib/CleantalkSP/SpbctWP/Scanner/ScannerAjaxEndpoints.php
+++ b/lib/CleantalkSP/SpbctWP/Scanner/ScannerAjaxEndpoints.php
@@ -33,7 +33,7 @@ public static function getLastScanInfo()
*/
public static function restoreFileFromBackup()
{
- spbc_check_ajax_referer('spbc_secret_nonce', 'security');
+ spbc_check_ajax_referer('spbc_secret_nonce', 'security', true, true, true);
$file_fast_hash = isset($_POST['file_fast_hash']) ? esc_sql($_POST['file_fast_hash']) : null;
@@ -52,7 +52,7 @@ public static function restoreFileFromBackup()
*/
public static function cureFile()
{
- spbc_check_ajax_referer('spbc_secret_nonce', 'security');
+ spbc_check_ajax_referer('spbc_secret_nonce', 'security', true, true, true);
$file_fast_hash = isset($_POST['file_fast_hash']) ? esc_sql($_POST['file_fast_hash']) : null;
@@ -139,7 +139,7 @@ public static function loadMoreScanLogs()
*/
public static function deleteFile()
{
- spbc_check_ajax_referer('spbc_secret_nonce', 'security');
+ spbc_check_ajax_referer('spbc_secret_nonce', 'security', true, true, true);
$time_start = microtime(true);
@@ -218,7 +218,7 @@ public static function deleteFileFromAnalysisLog($direct_call = false)
public static function restoreFromQuarantine($direct_call = false, $file_id = null)
{
if ( ! $direct_call) {
- spbc_check_ajax_referer('spbc_secret_nonce', 'security');
+ spbc_check_ajax_referer('spbc_secret_nonce', 'security', true, true, true);
}
$file_id = $direct_call ? $file_id : Post::get('file_id', 'hash');
@@ -277,7 +277,7 @@ public static function restoreFromQuarantine($direct_call = false, $file_id = nu
public static function doQuarantineFile($direct_call = false, $file_id = null)
{
if ( ! $direct_call ) {
- spbc_check_ajax_referer('spbc_secret_nonce', 'security');
+ spbc_check_ajax_referer('spbc_secret_nonce', 'security', true, true, true);
}
$file_id = $direct_call ? $file_id : Post::get('file_id', 'hash');
@@ -302,7 +302,7 @@ public static function doQuarantineFile($direct_call = false, $file_id = null)
public static function deleteFromQuarantine($direct_call = false, $file_id = null)
{
if ( ! $direct_call ) {
- spbc_check_ajax_referer('spbc_secret_nonce', 'security');
+ spbc_check_ajax_referer('spbc_secret_nonce', 'security', true, true, true);
}
$file_id = $direct_call ? $file_id : Post::get('file_id', 'hash');
@@ -327,7 +327,7 @@ public static function deleteFromQuarantine($direct_call = false, $file_id = nul
public static function replaceFileWithOriginal($direct_call = false, $file_id = null)
{
if ( ! $direct_call) {
- spbc_check_ajax_referer('spbc_secret_nonce', 'security');
+ spbc_check_ajax_referer('spbc_secret_nonce', 'security', true, true, true);
}
$file_id = $direct_call ? $file_id : Post::get('file_id', 'hash');
@@ -371,7 +371,7 @@ public static function viewPage()
*/
public static function viewFile()
{
- spbc_check_ajax_referer('spbc_secret_nonce', 'security');
+ spbc_check_ajax_referer('spbc_secret_nonce', 'security', true, true, true);
$output = FileSystemActions::viewFile(Post::get('file_id', 'hash'));
diff --git a/lib/CleantalkSP/SpbctWP/Settings/SettingsGeneralReact.php b/lib/CleantalkSP/SpbctWP/Settings/SettingsGeneralReact.php
index aed31a093..d83b310ab 100644
--- a/lib/CleantalkSP/SpbctWP/Settings/SettingsGeneralReact.php
+++ b/lib/CleantalkSP/SpbctWP/Settings/SettingsGeneralReact.php
@@ -233,7 +233,10 @@ private static function orderGroupElements($grouped_settings)
$ordering_ruleset = self::getOrderingRuleset();
// UI-only field (not a saved option); rendered by React ActionShuffleSalts
- $grouped_settings['auth_and_logging_in']['action_shuffle_salts'] = true;
+ // It rewrites the wp-config.php shared by the whole network, so on a multisite it is for Super Admins only
+ if ( \CleantalkSP\SpbctWP\Scanner\Helper::canCurrentUserDoNetworkWideActions() ) {
+ $grouped_settings['auth_and_logging_in']['action_shuffle_salts'] = true;
+ }
foreach ($ordering_ruleset as $group_name => $ruleset) {
$ordered = [];
diff --git a/lib/CleantalkSP/SpbctWP/Views/Settings.php b/lib/CleantalkSP/SpbctWP/Views/Settings.php
index 5c1f5c207..f2d30777d 100644
--- a/lib/CleantalkSP/SpbctWP/Views/Settings.php
+++ b/lib/CleantalkSP/SpbctWP/Views/Settings.php
@@ -108,7 +108,8 @@ public static function page()
"displayDebug" => $display_debug,
"isWPMSMainSite" => is_main_site(),
"isSecFWEnabled" => (bool) $spbc->settings['secfw__enabled'],
- "isFsWatcherEnabled" => (bool) $spbc->settings['scanner__fs_watcher'],
+ "isFsWatcherEnabled" => (bool) $spbc->settings['scanner__fs_watcher']
+ && \CleantalkSP\SpbctWP\Scanner\Helper::canCurrentUserDoNetworkWideActions(),
"isVulnerabilityCheckEnabled" => (bool) $spbc->settings['vulnerability_check__enable_cron'],
];
}
diff --git a/security-malware-firewall.php b/security-malware-firewall.php
index 1cbcee25b..6e2f7aa21 100644
--- a/security-malware-firewall.php
+++ b/security-malware-firewall.php
@@ -1437,9 +1437,10 @@ function spbc_PHP_logs__send()
* @param string|false $query_arg query arg where to search a nonce
* @param bool $die is need to stop the flow with 403 wp_die()
* @param bool $strict_admins is need to check current user capabilities comply to admin's
+ * @param bool $network_wide on a multisite also require a Super Admin (for actions on the file system or wp-config.php shared by the whole network)
* @return bool
*/
-function spbc_check_ajax_referer($action = -1, $query_arg = false, $die = true, $strict_admins = true)
+function spbc_check_ajax_referer($action = -1, $query_arg = false, $die = true, $strict_admins = true, $network_wide = false)
{
$result = true;
if (function_exists('check_ajax_referer')) {
@@ -1453,6 +1454,9 @@ function spbc_check_ajax_referer($action = -1, $query_arg = false, $die = true,
if ($result && $strict_admins) {
$result = current_user_can('manage_options');
}
+ if ($result && $network_wide) {
+ $result = \CleantalkSP\SpbctWP\Scanner\Helper::canCurrentUserDoNetworkWideActions();
+ }
if (!$result && $die) {
wp_die('-1', 403);
}
diff --git a/tests/lib/CleantalkSP/SpbctWP/ActivatorNewBlogTest.php b/tests/lib/CleantalkSP/SpbctWP/ActivatorNewBlogTest.php
new file mode 100644
index 000000000..f0eb0dccd
--- /dev/null
+++ b/tests/lib/CleantalkSP/SpbctWP/ActivatorNewBlogTest.php
@@ -0,0 +1,52 @@
+assertFalse(has_action('wp_insert_site', array(Activator::class, 'actionNewBlog')));
+ $this->assertSame(11, has_action('wp_initialize_site', array(Activator::class, 'actionNewBlog')));
+ }
+
+ /**
+ * @test
+ */
+ public function testNewBlogGetsPluginDataOnNetworkActivePlugin()
+ {
+ if ( ! is_multisite() ) {
+ $this->markTestSkipped('Multisite only, run with WP_MULTISITE=1.');
+ }
+
+ // Core creates the blog tables here, the plugin must run later
+ $this->assertSame(10, has_action('wp_initialize_site', 'wp_initialize_site'));
+
+ $plugin = 'security-malware-firewall/security-malware-firewall.php';
+ $active_plugins_backup = get_site_option('active_sitewide_plugins');
+ $active_plugins = is_array($active_plugins_backup) ? $active_plugins_backup : array();
+ $active_plugins[$plugin] = time();
+ update_site_option('active_sitewide_plugins', $active_plugins);
+
+ $blog_id = wpmu_create_blog(
+ (string) get_network()->domain,
+ '/spbc-new-blog-' . uniqid() . '/',
+ 'SPBC new blog test',
+ 1
+ );
+
+ try {
+ $this->assertIsInt($blog_id);
+ $this->assertNotFalse(get_blog_option($blog_id, 'spbc_data'));
+ } finally {
+ update_site_option('active_sitewide_plugins', $active_plugins_backup);
+ if (is_int($blog_id)) {
+ wp_delete_site($blog_id);
+ }
+ }
+ }
+}
diff --git a/tests/lib/CleantalkSP/SpbctWP/FSWatcher/AnalyzerViewFileTest.php b/tests/lib/CleantalkSP/SpbctWP/FSWatcher/AnalyzerViewFileTest.php
new file mode 100644
index 000000000..81c7abe32
--- /dev/null
+++ b/tests/lib/CleantalkSP/SpbctWP/FSWatcher/AnalyzerViewFileTest.php
@@ -0,0 +1,185 @@
+storage_backup = Controller::$storage;
+ $this->user_backup = get_current_user_id();
+ Controller::$storage = FileStorage::class;
+ wp_set_current_user(1);
+ }
+
+ protected function tearDown(): void
+ {
+ foreach ($this->files_to_remove as $file) {
+ if (is_file($file)) {
+ unlink($file);
+ }
+ }
+ $this->files_to_remove = array();
+ Controller::$storage = $this->storage_backup;
+ wp_set_current_user($this->user_backup);
+ $_POST = array();
+ parent::tearDown();
+ }
+
+ /**
+ * Site-root wp-config.php must not be readable through the viewer by a non Super Admin.
+ * @test
+ */
+ public function testWpConfigInDiffIsDeniedWithoutSuperAdmin()
+ {
+ $wp_config = $this->prepareWpConfig();
+ $this->writeJournals(array($wp_config => '1000'), array($wp_config => '2000'));
+ wp_set_current_user(0);
+
+ $result = $this->view($wp_config);
+
+ $this->assertArrayHasKey('error', $result, 'wp-config.php contents must not be returned');
+ $this->assertStringContainsString('Super Admin', $result['error']);
+ }
+
+ /**
+ * The same file stays viewable for a Super Admin.
+ * @test
+ */
+ public function testWpConfigInDiffIsReadableForSuperAdmin()
+ {
+ $wp_config = $this->prepareWpConfig();
+ $this->writeJournals(array($wp_config => '1000'), array($wp_config => '2000'));
+ wp_set_current_user(1);
+
+ $result = $this->view($wp_config);
+
+ $this->assertArrayHasKey('data', $result);
+ $this->assertStringContainsString('spbc-fsw-test-marker', $result['data']);
+ }
+
+ /**
+ * Legit case: ordinary changed file is viewable.
+ * @test
+ */
+ public function testChangedFileIsReadable()
+ {
+ $file = $this->makeTempFile('ordinary');
+ $this->writeJournals(array($file => '1000'), array($file => '2000'));
+
+ $result = $this->view($file);
+
+ $this->assertArrayHasKey('data', $result);
+ $this->assertStringContainsString('spbc-fsw-test-marker', $result['data']);
+ }
+
+ /**
+ * A file that exists but is not part of the diff must be rejected.
+ * @test
+ */
+ public function testFileOutOfDiffIsDenied()
+ {
+ $file = $this->makeTempFile('victim');
+ $other = $this->makeTempFile('other');
+ $this->writeJournals(array($other => '1000'), array($other => '2000'));
+
+ $result = $this->view($file);
+
+ $this->assertArrayHasKey('error', $result);
+ $this->assertStringContainsString('out of FSWatcher journals', $result['error']);
+ }
+
+ /**
+ * Path that is only a substring of a journal line must not pass.
+ * @test
+ */
+ public function testPathThatIsOnlySubstringOfJournalEntryIsDenied()
+ {
+ $file = $this->makeTempFile('victim');
+ $longer = $file . '.bak';
+ $this->writeJournals(array($longer => '1000'), array($longer => '2000'));
+
+ $result = $this->view($file);
+
+ $this->assertArrayHasKey('error', $result);
+ $this->assertStringContainsString('out of FSWatcher journals', $result['error']);
+ }
+
+ private function prepareWpConfig()
+ {
+ $wp_config = ABSPATH . 'wp-config.php';
+ if (file_exists($wp_config)) {
+ $this->markTestSkipped('A real wp-config.php exists in ABSPATH, it is not touched by tests.');
+ }
+ file_put_contents($wp_config, "files_to_remove[] = $wp_config;
+
+ return $wp_config;
+ }
+
+ private function makeTempFile($name)
+ {
+ $path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'spbc-fsw-' . $name . '-' . uniqid() . '.php';
+ file_put_contents($path, "files_to_remove[] = $path;
+
+ return $path;
+ }
+
+ private function writeJournals(array $first, array $second)
+ {
+ global $spbc;
+ $dir = FileStorage::getJournalsPath();
+ $key_hash = md5($spbc->settings['spbc_key']);
+ foreach (array(self::FIRST_JOURNAL => $first, self::SECOND_JOURNAL => $second) as $id => $rows) {
+ $csv = '';
+ foreach ($rows as $path => $mtime) {
+ $csv .= '"' . $path . '",' . $mtime . "\n";
+ }
+ $journal = $dir . $id . '_' . $key_hash . FileStorage::STATUS_COMPLETED . '.csv.gz';
+ $gz = gzopen($journal, 'w9');
+ gzwrite($gz, $csv);
+ gzclose($gz);
+ $this->files_to_remove[] = $journal;
+ }
+ }
+
+ /**
+ * @return array{data?: string, error?: string}
+ */
+ private function view($path)
+ {
+ $_POST = wp_slash(array(
+ 'fswatcher_file_path' => $path,
+ 'fswatcher__first_date' => (string) self::FIRST_JOURNAL,
+ 'fswatcher__second_date' => (string) self::SECOND_JOURNAL,
+ ));
+
+ try {
+ return array('data' => Analyzer::getViewFile());
+ } catch (\Exception $e) {
+ return array('error' => $e->getMessage());
+ }
+ }
+}