From f1fa0d788632cc1fa1f0f73b0d4b64c6923e63d7 Mon Sep 17 00:00:00 2001 From: svedge Date: Sun, 20 Sep 2026 16:17:52 +0500 Subject: [PATCH 1/5] Upd. Scan. Improve surface flow. --- inc/spbc-settings.php | 1 + js/spbc-scanner-plugin.min.js | 2 +- js/src/spbc-scanner-plugin.js | 1 + .../Scanner/DirectoryListingSnapshot.php | 255 ++++++++++++++++++ .../SpbctWP/Scanner/IteratorResult.php | 6 + .../SpbctWP/Scanner/ScannerQueue.php | 144 +++++++--- .../Stages/CountFiles.php | 36 +++ lib/CleantalkSP/SpbctWP/Scanner/Surface.php | 153 +++++++++-- lib/CleantalkSP/SpbctWP/SpbcEnqueue.php | 1 + security-malware-firewall.php | 4 + .../Scanner/DirectoryListingSnapshotTest.php | 105 ++++++++ 11 files changed, 646 insertions(+), 62 deletions(-) create mode 100644 lib/CleantalkSP/SpbctWP/Scanner/DirectoryListingSnapshot.php create mode 100644 lib/CleantalkSP/SpbctWP/Scanner/ScanningStagesModule/Stages/CountFiles.php create mode 100644 tests/lib/CleantalkSP/SpbctWP/Scanner/DirectoryListingSnapshotTest.php 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..2591d02fa --- /dev/null +++ b/lib/CleantalkSP/SpbctWP/Scanner/DirectoryListingSnapshot.php @@ -0,0 +1,255 @@ + $threshold; + } + + private static function resolveStorageDir() + { + if (self::$storage_dir) { + return self::$storage_dir; + } + + if (defined('WP_CONTENT_DIR')) { + $dir = WP_CONTENT_DIR . '/uploads/spbc_scan_dir_snapshot'; + if (is_dir($dir) || @mkdir($dir, 0755, true)) { + return $dir; + } + } + + return sys_get_temp_dir() . '/spbc_scan_dir_snapshot'; + } + + private function __construct($dir) + { + if (!is_dir($dir) && !@mkdir($dir, 0755, true) && !is_dir($dir)) { + throw new \RuntimeException('Cannot create directory listing snapshot storage'); + } + $this->dir = $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 + * @return void + * @psalm-suppress PossiblyUnusedMethod Called from Surface via getInstance(). + */ + public function materialize($parent_dir) + { + $entries = array(); + $iterator = new \FilesystemIterator($parent_dir, \FilesystemIterator::SKIP_DOTS); + foreach ($iterator as $info) { + $entries[] = array( + 'name' => $info->getFilename(), + 'is_dir' => $info->isDir() && !$info->isLink() ? 1 : 0, + ); + } + + 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'); + } + foreach ($entries as $entry) { + $name = str_replace(array("\t", "\n", "\r"), '', $entry['name']); + fwrite($fh, $entry['is_dir'] . "\t" . $name . "\n"); + } + 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 = '') + { + $path = $this->listingPath($parent_dir); + $fh = @fopen($path, 'rb'); + if ($fh === false) { + return; + } + + while (($line = fgets($fh)) !== false) { + $line = rtrim($line, "\r\n"); + if ($line === '') { + continue; + } + $tab = strpos($line, "\t"); + if ($tab === false) { + continue; + } + $name = 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', + ); + } + 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() + { + if (!is_dir($this->dir)) { + return; + } + foreach (glob($this->dir . '/*.lst') ?: array() as $file) { + @unlink($file); + } + } + + private function listingPath($parent_dir) + { + return $this->dir . '/' . hash('sha256', $parent_dir) . '.lst'; + } +} diff --git a/lib/CleantalkSP/SpbctWP/Scanner/IteratorResult.php b/lib/CleantalkSP/SpbctWP/Scanner/IteratorResult.php index c7e69d330..7eb105d43 100644 --- a/lib/CleantalkSP/SpbctWP/Scanner/IteratorResult.php +++ b/lib/CleantalkSP/SpbctWP/Scanner/IteratorResult.php @@ -16,6 +16,12 @@ class IteratorResult */ public $on_exit_dir_offset = 0; + /** + * Last collected file name in the interrupted directory (snapshot pagination cursor). + * @var string + */ + public $on_exit_entry_name = ''; + /** * Already completed dirs. Cleans up on every stage iteration, the new set will be set from the database. * @var array diff --git a/lib/CleantalkSP/SpbctWP/Scanner/ScannerQueue.php b/lib/CleantalkSP/SpbctWP/Scanner/ScannerQueue.php index 2c0957dff..9eeb2adea 100644 --- a/lib/CleantalkSP/SpbctWP/Scanner/ScannerQueue.php +++ b/lib/CleantalkSP/SpbctWP/Scanner/ScannerQueue.php @@ -23,6 +23,7 @@ use CleantalkSP\SpbctWP\Scanner\ScanningStagesModule\ScannerFileStatuses; use CleantalkSP\SpbctWP\Scanner\ScanningStagesModule\ScanningStagesStorage; use CleantalkSP\SpbctWP\Scanner\ScanningStagesModule\Stages\DBTriggerAnalysis; +use CleantalkSP\SpbctWP\Scanner\ScanningStagesModule\Stages\CountFiles; use CleantalkSP\SpbctWP\Scanner\ScanningStagesModule\Stages\FileSystemAnalysis; use CleantalkSP\SpbctWP\Scanner\ScanningStagesModule\Stages\FrontendAnalysis; use CleantalkSP\SpbctWP\Scanner\ScanningStagesModule\Stages\GetApprovedHashes; @@ -54,6 +55,7 @@ class ScannerQueue 'get_cms_hashes', 'get_modules_hashes', 'clean_results', + 'count_files', 'file_system_analysis', 'os_cron_analysis', 'db_trigger_analysis', @@ -935,49 +937,33 @@ public function countFilesByStatusAndChecked($status = '', $caller = '') } /** - * Scan file system for alterations - * Save it to DB/Storage - * - * @param int $offset - * @param int $amount - * @param string $path_to_scan + * Shared Surface filters for count and file-system collection. * - * @return array|string[] + * @param int|null $offset + * @param int|null $amount + * @return array */ - public function file_system_analysis($offset = null, $amount = null, $path_to_scan = ABSPATH) // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps + private function getFileSystemSurfaceParams($offset = null, $amount = null) { - $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 = 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..dc80db4d8 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()) { + DirectoryListingSnapshot::getInstance()->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 ); @@ -608,30 +638,22 @@ 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, '/\\'); @@ -643,31 +665,36 @@ public function getFileStructure($main_path, $iterator_result, $is_root_dir) $skip_by_filename = in_array(ltrim($relative_path, '/\\'), $files_except_array, true); } - //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 +704,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 +719,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()) { + DirectoryListingSnapshot::getInstance()->dropListing($main_path); + } + } catch (\Exception $e) { + // Ignore snapshot cleanup failures. + } } } catch ( \Exception $exception ) { return $iterator_result; @@ -707,6 +738,80 @@ public function getFileStructure($main_path, $iterator_result, $is_root_dir) return $iterator_result; } + /** + * @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 = DirectoryListingSnapshot::getInstance(); + $has_listing = $store->hasListing($main_path); + if ($has_listing || DirectoryListingSnapshot::shouldSnapshot($main_path)) { + if (!$has_listing) { + $store->materialize($main_path); + } + $use_snapshot = true; + + return $store->iterate($main_path, $resume_name); + } + } + } 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 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..da4e6e3b8 --- /dev/null +++ b/tests/lib/CleantalkSP/SpbctWP/Scanner/DirectoryListingSnapshotTest.php @@ -0,0 +1,105 @@ +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); + } + + 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); + } +} From 47516c88d4eddc5719bae647491baff0b4a4b26f Mon Sep 17 00:00:00 2001 From: svedge Date: Sun, 20 Sep 2026 16:54:24 +0500 Subject: [PATCH 2/5] fix cp --- .gitignore | 1 + .../Scanner/DirectoryListingSnapshot.php | 161 +++++++++++++----- lib/CleantalkSP/SpbctWP/Scanner/Surface.php | 34 +++- .../Scanner/DirectoryListingSnapshotTest.php | 49 ++++++ 4 files changed, 196 insertions(+), 49 deletions(-) diff --git a/.gitignore b/.gitignore index eff5a4137..5f0c7e1ce 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ backups /node_modules/ /.idea/ /quarantine/* +/dir_snapshots/* /vendor/ composer.lock psalm.xml diff --git a/lib/CleantalkSP/SpbctWP/Scanner/DirectoryListingSnapshot.php b/lib/CleantalkSP/SpbctWP/Scanner/DirectoryListingSnapshot.php index 2591d02fa..6ddd8c47b 100644 --- a/lib/CleantalkSP/SpbctWP/Scanner/DirectoryListingSnapshot.php +++ b/lib/CleantalkSP/SpbctWP/Scanner/DirectoryListingSnapshot.php @@ -7,17 +7,17 @@ * * Fat directories are materialized once (one FilesystemIterator + one sort), * then paginated with a name cursor. Subsequent chunks never scandir+sort - * the same folder again. Storage is plain files so hosts without pdo_sqlite - * still get the speedup. + * the same folder again. Storage is outside uploads and scoped per scan + * owner (stage vs other) so cleanup cannot wipe a parallel walk. */ class DirectoryListingSnapshot { const DEFAULT_THRESHOLD = 2000; /** - * @var self|null + * @var array */ - private static $instance; + private static $instances = array(); /** * @var string @@ -29,6 +29,11 @@ class DirectoryListingSnapshot */ private $dir; + /** + * @var string + */ + private $scope; + public static function isAvailable() { $dir = self::resolveStorageDir(); @@ -58,11 +63,16 @@ public static function threshold() */ 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::$instance = null; + self::$instances = array(); } /** @@ -71,44 +81,54 @@ public static function setDbPath($path) */ public static function resetInstance() { - self::$instance = null; + self::$instances = array(); } /** + * @param string $scope * @return self */ - public static function getInstance() + public static function getInstance($scope = 'stage') { - if (self::$instance === null) { - self::$instance = new self(self::resolveStorageDir()); + $scope = self::normalizeScope($scope); + if (!isset(self::$instances[$scope])) { + self::$instances[$scope] = new self(self::resolveStorageDir(), $scope); } - return self::$instance; + return self::$instances[$scope]; } - public static function countImmediateEntries($path) + /** + * @param string $path + * @return array + */ + public static function collectImmediateEntries($path) { + $entries = array(); try { $iterator = new \FilesystemIterator($path, \FilesystemIterator::SKIP_DOTS); - - return iterator_count($iterator); + 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 0; + return array(); } + + return $entries; } - public static function shouldSnapshot($path) + /** + * @param string $scope + * @return string + */ + public static function normalizeScope($scope) { - if (!self::isAvailable()) { - return false; - } - - $threshold = self::threshold(); - if ($threshold <= 0) { - return false; - } - - return self::countImmediateEntries($path) > $threshold; + return $scope === 'other' ? 'other' : 'stage'; } private static function resolveStorageDir() @@ -117,22 +137,43 @@ private static function resolveStorageDir() return self::$storage_dir; } - if (defined('WP_CONTENT_DIR')) { - $dir = WP_CONTENT_DIR . '/uploads/spbc_scan_dir_snapshot'; + $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; + } + } + + if (defined('SPBC_PLUGIN_DIR')) { + $dir = SPBC_PLUGIN_DIR . 'dir_snapshots'; if (is_dir($dir) || @mkdir($dir, 0755, true)) { return $dir; } } - return sys_get_temp_dir() . '/spbc_scan_dir_snapshot'; + return $temp; } - private function __construct($dir) + /** + * @param string $dir + * @param string $scope + */ + private function __construct($dir, $scope) { 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); + $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); } /** @@ -147,18 +188,14 @@ public function hasListing($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) + public function materialize($parent_dir, $entries = null) { - $entries = array(); - $iterator = new \FilesystemIterator($parent_dir, \FilesystemIterator::SKIP_DOTS); - foreach ($iterator as $info) { - $entries[] = array( - 'name' => $info->getFilename(), - 'is_dir' => $info->isDir() && !$info->isLink() ? 1 : 0, - ); + if ($entries === null) { + $entries = self::collectImmediateEntries($parent_dir); } usort($entries, function ($a, $b) { @@ -173,7 +210,8 @@ public function materialize($parent_dir) } foreach ($entries as $entry) { $name = str_replace(array("\t", "\n", "\r"), '', $entry['name']); - fwrite($fh, $entry['is_dir'] . "\t" . $name . "\n"); + $is_dir = !empty($entry['is_dir']) ? 1 : 0; + fwrite($fh, $is_dir . "\t" . $name . "\n"); } fclose($fh); if (!@rename($tmp, $path)) { @@ -240,16 +278,57 @@ public function dropListing($parent_dir) */ public function purgeAll() { - if (!is_dir($this->dir)) { + $scope_dir = $this->scopeDir(); + if (!is_dir($scope_dir)) { return; } - foreach (glob($this->dir . '/*.lst') ?: array() as $file) { + foreach (glob($scope_dir . '/*.lst') ?: array() as $file) { @unlink($file); } } private function listingPath($parent_dir) { - return $this->dir . '/' . hash('sha256', $parent_dir) . '.lst'; + return $this->scopeDir() . '/' . hash('sha256', $parent_dir) . '.lst'; + } + + private function scopeDir() + { + return $this->dir . '/' . $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, "save('data'); try { if (DirectoryListingSnapshot::isAvailable()) { - DirectoryListingSnapshot::getInstance()->purgeAll(); + $this->snapshotStore()->purgeAll(); } } catch (\Exception $e) { // Snapshot is optional; scandir fallback still works. @@ -721,7 +721,7 @@ public function getFileStructure($main_path, $iterator_result, $is_root_dir) $iterator_result->completed_dirs[] = $main_path; try { if (DirectoryListingSnapshot::isAvailable()) { - DirectoryListingSnapshot::getInstance()->dropListing($main_path); + $this->snapshotStore()->dropListing($main_path); } } catch (\Exception $e) { // Ignore snapshot cleanup failures. @@ -738,6 +738,14 @@ 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 @@ -750,16 +758,26 @@ private function iterateDirectoryEntries($main_path, &$use_snapshot) try { if (DirectoryListingSnapshot::isAvailable() && DirectoryListingSnapshot::threshold() > 0) { - $store = DirectoryListingSnapshot::getInstance(); - $has_listing = $store->hasListing($main_path); - if ($has_listing || DirectoryListingSnapshot::shouldSnapshot($main_path)) { - if (!$has_listing) { - $store->materialize($main_path); - } + $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; diff --git a/tests/lib/CleantalkSP/SpbctWP/Scanner/DirectoryListingSnapshotTest.php b/tests/lib/CleantalkSP/SpbctWP/Scanner/DirectoryListingSnapshotTest.php index da4e6e3b8..2f0aa40ee 100644 --- a/tests/lib/CleantalkSP/SpbctWP/Scanner/DirectoryListingSnapshotTest.php +++ b/tests/lib/CleantalkSP/SpbctWP/Scanner/DirectoryListingSnapshotTest.php @@ -88,6 +88,55 @@ public function testNameCursorDoesNotSkipRemainingFiles() $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 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->assertFileExists($dir . '/.htaccess'); + $this->assertFileExists($dir . '/index.php'); + } + private function removeTree($dir) { if (!is_dir($dir)) { From dc16c17eada1beabcf07221b1e20bcc939dcc652 Mon Sep 17 00:00:00 2001 From: svedge Date: Sun, 20 Sep 2026 17:19:32 +0500 Subject: [PATCH 3/5] fix cp --- .gitignore | 1 - .../Scanner/DirectoryListingSnapshot.php | 117 ++++++++++++------ lib/CleantalkSP/SpbctWP/Scanner/Surface.php | 4 +- .../Scanner/DirectoryListingSnapshotTest.php | 26 ++++ 4 files changed, 107 insertions(+), 41 deletions(-) diff --git a/.gitignore b/.gitignore index 5f0c7e1ce..eff5a4137 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,6 @@ backups /node_modules/ /.idea/ /quarantine/* -/dir_snapshots/* /vendor/ composer.lock psalm.xml diff --git a/lib/CleantalkSP/SpbctWP/Scanner/DirectoryListingSnapshot.php b/lib/CleantalkSP/SpbctWP/Scanner/DirectoryListingSnapshot.php index 6ddd8c47b..30a72ea60 100644 --- a/lib/CleantalkSP/SpbctWP/Scanner/DirectoryListingSnapshot.php +++ b/lib/CleantalkSP/SpbctWP/Scanner/DirectoryListingSnapshot.php @@ -20,7 +20,7 @@ class DirectoryListingSnapshot private static $instances = array(); /** - * @var string + * @var string|null */ private static $storage_dir; @@ -37,11 +37,8 @@ class DirectoryListingSnapshot public static function isAvailable() { $dir = self::resolveStorageDir(); - if (is_dir($dir)) { - return is_writable($dir); - } - return @mkdir($dir, 0755, true) && is_writable($dir); + return $dir !== '' && is_dir($dir) && is_writable($dir); } /** @@ -90,9 +87,13 @@ public static function resetInstance() */ 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); if (!isset(self::$instances[$scope])) { - self::$instances[$scope] = new self(self::resolveStorageDir(), $scope); + self::$instances[$scope] = new self($dir, $scope); } return self::$instances[$scope]; @@ -131,10 +132,20 @@ public static function normalizeScope($scope) return $scope === 'other' ? 'other' : 'stage'; } + /** + * 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) { - return 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') @@ -147,14 +158,7 @@ private static function resolveStorageDir() } } - if (defined('SPBC_PLUGIN_DIR')) { - $dir = SPBC_PLUGIN_DIR . 'dir_snapshots'; - if (is_dir($dir) || @mkdir($dir, 0755, true)) { - return $dir; - } - } - - return $temp; + return ''; } /** @@ -208,10 +212,22 @@ public function materialize($parent_dir, $entries = null) if ($fh === false) { throw new \RuntimeException('Cannot write directory listing snapshot'); } - foreach ($entries as $entry) { - $name = str_replace(array("\t", "\n", "\r"), '', $entry['name']); - $is_dir = !empty($entry['is_dir']) ? 1 : 0; - fwrite($fh, $is_dir . "\t" . $name . "\n"); + 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)) { @@ -230,33 +246,58 @@ public function materialize($parent_dir, $entries = null) * @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) { - return; + throw new \RuntimeException('Cannot read directory listing snapshot'); } - while (($line = fgets($fh)) !== false) { - $line = rtrim($line, "\r\n"); - if ($line === '') { - continue; - } - $tab = strpos($line, "\t"); - if ($tab === false) { - continue; - } - $name = substr($line, $tab + 1); - if ($after_name !== '' && strcmp($name, $after_name) <= 0) { - continue; + 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', + ); } - yield array( - 'name' => $name, - 'path' => $parent_dir . DIRECTORY_SEPARATOR . $name, - 'is_dir' => substr($line, 0, $tab) === '1', - ); + } finally { + fclose($fh); } - fclose($fh); } /** diff --git a/lib/CleantalkSP/SpbctWP/Scanner/Surface.php b/lib/CleantalkSP/SpbctWP/Scanner/Surface.php index 7e6386b8b..893b120db 100644 --- a/lib/CleantalkSP/SpbctWP/Scanner/Surface.php +++ b/lib/CleantalkSP/SpbctWP/Scanner/Surface.php @@ -762,7 +762,7 @@ private function iterateDirectoryEntries($main_path, &$use_snapshot) if ($store->hasListing($main_path)) { $use_snapshot = true; - return $store->iterate($main_path, $resume_name); + return iterator_to_array($store->iterate($main_path, $resume_name), false); } $entries = DirectoryListingSnapshot::collectImmediateEntries($main_path); @@ -770,7 +770,7 @@ private function iterateDirectoryEntries($main_path, &$use_snapshot) $store->materialize($main_path, $entries); $use_snapshot = true; - return $store->iterate($main_path, $resume_name); + return iterator_to_array($store->iterate($main_path, $resume_name), false); } usort($entries, function ($a, $b) { diff --git a/tests/lib/CleantalkSP/SpbctWP/Scanner/DirectoryListingSnapshotTest.php b/tests/lib/CleantalkSP/SpbctWP/Scanner/DirectoryListingSnapshotTest.php index 2f0aa40ee..36c61fb3e 100644 --- a/tests/lib/CleantalkSP/SpbctWP/Scanner/DirectoryListingSnapshotTest.php +++ b/tests/lib/CleantalkSP/SpbctWP/Scanner/DirectoryListingSnapshotTest.php @@ -124,6 +124,30 @@ public function testMaterializeAcceptsPrecollectedEntries() $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 testStorageIsNotUnderUploads() { DirectoryListingSnapshot::resetInstance(); @@ -133,6 +157,8 @@ public function testStorageIsNotUnderUploads() $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'); } From 43a6a75e151b4da1c25f7ef2652a3547bd600561 Mon Sep 17 00:00:00 2001 From: svedge Date: Sun, 20 Sep 2026 17:51:46 +0500 Subject: [PATCH 4/5] fix cp --- lib/CleantalkSP/SpbctWP/Scanner/Surface.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/CleantalkSP/SpbctWP/Scanner/Surface.php b/lib/CleantalkSP/SpbctWP/Scanner/Surface.php index 893b120db..7e6386b8b 100644 --- a/lib/CleantalkSP/SpbctWP/Scanner/Surface.php +++ b/lib/CleantalkSP/SpbctWP/Scanner/Surface.php @@ -762,7 +762,7 @@ private function iterateDirectoryEntries($main_path, &$use_snapshot) if ($store->hasListing($main_path)) { $use_snapshot = true; - return iterator_to_array($store->iterate($main_path, $resume_name), false); + return $store->iterate($main_path, $resume_name); } $entries = DirectoryListingSnapshot::collectImmediateEntries($main_path); @@ -770,7 +770,7 @@ private function iterateDirectoryEntries($main_path, &$use_snapshot) $store->materialize($main_path, $entries); $use_snapshot = true; - return iterator_to_array($store->iterate($main_path, $resume_name), false); + return $store->iterate($main_path, $resume_name); } usort($entries, function ($a, $b) { From 888b3fc7bab13e6304c58477d25826688d702e3c Mon Sep 17 00:00:00 2001 From: svedge Date: Sun, 20 Sep 2026 18:18:34 +0500 Subject: [PATCH 5/5] fix cp --- .../Scanner/DirectoryListingSnapshot.php | 92 +++++++++++++++++-- lib/CleantalkSP/SpbctWP/Scanner/Surface.php | 37 +++++--- .../Scanner/DirectoryListingSnapshotTest.php | 27 ++++++ .../Scanner/classSurface/SurfaceTest.php | 19 ++++ 4 files changed, 155 insertions(+), 20 deletions(-) diff --git a/lib/CleantalkSP/SpbctWP/Scanner/DirectoryListingSnapshot.php b/lib/CleantalkSP/SpbctWP/Scanner/DirectoryListingSnapshot.php index 30a72ea60..545c870d9 100644 --- a/lib/CleantalkSP/SpbctWP/Scanner/DirectoryListingSnapshot.php +++ b/lib/CleantalkSP/SpbctWP/Scanner/DirectoryListingSnapshot.php @@ -7,8 +7,8 @@ * * Fat directories are materialized once (one FilesystemIterator + one sort), * then paginated with a name cursor. Subsequent chunks never scandir+sort - * the same folder again. Storage is outside uploads and scoped per scan - * owner (stage vs other) so cleanup cannot wipe a parallel walk. + * the same folder again. Storage is outside uploads and scoped per blog + * and scan owner (stage vs other) so cleanup cannot wipe a parallel walk. */ class DirectoryListingSnapshot { @@ -34,6 +34,11 @@ class DirectoryListingSnapshot */ private $scope; + /** + * @var int + */ + private $blog_id; + public static function isAvailable() { $dir = self::resolveStorageDir(); @@ -92,11 +97,13 @@ public static function getInstance($scope = 'stage') throw new \RuntimeException('Directory listing snapshot storage is not available'); } $scope = self::normalizeScope($scope); - if (!isset(self::$instances[$scope])) { - self::$instances[$scope] = new self($dir, $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[$scope]; + return self::$instances[$key]; } /** @@ -132,6 +139,18 @@ 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). @@ -164,8 +183,9 @@ private static function resolveStorageDir() /** * @param string $dir * @param string $scope + * @param int $blog_id */ - private function __construct($dir, $scope) + 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'); @@ -173,6 +193,12 @@ private function __construct($dir, $scope) 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'); @@ -197,6 +223,24 @@ public function hasListing($parent_dir) * @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); @@ -328,14 +372,48 @@ public function purgeAll() } } + /** + * @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->dir . '/' . $this->scope; + return $this->blogDir() . '/' . $this->scope; } /** diff --git a/lib/CleantalkSP/SpbctWP/Scanner/Surface.php b/lib/CleantalkSP/SpbctWP/Scanner/Surface.php index 7e6386b8b..aa5ebf105 100644 --- a/lib/CleantalkSP/SpbctWP/Scanner/Surface.php +++ b/lib/CleantalkSP/SpbctWP/Scanner/Surface.php @@ -576,7 +576,7 @@ function ($current) { $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; @@ -652,18 +652,7 @@ public function getFileStructure($main_path, $iterator_result, $is_root_dir) $probably_binary = BinaryCheckModule::probablyBinaryExecutableFile($path, $current_file_extension); $skip_by_restrictions = $this->skipFileByRestrictions($current_file_extension, $path); - $skip_by_filename = false; - - 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); if (!$probably_binary && ($skip_by_restrictions || $skip_by_filename)) { continue; @@ -805,6 +794,28 @@ private function iterateDirectoryEntries($main_path, &$use_snapshot) 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 diff --git a/tests/lib/CleantalkSP/SpbctWP/Scanner/DirectoryListingSnapshotTest.php b/tests/lib/CleantalkSP/SpbctWP/Scanner/DirectoryListingSnapshotTest.php index 36c61fb3e..5ff0e3fcd 100644 --- a/tests/lib/CleantalkSP/SpbctWP/Scanner/DirectoryListingSnapshotTest.php +++ b/tests/lib/CleantalkSP/SpbctWP/Scanner/DirectoryListingSnapshotTest.php @@ -148,6 +148,33 @@ public function testIterateThrowsWhenListingDisappears() $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(); 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();