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('
'),"signature_analysis"===this.state&&0!==typeof e.total&&this.logRaw(''),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(''),"signature_analysis"===this.state&&0!==typeof e.total&&this.logRaw(''),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();