def __init__(self, files=None, string=None): self.files = files if files else [] # Init config parser self.parser = configparser.ConfigParser() # Load default configuration self.parser.read_dict({"wpwatcher": self.DEFAULT_CONFIG}) if string: self.parser.read_string(string) else: if not self.files: self.files = self.find_config_files() if not self.files: log.info( "Could not find default config: `~/.wpwatcher/wpwatcher.conf`, `~/wpwatcher.conf` or `./wpwatcher.conf`" ) else: for f in self.files: try: with open(f, "r") as fp: self.parser.read_file(fp) except (FileNotFoundError, OSError) as err: raise ValueError( "Could not read config %s. Make sure the file exists and you have correct access right." % (f) ) from err
def __init__(self, files=None, string=None): self.files = files if files else [] # Init config parser self.parser = configparser.ConfigParser() # Load default configuration self.parser.read_dict({'wpwatcher': self.DEFAULT_CONFIG}) if string: self.parser.read_string(string) elif not self.files or len(self.files) == 0: self.files = self.find_config_files() if not self.files: log.info( "Could not find default config: `~/.wpwatcher/wpwatcher.conf`, `~/wpwatcher.conf` or `./wpwatcher.conf`" ) if self.files: for f in self.files: try: with open(f, 'r') as fp: self.parser.read_file(fp) except (OSError): log.error( "Could not read config %s. Make sure the file exists and you have correct access right." % (f)) raise # No config file notice else: log.info("No config file loaded")
def print_progress_bar(count: int, total: int) -> None: """Helper method to print progress bar. Stolen on the web""" size = 0.3 # size of progress bar percent = int(float(count) / float(total) * 100) log.info( f"Progress - [{'=' * int(int(percent) * size)}{' ' * int((100 - int(percent)) * size)}] {percent}% - {count} / {total}" )
def _notify(self, wp_site, wp_report, last_wp_report): # Send the report to if len(self.email_errors_to) > 0 and wp_report['status'] == 'ERROR': to = ','.join(self.email_errors_to) else: to = ','.join(wp_site['email_to'] + self.email_to) if not to: log.info( "Not sending WPWatcher %s email report because no email is configured for site %s" % (wp_report['status'], wp_report['site'])) return while mail_lock.locked(): time.sleep(0.01) try: with mail_lock: self.send_report(wp_report, to) return True # Handle send mail error except (smtplib.SMTPException, ConnectionRefusedError, TimeoutError): log.error("Unable to send mail report for site " + wp_site['url'] + "\n" + traceback.format_exc()) wp_report['errors'].append("Unable to send mail report" + "\n" + traceback.format_exc()) raise RuntimeError("Unable to send mail report")
def update_wpscan(self): # Update wpscan database log.info("Updating WPScan") exit_code, _ = self._wpscan("--update") if exit_code != 0: log.error("Error updating WPScan") exit(-1)
def _handle_wpscan_err_follow_redirect( self, failed_process: subprocess.CompletedProcess ) -> subprocess.CompletedProcess: # type: ignore [type-arg] """Parse URL in WPScan output and retry. """ if "The URL supplied redirects to" in failed_process.stdout: urls = re.findall( r"http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+", failed_process.stdout.split("The URL supplied redirects to") [1], ) if len(urls) == 1: url = urls[0].strip() log.info(f"Following redirection to {url}") cmd = failed_process.args cmd[cmd.index('--url') + 1] = url return self._wpscan(*cmd) else: raise ValueError( f"Could not parse the URL to follow in WPScan output after words 'The URL supplied redirects to'\nOutput:\n{failed_process.stdout}" ) else: return failed_process
def interrupt(self, sig=None, frame=None): '''Interrupt sequence''' log.error("Interrupting...") # If called inside ThreadPoolExecutor, raise Exeception if not isinstance(threading.current_thread(), threading._MainThread): raise InterruptedError() # Cancel all scans self.cancel_pending_futures() # future scans # Wait all scans finished self.scanner.cancel_scans() # running scans # Give a 5 seconds timeout to buggy WPScan jobs to finish or ignore them try: timeout(5, self.executor.shutdown, kwargs=dict(wait=True)) except TimeoutError: pass # Recover reports from futures results new_reports = [] for f in self.futures: if f.done(): try: new_reports.append(f.result()) except Exception: pass # Display results and quit self.print_scanned_sites_results(new_reports) log.info("Scans interrupted.") sys.exit(-1)
def _run_scans(self, wp_sites: List[Site]) -> ReportCollection: """ Helper method to deal with : - executor, concurent futures - Trigger self.interrupt() on InterruptedError (raised if fail fast enabled) - Append result to `self.new_reports` list. """ log.info(f"Starting scans on {len(wp_sites)} configured sites") # reset new reports and scanned sites list. self._futures.clear() self.new_reports.clear() self.scanner.scanned_sites.clear() for wp_site in wp_sites: self._futures.append( self._executor.submit(self._scan_site, wp_site)) for f in self._futures: try: self.new_reports.append(f.result()) except concurrent.futures.CancelledError: pass # Ensure everything is down self._cancel_pending_futures() return self.new_reports
def interrupt(self, sig=None, frame=None) -> None: # type: ignore [no-untyped-def] """Interrupt the program and exit. """ log.error("Interrupting...") # If called inside ThreadPoolExecutor, raise Exeception if not isinstance( threading.current_thread(), threading._MainThread): # type: ignore [attr-defined] raise InterruptedError() self.interrupt_scans() # Give a 5 seconds timeout to buggy WPScan jobs to finish or ignore them try: timeout(5, self._executor.shutdown, kwargs=dict(wait=True)) except TimeoutError: pass # Display results log.info(repr(self.new_reports)) log.info("Scans interrupted.") # and quit sys.exit(-1)
def run_scans_and_notify(self): # Check sites are in the config if len(self.wp_sites) == 0: log.error( "No sites configured, please provide wp_sites in config file or use arguments --url URL [URL...] or --urls File path" ) return ((-1, [])) new_reports = self.run_scans_wrapper(self.wp_sites) # Print results and finish self.print_scanned_sites_results(new_reports) # Second scans if needed if len(self.scanner.prescanned_sites_warn) > 0: new_reports += self.re_run_scans( self.scanner.prescanned_sites_warn) self.print_scanned_sites_results(new_reports) if not any([r['status'] == 'ERROR' for r in new_reports if r]): log.info("Scans finished successfully.") return ((0, new_reports)) else: log.info("Scans finished with errors.") return ((-1, new_reports))
def update_wpscan(self): # Update wpscan database log.info("Updating WPScan") exit_code, out = self._wpscan("--update", "--format", "json", "--no-banner") if exit_code != 0: raise Exception("Error updating WPScan.\nOutput:\n{}".format(out))
def _scan_site(self, wp_site: Site) -> Optional[ScanReport]: """ Helper method to wrap the scanning process of `WPWatcherScanner.scan_site` and add the following: - Find the last report in the database and launch the scan - Write it in DB after scan. - Print progress bar This function can be called asynchronously. """ last_wp_report = self.wp_reports.find(ScanReport(site=wp_site["url"])) # Launch scanner wp_report = self.scanner.scan_site(wp_site, last_wp_report) # Save report in global instance database and to file when a site has been scanned if wp_report: self.wp_reports.write([wp_report]) else: log.info(f"No report saved for site {wp_site['url']}") # Print progress print_progress_bar(len(self.scanner.scanned_sites), len(self.wp_sites)) return wp_report
def scan_site_wrapper(self, wp_site): """ Helper method to wrap the raw scanning process that offer WPWatcherScanner.scan_site() and add the following: - Handle site structure formatting - Find the last report in the database and launch the scan - Write it in DB after scan. - Print progress bar This function will be called asynchronously. Return one report """ wp_site = self.format_site(wp_site) last_wp_report = self.wp_reports.find_last_wp_report( {"site": wp_site["url"]}) # Launch scanner wp_report = self.scanner.scan_site(wp_site, last_wp_report) # Save report in global instance database and to file when a site has been scanned if wp_report: self.wp_reports.update_and_write_wp_reports([wp_report]) else: log.info("No report saved for site %s" % wp_site["url"]) # Print progress print_progress_bar(len(self.scanner.scanned_sites), len(self.wp_sites)) return wp_report
def find_files(env_location, potential_files, default_content="", create=False): """Find existent files based on folders name and file names. Arguments: - `env_location`: list of environment variable to use as a base path. Exemple: ['HOME', 'XDG_CONFIG_HOME', 'APPDATA', 'PWD'] - `potential_files`: list of filenames. Exemple: ['.wpwatcher/wpwatcher.conf', 'wpwatcher.conf'] - `default_content`: Write default content if the file does not exist - `create`: Create the file in the first existing env_location with default content if the file does not exist """ potential_paths = [] existent_files = [] # build potential_paths of config file for env_var in env_location: if env_var in os.environ: for file_path in potential_files: potential_paths.append(os.path.join(os.environ[env_var], file_path)) # If file exist, add to list for p in potential_paths: if os.path.isfile(p): existent_files.append(p) # If no file foud and create=True, init new template config if len(existent_files) == 0 and create: os.makedirs(os.path.dirname(potential_paths[0]), exist_ok=True) with open(potential_paths[0], "w") as config_file: config_file.write(default_content) log.info("Init new file: %s" % (p)) existent_files.append(potential_paths[0]) return existent_files
def print_progress_bar(count, total): """Helper method to print progress bar. Stolen on the web""" size = 0.3 #size of progress bar percent = int(float(count) / float(total) * 100) log.info("Progress - [{}{}] {}% - {} / {}".format( '=' * int(int(percent) * size), ' ' * int((100 - int(percent)) * size), percent, count, total))
def __init__(self): """Main program entrypoint""" # Parse arguments args = self.parse_args() # Init logger with CLi arguments init_log(args.verbose, args.quiet) # If template conf , print and exit if args.template_conf: self.template_conf() # Print "banner" log.info( "WPWatcher - Automating WPscan to scan and report vulnerable Wordpress sites" ) # If version, print and exit if args.version: self.verion() # Init WPWatcher obhect and dump reports if args.wprs != False: self.wprs(args.wprs, args.daemon) # Read config configuration = self.build_config_cli(args) # If daemon lopping if configuration['daemon']: # Run 4 ever WPWatcherDaemon(configuration) else: # Run scans and quit # Create main object wpwatcher = WPWatcher(configuration) exit_code, _ = wpwatcher.run_scans_and_notify() exit(exit_code)
def __init__(self, files=None, string=None): self.files = files # Init config parser self.parser = configparser.ConfigParser() # Load default configuration self.parser.read_dict({'wpwatcher': self.DEFAULT_CONFIG}) if string: self.parser.read_string(string) if (not self.files or len(self.files) == 0) and not string: self.files = self.find_config_files() if self.files: read_files = self.parser.read(self.files) if len(read_files) < len(self.files): log.error( "Could not read config " + str(list(set(self.files) - set(read_files))) + ". Make sure the file exists, the format is OK and you have correct access right." ) exit(-1) # No config file notice else: log.info( "No config file loaded and could not find default config `~/.wpwatcher/wpwatcher.conf`, `~/wpwatcher.conf` or `./wpwatcher.conf`" )
def skip_this_site(self, wp_report, last_wp_report): '''Return true if the daemon mode is enabled and scan already happend in the last configured `daemon_loop_wait`''' if ( self.daemon and datetime.strptime(wp_report['datetime'],DATE_FORMAT) - datetime.strptime(last_wp_report['datetime'],DATE_FORMAT) < self.daemon_loop_sleep): log.info("Daemon skipping site %s because already scanned in the last %s"%(wp_report['site'] , self.daemon_loop_sleep)) self.scanned_sites.append(None) return True return False
def _log_db_reports_infos(self) -> None: if len(self.new_reports) > 0 and repr( self.new_reports) != "No scan report to show": if self.wp_reports.filepath != "null": log.info( f"Updated reports in database: {self.wp_reports.filepath}") else: log.info("Local database disabled, no reports updated.")
def _scan_site(self, wp_site: Site, wp_report: ScanReport) -> Optional[ScanReport]: """ Handled WPScan scanning , parsing, errors and reporting. Returns filled wp_report, None if interrupted or killed. Can raise `RuntimeError` if any errors. """ # WPScan arguments wpscan_arguments = (self.wpscan_args + wp_site["wpscan_args"] + ["--url", wp_site["url"]]) # Output log.info(f"Scanning site {wp_site['url']}") # Launch WPScan wpscan_process = self.wpscan.wpscan(*wpscan_arguments) wp_report["wpscan_output"] = wpscan_process.stdout log.debug(f"WPScan raw output:\n{wp_report['wpscan_output']}") log.debug("Parsing WPScan output") try: # Use wpscan_out_parse module try: parser = WPScanJsonParser( json.loads(wp_report["wpscan_output"]), self.false_positive_strings + wp_site["false_positive_strings"] + [ "No WPVulnDB API Token given", "No WPScan API Token given" ]) except ValueError as err: parser = WPScanCliParser( wp_report["wpscan_output"], self.false_positive_strings + wp_site["false_positive_strings"] + [ "No WPVulnDB API Token given", "No WPScan API Token given" ]) finally: wp_report.load_parser(parser) except Exception as err: raise RuntimeError( f"Could not parse WPScan output for site {wp_site['url']}\nOutput:\n{wp_report['wpscan_output']}" ) from err # Exit code 0: all ok. Exit code 5: Vulnerable. Other exit code are considered as errors if wpscan_process.returncode in [0, 5]: return wp_report # Quick return if interrupting and/or if user cancelled scans if self.interrupting or wpscan_process.returncode in [2, -2, -9]: return None # Other errors codes : 127, etc, simply raise error err_str = f"WPScan failed with exit code {wpscan_process.returncode}. \nArguments: {safe_log_wpscan_args(wpscan_arguments)}. \nOutput: \n{remove_color(wp_report['wpscan_output'])}\nError: \n{wpscan_process.stderr}" raise RuntimeError(err_str)
def terminate_scan(self, wp_site, wp_report): # Kill process if stilla live for p in self.wpscan.processes: if (wp_site['url'] in p.args) and not p.returncode: log.info('Killing WPScan process %s' % (safe_log_wpscan_args(p.args))) p.kill() # Discard wpscan_output from report if 'wpscan_output' in wp_report: del wp_report['wpscan_output']
def _update_wpscan(self) -> None: # Update wpscan database log.info("Updating WPScan") process = self._wpscan("--update", "--format", "json", "--no-banner") if process.returncode != 0: raise RuntimeError( f"Error updating WPScan.\nOutput:{process.stdout}\nError:\n{process.stderr}" ) self._lazy_last_db_update = datetime.now()
def __init__(self, conf): # Create (lazy) wpscan link self.wpscan = WPScanWrapper(conf['wpscan_path']) # Init mail link self.mail = WPWatcherNotification(conf) # Storing the Event object to wait and cancel the waiting self.api_wait = threading.Event() # Toogle if aborting so other errors doesnt get triggerred and exit faster self.interrupting = False # List of urls scanend self.scanned_sites = [] # Save required config options self.api_limit_wait = conf['api_limit_wait'] self.follow_redirect = conf['follow_redirect'] self.wpscan_output_folder = conf['wpscan_output_folder'] self.wpscan_args = conf['wpscan_args'] self.fail_fast = conf['fail_fast'] self.false_positive_strings = conf['false_positive_strings'] self.daemon = conf['daemon'] self.daemon_loop_sleep = conf['daemon_loop_sleep'] self.prescan_without_api_token = conf['prescan_without_api_token'] # Scan timeout self.scan_timeout = conf['scan_timeout'] # Setup prescan options self.prescanned_sites_warn = [] self.api_token = None if self.prescan_without_api_token: log.info("Prescan without API token...") if not self.check_api_token_not_installed(): exit(-1) self.api_token = self.retreive_api_token(self.wpscan_args) if not self.api_token: log.error( "No --api-token in WPScan arguments, please set --api-token in config file wpscan_args values or use --wpargs [...] to allow WPWatcher to handle WPScan API token" ) exit(-1) api_token_index = self.wpscan_args.index("--api-token") + 1 del self.wpscan_args[api_token_index] del self.wpscan_args[api_token_index - 1] # Init wpscan output folder if conf['wpscan_output_folder']: os.makedirs(conf['wpscan_output_folder'], exist_ok=True) os.makedirs(os.path.join(conf['wpscan_output_folder'], 'error/'), exist_ok=True) os.makedirs(os.path.join(conf['wpscan_output_folder'], 'alert/'), exist_ok=True) os.makedirs(os.path.join(conf['wpscan_output_folder'], 'warning/'), exist_ok=True) os.makedirs(os.path.join(conf['wpscan_output_folder'], 'info/'), exist_ok=True)
def __init__(self, conf): log.info("Daemon mode selected, looping for ever...") # keep data in memory wpwatcher = WPWatcher(conf) while True: # Run scans for ever wpwatcher.run_scans_and_notify() log.info("Daemon sleeping %s and scanning again..." % conf['daemon_loop_sleep']) time.sleep(conf['daemon_loop_sleep'].total_seconds())
def handle_wpscan_err_api_wait(self, wp_site, wp_report): log.info( "API limit has been reached after %s sites, sleeping %s and continuing the scans..." % (len(self.scanned_sites), API_WAIT_SLEEP)) self.wpscan.init_check_done = False # will re-trigger wpscan update next time wpscan() is called self.api_wait.wait(API_WAIT_SLEEP.total_seconds()) if self.interrupting: return ((None, True)) new_report = self.scan_site(wp_site) return ((new_report, new_report != None))
def delete_tmp_wpscan_files(): '''Delete temp wpcan files''' # Try delete temp files. if os.path.isdir('/tmp/wpscan'): try: shutil.rmtree('/tmp/wpscan') log.info("Deleted temp WPScan files in /tmp/wpscan/") except (FileNotFoundError, OSError, Exception): log.info( "Could not delete temp WPScan files in /tmp/wpscan/\n%s" % (traceback.format_exc()))
def _delete_tmp_wpscan_files() -> None: """Delete temp wpcan files""" # Try delete temp files. if os.path.isdir("/tmp/wpscan"): try: shutil.rmtree("/tmp/wpscan") log.info("Deleted temp WPScan files in /tmp/wpscan/") except (FileNotFoundError, OSError, Exception): log.info( f"Could not delete temp WPScan files in /tmp/wpscan/\n{traceback.format_exc()}" )
def log_report_results(self, wp_report: ScanReport) -> None: """Print WPScan findings""" for info in wp_report["infos"]: log.info(oneline(f"** WPScan INFO {wp_report['site']} ** {info}")) for fix in wp_report["fixed"]: log.info(oneline(f"** FIXED Issue {wp_report['site']} ** {fix}")) for warning in wp_report["warnings"]: log.warning( oneline(f"** WPScan WARNING {wp_report['site']} ** {warning}")) for alert in wp_report["alerts"]: log.critical( oneline(f"** WPScan ALERT {wp_report['site']} ** {alert}"))
def main(_args: Optional[Sequence[Text]] = None) -> None: """Main program entrypoint""" # Parse arguments args: argparse.Namespace = get_arg_parser().parse_args(_args) # Init logger with CLi arguments _init_log(args.verbose, args.quiet) # If template conf , print and exit if args.template_conf: template_conf() # Print "banner" log.info( "WPWatcher - Automating WPscan to scan and report vulnerable Wordpress sites" ) if args.version: # Print and exit version() if args.wprs != False: # Init WPWatcherDataBase object and dump reports wprs(filepath=args.wprs, daemon=args.daemon) # Read config configuration = Config.fromcliargs(args) if args.show: # Init WPWatcherDataBase object and dump cli formatted report show( urlpart=args.show, filepath=configuration["wp_reports"], daemon=args.daemon, ) # Launch syslog test if args.syslog_test: syslog_test(configuration) # If daemon lopping if configuration["daemon"]: # Run 4 ever daemon = Daemon(configuration) daemon.loop() else: # Run scans and quit wpwatcher = WPWatcher(configuration) exit_code, reports = wpwatcher.run_scans() exit(exit_code)
def _handle_wpscan_err_api_wait( self, failed_process: subprocess.CompletedProcess ) -> subprocess.CompletedProcess: # type: ignore [type-arg] """ Sleep 24 hours and retry. """ log.info( f"API limit has been reached, sleeping 24h and continuing the scans..." ) self._api_wait.wait(API_WAIT_SLEEP.total_seconds()) if self._interrupting: return failed_process return self._wpscan(*failed_process.args)