diff --git a/inc/spbc-settings.php b/inc/spbc-settings.php index 991cc6311..657432960 100644 --- a/inc/spbc-settings.php +++ b/inc/spbc-settings.php @@ -1711,6 +1711,7 @@ function spbc_field_scanner() . '' . __('Receiving core hashes', 'security-malware-firewall') . ' -> ' . '' . __('Receiving plugin and theme hashes', 'security-malware-firewall') . ' -> ' . '' . __('Preparing', 'security-malware-firewall') . ' -> ' + . '' . __('Counting files', 'security-malware-firewall') . ' -> ' . '' . __('Scanning for modifications', 'security-malware-firewall') . ' -> '; if ($spbc->settings['scanner__os_cron_analysis']) { diff --git a/js/spbc-scanner-plugin.min.js b/js/spbc-scanner-plugin.min.js index 3f805b82b..c5d3e2cc7 100644 --- a/js/spbc-scanner-plugin.min.js +++ b/js/spbc-scanner-plugin.min.js @@ -1,2 +1,2 @@ -class SpbcMalwareScanner{first_start=!0;active=!1;root="";settings=[];states=["get_cms_hashes","get_modules_hashes","clean_results","file_system_analysis","get_approved_hashes","get_denied_hashes","signature_analysis","heuristic_analysis","schedule_send_heuristic_suspicious_files","auto_cure_backup","auto_cure","os_cron_analysis","db_trigger_analysis","binary_analysis","outbound_links","frontend_analysis","important_files_listing","send_results"];state=null;offset=0;amount=0;amount_coefficient=1;total_scanned=0;scan_percent=0;percent_completed=0;paused=!1;button=null;spinner=null;progress_overall=null;progressbar=null;progressbar_text=null;timeout=6e4;state_timer=0;constructor(t){for(var e in jQuery("#spbcscan-results-log-module").length&&jQuery(".spbc-scan-log-title").removeClass("spbc---hidden"),void 0!==t.settings.auto_cure&&(t.settings.scanner__auto_cure_backup="1"),t)void 0!==this[e]&&(this[e]=t[e])}actionControl(){null===this.state?this.start():this.paused?(this.resume(),this.controller()):this.pause()}start(){this.active=!0,this.state_timer=Math.round((new Date).getTime()/1e3),this.state=this.getNextState(null),this.setPercents(0),this.scan_percent=0,this.offset=0,this.progress_overall.children("span").removeClass("spbc_bold").filter(".spbc_overall_scan_status_"+this.state).addClass("spbc_bold"),this.progressbar.show(500),this.progress_overall.show(500),this.button.html(spbcScaner.button_scan_pause),this.spinner.css({display:"inline"}),setTimeout(()=>{this.controller()},1e3)}pause(t,e,s){console.log("PAUSE"),this.button.html(spbcScaner.button_scan_resume),this.spinner.css({display:"none"}),this.paused=!0,this.active=!1}resume(t){console.log("RESUME"),this.button.html(spbcScaner.button_scan_pause),this.spinner.css({display:"inline"}),this.paused=!1,this.active=!0}end(t){this.progressbar.hide(500),this.progress_overall.hide(500),this.button.html(spbcScaner.button_scan_perform),this.spinner.css({display:"none"}),this.state=null,this.plug=!1,this.total_scanned=0,this.active=!1,t?document.location=document.location:spbcSendAJAXRequest({action:"spbc_scanner_tab__reload_accordion"},{notJson:!0,callback:function(t,e,s,a){jQuery(a).accordion("destroy").html(t).accordion({header:"h3",heightStyle:"content",collapsible:!0,active:!1}),spbcTblBulkActionsListen(),spbcTblRowActionsListen(),spbcTblPaginationListen(),spbcTblSortListen(),spbcStartShowHide(),spbcScannerReloadScanInfo()}},jQuery("#spbc_scan_accordion"))}controller(t){if(console.log(this.state),void 0!==t&&t.end){if(this.state=this.getNextState(this.state),void 0===this.state)return void this.end();this.setPercents(0),this.scan_percent=0,this.offset=0,this.progress_overall.children("span").removeClass("spbc_bold").filter(".spbc_overall_scan_status_"+this.state).addClass("spbc_bold")}if(!0!==this.paused){var e={action:"spbc_scanner_controller_front",method:this.state,offset:this.offset},t={type:"GET",success:this.success,callback:this.successCallback,error:this.error,errorOutput:this.errorOutput,complete:null,context:this,timeout:12e4};switch(this.state){case"get_modules_hashes":this.amount=2;break;case"clear_table":this.amount=1e4;break;case"file_system_analysis":this.amount=700;break;case"auto_cure":this.amount=5;break;case"outbound_links":this.amount=10;break;case"frontend_analysis":this.amount=spbcSettings.frontendAnalysisAmount;break;case"signature_analysis":this.amount=10,e.status="UNKNOWN,MODIFIED,OK,INFECTED,ERROR";break;case"heuristic_analysis":case"binary_analysis":this.amount=4,e.status="UNKNOWN,MODIFIED,OK,INFECTED,ERROR";break;case"schedule_send_heuristic_suspicious_files":this.amount=1}e.amount=Math.round(this.amount*this.amount_coefficient),spbcSendAJAXRequest(e,t,jQuery("#spbc_scan_accordion"))}}setCoefficients(t){let e=this.amount_coefficient;"file_system_analysis"===t&&(e*=1.5),this.amount_coefficient=e}getNextState(t){return t=null===t?this.states[0]:this.states[this.states.indexOf(t)+1],t=void 0!==this.settings["scanner__"+t]&&0==+this.settings["scanner__"+t]?this.getNextState(t):t}setPercents(t){this.percent_completed=Math.floor(100*t)/100,this.progressbar.progressbar("option","value",this.percent_completed),this.progressbar_text.text(spbcScaner["progressbar_"+this.state]+" - "+this.percent_completed+"%")}success(t){t.error?this.error({status:200,responseText:t.error},t.error,t.msg):this.successCallback&&this.successCallback(t,this.data,this.obj)}successCallback(e){if(console.log(e),this.interactAccordion(e),void 0!==e.total&&(this.scan_percent=100/e.total),void 0!==e.processed_items&&("heuristic_analysis"===this.state&&0!==typeof e.total&&this.logRaw('

Heuristic Analysis

'),"signature_analysis"===this.state&&0!==typeof e.total&&this.logRaw('

Signature Analysis

'),this.logFileEntry(e.processed_items)),void 0!==e.stage_data_for_logging&&this.logStageEntry(e.stage_data_for_logging),void 0!==e.cured&&0{this.controller(e)},300)}interactAccordion(t){t.hasOwnProperty("interactivity_data")&&t.interactivity_data.hasOwnProperty("update_text")&&t.interactivity_data.update_text&&t.interactivity_data.hasOwnProperty("refresh_data")&&t.interactivity_data.refresh_data.hasOwnProperty("do_refresh")&&t.interactivity_data.refresh_data.do_refresh&&t.interactivity_data.refresh_data.hasOwnProperty("control_tab")&&t.interactivity_data.refresh_data.control_tab&&spbcReloadAccordion(t.interactivity_data.refresh_data.control_tab,t.interactivity_data.update_text)}error(t,e,s){var a=this.errorOutput;if(console.log("%c APBCT_AJAX_ERROR","color: red;"),console.log(e),console.log(s),console.log(t),"error"==e&&(""==s||"Not found"==s)&&(this.tryCount||(this.tryCount=0,this.retryLimit=30),this.tryCount++,console.log("Try #"+this.tryCount),this.setCoefficients(this.state),this.tryCount<=this.retryLimit))this.pause(),this.resume(),this.controller();else{if(200===t.status)if("parsererror"===e)a("Unexpected response from server. See console for details.",this.state),console.log("%c "+t.responseText,"color: pink;");else{let t=e;void 0!==s&&(t+=" Additional info: "+s),a(t,this.state)}else 500===t.status?a("Internal server error.",this.state):a("Unexpected response code: "+t.status+". Error: "+e,this.state);this.progressbar&&this.progressbar.fadeOut("slow"),this.end()}}errorOutput(t,e){spbcModal.open().putError(t+"
Stage: "+e)}logRaw(t){jQuery(".spbc-scan-log-title").removeClass("spbc---hidden"),jQuery(".spbc_log-wrapper").removeClass("spbc---hidden"),jQuery(".spbc_log-wrapper .panel-body").prepend(t)}logFileEntry(t){for(var e in t)e&&this.logRaw('

'+this.getSiteUTCShiftedTimeString()+" - "+t[e].path+" - "+t[e].module+": "+t[e].status+"

")}logStageEntry(t){void 0!==jQuery(".panel-body .spbc_log-line span").first()&&void 0!==jQuery(".panel-body .spbc_log-line span").first()[0]&&jQuery(".panel-body .spbc_log-line span").first()[0].textContent===t.description||this.logRaw('

test '+this.getSiteUTCShiftedTimeString()+" - "+t.title+" "+t.description+"

")}showLinkForShuffleSalts(t){jQuery("#spbc_notice_about_shuffle_link").remove(),jQuery(jQuery(".spbc_tab--active .spbc_wrapper_field p")[1]).after('")}getSiteUTCShiftedTimeString(){let t=!1;var e=-1*(new Date).getTimezoneOffset()*1e3*60,e=(t="undefined"!=typeof spbcScaner&&void 0!==spbcScaner.timezone_shift&&!1!==spbcScaner.timezone_shift?Date.now()-e+1e3*spbcScaner.timezone_shift:t)?new Date(t):new Date,s=new Intl.DateTimeFormat("en-US",{month:"short"}).format,a=String(e.getMinutes()).padStart(2,"0"),i=String(e.getSeconds()).padStart(2,"0");return s(e)+" "+e.getDate()+" "+e.getFullYear()+" "+e.getHours()+":"+a+":"+i}} +class SpbcMalwareScanner{first_start=!0;active=!1;root="";settings=[];states=["get_cms_hashes","get_modules_hashes","clean_results","count_files","file_system_analysis","get_approved_hashes","get_denied_hashes","signature_analysis","heuristic_analysis","schedule_send_heuristic_suspicious_files","auto_cure_backup","auto_cure","os_cron_analysis","db_trigger_analysis","binary_analysis","outbound_links","frontend_analysis","important_files_listing","send_results"];state=null;offset=0;amount=0;amount_coefficient=1;total_scanned=0;scan_percent=0;percent_completed=0;paused=!1;button=null;spinner=null;progress_overall=null;progressbar=null;progressbar_text=null;timeout=6e4;state_timer=0;constructor(t){for(var e in jQuery("#spbcscan-results-log-module").length&&jQuery(".spbc-scan-log-title").removeClass("spbc---hidden"),void 0!==t.settings.auto_cure&&(t.settings.scanner__auto_cure_backup="1"),t)void 0!==this[e]&&(this[e]=t[e])}actionControl(){null===this.state?this.start():this.paused?(this.resume(),this.controller()):this.pause()}start(){this.active=!0,this.state_timer=Math.round((new Date).getTime()/1e3),this.state=this.getNextState(null),this.setPercents(0),this.scan_percent=0,this.offset=0,this.progress_overall.children("span").removeClass("spbc_bold").filter(".spbc_overall_scan_status_"+this.state).addClass("spbc_bold"),this.progressbar.show(500),this.progress_overall.show(500),this.button.html(spbcScaner.button_scan_pause),this.spinner.css({display:"inline"}),setTimeout(()=>{this.controller()},1e3)}pause(t,e,s){console.log("PAUSE"),this.button.html(spbcScaner.button_scan_resume),this.spinner.css({display:"none"}),this.paused=!0,this.active=!1}resume(t){console.log("RESUME"),this.button.html(spbcScaner.button_scan_pause),this.spinner.css({display:"inline"}),this.paused=!1,this.active=!0}end(t){this.progressbar.hide(500),this.progress_overall.hide(500),this.button.html(spbcScaner.button_scan_perform),this.spinner.css({display:"none"}),this.state=null,this.plug=!1,this.total_scanned=0,this.active=!1,t?document.location=document.location:spbcSendAJAXRequest({action:"spbc_scanner_tab__reload_accordion"},{notJson:!0,callback:function(t,e,s,a){jQuery(a).accordion("destroy").html(t).accordion({header:"h3",heightStyle:"content",collapsible:!0,active:!1}),spbcTblBulkActionsListen(),spbcTblRowActionsListen(),spbcTblPaginationListen(),spbcTblSortListen(),spbcStartShowHide(),spbcScannerReloadScanInfo()}},jQuery("#spbc_scan_accordion"))}controller(t){if(console.log(this.state),void 0!==t&&t.end){if(this.state=this.getNextState(this.state),void 0===this.state)return void this.end();this.setPercents(0),this.scan_percent=0,this.offset=0,this.progress_overall.children("span").removeClass("spbc_bold").filter(".spbc_overall_scan_status_"+this.state).addClass("spbc_bold")}if(!0!==this.paused){var e={action:"spbc_scanner_controller_front",method:this.state,offset:this.offset},t={type:"GET",success:this.success,callback:this.successCallback,error:this.error,errorOutput:this.errorOutput,complete:null,context:this,timeout:12e4};switch(this.state){case"get_modules_hashes":this.amount=2;break;case"clear_table":this.amount=1e4;break;case"file_system_analysis":this.amount=700;break;case"auto_cure":this.amount=5;break;case"outbound_links":this.amount=10;break;case"frontend_analysis":this.amount=spbcSettings.frontendAnalysisAmount;break;case"signature_analysis":this.amount=10,e.status="UNKNOWN,MODIFIED,OK,INFECTED,ERROR";break;case"heuristic_analysis":case"binary_analysis":this.amount=4,e.status="UNKNOWN,MODIFIED,OK,INFECTED,ERROR";break;case"schedule_send_heuristic_suspicious_files":this.amount=1}e.amount=Math.round(this.amount*this.amount_coefficient),spbcSendAJAXRequest(e,t,jQuery("#spbc_scan_accordion"))}}setCoefficients(t){let e=this.amount_coefficient;"file_system_analysis"===t&&(e*=1.5),this.amount_coefficient=e}getNextState(t){return t=null===t?this.states[0]:this.states[this.states.indexOf(t)+1],t=void 0!==this.settings["scanner__"+t]&&0==+this.settings["scanner__"+t]?this.getNextState(t):t}setPercents(t){this.percent_completed=Math.floor(100*t)/100,this.progressbar.progressbar("option","value",this.percent_completed),this.progressbar_text.text(spbcScaner["progressbar_"+this.state]+" - "+this.percent_completed+"%")}success(t){t.error?this.error({status:200,responseText:t.error},t.error,t.msg):this.successCallback&&this.successCallback(t,this.data,this.obj)}successCallback(e){if(console.log(e),this.interactAccordion(e),void 0!==e.total&&(this.scan_percent=100/e.total),void 0!==e.processed_items&&("heuristic_analysis"===this.state&&0!==typeof e.total&&this.logRaw('

Heuristic Analysis

'),"signature_analysis"===this.state&&0!==typeof e.total&&this.logRaw('

Signature Analysis

'),this.logFileEntry(e.processed_items)),void 0!==e.stage_data_for_logging&&this.logStageEntry(e.stage_data_for_logging),void 0!==e.cured&&0{this.controller(e)},300)}interactAccordion(t){t.hasOwnProperty("interactivity_data")&&t.interactivity_data.hasOwnProperty("update_text")&&t.interactivity_data.update_text&&t.interactivity_data.hasOwnProperty("refresh_data")&&t.interactivity_data.refresh_data.hasOwnProperty("do_refresh")&&t.interactivity_data.refresh_data.do_refresh&&t.interactivity_data.refresh_data.hasOwnProperty("control_tab")&&t.interactivity_data.refresh_data.control_tab&&spbcReloadAccordion(t.interactivity_data.refresh_data.control_tab,t.interactivity_data.update_text)}error(t,e,s){var a=this.errorOutput;if(console.log("%c APBCT_AJAX_ERROR","color: red;"),console.log(e),console.log(s),console.log(t),"error"==e&&(""==s||"Not found"==s)&&(this.tryCount||(this.tryCount=0,this.retryLimit=30),this.tryCount++,console.log("Try #"+this.tryCount),this.setCoefficients(this.state),this.tryCount<=this.retryLimit))this.pause(),this.resume(),this.controller();else{if(200===t.status)if("parsererror"===e)a("Unexpected response from server. See console for details.",this.state),console.log("%c "+t.responseText,"color: pink;");else{let t=e;void 0!==s&&(t+=" Additional info: "+s),a(t,this.state)}else 500===t.status?a("Internal server error.",this.state):a("Unexpected response code: "+t.status+". Error: "+e,this.state);this.progressbar&&this.progressbar.fadeOut("slow"),this.end()}}errorOutput(t,e){spbcModal.open().putError(t+"
Stage: "+e)}logRaw(t){jQuery(".spbc-scan-log-title").removeClass("spbc---hidden"),jQuery(".spbc_log-wrapper").removeClass("spbc---hidden"),jQuery(".spbc_log-wrapper .panel-body").prepend(t)}logFileEntry(t){for(var e in t)e&&this.logRaw('

'+this.getSiteUTCShiftedTimeString()+" - "+t[e].path+" - "+t[e].module+": "+t[e].status+"

")}logStageEntry(t){void 0!==jQuery(".panel-body .spbc_log-line span").first()&&void 0!==jQuery(".panel-body .spbc_log-line span").first()[0]&&jQuery(".panel-body .spbc_log-line span").first()[0].textContent===t.description||this.logRaw('

test '+this.getSiteUTCShiftedTimeString()+" - "+t.title+" "+t.description+"

")}showLinkForShuffleSalts(t){jQuery("#spbc_notice_about_shuffle_link").remove(),jQuery(jQuery(".spbc_tab--active .spbc_wrapper_field p")[1]).after('")}getSiteUTCShiftedTimeString(){let t=!1;var e=-1*(new Date).getTimezoneOffset()*1e3*60,e=(t="undefined"!=typeof spbcScaner&&void 0!==spbcScaner.timezone_shift&&!1!==spbcScaner.timezone_shift?Date.now()-e+1e3*spbcScaner.timezone_shift:t)?new Date(t):new Date,s=new Intl.DateTimeFormat("en-US",{month:"short"}).format,a=String(e.getMinutes()).padStart(2,"0"),i=String(e.getSeconds()).padStart(2,"0");return s(e)+" "+e.getDate()+" "+e.getFullYear()+" "+e.getHours()+":"+a+":"+i}} //# sourceMappingURL=spbc-scanner-plugin.min.js.map diff --git a/js/src/spbc-scanner-plugin.js b/js/src/spbc-scanner-plugin.js index a3990bf60..d634265e7 100644 --- a/js/src/spbc-scanner-plugin.js +++ b/js/src/spbc-scanner-plugin.js @@ -14,6 +14,7 @@ class SpbcMalwareScanner {/* eslint-disable-line no-unused-vars */ 'get_cms_hashes', 'get_modules_hashes', 'clean_results', + 'count_files', 'file_system_analysis', 'get_approved_hashes', 'get_denied_hashes', diff --git a/lib/CleantalkSP/SpbctWP/Scanner/DirectoryListingSnapshot.php b/lib/CleantalkSP/SpbctWP/Scanner/DirectoryListingSnapshot.php new file mode 100644 index 000000000..545c870d9 --- /dev/null +++ b/lib/CleantalkSP/SpbctWP/Scanner/DirectoryListingSnapshot.php @@ -0,0 +1,453 @@ + + */ + private static $instances = array(); + + /** + * @var string|null + */ + private static $storage_dir; + + /** + * @var string + */ + private $dir; + + /** + * @var string + */ + private $scope; + + /** + * @var int + */ + private $blog_id; + + public static function isAvailable() + { + $dir = self::resolveStorageDir(); + + return $dir !== '' && is_dir($dir) && is_writable($dir); + } + + /** + * @return int + */ + public static function threshold() + { + if (defined('SPBC_SCAN_DIR_SNAPSHOT_THRESHOLD')) { + return SPBC_SCAN_DIR_SNAPSHOT_THRESHOLD; + } + + return self::DEFAULT_THRESHOLD; + } + + /** + * @param string $path + * @return void + * @psalm-suppress PossiblyUnusedMethod Used from unit tests. + */ + public static function setDbPath($path) + { + if ($path === '' || $path === null) { + self::$storage_dir = null; + self::$instances = array(); + return; + } + if (is_string($path) && preg_match('/\.(sqlite|lst)$/', $path)) { + $path = dirname($path); + } + self::$storage_dir = $path; + self::$instances = array(); + } + + /** + * @return void + * @psalm-suppress PossiblyUnusedMethod Used from unit tests. + */ + public static function resetInstance() + { + self::$instances = array(); + } + + /** + * @param string $scope + * @return self + */ + public static function getInstance($scope = 'stage') + { + $dir = self::resolveStorageDir(); + if ($dir === '') { + throw new \RuntimeException('Directory listing snapshot storage is not available'); + } + $scope = self::normalizeScope($scope); + $blog_id = self::currentBlogId(); + $key = $blog_id . ':' . $scope; + if (!isset(self::$instances[$key])) { + self::$instances[$key] = new self($dir, $scope, $blog_id); + } + + return self::$instances[$key]; + } + + /** + * @param string $path + * @return array + */ + public static function collectImmediateEntries($path) + { + $entries = array(); + try { + $iterator = new \FilesystemIterator($path, \FilesystemIterator::SKIP_DOTS); + foreach ($iterator as $info) { + $name = $info->getFilename(); + $entries[] = array( + 'name' => $name, + 'path' => $path . DIRECTORY_SEPARATOR . $name, + 'is_dir' => $info->isDir() && !$info->isLink(), + ); + } + } catch (\UnexpectedValueException $e) { + return array(); + } + + return $entries; + } + + /** + * @param string $scope + * @return string + */ + public static function normalizeScope($scope) + { + return $scope === 'other' ? 'other' : 'stage'; + } + + /** + * @return int + */ + private static function currentBlogId() + { + if (function_exists('get_current_blog_id')) { + return (int) get_current_blog_id(); + } + + return 0; + } + + /** + * Private writable directory only. No document-root fallback + * (.htaccess is ignored by Nginx). + * + * @return string Empty when snapshots must stay disabled. + */ + private static function resolveStorageDir() + { + if (self::$storage_dir) { + if (is_dir(self::$storage_dir) || @mkdir(self::$storage_dir, 0700, true)) { + return is_writable(self::$storage_dir) ? self::$storage_dir : ''; + } + + return ''; + } + + $site_key = defined('ABSPATH') + ? substr(hash('sha256', ABSPATH), 0, 16) + : 'default'; + $temp = rtrim(sys_get_temp_dir(), '/\\') . '/spbc_scan_dir_snapshot_' . $site_key; + if (is_dir($temp) || @mkdir($temp, 0700, true)) { + if (is_writable($temp)) { + return $temp; + } + } + + return ''; + } + + /** + * @param string $dir + * @param string $scope + * @param int $blog_id + */ + private function __construct($dir, $scope, $blog_id) + { + if (!is_dir($dir) && !@mkdir($dir, 0755, true) && !is_dir($dir)) { + throw new \RuntimeException('Cannot create directory listing snapshot storage'); + } + self::protectStorage($dir); + $this->dir = $dir; + $this->scope = self::normalizeScope($scope); + $this->blog_id = (int) $blog_id; + $blog_dir = $this->blogDir(); + if (!is_dir($blog_dir) && !@mkdir($blog_dir, 0755, true) && !is_dir($blog_dir)) { + throw new \RuntimeException('Cannot create directory listing snapshot blog scope'); + } + self::protectStorage($blog_dir); + $scope_dir = $this->scopeDir(); + if (!is_dir($scope_dir) && !@mkdir($scope_dir, 0755, true) && !is_dir($scope_dir)) { + throw new \RuntimeException('Cannot create directory listing snapshot scope'); + } + self::protectStorage($scope_dir); + } + + /** + * @param string $parent_dir + * @return bool + * @psalm-suppress PossiblyUnusedMethod Called from Surface via getInstance(). + */ + public function hasListing($parent_dir) + { + return is_file($this->listingPath($parent_dir)); + } + + /** + * @param string $parent_dir + * @param array|null $entries + * @return void + * @psalm-suppress PossiblyUnusedMethod Called from Surface via getInstance(). + */ + public function materialize($parent_dir, $entries = null) + { + $lock = $this->acquireListingLock($parent_dir); + try { + if ($this->hasListing($parent_dir)) { + return; + } + $this->writeListing($parent_dir, $entries); + } finally { + $this->releaseListingLock($lock); + } + } + + /** + * @param string $parent_dir + * @param array|null $entries + * @return void + */ + private function writeListing($parent_dir, $entries) + { + if ($entries === null) { + $entries = self::collectImmediateEntries($parent_dir); + } + + usort($entries, function ($a, $b) { + return strcmp($a['name'], $b['name']); + }); + + $path = $this->listingPath($parent_dir); + $tmp = $path . '.tmp'; + $fh = fopen($tmp, 'wb'); + if ($fh === false) { + throw new \RuntimeException('Cannot write directory listing snapshot'); + } + try { + foreach ($entries as $entry) { + $is_dir = !empty($entry['is_dir']) ? 1 : 0; + $line = $is_dir . "\t" . rawurlencode($entry['name']) . "\n"; + $written = fwrite($fh, $line); + if ($written === false || $written !== strlen($line)) { + throw new \RuntimeException('Cannot write directory listing snapshot'); + } + } + if (!fflush($fh)) { + throw new \RuntimeException('Cannot write directory listing snapshot'); + } + } catch (\Exception $e) { + fclose($fh); + @unlink($tmp); + throw $e; + } + fclose($fh); + if (!@rename($tmp, $path)) { + @unlink($path); + if (!@rename($tmp, $path)) { + @unlink($tmp); + throw new \RuntimeException('Cannot publish directory listing snapshot'); + } + } + } + + /** + * @param string $parent_dir + * @param string $after_name Exclusive name cursor (name > after), same order as scandir+SORT_STRING. + * @return \Generator + * @psalm-suppress PossiblyUnusedMethod Called from Surface via getInstance(). + */ + public function iterate($parent_dir, $after_name = '') + { + $fh = $this->openListingHandle($parent_dir); + + return $this->iterateFromHandle($fh, $parent_dir, $after_name); + } + + /** + * @param string $parent_dir + * @return resource + */ + private function openListingHandle($parent_dir) + { + $path = $this->listingPath($parent_dir); + $fh = @fopen($path, 'rb'); + if ($fh === false) { + throw new \RuntimeException('Cannot read directory listing snapshot'); + } + + return $fh; + } + + /** + * @param resource $fh + * @param string $parent_dir + * @param string $after_name + * @return \Generator + */ + private function iterateFromHandle($fh, $parent_dir, $after_name) + { + try { + while (($line = fgets($fh)) !== false) { + $line = rtrim($line, "\r\n"); + if ($line === '') { + continue; + } + $tab = strpos($line, "\t"); + if ($tab === false) { + continue; + } + $name = rawurldecode(substr($line, $tab + 1)); + if ($after_name !== '' && strcmp($name, $after_name) <= 0) { + continue; + } + yield array( + 'name' => $name, + 'path' => $parent_dir . DIRECTORY_SEPARATOR . $name, + 'is_dir' => substr($line, 0, $tab) === '1', + ); + } + } finally { + fclose($fh); + } + } + + /** + * @param string $parent_dir + * @return void + * @psalm-suppress PossiblyUnusedMethod Called from Surface via getInstance(). + */ + public function dropListing($parent_dir) + { + $path = $this->listingPath($parent_dir); + if (is_file($path)) { + @unlink($path); + } + } + + /** + * @return void + * @psalm-suppress PossiblyUnusedMethod Called from Surface via getInstance(). + */ + public function purgeAll() + { + $scope_dir = $this->scopeDir(); + if (!is_dir($scope_dir)) { + return; + } + foreach (glob($scope_dir . '/*.lst') ?: array() as $file) { + @unlink($file); + } + } + + /** + * @param string $parent_dir + * @return resource + */ + private function acquireListingLock($parent_dir) + { + $lock_path = $this->listingPath($parent_dir) . '.lock'; + $fh = fopen($lock_path, 'c'); + if ($fh === false) { + throw new \RuntimeException('Cannot lock directory listing snapshot'); + } + if (!flock($fh, LOCK_EX)) { + fclose($fh); + throw new \RuntimeException('Cannot lock directory listing snapshot'); + } + + return $fh; + } + + /** + * @param resource $fh + * @return void + */ + private function releaseListingLock($fh) + { + flock($fh, LOCK_UN); + fclose($fh); + } + + private function listingPath($parent_dir) + { + return $this->scopeDir() . '/' . hash('sha256', $parent_dir) . '.lst'; + } + + private function blogDir() + { + return $this->dir . '/' . $this->blog_id; + } + + private function scopeDir() + { + return $this->blogDir() . '/' . $this->scope; + } + + /** + * @param string $dir + * @return void + */ + private static function protectStorage($dir) + { + if (!is_dir($dir)) { + return; + } + + $htaccess = $dir . '/.htaccess'; + if (!is_file($htaccess)) { + @file_put_contents( + $htaccess, + "\nRequire all denied\n\n" + . "\nDeny from all\n\n" + ); + } + + $web_config = $dir . '/web.config'; + if (!is_file($web_config)) { + @file_put_contents( + $web_config, + "\n" + . "" + . "\n" + ); + } + + $index = $dir . '/index.php'; + if (!is_file($index)) { + @file_put_contents($index, "checkWpVersionConsistency(); - if ($version_check !== null) { - return $version_check; - } - global $spbc; - $offset = isset($offset) ? $offset : $this->offset; - $amount = isset($amount) ? $amount : $this->amount; - $path_to_scan = realpath($path_to_scan); - $scanning_stages_storage = new ScanningStagesStorage(); - $scanning_stages_storage->converter->loadCollection(); - $stage_data_obj = $scanning_stages_storage->getStage(FileSystemAnalysis::class); - $init_params = array( 'fast_hash' => true, 'full_hash' => true, 'offset' => $offset, 'amount' => $amount, 'extensions' => 'php, html, htm, js, php2, php3, php4, php5, php6, php7, phtml, shtml, phar, odf, [ot.]', - 'extensions_exceptions' => '', //array('jpg', 'jpeg', 'png', 'gif', 'css', 'txt', 'zip', 'xml', 'json') + 'extensions_exceptions' => '', 'file_exceptions' => array(), 'files_mandatory' => array(), 'dir_exceptions' => array(SPBC_PLUGIN_DIR . 'quarantine'), - // important! - 'running_due_stage' => true + 'running_due_stage' => true, ); if ( ! empty($spbc->settings['scanner__path_exclusions']) ) { $exclusions = array_map('trim', explode("\n", $spbc->settings['scanner__path_exclusions'])); - $dir_exceptions = []; - $file_exceptions = []; + $dir_exceptions = array(); + $file_exceptions = array(); foreach ($exclusions as $exclusion) { if ($exclusion === '') { @@ -1004,6 +990,100 @@ public function file_system_analysis($offset = null, $amount = null, $path_to_sc : $file_exceptions; } + return $init_params; + } + + /** + * Count files to scan. Separate HTTP stage so the first collection request + * does not also walk the whole tree. + * + * @param string $path_to_scan + * @return array + */ + public function count_files($path_to_scan = ABSPATH) // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps + { + $version_check = $this->checkWpVersionConsistency(); + if ($version_check !== null) { + return $version_check; + } + + global $spbc; + + $path_to_scan = realpath($path_to_scan); + $scanning_stages_storage = new ScanningStagesStorage(); + $scanning_stages_storage->converter->loadCollection(); + $stage_data_obj = $scanning_stages_storage->getStage(CountFiles::class); + + $init_params = $this->getFileSystemSurfaceParams(); + $init_params['count'] = true; + unset($init_params['amount'], $init_params['offset']); + + $scanner = new Surface($path_to_scan, $this->root, $init_params); + if ($scanner->has_errors) { + return array('error' => __FUNCTION__ . ' Counting files internal error.'); + } + + $total = (int) $scanner->output_files_count; + if ($total === 0) { + return array('error' => __FUNCTION__ . ' No files to scan'); + } + + $spbc->data['scanner']['files_total'] = $total; + $spbc->save('data'); + + $stage_data_obj->set('total_count_files', $total); + $scanning_stages_storage->saveToDb(); + + ScanningLogFacade::writeToLog( + '' . $stage_data_obj::getTitle() . ' ' . $stage_data_obj->getDescription() + ); + + Cron::updateTask( + 'upload_dir_prevent_php_execution', + 'spbc_upload_dir_prevent_php_execution', + 86400, + time() + 100 + ); + + return array( + 'end' => 1, + 'processed' => $total, + 'total' => $total, + 'stage_data_for_logging' => array( + 'title' => $stage_data_obj::getTitle(), + 'description' => $stage_data_obj->getDescription(), + ), + ); + } + + /** + * Scan file system for alterations + * Save it to DB/Storage + * + * @param int $offset + * @param int $amount + * @param string $path_to_scan + * + * @return array|string[] + */ + public function file_system_analysis($offset = null, $amount = null, $path_to_scan = ABSPATH) // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps + { + $version_check = $this->checkWpVersionConsistency(); + if ($version_check !== null) { + return $version_check; + } + + global $spbc; + + $offset = isset($offset) ? $offset : $this->offset; + $amount = isset($amount) ? $amount : $this->amount; + $path_to_scan = realpath($path_to_scan); + $scanning_stages_storage = new ScanningStagesStorage(); + $scanning_stages_storage->converter->loadCollection(); + $stage_data_obj = $scanning_stages_storage->getStage(FileSystemAnalysis::class); + + $init_params = $this->getFileSystemSurfaceParams($offset, $amount); + $scanner = new Surface($path_to_scan, $this->root, $init_params); if ($scanner->has_errors) { @@ -1204,18 +1284,8 @@ public function file_system_analysis($offset = null, $amount = null, $path_to_sc ); } - // Count files if it's first iteration - if ( $offset === 0 ) { - $init_params['count'] = true; - unset($init_params['amount'], $init_params['offset']); - $scanner = new Surface($path_to_scan, $this->root, $init_params); - if ($scanner->has_errors) { - return array('error' => __FUNCTION__ . ' Counting files internal error.'); - } - $spbc->data['scanner']['files_total'] = $scanner->output_files_count; - $spbc->save('data'); - $output['total'] = $scanner->output_files_count; - Cron::updateTask('upload_dir_prevent_php_execution', 'spbc_upload_dir_prevent_php_execution', 86400, time() + 100); + if ( empty($output['error']) && isset($spbc->data['scanner']['files_total']) ) { + $output['total'] = (int) $spbc->data['scanner']['files_total']; } $scanning_stages_storage->saveToDb(); diff --git a/lib/CleantalkSP/SpbctWP/Scanner/ScanningStagesModule/Stages/CountFiles.php b/lib/CleantalkSP/SpbctWP/Scanner/ScanningStagesModule/Stages/CountFiles.php new file mode 100644 index 000000000..c5efd0435 --- /dev/null +++ b/lib/CleantalkSP/SpbctWP/Scanner/ScanningStagesModule/Stages/CountFiles.php @@ -0,0 +1,36 @@ +total_count_files + . '.'; + } + + public function getName() + { + return __CLASS__; + } + + public function getData() + { + return array( + 'total_count_files' => $this->total_count_files, + ); + } +} diff --git a/lib/CleantalkSP/SpbctWP/Scanner/Surface.php b/lib/CleantalkSP/SpbctWP/Scanner/Surface.php index 4f18b6072..aa5ebf105 100644 --- a/lib/CleantalkSP/SpbctWP/Scanner/Surface.php +++ b/lib/CleantalkSP/SpbctWP/Scanner/Surface.php @@ -143,6 +143,12 @@ class Surface */ const SPBC_SURFACE__ALLOWED_MAX_FILE_SIZE = 2 * 1024 * 1024; + /** + * Last collected basename in the interrupted directory (snapshot cursor). + * @var string + */ + private $last_entry_on_exit = ''; + public function __construct($path, $rootpath, $params = array('count' => true)) { /** @@ -251,6 +257,9 @@ private function init($path, $rootpath, $params) // extract last dir offset interrupted from saved offset data $this->output_files_offset = $this->last_iterator_result->on_exit_dir_offset; + $this->last_entry_on_exit = isset($this->last_iterator_result->on_exit_entry_name) + ? (string) $this->last_iterator_result->on_exit_entry_name + : ''; // Processing filters $this->ext = ! empty($params['extensions']) ? $this->filterParams($params['extensions']) : array(); @@ -426,6 +435,13 @@ private function resetIteratorDataOption() global $spbc; $spbc->data[$this->data_option_name] = new IteratorResult(); $spbc->save('data'); + try { + if (DirectoryListingSnapshot::isAvailable()) { + $this->snapshotStore()->purgeAll(); + } + } catch (\Exception $e) { + // Snapshot is optional; scandir fallback still works. + } } /** @@ -518,8 +534,22 @@ public function countFilesMandatory($files) public function countFilesInDir($main_path) { try { + $directory_iterator = new \RecursiveDirectoryIterator($main_path, \FilesystemIterator::SKIP_DOTS); + $filtered_iterator = new \RecursiveCallbackFilterIterator( + $directory_iterator, + function ($current) { + if ($current->isLink()) { + return false; + } + if ($current->isDir()) { + return !$this->isExcludedDirectory($current->getPathname()); + } + + return true; + } + ); $iterator = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($main_path, \FilesystemIterator::SKIP_DOTS), + $filtered_iterator, \RecursiveIteratorIterator::SELF_FIRST ); @@ -546,7 +576,7 @@ public function countFilesInDir($main_path) $probably_binary = BinaryCheckModule::probablyBinaryExecutableFile($real_path, $current_file_extension); $skip_by_restrictions = $this->skipFileByRestrictions($current_file_extension, $real_path); - $skip_by_filename = ! empty($this->files_except) && in_array($fileInfo->getPathname(), $this->files_except, true); + $skip_by_filename = $this->isExcludedFileByName($fileInfo->getPathname()); if (!$probably_binary && ($skip_by_restrictions || $skip_by_filename)) { continue; @@ -608,66 +638,52 @@ public function getFileStructure($main_path, $iterator_result, $is_root_dir) } try { - $scan = @scandir($main_path, SCANDIR_SORT_NONE); - if ( $scan === false ) { - return $iterator_result; - } - sort($scan, SORT_STRING); + $use_snapshot = false; + $entries = $this->iterateDirectoryEntries($main_path, $use_snapshot); $iterator_last_dir_offset = 0; - foreach ( $scan as $file_name ) { - if ( $file_name === '..' || $file_name === '.' ) { - continue; - } - - $path = $main_path . DIRECTORY_SEPARATOR . $file_name; + foreach ( $entries as $entry ) { + $file_name = $entry['name']; + $path = $entry['path']; if ( is_file($path) && !is_link($path) ) { - // $current_file_extension = pathinfo($path, PATHINFO_EXTENSION); $probably_binary = BinaryCheckModule::probablyBinaryExecutableFile($path, $current_file_extension); $skip_by_restrictions = $this->skipFileByRestrictions($current_file_extension, $path); - $skip_by_filename = false; - - // Filenames exception filter - if (!empty($this->files_except) && is_array($this->files_except)) { - $files_except_array = array_map(function ($item) { - return ltrim($item, '/\\'); - }, $this->files_except); - $relative_path = str_replace(realpath(ABSPATH), '', $path); - if (DIRECTORY_SEPARATOR === '\\') { - $relative_path = str_replace('/', '\\', $relative_path); - } - $skip_by_filename = in_array(ltrim($relative_path, '/\\'), $files_except_array, true); - } + $skip_by_filename = $this->isExcludedFileByName($path); - //main skip logic check if (!$probably_binary && ($skip_by_restrictions || $skip_by_filename)) { continue; } - //do collection work $this->output_counter_files++; - // Resume in this directory (root or subdir): skip first on_exit_dir_offset matching files. $iterator_last_dir_offset++; - if ( $main_path === $this->last_dir_on_exit && $iterator_last_dir_offset - 1 < $this->output_files_offset ) { + if ( + ! $use_snapshot + && $main_path === $this->last_dir_on_exit + && $iterator_last_dir_offset - 1 < $this->output_files_offset + ) { continue; } $this->output_files[]['path'] = $path; - // Return if file limit is reached - if ($this->output_counter_files >= $this->output_files_maximum ) { + $chunk_amount = max(1, $this->output_files_maximum - $this->output_files_offset); + $hit_limit = $use_snapshot + ? count($this->output_files) >= $chunk_amount + : $this->output_counter_files >= $this->output_files_maximum; + + if ($hit_limit) { $iterator_result->max_files_interrupt = true; $iterator_result->on_exit_dir_path = $main_path; $iterator_result->on_exit_dir_offset = $iterator_last_dir_offset; + $iterator_result->on_exit_entry_name = $file_name; return $iterator_result; } } elseif ( is_dir($path) && !is_link($path) ) { - // Directory names filter $relative_path = str_replace(realpath(ABSPATH), '', $path); if (DIRECTORY_SEPARATOR === '\\') { $relative_path = str_replace('/', '\\', $relative_path); @@ -677,13 +693,10 @@ public function getFileStructure($main_path, $iterator_result, $is_root_dir) continue(2); } } - //skip already handled dirs if (in_array($path, $iterator_result->completed_dirs)) { continue; } - // new sub-dir iteration start $iterator_result = $this->getFileStructure($path, $iterator_result, false); - // if iteration is interrupted by files limit, return current results if ($iterator_result->max_files_interrupt) { return $iterator_result; } @@ -695,6 +708,13 @@ public function getFileStructure($main_path, $iterator_result, $is_root_dir) // foreach is finished - iterator completed with no interrupts, save the dir to the completed set if (!$is_root_dir) { $iterator_result->completed_dirs[] = $main_path; + try { + if (DirectoryListingSnapshot::isAvailable()) { + $this->snapshotStore()->dropListing($main_path); + } + } catch (\Exception $e) { + // Ignore snapshot cleanup failures. + } } } catch ( \Exception $exception ) { return $iterator_result; @@ -707,6 +727,120 @@ public function getFileStructure($main_path, $iterator_result, $is_root_dir) return $iterator_result; } + /** + * @return DirectoryListingSnapshot + */ + private function snapshotStore() + { + return DirectoryListingSnapshot::getInstance($this->running_due_stage ? 'stage' : 'other'); + } + + /** + * @param string $main_path + * @param bool $use_snapshot + * @return iterable + */ + private function iterateDirectoryEntries($main_path, &$use_snapshot) + { + $use_snapshot = false; + $resume_name = ($main_path === $this->last_dir_on_exit) ? $this->last_entry_on_exit : ''; + + try { + if (DirectoryListingSnapshot::isAvailable() && DirectoryListingSnapshot::threshold() > 0) { + $store = $this->snapshotStore(); + if ($store->hasListing($main_path)) { + $use_snapshot = true; + + return $store->iterate($main_path, $resume_name); + } + + $entries = DirectoryListingSnapshot::collectImmediateEntries($main_path); + if (count($entries) > DirectoryListingSnapshot::threshold()) { + $store->materialize($main_path, $entries); + $use_snapshot = true; + + return $store->iterate($main_path, $resume_name); + } + + usort($entries, function ($a, $b) { + return strcmp($a['name'], $b['name']); + }); + + return $entries; + } + } catch (\Exception $e) { + $use_snapshot = false; + } + + $scan = @scandir($main_path, SCANDIR_SORT_NONE); + if ($scan === false) { + return array(); + } + sort($scan, SORT_STRING); + + $entries = array(); + foreach ($scan as $file_name) { + if ($file_name === '.' || $file_name === '..') { + continue; + } + $path = $main_path . DIRECTORY_SEPARATOR . $file_name; + $entries[] = array( + 'name' => $file_name, + 'path' => $path, + 'is_dir' => is_dir($path) && !is_link($path), + ); + } + + return $entries; + } + + /** + * @param string $absolute_path + * @return bool + */ + private function isExcludedFileByName($absolute_path) + { + if (empty($this->files_except) || !is_array($this->files_except)) { + return false; + } + + $files_except_array = array_map(function ($item) { + return ltrim($item, '/\\'); + }, $this->files_except); + $root = function_exists('realpath') ? realpath(ABSPATH) : ABSPATH; + $relative_path = str_replace($root, '', $absolute_path); + if (DIRECTORY_SEPARATOR === '\\') { + $relative_path = str_replace('/', '\\', $relative_path); + } + + return in_array(ltrim($relative_path, '/\\'), $files_except_array, true); + } + + /** + * @param string $absolute_path + * @return bool + */ + private function isExcludedDirectory($absolute_path) + { + if (empty($this->dirs_except)) { + return false; + } + + $root = function_exists('realpath') ? realpath(ABSPATH) : ABSPATH; + $relative_path = str_replace($root, '', $absolute_path); + if (DIRECTORY_SEPARATOR === '\\') { + $relative_path = str_replace('/', '\\', $relative_path); + } + + foreach ($this->dirs_except as $dir_except) { + if (strpos($relative_path, $dir_except) === 0) { + return true; + } + } + + return false; + } + /** * Getting file details like last modified time, size, permissions * diff --git a/lib/CleantalkSP/SpbctWP/SpbcEnqueue.php b/lib/CleantalkSP/SpbctWP/SpbcEnqueue.php index 462dd8344..da5f69873 100644 --- a/lib/CleantalkSP/SpbctWP/SpbcEnqueue.php +++ b/lib/CleantalkSP/SpbctWP/SpbcEnqueue.php @@ -332,6 +332,7 @@ function ($key) { 'progressbar_get_denied_hashes' => __('Updating statuses for the denied files', 'security-malware-firewall'), 'progressbar_clean_results' => __('Preparing', 'security-malware-firewall'), // Scanning core + 'progressbar_count_files' => __('Counting files', 'security-malware-firewall'), 'progressbar_file_system_analysis' => __('Scanning for modifications', 'security-malware-firewall'), 'progressbar_heuristic_analysis' => __('Heuristic analysis', 'security-malware-firewall'), 'progressbar_schedule_send_heuristic_suspicious_files' => __('Schedule suspicious files to be automatically sent for analysis', 'security-malware-firewall'), diff --git a/security-malware-firewall.php b/security-malware-firewall.php index 543a47724..5daa84009 100644 --- a/security-malware-firewall.php +++ b/security-malware-firewall.php @@ -99,6 +99,10 @@ define('SPBC_REMOTE_CALL_SLEEP', 10); //Minimum time between remote call define('SPBC_LAST_ACTIONS_TO_VIEW', 20); //Nubmer of last actions to show in plugin settings page. +if (!defined('SPBC_SCAN_DIR_SNAPSHOT_THRESHOLD')) { + define('SPBC_SCAN_DIR_SNAPSHOT_THRESHOLD', 2000); +} + // Auth params define('SPBC_2FA_KEY_TTL', 600); // 2fa key lifetime in seconds diff --git a/tests/lib/CleantalkSP/SpbctWP/Scanner/DirectoryListingSnapshotTest.php b/tests/lib/CleantalkSP/SpbctWP/Scanner/DirectoryListingSnapshotTest.php new file mode 100644 index 000000000..5ff0e3fcd --- /dev/null +++ b/tests/lib/CleantalkSP/SpbctWP/Scanner/DirectoryListingSnapshotTest.php @@ -0,0 +1,207 @@ +markTestSkipped('Directory listing snapshot storage is not writable'); + } + + $this->dir = sys_get_temp_dir() . '/spbc_snap_' . uniqid(); + mkdir($this->dir); + $this->db = $this->dir . '_store'; + mkdir($this->db); + DirectoryListingSnapshot::setDbPath($this->db); + + for ($i = 0; $i < 25; $i++) { + file_put_contents($this->dir . '/f' . sprintf('%02d', $i) . '.php', "dir . '/sub'); + file_put_contents($this->dir . '/sub/x.php', "removeTree($this->dir); + $this->removeTree($this->db); + parent::tearDown(); + } + + public function testSnapshotMatchesScandirOrderAndDoesNotSkip() + { + $store = DirectoryListingSnapshot::getInstance(); + $store->materialize($this->dir); + + $from_snapshot = array(); + foreach ($store->iterate($this->dir) as $entry) { + $from_snapshot[] = $entry['name']; + } + + $from_scandir = scandir($this->dir, SCANDIR_SORT_NONE); + $from_scandir = array_values(array_diff($from_scandir, array('.', '..'))); + sort($from_scandir, SORT_STRING); + + $this->assertSame($from_scandir, $from_snapshot); + $this->assertCount(26, $from_snapshot); + } + + public function testNameCursorDoesNotSkipRemainingFiles() + { + $store = DirectoryListingSnapshot::getInstance(); + $store->materialize($this->dir); + + $first = array(); + foreach ($store->iterate($this->dir) as $entry) { + if ($entry['is_dir']) { + continue; + } + $first[] = $entry['name']; + if (count($first) === 10) { + break; + } + } + + $rest = array(); + foreach ($store->iterate($this->dir, end($first)) as $entry) { + if (!$entry['is_dir']) { + $rest[] = $entry['name']; + } + } + + $this->assertCount(15, $rest); + $this->assertEmpty(array_intersect($first, $rest)); + + $all = array_merge($first, $rest); + sort($all, SORT_STRING); + $expected = array(); + for ($i = 0; $i < 25; $i++) { + $expected[] = 'f' . sprintf('%02d', $i) . '.php'; + } + $this->assertSame($expected, $all); + } + + public function testPurgeAllDoesNotTouchOtherScope() + { + $stage = DirectoryListingSnapshot::getInstance('stage'); + $other = DirectoryListingSnapshot::getInstance('other'); + $stage->materialize($this->dir); + $other->materialize($this->dir); + + $this->assertTrue($stage->hasListing($this->dir)); + $this->assertTrue($other->hasListing($this->dir)); + + $other->purgeAll(); + + $this->assertTrue($stage->hasListing($this->dir)); + $this->assertFalse($other->hasListing($this->dir)); + } + + public function testMaterializeAcceptsPrecollectedEntries() + { + $entries = DirectoryListingSnapshot::collectImmediateEntries($this->dir); + $this->assertCount(26, $entries); + + $store = DirectoryListingSnapshot::getInstance(); + $store->materialize($this->dir, $entries); + + $from_snapshot = array(); + foreach ($store->iterate($this->dir) as $entry) { + $from_snapshot[] = $entry['name']; + } + $expected = array(); + foreach ($entries as $entry) { + $expected[] = $entry['name']; + } + sort($expected, SORT_STRING); + $this->assertSame($expected, $from_snapshot); + } + + public function testPreservesTabAndNewlineInFileName() + { + file_put_contents($this->dir . "/a\tb.php", "dir . "/c\nd.php", "materialize($this->dir); + $names = array(); + foreach ($store->iterate($this->dir) as $entry) { + $names[] = $entry['name']; + } + $this->assertContains("a\tb.php", $names); + $this->assertContains("c\nd.php", $names); + } + + public function testIterateThrowsWhenListingDisappears() + { + $store = DirectoryListingSnapshot::getInstance(); + $store->materialize($this->dir); + $this->assertTrue($store->hasListing($this->dir)); + $store->dropListing($this->dir); + $this->expectException(\RuntimeException::class); + $store->iterate($this->dir); + } + + public function testListingsAreScopedByBlogId() + { + $store = DirectoryListingSnapshot::getInstance(); + $store->materialize($this->dir); + $blog_id = function_exists('get_current_blog_id') ? (int) get_current_blog_id() : 0; + $found = glob($this->db . '/' . $blog_id . '/stage/*.lst') ?: array(); + $this->assertNotEmpty($found); + $this->assertEmpty(glob($this->db . '/stage/*.lst') ?: array()); + } + + public function testMaterializeIsIdempotentWhenListingExists() + { + $store = DirectoryListingSnapshot::getInstance(); + $store->materialize($this->dir); + $first = array(); + foreach ($store->iterate($this->dir) as $entry) { + $first[] = $entry['name']; + } + $store->materialize($this->dir, array()); + $second = array(); + foreach ($store->iterate($this->dir) as $entry) { + $second[] = $entry['name']; + } + $this->assertSame($first, $second); + $this->assertNotEmpty($second); + } + + public function testStorageIsNotUnderUploads() + { + DirectoryListingSnapshot::resetInstance(); + DirectoryListingSnapshot::setDbPath(''); + DirectoryListingSnapshot::getInstance(); + $site_key = substr(hash('sha256', ABSPATH), 0, 16); + $dir = rtrim(sys_get_temp_dir(), '/\\') . '/spbc_scan_dir_snapshot_' . $site_key; + $this->assertDirectoryExists($dir); + $this->assertStringNotContainsString('/uploads/spbc_scan_dir_snapshot', $dir); + $this->assertStringNotContainsString('security-malware-firewall', $dir); + $this->assertStringStartsWith(rtrim(sys_get_temp_dir(), '/\\'), $dir); + $this->assertFileExists($dir . '/.htaccess'); + $this->assertFileExists($dir . '/index.php'); + } + + private function removeTree($dir) + { + if (!is_dir($dir)) { + return; + } + $it = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($it as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + @rmdir($dir); + } +} diff --git a/tests/lib/CleantalkSP/SpbctWP/Scanner/classSurface/SurfaceTest.php b/tests/lib/CleantalkSP/SpbctWP/Scanner/classSurface/SurfaceTest.php index 9dc79e1ee..e906be7b6 100644 --- a/tests/lib/CleantalkSP/SpbctWP/Scanner/classSurface/SurfaceTest.php +++ b/tests/lib/CleantalkSP/SpbctWP/Scanner/classSurface/SurfaceTest.php @@ -21,6 +21,25 @@ private function initParams() ); } + public function testCountSkipsRelativeFileExceptions() + { + $path_to_scan = __DIR__ . '/test_just_root_directory'; + $excluded = realpath($path_to_scan . '/test1.php'); + $root = realpath(ABSPATH); + $this->assertNotFalse($excluded); + $this->assertNotFalse($root); + + $relative = ltrim(str_replace($root, '', $excluded), '/\\'); + $init_params = $this->initParams(); + $init_params['count'] = true; + $init_params['file_exceptions'] = array($relative); + unset($init_params['amount'], $init_params['offset']); + + $scanner = new Surface($path_to_scan, __DIR__, $init_params); + $this->assertFalse($scanner->has_errors); + $this->assertEquals(2, $scanner->output_files_count); + } + public function testSurfaceRoot() { $init_params = $this->initParams();