Esempio n. 1
0
class DataBase:
    """
    Interface to JSON database file. 
    Write all reports in a thread safe way. 
    """

    def __repr__(self) -> str:
        return repr(self._data)

    def __init__(self, filepath: Optional[str] = None, daemon: bool = False):

        if not filepath:
            filepath = self._find_db_file(daemon=daemon)

        self.no_local_storage: bool = filepath == "null"
        "True if the DB is disabled"
        self.filepath = filepath

        self._data = ReportCollection()
        self._data.extend(self._build_db(self.filepath))

        # Writing into the database file is thread safe
        self._wp_report_lock: threading.Lock = threading.Lock()

        # Only once instance of WPWatcher can use a database file at a time. 
        self._wp_report_file_lock: FileLock = FileLock(f"{self.filepath}.lock")
        

    def open(self) -> None:
        """
        Acquire the file lock for the DB file. 
        """
        try:
            self._wp_report_file_lock.acquire(timeout=1)
        except Timeout as err:
            raise RuntimeError(f"Could not use the database file '{self.filepath}' because another instance of WPWatcher is using it. ") from err
        log.debug(f"Acquired DB lock file '{self.filepath}.lock'")
        try:
            self.write()
        except:
            log.error(
                f"Could not write wp_reports database: {self.filepath}. Use '--reports null' to ignore local Json database."
            )
            raise

    def close(self) -> None:
        """
        Release the file lock.
        """
        self._wp_report_file_lock.release()
        log.debug(f"Released DB lock file '{self.filepath}.lock'")

    @staticmethod
    def _find_db_file(daemon: bool = False) -> str:
        files = [DEFAULT_REPORTS] if not daemon else [DEFAULT_REPORTS_DAEMON]
        env = ["HOME", "PWD", "XDG_CONFIG_HOME", "APPDATA"]
        return Config.find_files(env, files, "[]", create=True)[0]

    # Read wp_reports database
    def _build_db(self, filepath: str) -> ReportCollection:
        """Load reports database and return the complete structure"""
        wp_reports = ReportCollection()
        if self.no_local_storage:
            return wp_reports

        if os.path.isfile(filepath):
            try:
                with open(filepath, "r") as reportsfile:
                    wp_reports.extend(
                        ScanReport(item) for item in json.load(reportsfile)
                    )
                log.info(f"Load wp_reports database: {filepath}")
            except Exception:
                log.error(
                    f"Could not read wp_reports database: {filepath}. Use '--reports null' to ignore local Json database"
                )
                raise
        else:
            log.info(f"The database file {filepath} do not exist. It will be created.")
        return wp_reports

    def write(
        self, wp_reports: Optional[Iterable[ScanReport]] = None
    ) -> bool:
        """
        Write the reports to the database. 

        Returns `True` if the reports have been successfully written. 
        """

        if not self._wp_report_file_lock.is_locked:
            raise RuntimeError("The file lock must be acquired before writing data. ")

        if not wp_reports:
            wp_reports = self._data

        for newr in wp_reports:
            new = True
            for r in self._data:
                if r["site"] == newr["site"]:
                    self._data[self._data.index(r)] = newr
                    new = False
                    break
            if new:
                self._data.append(newr)
        # Write to file if not null
        if not self.no_local_storage:
            # Write method thread safe
            while self._wp_report_lock.locked():
                time.sleep(0.01)
            self._wp_report_lock.acquire()
            with open(self.filepath, "w") as reportsfile:
                json.dump(self._data, reportsfile, indent=4)
                self._wp_report_lock.release()
            return True
        else:
            return False

    def find(self, wp_report: ScanReport) -> Optional[ScanReport]:
        """
        Find the pre-existing report if any.
        """
        last_wp_reports = [r for r in self._data if r["site"] == wp_report["site"]]
        last_wp_report: Optional[ScanReport]
        if len(last_wp_reports) > 0:
            last_wp_report = last_wp_reports[0]
        else:
            last_wp_report = None
        return last_wp_report
Esempio n. 2
0
class WPWatcher:
    """WPWatcher object

    Usage exemple:

    .. python::

        from wpwatcher.config import Config
        from wpwatcher.core import WPWatcher
        config = Config.fromenv()
        config.update({ 'send_infos':   True,
                        'wp_sites':     [   {'url':'exemple1.com'},
                                            {'url':'exemple2.com'}  ],
                        'wpscan_args': ['--format', 'json', '--stealthy']
                    })
        watcher = WPWatcher(config)
        exit_code, reports = watcher.run_scans()
        for r in reports:
            print("%s\t\t%s"%( r['site'], r['status'] ))
    """

    # WPWatcher must use a configuration dict
    def __init__(self, conf: Config):
        """
        Arguments:
        - `conf`: the configuration dict. Required
        """
        # (Re)init logger with config
        _init_log(verbose=conf["verbose"],
                  quiet=conf["quiet"],
                  logfile=conf["log_file"])

        self._delete_tmp_wpscan_files()

        # Init DB interface
        self.wp_reports: DataBase = DataBase(filepath=conf["wp_reports"],
                                             daemon=conf['daemon'])

        # Update config before passing it to WPWatcherScanner
        conf.update({"wp_reports": self.wp_reports.filepath})

        # Init scanner
        self.scanner: Scanner = Scanner(conf)

        # Save sites
        conf["wp_sites"] = [Site(site_conf) for site_conf in conf["wp_sites"]]
        self.wp_sites: List[Site] = conf["wp_sites"]

        # Asynchronous executor
        self._executor: concurrent.futures.ThreadPoolExecutor = (
            concurrent.futures.ThreadPoolExecutor(
                max_workers=conf["asynch_workers"]))

        # List of conccurent futures
        self._futures: List[concurrent.futures.Future] = [
        ]  # type: ignore [type-arg]

        # Register the signals to be caught ^C , SIGTERM (kill) , service restart , will trigger interrupt()
        signal.signal(signal.SIGINT, self.interrupt)
        signal.signal(signal.SIGTERM, self.interrupt)

        self.new_reports = ReportCollection()
        "New reports, cleared and filled when running `run_scans`."

        self.all_reports = ReportCollection()
        "All reports an instance of `WPWatcher` have generated using `run_scans`."

        # Dump config
        log.debug(f"Configuration:{repr(conf)}")

    @staticmethod
    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 _cancel_pending_futures(self) -> None:
        """Cancel all asynchronous jobs"""
        for f in self._futures:
            if not f.done():
                f.cancel()

    def interrupt_scans(self) -> None:
        """
        Interrupt the scans and append finished scan reports to self.new_reports
        """
        # Cancel all scans
        self._cancel_pending_futures()  # future scans
        self.scanner.interrupt()  # running scans
        self._rebuild_rew_reports()

    def _rebuild_rew_reports(self) -> None:
        "Recover reports from futures results"
        self.new_reports = ReportCollection()
        for f in self._futures:
            if f.done():
                try:
                    self.new_reports.append(f.result())
                except Exception:
                    pass

    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 _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) -> 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 _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 run_scans(self) -> Tuple[int, ReportCollection]:
        """
        Run WPScan on defined websites and send notifications.

        :Returns: `tuple (exit code, reports)`
        """

        # 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, self.new_reports)

        self.wp_reports.open()
        try:
            self._run_scans(self.wp_sites)
        # Handle interruption from inside threads when using --ff
        except InterruptedError:
            self.interrupt()
        finally:
            self.wp_reports.close()

        # Print results and finish
        log.info(repr(self.new_reports))

        if not any([r["status"] == "ERROR" for r in self.new_reports if r]):
            log.info("Scans finished successfully.")
            return (0, self.new_reports)
        else:
            log.info("Scans finished with errors.")
            return (-1, self.new_reports)