├── tests ├── utils_test.py ├── __init__.py ├── wpscan_test.py ├── core_test.py ├── config_test.py ├── notification_test.py ├── db_test.py ├── static │ ├── wordpress_one_warning.txt │ ├── wordpress_no_vuln.txt │ ├── wordpress_one_vuln.txt │ ├── wordpress_no_vuln.json │ ├── wordpress_one_vuln.json │ ├── wordpress_many_vuln.json │ └── wordpress_many_vuln.txt ├── scan_random_sites.py └── scan_test.py ├── Gemfile ├── codecov.yml ├── publish.sh ├── docs ├── source │ ├── _static │ │ ├── logo.png │ │ ├── wpwatcher-report.png │ │ └── reports-summary-wprs.png │ ├── index.rst │ ├── schedule-scans-with-cron.md │ ├── other-features.md │ ├── reports-db.md │ ├── docker.md │ ├── syslog-output.md │ ├── linux-service.md │ ├── false-positives.md │ ├── conf.py │ ├── wpscan-config.md │ ├── email-reports.md │ ├── quickstart.rst │ ├── all-options.rst │ └── output.md ├── Makefile └── make.bat ├── .gitignore ├── wpwatcher ├── __main__.py ├── __version__.py ├── site.py ├── __init__.py ├── daemon.py ├── syslog.py ├── utils.py ├── db.py ├── report.py ├── core.py ├── wpscan.py ├── cli.py └── scan.py ├── readthedocs.yml ├── CONTRIBUTING.md ├── mypy.ini ├── tox.ini ├── Dockerfile ├── setup.py ├── README.md ├── .github └── workflows │ └── test.yml └── LICENSE /tests/utils_test.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gem 'wpscan' 3 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - "tests" # ignore folders and all its contents 3 | -------------------------------------------------------------------------------- /publish.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | git tag $(python3 setup.py -V) 3 | git push --tags 4 | -------------------------------------------------------------------------------- /docs/source/_static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tristanlatr/WPWatcher/HEAD/docs/source/_static/logo.png -------------------------------------------------------------------------------- /docs/source/_static/wpwatcher-report.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tristanlatr/WPWatcher/HEAD/docs/source/_static/wpwatcher-report.png -------------------------------------------------------------------------------- /docs/source/_static/reports-summary-wprs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tristanlatr/WPWatcher/HEAD/docs/source/_static/reports-summary-wprs.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | *.conf 3 | *.pyc 4 | build 5 | dist 6 | wpwatcher.egg-info 7 | __pycache__ 8 | .coverage 9 | .vscode 10 | .tox 11 | coverage.xml 12 | -------------------------------------------------------------------------------- /wpwatcher/__main__.py: -------------------------------------------------------------------------------- 1 | """Main program if called with `python -m wpwatcher`""" 2 | 3 | from .cli import main 4 | 5 | if __name__ == "__main__": 6 | main() 7 | -------------------------------------------------------------------------------- /readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | sphinx: 4 | fail_on_warning: false 5 | 6 | python: 7 | version: 3.8 8 | system_packages: false 9 | install: 10 | - method: pip 11 | path: . 12 | extra_requirements: 13 | - docs 14 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | WP_SITES=[ {"url":"exemple.com"}, 4 | {"url":"exemple2.com"} ] 5 | 6 | DEFAULT_CONFIG=""" 7 | [wpwatcher] 8 | wp_sites=%s 9 | smtp_server=localhost:1025 10 | from_email=testing-wpwatcher@exemple.com 11 | email_to=["test@mail.com"] 12 | """%json.dumps(WP_SITES) 13 | -------------------------------------------------------------------------------- /tests/wpscan_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from wpwatcher.scan import WPScanWrapper 3 | 4 | class T(unittest.TestCase): 5 | 6 | def test_wpscan(self): 7 | wpscan = WPScanWrapper('wpscan') 8 | p = wpscan.wpscan("--url", "wp.exemple.com") 9 | self.assertEqual(p.returncode, 4, "Scanning wp.exemple.com successful, that's weird !") 10 | p = wpscan.wpscan("--version") 11 | self.assertEqual(p.returncode, 0, "WPScan failed when printing version") -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 2 | ## Questions ? 3 | If you have any questions, please create a new issue. 4 | 5 | ## Contribute 6 | If you like the project and think you could help with making it better, there are many ways you can do it: 7 | 8 | - Create new issue for new feature proposal or a bug 9 | - Implement existing issues 10 | - Help with improving the documentation 11 | - Spread a word about the project to your collegues, friends, blogs or any other channels 12 | - Any other things you could imagine 13 | - Any contribution would be of great help 14 | 15 | ## Running tests 16 | ``` 17 | tox 18 | ``` 19 | 20 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to WPWatcher's documentation! 2 | ===================================== 3 | 4 | Wordpress Watcher is a wrapper for WPScan that manages scans on multiple sites and reports by email and/or syslog. 5 | Schedule scans and get notified when vulnerabilities, outdated plugins and other risks are found. 6 | 7 | .. toctree:: 8 | :maxdepth: 6 9 | 10 | quickstart 11 | all-options 12 | wpscan-config 13 | email-reports 14 | false-positives 15 | reports-db 16 | output 17 | syslog-output 18 | other-features 19 | docker 20 | linux-service 21 | schedule-scans-with-cron 22 | -------------------------------------------------------------------------------- /wpwatcher/__version__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Wordpress Watcher 3 | Automating WPscan to scan and report vulnerable Wordpress sites 4 | 5 | Project version and meta informations. 6 | """ 7 | 8 | __version__ = "3.0.7" 9 | __title__ = "wpwatcher" 10 | __description__ = "WPWatcher - Automating WPScan to scan and report vulnerable Wordpress sites" 11 | __author__ = "Florian Roth, Tristan Landes" 12 | __author_email__ = "" 13 | __license__ = "Apache License 2.0" 14 | __url__ = "https://github.com/tristanlatr/WPWatcher" 15 | __keywords__ = "wpscan auto multiple bulk batch scan wordpress email report alerts warnings service automate mass vulnerable sites asynchronous syslog" 16 | -------------------------------------------------------------------------------- /docs/source/schedule-scans-with-cron.md: -------------------------------------------------------------------------------- 1 | # Shedule scans with cron 2 | 3 | Caution: **do not configure crontab execution and linux service at the same time** . 4 | 5 | 6 | - Config files: 7 | 8 | - `wpwatcher.conf`: your config file 9 | 10 | 11 | In **your crontab**, configure script to run at your convenience: 12 | ``` 13 | # Will run at 00:00 on Mondays: 14 | 0 0 * * 1 wpwatcher --conf /path/to/wpwatcher.conf --wait > /dev/null 15 | ``` 16 | 17 | **Warning**: This kind of setup can lead into having two `wpwatcher` executions at the same time if you have too many URLs. This might result into failure and/or database corruption because of conccurent accesses to reports database file. 18 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | 2 | [mypy] 3 | disallow_any_generics=True 4 | disallow_incomplete_defs=True 5 | disallow_untyped_defs=True 6 | namespace_packages=True 7 | no_implicit_optional=True 8 | show_error_codes=True 9 | warn_no_return=True 10 | warn_redundant_casts=True 11 | warn_return_any=True 12 | warn_unreachable=True 13 | warn_unused_configs=True 14 | warn_unused_ignores=True 15 | 16 | 17 | # The following external libraries don't support annotations (yet): 18 | 19 | [mypy-wpscan_out_parse.*] 20 | ignore_missing_imports=True 21 | 22 | [mypy-rfc5424logging.*] 23 | ignore_missing_imports=True 24 | 25 | [mypy-cefevent.*] 26 | ignore_missing_imports=True 27 | 28 | [mypy-filelock.*] 29 | ignore_missing_imports=True 30 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= -a 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | minversion=3.20.1 3 | requires= 4 | virtualenv>=20.0.35 5 | envlist = 6 | test,mypy,docs 7 | 8 | [testenv] 9 | description = run tests (unittest) 10 | 11 | passenv = * 12 | 13 | extras = dev 14 | 15 | commands = 16 | pytest --cov=./ --cov-report=xml 17 | 18 | [testenv:mypy] 19 | description = run mypy (static type checker) 20 | 21 | extras = dev 22 | 23 | commands = 24 | mypy \ 25 | --cache-dir="{toxworkdir}/mypy_cache" \ 26 | {tty:--pretty:} \ 27 | {posargs:wpwatcher} 28 | 29 | [testenv:docs] 30 | description = build the documentation 31 | 32 | extras = 33 | docs 34 | 35 | setenv = 36 | TOX_INI_DIR = {toxinidir} 37 | 38 | commands = 39 | sphinx-build -aE -b html {toxinidir}/docs/source {toxinidir}/build/docs -------------------------------------------------------------------------------- /docs/source/other-features.md: -------------------------------------------------------------------------------- 1 | # Other features 2 | 3 | 4 | ## Fail fast 5 | 6 | You might want to use this option when you're setting up and configuring the script. Abort scans if WPScan fails, useful to troubleshoot. 7 | 8 | Default behaviour is to log error, continue scans and return non zero status code when all scans are over 9 | ```ini 10 | fail_fast=No 11 | ``` 12 | Overwrite with arguments: `--ff` 13 | 14 | ## Asynchronous workers 15 | 16 | You can use asynchronous workers to speed up the scans. 17 | 18 | Default to `1`. 19 | 20 | ```ini 21 | asynch_workers=5 22 | ``` 23 | Overwrite with arguments: `--workers Number` 24 | 25 | **Warning**: Using too many asynchronous workers (let's say, more than 5) can lead to incomplete WPScan reports since the token limit might be reach much faster and affect multiple scans concurrently. Unexpected behaviour can happend. 26 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # WPWatcher Dockerfile 2 | FROM ruby:alpine 3 | # Install dependencies ruby gem 4 | RUN apk --update add --virtual build-dependencies ruby-dev build-base &&\ 5 | apk --update add curl &&\ 6 | apk --update add git 7 | # Install WPScan lastest version 8 | RUN gem install wpscan 9 | # Python install 10 | ENV PYTHONUNBUFFERED=1 11 | RUN apk add --no-cache python3 12 | RUN apk add --no-cache py3-setuptools 13 | 14 | # Setup user and group if specified 15 | ARG USER_ID 16 | # ARG GROUP_ID 17 | # Delete curent user 18 | RUN deluser --remove-home wpwatcher >/dev/null 2>&1 || true 19 | # RUN delgroup wp >/dev/null 2>&1 || true 20 | # Init folder tree 21 | RUN mkdir /wpwatcher && mkdir /wpwatcher/.wpwatcher 22 | # Add only required scripts 23 | COPY setup.py /wpwatcher/ 24 | COPY README.md /wpwatcher/ 25 | COPY ./wpwatcher/* /wpwatcher/wpwatcher/ 26 | WORKDIR /wpwatcher 27 | # Install WPWatcher 28 | RUN python3 ./setup.py install 29 | RUN if [ ${USER_ID:-0} -ne 0 ]; then adduser -h /wpwatcher -D -u ${USER_ID} wpwatcher; fi 30 | RUN adduser -h /wpwatcher -D wpwatcher >/dev/null 2>&1 || true 31 | # RUN if [ ${GROUP_ID:-0} -ne 0 ]; then addgroup -g ${GROUP_ID} wp && addgroup wpwatcher wp ; fi 32 | RUN chown -R wpwatcher /wpwatcher 33 | USER wpwatcher 34 | # Run command 35 | ENTRYPOINT ["wpwatcher"] 36 | -------------------------------------------------------------------------------- /wpwatcher/site.py: -------------------------------------------------------------------------------- 1 | """ 2 | Container for a website to be scanned. 3 | """ 4 | 5 | from urllib.parse import urlparse 6 | from typing import Iterable, Dict, Any 7 | 8 | 9 | class Site(Dict[str, Any]): 10 | """ 11 | Dict-Like object to store site config. 12 | 13 | >>> Site(url="exemple.com", wpscan_args=["--verbose"]) 14 | {'url': 'http://exemple.com', 'wpscan_args': ['--verbose'], 'email_to': [], 'false_positive_strings': []} 15 | 16 | """ 17 | 18 | DEFAULT_SITE: Dict[str, Any] = { 19 | "url": "", 20 | "email_to": [], 21 | "false_positive_strings": [], 22 | "wpscan_args": [], 23 | } 24 | 25 | FIELDS: Iterable[str] = list(DEFAULT_SITE.keys()) 26 | 27 | def __init__(self, *args, **kwargs) -> None: # type: ignore [no-untyped-def] 28 | super().__init__(*args, **kwargs) 29 | 30 | if "url" not in self: 31 | raise ValueError(f"Invalid site {self}\nMust contain 'url' key") 32 | else: 33 | # Strip URL string 34 | self["url"] = self["url"].strip() 35 | # Format sites with scheme indication 36 | p_url = list(urlparse(self["url"])) 37 | if p_url[0] == "": 38 | self["url"] = f"http://{self['url']}" 39 | 40 | for key in self.FIELDS: 41 | self.setdefault(key, self.DEFAULT_SITE[key]) 42 | -------------------------------------------------------------------------------- /docs/source/reports-db.md: -------------------------------------------------------------------------------- 1 | # Reports database 2 | 3 | WPWatcher store a database of reports and compare reports one scan after another to notice for fixed and unfixed issues. 4 | 5 | Default location is `~/.wpwatcher/wp_reports.json`. 6 | 7 | Set `wp_reports=null` in the config to disable this feature. 8 | 9 | ## Reports database file 10 | If missing, will figure out a place based on your environment to store the database. 11 | 12 | Use `null` keyword to disable the storage of the Json database file and turn off the tracking of the fixed issues. 13 | 14 | ```ini 15 | wp_reports=/home/user/.wpwatcher/wp_reports.json 16 | ``` 17 | 18 | Overwrite with arguments: `--reports File path` 19 | 20 | ## Dump database summary 21 | 22 | Table-like dump of the `Status`, `Last scan`, `Last email`, `Issues` count and `Problematic component(s)` of all your scanned sites. 23 | 24 | Load default database 25 | 26 | wpwatcher --wprs 27 | 28 | Load specific file 29 | 30 | wpwatcher --wprs ~/.wpwatcher/wp_reports.json 31 | 32 |
See exemple 33 |

34 | 35 | ![WPWatcher Report summary](https://wpwatcher.readthedocs.io/en/latest/_static/reports-summary-wprs.png "WPWatcher Reports summary") 36 | 37 |

38 |
39 | 40 | ## Inspect a specific report in database 41 | 42 | Get the report text for a specific site. 43 | 44 | wpwatcher --show -------------------------------------------------------------------------------- /docs/source/docker.md: -------------------------------------------------------------------------------- 1 | # Docker install 2 | 3 | 4 | - Clone the repository 5 | - Install docker image 6 | 7 | 18 | 19 | ```bash 20 | docker image build -t wpwatcher . 21 | ``` 22 | 23 | - `wpwatcher` command would look like : 24 | ``` 25 | docker run -it -v 'wpwatcher_data:/wpwatcher/.wpwatcher/' wpwatcher 26 | ``` 27 | 28 | It will use [docker volumes](https://stackoverflow.com/questions/18496940/how-to-deal-with-persistent-storage-e-g-databases-in-docker?answertab=votes#tab-top) in order to write files and save reports 29 | 30 | - Create config file: As root, check `docker volume inspect wpwatcher_data` to see Mountpoint, then create the config file 31 | ```bash 32 | docker run -it wpwatcher --template_conf > /var/lib/docker/volumes/wpwatcher_data/_data/wpwatcher.conf 33 | vim /var/lib/docker/volumes/wpwatcher_data/_data/wpwatcher.conf 34 | ``` 35 | 36 | - Create an alias and your good to go 37 | ``` 38 | alias wpwatcher="docker run -it -v 'wpwatcher_data:/wpwatcher/.wpwatcher/' wpwatcher" 39 | ``` 40 | -------------------------------------------------------------------------------- /wpwatcher/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Wordpress Watcher 4 | Automating WPscan to scan and report vulnerable Wordpress sites 5 | 6 | DISCLAIMER - USE AT YOUR OWN RISK. 7 | """ 8 | import logging 9 | import sys 10 | import os 11 | from typing import Optional 12 | 13 | log = logging.getLogger("wpwatcher") 14 | "Log handler" 15 | 16 | # Setup stdout logger 17 | def _init_log( 18 | verbose: bool = False, 19 | quiet: bool = False, 20 | logfile: Optional[str] = None, 21 | nostd: bool = False, 22 | ) -> logging.Logger: 23 | 24 | format_string = "%(asctime)s - %(levelname)s - %(message)s" 25 | format_string_cli = "%(levelname)s - %(message)s" 26 | if verbose: 27 | verb_level = logging.DEBUG 28 | elif quiet: 29 | verb_level = logging.ERROR 30 | else: 31 | verb_level = logging.INFO 32 | 33 | # Add stdout: configurable 34 | log.setLevel(verb_level) 35 | std = logging.StreamHandler(sys.stdout) 36 | std.setLevel(verb_level) 37 | std.setFormatter(logging.Formatter(format_string_cli)) 38 | log.handlers = [] 39 | if not nostd: 40 | log.addHandler(std) 41 | else: 42 | log.addHandler(logging.StreamHandler(open(os.devnull, "w"))) 43 | if logfile: 44 | fh = logging.FileHandler(logfile) 45 | fh.setLevel(verb_level) 46 | fh.setFormatter(logging.Formatter(format_string)) 47 | log.addHandler(fh) 48 | if verbose and quiet: 49 | log.info( 50 | "Verbose and quiet values are both set to True. By default, verbose value has priority." 51 | ) 52 | return log 53 | -------------------------------------------------------------------------------- /docs/source/syslog-output.md: -------------------------------------------------------------------------------- 1 | # Syslog output 2 | 3 | ## Install syslog library 4 | 5 | ```bash 6 | pip install -U 'wpwatcher[syslog]' 7 | ``` 8 | *Installs WPWatcher with syslog output support*. 9 | 10 | Syslog feature uses library `rfc5424-logging-handler` and `cefevent`. 11 | 12 | ## Configure 13 | 14 | ```ini 15 | # Your syslog server 16 | syslog_server=syslogserver.ca 17 | syslog_port=514 18 | 19 | # TCP or UDP: 20 | # `SOCK_STREAM` to use TCP stream 21 | # `SOCK_DGRAM` to send UDP packets (not recommended) 22 | syslog_stream=SOCK_STREAM 23 | 24 | # Additionnal settings, must be valid JSON 25 | syslog_kwargs={"enterprise_id":42, "msg_as_utf8":true, "utc_timestamp":true} 26 | ``` 27 | 28 | Additional parameters can be passed during `Rfc5424SysLogHandler` initiation with the `syslog_kwargs` configuration options. 29 | See [the package docs](https://rfc5424-logging-handler.readthedocs.io/en/latest/basics.html#usage) for more infos on init arguments. 30 | 31 | Multiple CEF syslog messages are sent per scanned website. 32 | 33 | Syslog message exemple: 34 | ``` 35 | <14>1 2020-09-17T14:07:20.624590+00:00 localhost WPWatcher 29016 - - CEF:0|Github|WPWatcher|2.4.0.dev1|3|WPScan WARNING|6|msg=Plugin: woocommerce\nThe version is out of date\nVersion: 4.2.2 (latest is 4.5.2) shost=http://exemple.com 36 | ``` 37 | 38 | ## Send test events 39 | 40 | ``` 41 | wpwatcher -c testing.conf --syslog_test 42 | ``` 43 | Will send 5 test events, one per possible event type (`WPScan ALERT`, `WPScan WARNING`, `WPScan INFO`, `WPScan issue FIXED` and `WPScan ERROR`). 44 | 45 | Syslog sender code is [here](https://github.com/tristanlatr/WPWatcher/blob/master/wpwatcher/syslog.py) 46 | -------------------------------------------------------------------------------- /tests/core_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import shlex 3 | from datetime import timedelta 4 | from . import DEFAULT_CONFIG 5 | from wpwatcher.core import WPWatcher 6 | from wpwatcher.config import Config 7 | from wpwatcher.scan import Scanner 8 | from wpwatcher.email import EmailSender 9 | from wpwatcher.wpscan import WPScanWrapper 10 | from wpwatcher.daemon import Daemon 11 | from wpwatcher.utils import timeout 12 | class T(unittest.TestCase): 13 | 14 | def test_interrupt(self): 15 | wpwatcher=WPWatcher(Config.fromstring(DEFAULT_CONFIG)) 16 | 17 | with self.assertRaises(SystemExit): 18 | wpwatcher.interrupt() 19 | 20 | def test_init_wpwatcher(self): 21 | # Init deafult watcher 22 | wpwatcher=WPWatcher(Config.fromstring(DEFAULT_CONFIG)) 23 | 24 | self.assertEqual(type(wpwatcher.scanner), Scanner, "Scanner doesn't seem to have been initialized") 25 | self.assertEqual(type(wpwatcher.scanner.mail), EmailSender, "EmailSender doesn't seem to have been initialized") 26 | self.assertEqual(type(wpwatcher.scanner.wpscan), WPScanWrapper, "WPScanWrapper doesn't seem to have been initialized") 27 | self.assertEqual(shlex.split(Config.fromstring(DEFAULT_CONFIG)['wpscan_path']), wpwatcher.scanner.wpscan._wpscan_path, "WPScan path seems to be wrong") 28 | 29 | def test_asynch_exec(self): 30 | # test max number of threads respected 31 | pass 32 | 33 | def test_daemon(self): 34 | # test daemon_loop_sleep and daemon mode 35 | 36 | conf = Config.fromstring(DEFAULT_CONFIG) 37 | conf['asynch_workers']+=1 38 | daemon = Daemon(conf) 39 | 40 | daemon.loop(ttl=timedelta(seconds=5)) 41 | 42 | self.assertTrue(not any([r.status() != 'ERROR' for r in daemon.wpwatcher.new_reports])) 43 | self.assertGreater(len(daemon.wpwatcher.new_reports), 1) 44 | 45 | 46 | def test_fail_fast(self): 47 | pass 48 | 49 | 50 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | from setuptools import setup, find_packages 3 | import pathlib 4 | import sys 5 | # The directory containing this file 6 | HERE = pathlib.Path(__file__).parent 7 | # About the project 8 | ABOUT = {} 9 | exec((HERE / "wpwatcher" / "__version__.py").read_text(), ABOUT) 10 | # The text of the README file 11 | README = (HERE / "README.md").read_text() 12 | setup( 13 | name = ABOUT['__title__'], 14 | description = ABOUT['__description__'], 15 | url = ABOUT['__url__'], 16 | maintainer = ABOUT['__author__'], 17 | version = ABOUT['__version__'], 18 | packages = find_packages(exclude=('tests')), 19 | entry_points = {'console_scripts': ['wpwatcher = wpwatcher.cli:main'],}, 20 | classifiers = [ 21 | "Development Status :: 5 - Production/Stable", 22 | "Intended Audience :: Information Technology", 23 | "Environment :: Console", 24 | "Topic :: Security", 25 | "Topic :: Utilities", 26 | "Topic :: System :: Monitoring", 27 | "Programming Language :: Python :: 3", 28 | "Typing :: Typed", 29 | "License :: OSI Approved :: Apache Software License", ], 30 | license = ABOUT['__license__'], 31 | long_description = README, 32 | long_description_content_type = "text/markdown", 33 | python_requires = '>=3.6', 34 | install_requires = ['wpscan-out-parse>=1.9.3', 35 | # filelock dropped support for python 3.6 in version 3.4.2 https://github.com/tox-dev/py-filelock/pull/125 36 | 'filelock<3.4.2' if sys.version_info < (3,7) else 'filelock', ], 37 | extras_require = {'syslog' : ['rfc5424-logging-handler', 'cefevent'], 38 | 'docs': ["Sphinx", "recommonmark"], 39 | 'dev': ["pytest", "pytest-cov", "codecov", "coverage", "tox", "mypy"]}, 40 | keywords = ABOUT['__keywords__'], 41 | ) 42 | -------------------------------------------------------------------------------- /tests/config_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import os 3 | from . import DEFAULT_CONFIG 4 | from wpwatcher.config import Config 5 | 6 | class T(unittest.TestCase): 7 | 8 | def test_init_config_from_string(self): 9 | 10 | # Test minimal config 11 | config_dict=Config.fromstring(DEFAULT_CONFIG) 12 | self.assertEqual(config_dict['email_to'], ["test@mail.com"]) 13 | self.assertEqual(config_dict['from_email'], "testing-wpwatcher@exemple.com") 14 | self.assertEqual(config_dict['smtp_server'], "localhost:1025") 15 | 16 | # Test config template file 17 | config_dict2=Config.fromstring(Config.TEMPLATE_FILE) 18 | self.assertEqual(config_dict2['smtp_server'], "mailserver.de:587") 19 | 20 | def test_init_config_from_file(self): 21 | 22 | # Test find config file, rename default file if already exist and restore after test 23 | paths_found=Config.find_config_files() 24 | existent_files=[] 25 | if len(paths_found)==0: 26 | paths_found=Config.find_config_files(create=True) 27 | else: 28 | existent_files=paths_found 29 | for p in paths_found: 30 | os.rename(p,'%s.temp'%p) 31 | paths_found=Config.find_config_files(create=True) 32 | 33 | # Init config and compare 34 | config_object=Config.fromenv() 35 | config_object2=Config.fromfiles(paths_found) 36 | self.assertEqual(config_object, config_object2, "Config built with config path and without are different even if files are the same") 37 | for f in paths_found: 38 | os.remove(f) 39 | for f in existent_files: 40 | os.rename('%s.temp'%f , f) 41 | 42 | def test_read_config_error(self): 43 | 44 | with self.assertRaisesRegex((ValueError), 'Make sure the file exists and you have correct access right'): 45 | Config.fromfiles(['/tmp/this_file_is_inexistent.conf']) 46 | 47 | WRONG_CONFIG=DEFAULT_CONFIG+'\nverbose=I dont know' 48 | 49 | with self.assertRaisesRegex(ValueError, 'Could not read boolean value in config file'): 50 | Config.fromstring(WRONG_CONFIG) 51 | 52 | WRONG_CONFIG=DEFAULT_CONFIG+'\nwpscan_args=["forgot", "a" "commas"]' 53 | 54 | with self.assertRaisesRegex(ValueError, 'Could not read JSON value in config file'): 55 | Config.fromstring(WRONG_CONFIG) 56 | 57 | -------------------------------------------------------------------------------- /tests/notification_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import smtpd 3 | import asyncore 4 | import concurrent.futures 5 | from . import DEFAULT_CONFIG, WP_SITES 6 | from wpwatcher.email import EmailSender 7 | from wpwatcher.core import WPWatcher 8 | from wpwatcher.config import Config 9 | 10 | class T(unittest.TestCase): 11 | 12 | @classmethod 13 | def setUpClass(cls): 14 | # Launch SMPT debbug server 15 | smtpd.DebuggingServer(('localhost',1025), None ) 16 | executor = concurrent.futures.ThreadPoolExecutor(1) 17 | executor.submit(asyncore.loop) 18 | 19 | @classmethod 20 | def tearDownClass(cls): 21 | # Close mail server 22 | asyncore.close_all() 23 | 24 | def test_send_report(self): 25 | 26 | # Init WPWatcher 27 | wpwatcher = WPWatcher(Config.fromstring(DEFAULT_CONFIG+"\nattach_wpscan_output=Yes")) 28 | 29 | 30 | print(wpwatcher.__dict__) 31 | print(wpwatcher.scanner.__dict__) 32 | print(wpwatcher.scanner.mail.__dict__) 33 | 34 | # Send mail 35 | for s in WP_SITES: 36 | report={ 37 | "site": s['url'], 38 | "status": "WARNING", 39 | "datetime": "2020-04-08T16-05-16", 40 | "last_email": None, 41 | "error": '', 42 | "infos": [ 43 | "[+]","blablabla"], 44 | "warnings": [ 45 | "[+] WordPress version 5.2.2 identified (Insecure, released on 2019-06-18).\n| Found By: Emoji Settings (Passive Detection)\n", 46 | "[!] No WPVulnDB API Token given, as a result vulnerability data has not been output.\n[!] You can get a free API token with 50 daily requests by registering at https://wpvulndb.com/users/sign_up" 47 | ], 48 | "alerts": [], 49 | "fixed": ["This issue was fixed"], 50 | "summary":None, 51 | "wpscan_output":"This is real%s"%(s) 52 | } 53 | 54 | wpwatcher.scanner.mail._send_report(report, email_to='test', wpscan_command= 'just testing', wpscan_version='1.2.3') 55 | 56 | # self.assertEqual(report['fixed'], [], "Fixed item wasn't remove after email sent") 57 | # self.assertNotEqual(report['last_email'], None) 58 | 59 | def test_should_notify(self): 60 | # test send_errors, send_infos, send_warnings, resend_emails_after, email_errors_to 61 | # Init WPWatcher 62 | CONFIG=DEFAULT_CONFIG+"\nsend_infos=Yes\nsend_errors=Yes\nsend_warnings=No" 63 | wpwatcher = WPWatcher(Config.fromstring(CONFIG)) 64 | # wpwatcher.scanner.mail 65 | # TODO 66 | -------------------------------------------------------------------------------- /docs/source/linux-service.md: -------------------------------------------------------------------------------- 1 | # Linux service 2 | 3 | ## Daemon settings 4 | - Daemon mode: loops forever. Default to No. 5 | It's recommended to use `--daemon` argument and not the config file value, otherwise `wpwatcher` will start by default in daemon mode. 6 | ```ini 7 | daemon=No 8 | ``` 9 | 10 | - Sleep time between two scans. 11 | If missing, default to `0s` 12 | ```ini 13 | daemon_loop_sleep=12h 14 | ``` 15 | Overwrite with argument: `--loop Time string` 16 | 17 | ## Setup continuous scanning linux service 18 | 19 | Configure : 20 | - `daemon_loop_sleep`: i.e. `12h` 21 | - `resend_emails_after` i.e.`5d` and 22 | - `api_limit_wait=Yes`. 23 | 24 | wpwatcher --daemon [--urls ./my_sites.txt] ... 25 | 26 | Let's say you have 20 WordPress sites to scan but your API limit is reached after 8 sites, the program will sleep 24h and continue until all sites are scanned (2 days later). Then will sleep the configured time and start again. 27 | 28 | Tip: `wpwatcher` and `wpscan` might not be in your execution environement `PATH`. If you run into file not found error, try to configure the full paths to executables and config files. 29 | 30 | Note: By default a different database file will be used when using daemon mode `~/.wpwatcher/wp_reports.daemon.json` 31 | 32 | Setup WPWatcher as a service. 33 | - With `systemctl` 34 | 35 | Create and configure the service file `/lib/systemd/system/wpwatcher.service` 36 | ```bash 37 | nano /lib/systemd/system/wpwatcher.service 38 | ``` 39 | Adjust `ExecStart` and `User` in the following template service file: 40 | ``` 41 | [Unit] 42 | Description=WPWatcher 43 | After=network.target 44 | StartLimitIntervalSec=0 45 | 46 | [Service] 47 | Type=simple 48 | Restart=always 49 | RestartSec=1 50 | ExecStart=/usr/local/bin/wpwatcher --daemon --conf /path/to/wpwatcher.conf 51 | User=user 52 | 53 | [Install] 54 | WantedBy=multi-user.target 55 | ``` 56 | 57 | Enable the service to start on boot 58 | ``` 59 | systemctl daemon-reload 60 | systemctl enable wpwatcher.service 61 | ``` 62 | 63 | The service can be started/stopped with the following commands: 64 | ``` 65 | systemctl start wpwatcher 66 | systemctl stop wpwatcher 67 | ``` 68 | 69 | Follow logs 70 | ``` 71 | journalctl -u wpwatcher -f 72 | ``` 73 | [More infos on systemctl](https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/7/html/system_administrators_guide/sect-managing_services_with_systemd-unit_files) 74 | 75 | 76 | - For other systems, please refer to the appropriate [documentation](https://blog.frd.mn/how-to-set-up-proper-startstop-services-ubuntu-debian-mac-windows/) 77 | -------------------------------------------------------------------------------- /docs/source/false-positives.md: -------------------------------------------------------------------------------- 1 | # False positives 2 | 3 | ## Per site false positive strings 4 | 5 | You can use `false_positive_strings` key in the **`wp_sites` config file value** to ignore some warnings or alerts on a per site basis. 6 | False positives messages will still be processed as infos with `[False positive]` prefix. 7 | 8 | Use case: Vulnerabilities are found but WPScan can't determine plugin version, so all vulnerabilities are printed. If your plugin version is not vulnerable, add vulnerability title (start with plugin name) in the list of false positive in the `wp_sites` entry. You might have to add a lot of false positives if the plugin version could not be determined because all known vulnerabilities will be listed. 9 | 10 | ```ini 11 | wp_sites= [ 12 | { 13 | "url":"exemple.com", 14 | "false_positive_strings":[ 15 | "Yoast SEO 1.2.0-11.5 - Authenticated Stored XSS", 16 | "Yoast SEO <= 9.1 - Authenticated Race Condition" 17 | ] 18 | }, 19 | { 20 | "url":"exemple2.com", 21 | "false_positive_strings":[ 22 | "W3 Total Cache 0.9.2.4 - Username & Hash Extract", 23 | "W3 Total Cache - Remote Code Execution", 24 | "W3 Total Cache 0.9.4 - Edge Mode Enabling CSRF", 25 | "W3 Total Cache <= 0.9.4 - Cross-Site Request Forgery (CSRF)", 26 | "W3 Total Cache <= 0.9.4.1 - Weak Validation of Amazon SNS Push Messages", 27 | "W3 Total Cache <= 0.9.4.1 - Information Disclosure Race Condition", 28 | "W3 Total Cache 0.9.2.6-0.9.3 - Unauthenticated Arbitrary File Read", 29 | "W3 Total Cache < 0.9.7.3 - Cryptographic Signature Bypass", 30 | "W3 Total Cache <= 0.9.7.3 - Cross-Site Scripting (XSS)", 31 | "W3 Total Cache <= 0.9.7.3 - SSRF / RCE via phar" 32 | ] 33 | } 34 | ] 35 | ``` 36 | 37 | ## Global false positive strings 38 | False positives can also be applied to all websites at once. 39 | 40 | **For security reasons, it is recommended to use per site false positives**. 41 | 42 | (WPWatcher was historically working with global false positives only) 43 | 44 | Use cases: 45 | - You want to ignore all "Potential Vulnerability" (i.e. don't worry about the vulnerabilities found when WPScan can't determine plugin version). 46 | - You want to ignore all "Upload directory has listing enabled" warnings or other hard-coded warnings. 47 | 48 | **Note**: "No WPScan API Token given" warning is automatically ignored. 49 | 50 | ```ini 51 | false_positive_strings=["No WPScan API Token given, as a result vulnerability data has not been output."] 52 | ``` 53 | Or pass values by arguments: `--fpstr String [String ...]` 54 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | # import os 14 | # import sys 15 | # sys.path.insert(0, os.path.abspath('.')) 16 | 17 | 18 | # -- Project information ----------------------------------------------------- 19 | from wpwatcher.__version__ import __version__ 20 | project = 'WPWatcher' 21 | copyright = '2020, Florian Roth, Tristan Landes' 22 | author = 'Florian Roth, Tristan Landes' 23 | version = __version__ 24 | 25 | # -- General configuration --------------------------------------------------- 26 | 27 | # Add any Sphinx extension module names here, as strings. They can be 28 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 29 | # ones. 30 | extensions = [ 31 | "sphinx_rtd_theme", 32 | "sphinx.ext.intersphinx", 33 | "recommonmark", 34 | ] 35 | 36 | # Add any paths that contain templates here, relative to this directory. 37 | templates_path = ['_templates'] 38 | 39 | # List of patterns, relative to source directory, that match files and 40 | # directories to ignore when looking for source files. 41 | # This pattern also affects html_static_path and html_extra_path. 42 | exclude_patterns = [] 43 | 44 | 45 | # -- Options for HTML output ------------------------------------------------- 46 | 47 | # The theme to use for HTML and HTML Help pages. See the documentation for 48 | # a list of builtin themes. 49 | # 50 | html_theme = "alabaster" 51 | 52 | # Theme options are theme-specific and customize the look and feel of a theme 53 | # further. For a list of options available for each theme, see the 54 | # documentation. 55 | html_theme_options = { 56 | 'show_powered_by': False, 57 | 'github_user': 'tristanlatr', 58 | 'github_repo': 'WPWatcher', 59 | 'github_banner': True, 60 | 'github_type': 'star', 61 | 'note_bg': '#FFF59C', 62 | 'page_width': '80%', 63 | 'sidebar_width': '20%', 64 | 'logo': 'logo.png', 65 | 'description': 'Automating WPScan to scan and report vulnerable Wordpress sites', 66 | } 67 | 68 | # Add any paths that contain custom static files (such as style sheets) here, 69 | # relative to this directory. They are copied after the builtin static files, 70 | # so a file named "default.css" will overwrite the builtin "default.css". 71 | html_static_path = ['_static'] 72 | -------------------------------------------------------------------------------- /docs/source/wpscan-config.md: -------------------------------------------------------------------------------- 1 | # WPScan configurations 2 | 3 | ## WPScan path 4 | Path to wpscan executable. 5 | If WPScan is installed with RVM could be: `/usr/local/rvm/gems/default/wrappers/wpscan`. 6 | With docker, could be: `docker run -it --rm wpscanteam/wpscan`. 7 | If missing, assume `wpscan` is in your `PATH`. 8 | 9 | ```ini 10 | wpscan_path=wpscan 11 | ``` 12 | ## WPScan arguments 13 | **Global WPScan arguments**. 14 | Must be a valid Json string. 15 | 21 | 22 | See `wpscan --help` for more informations about WPScan options 23 | ```ini 24 | wpscan_args=[ "--format", "json", 25 | "--no-banner", 26 | "--random-user-agent", 27 | "--disable-tls-checks", 28 | "--detection-mode", "aggressive", 29 | "--enumerate", "t,p,tt,cb,dbe,u,m", 30 | "--api-token", "YOUR_API_TOKEN" ] 31 | ``` 32 | Overwrite with `--wpargs "WPScan arguments"`. If you run into option parsing error, start the arguments string with a space or use equals sign `--wpargs="[...]"` to avoid [argparse bug](https://stackoverflow.com/questions/16174992/cant-get-argparse-to-read-quoted-string-with-dashes-in-it?noredirect=1&lq=1). 33 | 34 | You can store the API Token in the WPScan default config file at `~/.wpscan/scan.yml` and not supply it via the wpscan CLI argument in the WPWatcher config file. See [WPSacn readme](https://github.com/wpscanteam/wpscan#save-api-token-in-a-file). 35 | 36 | **Per site WPScan arguments** 37 | Arguments will be appended to global WPScan arguments. 38 | ```ini 39 | wp_sites= [ 40 | { 41 | "url":"exemple.com", 42 | "wpscan_args":["--stealthy", "--http-auth", "myuser:p@assw0rD"] 43 | }, 44 | { 45 | "url":"exemple2.com", 46 | "wpscan_args":["--disable-tls-checks", "--enumerate", "ap,vt,tt,cb,dbe,u,m"] 47 | } 48 | ] 49 | ``` 50 | ## Sleep when API limit reached 51 | Wait 24h when API limit has been reached. 52 | Default behaviour will consider the API limit as a WPScan failure and continue the scans (if not fail_fast) leading into making lot's of failed commands 53 | ```ini 54 | api_limit_wait=No 55 | ``` 56 | Overwrite with arguments: `--wait` 57 | 58 | ## Follow redirection 59 | If WPScan fails and propose to use `--ignore-main-redirect`, parse output and scan redirected URL. 60 | Default to `No` 61 | ```ini 62 | follow_redirect=Yes 63 | ``` 64 | Overwrite with arguments: `--follow` 65 | 66 | ## Scan timeout 67 | Default to `15m`. You could have to increase scan timeout if you use enumerating features or password attack. 68 | ```ini 69 | scan_timeout=2h 70 | ``` 71 | -------------------------------------------------------------------------------- /tests/db_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import json 3 | from wpwatcher.config import Config 4 | from wpwatcher.db import DataBase 5 | from wpwatcher.report import ScanReport, ReportCollection 6 | from . import WP_SITES, DEFAULT_CONFIG 7 | 8 | class T(unittest.TestCase): 9 | 10 | def test_wp_reports_read_write(self): 11 | SPECIFIC_WP_REPORTS_FILE_CONFIG = DEFAULT_CONFIG+"\nwp_reports=%s" 12 | 13 | # Compare with config and no config 14 | db=DataBase() 15 | paths_found=db._find_db_file() 16 | db2=DataBase( 17 | filepath = Config.fromstring(SPECIFIC_WP_REPORTS_FILE_CONFIG%(paths_found))['wp_reports']) 18 | self.assertEqual(db._data, db2._data, "WP reports database are different even if files are the same") 19 | 20 | # Test Reports database 21 | reports = [ 22 | ScanReport({ 23 | "site": "exemple.com", 24 | "status": "WARNING", 25 | "datetime": "2020-04-08T16-05-16", 26 | "last_email": None, 27 | "error": '', 28 | "infos": [ 29 | "[+]","blablabla"], 30 | "warnings": [ 31 | "[+] WordPress version 5.2.2 identified (Insecure, released on 2019-06-18).\n| Found By: Emoji Settings (Passive Detection)\n", 32 | "[!] No WPVulnDB API Token given, as a result vulnerability data has not been output.\n[!] You can get a free API token with 50 daily requests by registering at https://wpvulndb.com/users/sign_up" 33 | ], 34 | "alerts": [], 35 | "fixed": [] 36 | }), 37 | ScanReport({ 38 | "site": "exemple2.com", 39 | "status": "INFO", 40 | "datetime": "2020-04-08T16-05-16", 41 | "last_email": None, 42 | "error": '', 43 | "infos": [ 44 | "[+]","blablabla"], 45 | "warnings": [], 46 | "alerts": [], 47 | "fixed": [] 48 | }) 49 | ] 50 | 51 | db=DataBase() 52 | db.open() 53 | db.write(reports) 54 | db.close() 55 | 56 | # Test internal _data gets updated after write() method 57 | for r in reports: 58 | self.assertIn(r, db._data, "The report do not seem to have been saved into WPWatcher.wp_report list") 59 | 60 | # Test write method 61 | wrote_db=ReportCollection(ScanReport(item) for item in db._build_db(db.filepath)) 62 | with open(db.filepath,'r') as dbf: 63 | wrote_db_alt=ReportCollection(ScanReport(item) for item in json.load(dbf)) 64 | for r in reports: 65 | self.assertIn(r, list(wrote_db), "The report do not seem to have been saved into db file") 66 | self.assertIn(r, list(wrote_db_alt), "The report do not seem to have been saved into db file (directly read with json.load)") 67 | self.assertIsNotNone(db.find(ScanReport(site=r['site'])), "The report do not seem to have been saved into db, cannot find it using find(). ") 68 | self.assertEqual(list(db._data), list(wrote_db_alt), "The database file wrote (directly read with json.load) differ from in memory database") 69 | self.assertEqual(list(db._data), list(wrote_db), "The database file wrote differ from in memory database") 70 | 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

3 | 4 |

5 | 6 |

7 | WPWatcher - Automating WPScan to scan and report vulnerable Wordpress sites 8 |
9 |

10 | 11 |

12 | 13 | 14 | 15 | 16 | 17 | 18 | Documentation Status 19 | 20 |

21 | 22 |

23 | Wordpress Watcher is a wrapper for WPScan that manages scans on multiple sites and reports by email and/or syslog. 24 | Schedule scans and get notified when vulnerabilities, outdated plugins and other risks are found. 25 |

26 | 27 | ## Features 28 | 29 | - Scan **multiple sites** with WPScan 30 | - **Parse WPScan output** and divide the results in *"Alerts"*, *"Warnings"* and *"Informations"* 31 | - **Handled VulnDB API limit** 32 | - Define **reporting emails addresses** for every configured site individually and globally 33 | - Define **false positives strings** for every configured site individually and globally 34 | - Define **WPScan arguments** for every configured site individually and globally 35 | - Send WPScan findings to **Syslog** server 36 | - Save raw WPScan output into files 37 | - Log file lists all the findings 38 | - Speed up scans using several asynchronous workers 39 | - **Follow URL redirection** if WPScan fails and propose to ignore main redirect 40 | - Scan sites continuously at defined interval and configure script as a linux service 41 | - Additionnal alerts depending of finding type (SQL dump, etc.) 42 | - Keep track of fixed and unfixed issues 43 | 44 | ## Documentation 45 | 46 | [Read The Docs](https://wpwatcher.readthedocs.io/en/latest/). 47 | 48 | ## Usage exemple 49 | 50 | Scan two sites, add WPScan arguments, follow URL redirection and email report to recepients. If you reach your API limit, it will wait and continue 24h later. 51 | 52 | ```bash 53 | wpwatcher --url exemple.com exemple1.com \ 54 | --wpscan_args "--force --stealthy --api-token " \ 55 | --follow_redirect --api_limit_wait \ 56 | --send --infos --email_to you@office.ca me@office.ca 57 | ``` 58 | 59 | WPWatcher must read a configuration file to send mail reports. This exemple assume you have filled your config file with mail server setings. 60 | 61 | ## Emails 62 | 63 | Sample email report. 64 | 65 | ![WPWatcher Report](https://github.com/tristanlatr/WPWatcher/raw/master/docs/source/_static/wpwatcher-report.png "WPWatcher Report") 66 | 67 | ## Authors 68 | - Florian Roth (Original author of [WPWatcher v0.2](https://github.com/Neo23x0/WPWatcher)) 69 | - Tristan Landes 70 | 71 | ## Disclamer 72 | 73 | Use at your own risks. 74 | -------------------------------------------------------------------------------- /tests/static/wordpress_one_warning.txt: -------------------------------------------------------------------------------- 1 | [+] URL: http://wp.exemple.com/ [50.87.253.23] 2 | [+] Started: Wed Apr 22 14:53:53 2020 3 | 4 | Interesting Finding(s): 5 | 6 | [+] Headers 7 | | Interesting Entries: 8 | | - Server: Apache 9 | | - Upgrade: h2,h2c 10 | | - host-header: c2hhcmVkLmJsdWVob3N0LmNvbQ== 11 | | Found By: Headers (Passive Detection) 12 | | Confidence: 100% 13 | 14 | [+] XML-RPC seems to be enabled: http://wp.exemple.com/xmlrpc.php 15 | | Found By: Headers (Passive Detection) 16 | | Confidence: 60% 17 | | Confirmed By: Link Tag (Passive Detection), 30% confidence 18 | | References: 19 | | - http://codex.wordpress.org/XML-RPC_Pingback_API 20 | | - https://www.rapid7.com/db/modules/auxiliary/scanner/http/wordpress_ghost_scanner 21 | | - https://www.rapid7.com/db/modules/auxiliary/dos/http/wordpress_xmlrpc_dos 22 | | - https://www.rapid7.com/db/modules/auxiliary/scanner/http/wordpress_xmlrpc_login 23 | | - https://www.rapid7.com/db/modules/auxiliary/scanner/http/wordpress_pingback_access 24 | 25 | [+] WordPress version 4.3.22 identified (Latest, released on 2019-12-12). 26 | | Found By: Rss Generator (Passive Detection) 27 | | - http://wp.exemple.com/feed/, https://wordpress.org/?v=4.3.22 28 | | - http://wp.exemple.com/comments/feed/, https://wordpress.org/?v=4.3.22 29 | 30 | [+] WordPress theme in use: foodie-pro 31 | | Location: http://wp.exemple.com/wp-content/themes/foodie-pro/ 32 | | Style URL: http://wp.exemple.com/wp-content/themes/foodie-pro/style.css?ver=2.0.3 33 | | Style Name: Foodie Pro Theme 34 | | Description: This is the Foodie Pro child theme created for the Genesis Framework.... 35 | | Author: Shay Bocks 36 | | Author URI: http://shaybocks.com/ 37 | | 38 | | Found By: Css Style In Homepage (Passive Detection) 39 | | Confirmed By: Css Style In 404 Page (Passive Detection) 40 | | 41 | | Version: 2.0.3 (80% confidence) 42 | | Found By: Style (Passive Detection) 43 | | - http://wp.exemple.com/wp-content/themes/foodie-pro/style.css?ver=2.0.3, Match: 'Version: 2.0.3' 44 | 45 | [+] Enumerating All Plugins (via Passive Methods) 46 | [+] Checking Plugin Versions (via Passive Methods) 47 | 48 | [i] Plugin(s) Identified: 49 | 50 | [+] contextual-related-posts 51 | | Location: http://wp.exemple.com/wp-content/plugins/contextual-related-posts/ 52 | | Latest Version: 2.9.0 53 | | Last Updated: 2020-04-18T11:37:00.000Z 54 | | 55 | | Found By: Urls In Homepage (Passive Detection) 56 | | Confirmed By: Urls In 404 Page (Passive Detection) 57 | | 58 | | The version could not be determined. 59 | 60 | [+] simple-social-icons 61 | | Location: http://wp.exemple.com/wp-content/plugins/simple-social-icons/ 62 | | Last Updated: 2020-04-15T19:57:00.000Z 63 | | [!] The version is out of date, the latest version is 3.0.2 64 | | 65 | | Found By: Urls In Homepage (Passive Detection) 66 | | Confirmed By: Urls In 404 Page (Passive Detection) 67 | | 68 | | Version: 1.0.5 (10% confidence) 69 | | Found By: Query Parameter (Passive Detection) 70 | | - http://wp.exemple.com/wp-content/plugins/simple-social-icons/css/style.css?ver=1.0.5 71 | 72 | [+] Enumerating Config Backups (via Passive Methods) 73 | 74 | [i] No Config Backups Found. 75 | 76 | [!] No WPVulnDB API Token given, as a result vulnerability data has not been output. 77 | [!] You can get a free API token with 50 daily requests by registering at https://wpvulndb.com/users/sign_up 78 | 79 | [+] Finished: Wed Apr 22 14:54:28 2020 80 | [+] Requests Done: 7 81 | [+] Cached Requests: 4 82 | [+] Data Sent: 1.808 KB 83 | [+] Data Received: 303.63 KB 84 | [+] Memory used: 198.02 MB 85 | [+] Elapsed time: 00:00:35 86 | -------------------------------------------------------------------------------- /tests/static/wordpress_no_vuln.txt: -------------------------------------------------------------------------------- 1 | [+] URL: http://wp.exemple.com [68.66.248.36] 2 | [+] Effective URL: https://wp.exemple.com 3 | [+] Started: Wed Apr 22 14:57:54 2020 4 | 5 | Interesting Finding(s): 6 | 7 | [+] Headers 8 | | Interesting Entries: 9 | | - Server: Apache 10 | | - X-Powered-By: PHP/5.6.40 11 | | Found By: Headers (Passive Detection) 12 | | Confidence: 100% 13 | 14 | [+] XML-RPC seems to be enabled: https://wp.exemple.comxmlrpc.php 15 | | Found By: Link Tag (Passive Detection) 16 | | Confidence: 30% 17 | | References: 18 | | - http://codex.wordpress.org/XML-RPC_Pingback_API 19 | | - https://www.rapid7.com/db/modules/auxiliary/scanner/http/wordpress_ghost_scanner 20 | | - https://www.rapid7.com/db/modules/auxiliary/dos/http/wordpress_xmlrpc_dos 21 | | - https://www.rapid7.com/db/modules/auxiliary/scanner/http/wordpress_xmlrpc_login 22 | | - https://www.rapid7.com/db/modules/auxiliary/scanner/http/wordpress_pingback_access 23 | 24 | [+] WordPress version 5.2.5 identified (Latest, released on 2019-12-12). 25 | | Found By: Rss Generator (Passive Detection) 26 | | - https://wp.exemple.comfeed/, https://wordpress.org/?v=5.2.5 27 | | - https://wp.exemple.comcomments/feed/, https://wordpress.org/?v=5.2.5 28 | 29 | [+] WordPress theme in use: xtreme 30 | | Location: http://wp.exemple.comwp-content/themes/xtreme/ 31 | | Style URL: http://wp.exemple.comwp-content/themes/xtreme/style.css 32 | | Style Name: Xtreme 33 | | Author: Guru 34 | | 35 | | Found By: Urls In Homepage (Passive Detection) 36 | | Confirmed By: Urls In 404 Page (Passive Detection) 37 | | 38 | | Version: 1.0.1 (80% confidence) 39 | | Found By: Style (Passive Detection) 40 | | - http://wp.exemple.comwp-content/themes/xtreme/style.css, Match: 'Version: 1.0.1' 41 | 42 | [+] Enumerating All Plugins (via Passive Methods) 43 | [+] Checking Plugin Versions (via Passive Methods) 44 | 45 | [i] Plugin(s) Identified: 46 | 47 | [+] revslider 48 | | Location: http://wp.exemple.comwp-content/plugins/revslider/ 49 | | 50 | | Found By: Urls In Homepage (Passive Detection) 51 | | Confirmed By: 52 | | Urls In 404 Page (Passive Detection) 53 | | Meta Generator (Passive Detection) 54 | | 55 | | Version: 5.1.5 (90% confidence) 56 | | Found By: Meta Generator (Passive Detection) 57 | | - https://wp.exemple.com, Match: 'Powered by Slider Revolution 5.1.5' 58 | | Confirmed By: Query Parameter (Passive Detection) 59 | | - https://wp.exemple.comwp-content/plugins/revslider/public/assets/css/settings.css?ver=5.1.5 60 | | - https://wp.exemple.comwp-content/plugins/revslider/public/assets/js/jquery.themepunch.tools.min.js?ver=5.1.5 61 | | - https://wp.exemple.comwp-content/plugins/revslider/public/assets/js/jquery.themepunch.revolution.min.js?ver=5.1.5 62 | 63 | [+] wordpress-seo-premium 64 | | Location: http://wp.exemple.comwp-content/plugins/wordpress-seo-premium/ 65 | | 66 | | Found By: Comment (Passive Detection) 67 | | 68 | | Version: 4.7 (60% confidence) 69 | | Found By: Comment (Passive Detection) 70 | | - https://wp.exemple.com, Match: 'optimized with the Yoast SEO Premium plugin v4.7 -' 71 | 72 | [+] Enumerating Config Backups (via Passive Methods) 73 | 74 | [i] No Config Backups Found. 75 | 76 | [!] No WPVulnDB API Token given, as a result vulnerability data has not been output. 77 | [!] You can get a free API token with 50 daily requests by registering at https://wpvulndb.com/users/sign_up 78 | 79 | [+] Finished: Wed Apr 22 14:58:24 2020 80 | [+] Requests Done: 8 81 | [+] Cached Requests: 3 82 | [+] Data Sent: 2.686 KB 83 | [+] Data Received: 215.532 KB 84 | [+] Memory used: 198.023 MB 85 | [+] Elapsed time: 00:00:29 86 | -------------------------------------------------------------------------------- /wpwatcher/daemon.py: -------------------------------------------------------------------------------- 1 | 2 | from typing import Optional 3 | import time 4 | from datetime import datetime, timedelta 5 | 6 | from wpwatcher import log 7 | from wpwatcher.core import WPWatcher 8 | from wpwatcher.config import Config 9 | from wpwatcher.report import ScanReport 10 | from wpwatcher.site import Site 11 | 12 | from filelock import FileLock, Timeout 13 | 14 | # Date format used everywhere 15 | DATE_FORMAT = "%Y-%m-%dT%H-%M-%S" 16 | 17 | class Daemon: 18 | """ 19 | Daemonizer for `WPWatcher.run_scans`. 20 | """ 21 | def __init__(self, conf: Config) -> None: 22 | self._daemon_loop_sleep = conf['daemon_loop_sleep'] 23 | # Make sure the daemon mode is enabled 24 | conf['daemon'] = True 25 | self.wpwatcher = WPWatcherDaemonMode(conf) 26 | self.pidfile = '/tmp/wpwatcher.daemon.pid.lock' 27 | self.pidfilelock = FileLock(self.pidfile, timeout=1) 28 | self._running: bool = False 29 | self._stopping: bool = False 30 | self._start_time: Optional[datetime] = None 31 | 32 | def loop(self, ttl:Optional[timedelta]=None) -> None: 33 | """Enter the infinite loop that is calling `WPWatcher.run_scans`. """ 34 | self._running = True 35 | self._start_time = datetime.now() 36 | log.info("Daemon mode selected, looping for ever...") 37 | try: 38 | with self.pidfilelock: 39 | while self._running: 40 | # Run scans for ever 41 | self.wpwatcher.run_scans() 42 | if ttl and datetime.now() - self._start_time > ttl: 43 | self._running = False 44 | self._stopping = True 45 | if not self._stopping: 46 | log.info( 47 | f"Sleeping {self._daemon_loop_sleep} and scanning again..." 48 | ) 49 | time.sleep(self._daemon_loop_sleep.total_seconds()) 50 | except Timeout as err: 51 | log.error("The WPWatcher daemon is already running") 52 | raise RuntimeError("The WPWatcher daemon is already running") from err 53 | finally: 54 | self._running = False 55 | self._stopping = False 56 | 57 | def stop(self) -> None: 58 | """Interrupt the scans and stop the loop, do NOT raise SystemExit. """ 59 | self._stopping = True 60 | self._running = False 61 | self.wpwatcher.interrupt_scans() 62 | 63 | class WPWatcherDaemonMode(WPWatcher): 64 | 65 | def __init__(self, conf: Config): 66 | super().__init__(conf) 67 | self._daemon_loop_sleep: timedelta = conf["daemon_loop_sleep"] 68 | 69 | def _scan_site(self, wp_site: Site) -> Optional[ScanReport]: 70 | """Skips the site if it has already been scanned lately. """ 71 | 72 | last_wp_report = self.wp_reports.find(ScanReport(site=wp_site["url"])) 73 | # Skip if the daemon mode is enabled and scan already happend in the last configured `daemon_loop_wait` 74 | if last_wp_report and self._skip_this_site(last_wp_report): 75 | return None 76 | 77 | return super()._scan_site(wp_site) 78 | 79 | def _skip_this_site(self, last_wp_report: ScanReport) -> bool: 80 | """Return true if the daemon mode is enabled and scan already happend in the last configured `daemon_loop_wait`""" 81 | if ( 82 | datetime.now() 83 | - datetime.strptime(last_wp_report["datetime"], DATE_FORMAT) 84 | < self._daemon_loop_sleep 85 | ): 86 | log.info( 87 | f"Daemon skipping site {last_wp_report['site']} because already scanned in the last {self._daemon_loop_sleep}" 88 | ) 89 | return True 90 | return False 91 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: test 5 | 6 | on: 7 | push: 8 | branches: '*' 9 | tags: 10 | - '**' 11 | pull_request: 12 | branches: [ master ] 13 | 14 | jobs: 15 | test: 16 | runs-on: ${{ matrix.os }} 17 | name: ${{ matrix.os }} py${{ matrix.python-version }} 18 | 19 | strategy: 20 | matrix: 21 | python-version: [3.6,3.7,3.8,3.9] 22 | os: [ubuntu-20.04] 23 | 24 | steps: 25 | - uses: actions/checkout@v2 26 | 27 | - name: Set up Python ${{ matrix.python-version }} 28 | uses: actions/setup-python@v1 29 | with: 30 | python-version: ${{ matrix.python-version }} 31 | 32 | - name: Set up Ruby 33 | uses: ruby/setup-ruby@v1 34 | with: 35 | ruby-version: '2.6' 36 | 37 | - name: Display Python version 38 | run: python --version 39 | 40 | - name: Display Ruby version 41 | run: ruby --version 42 | 43 | - name: Install WPScan 44 | run: gem install wpscan 45 | 46 | - name: Install WPWatcher 47 | run: python setup.py install 48 | 49 | - name: Install test dependencies 50 | run: | 51 | pip install tox 52 | 53 | # testing against real WP instance WIP 54 | 55 | # - name: Build and run vulnerable Wordpress image 56 | # run: | 57 | # git clone https://github.com/wpscanteam/VulnerableWordpress 58 | # cd VulnerableWordpress 59 | # docker build --rm -t wpscan/vulnerablewordpress . 60 | # docker run --name vulnerablewordpress -d -p 8080:8080 -p 3306:3306 wpscan/vulnerablewordpress 61 | # sleep 300 62 | # cd .. 63 | 64 | # - name: Run WPScan on vulnerable testing host 65 | # run: wpscan --url http://localhost:8080 66 | 67 | - name: Run tests 68 | run: tox -e test 69 | 70 | - name: Upload code coverage 71 | uses: codecov/codecov-action@v1 72 | with: 73 | token: ${{ secrets.CODECOV_TOKEN }} 74 | file: ./coverage.xml 75 | name: wpwatcher-code-coverage 76 | yml: ./codecov.yml 77 | fail_ci_if_error: true 78 | 79 | - name: Run mypy 80 | if: ${{ matrix.python-version != '3.6' }} 81 | run: tox -e mypy 82 | 83 | release: 84 | 85 | needs: [test] 86 | runs-on: ubuntu-latest 87 | 88 | steps: 89 | - uses: actions/checkout@v2 90 | 91 | - name: Set up Python 3.9 92 | uses: actions/setup-python@v2 93 | with: 94 | python-version: 3.9 95 | 96 | - name: Log system information 97 | run: | 98 | test -r /etc/os-release && sh -c '. /etc/os-release && echo "OS: $PRETTY_NAME"' 99 | python --version 100 | python -c "print('\nENVIRONMENT VARIABLES\n=====================\n')" 101 | python -c "import os; [print(f'{k}={v}') for k, v in os.environ.items()]" 102 | 103 | - name: Install build deps 104 | run: | 105 | python -m pip install --upgrade pip setuptools wheel 106 | 107 | - name: Build WPWatcher 108 | run: | 109 | python setup.py --quiet build check sdist bdist_wheel 110 | ls -alh ./dist/ 111 | 112 | - name: Publish WPWatcher to PyPI on tags 113 | if: startsWith(github.ref, 'refs/tags') 114 | uses: pypa/gh-action-pypi-publish@master 115 | with: 116 | user: __token__ 117 | password: ${{ secrets.PYPI_TOKEN }} 118 | -------------------------------------------------------------------------------- /docs/source/email-reports.md: -------------------------------------------------------------------------------- 1 | # Email reports 2 | 3 | WPWatcher **must read a configuration file to send mail reports**. 4 | 5 | Setup mail server settings and turn on `send_email_report` in the config file or use `--send` if you want to receive email alerts. 6 | 7 | ## Reports 8 | 9 | One report is generated per site and the reports are sent individually when finished scanning a website. 10 | Email notification can have 5 status: 11 | 12 | - **`ALERT`**: You have a vulnerable Wordpress, theme or plugin. 13 | - **`WARNING`**: You have an outdated Wordpress, theme or plugin. Not necessarily vulnerable but more risky. 14 | - **`INFO`**: WPScan did not find any issues with your site. 15 | - **`ERROR`**: WPScan failed. 16 | 17 | Alerts, Warnings and Infos might differ whether you're using cli or json format. 18 | 19 | ## Mail server settings 20 | 21 | Not configurable with CLI arguments 22 | 23 | ```ini 24 | # Configuration file: mail server settings 25 | 26 | # Send email reports as 27 | from_email=WordPressWatcher@inc.com 28 | # Mail server and port 29 | smtp_server=mailserver.inc.com:587 30 | # Use authentication, default to No 31 | smtp_auth=Yes 32 | # Auth username 33 | smtp_user=office@inc.com 34 | # Auth password 35 | smtp_pass=p@assw0rd 36 | # Use SSL, default to No 37 | smtp_ssl=Yes 38 | ``` 39 | 40 | If you use Gmail, make sure you set up gmail to work with "less secure apps" [here](https://myaccount.google.com/lesssecureapps?pli=1). 41 | 42 | ## Notification settings 43 | 44 | 45 | ```ini 46 | # Configuration file: notification settings 47 | 48 | # Send emails for alerting of the WPScan result (ALERT or other). Default to No. 49 | # Overwrite with arguments: `--send` 50 | send_email_report=No 51 | 52 | # Send WARNING notifications and will include warnings in ALERT reports. 53 | # Default to Yes, cannot be overwritten by CLI arguments. 54 | send_warnings=Yes 55 | 56 | # Send INFO notifications if no warnings or alerts are found. Default to No 57 | # Overwrite with arguments: `--infos` 58 | send_infos=No 59 | 60 | # Send ERROR notifications if wpscan failed. Default to No 61 | # Overwrite with arguments: `--errors` 62 | send_errors=No 63 | ``` 64 | 65 | ## Reports recipients 66 | 67 | Recipients can be configured globally and on a per site basis 68 | 69 | ### Global recipients 70 | ```ini 71 | # Configuration file: reports recipients 72 | 73 | # Global email report recepients, will always receive email reports for all sites. 74 | # Overwrite with arguments: `--email_to Email [Email...]` 75 | email_to=["securityalerts@domain.com"] 76 | 77 | # Send any error email to those addresses and not to other recipients (`email_to` options). 78 | # Applicable only if `send_errors=Yes`. 79 | email_errors_to=["admins@domain.com"] 80 | ``` 81 | 82 | ### Per site recipients 83 | ```ini 84 | # Configuration file: sites 85 | 86 | wp_sites=[ 87 | { 88 | "url":"exemple.com", 89 | "email_to":["site_owner@domain.com"] 90 | }, 91 | { 92 | "url":"exemple2.com", 93 | "email_to":["site_owner2@domain.com"] 94 | } 95 | ] 96 | ``` 97 | Global recipients will still receive reports 98 | 99 | 100 | ## Misc config 101 | 102 | ```ini 103 | # Minimum time inverval between sending two report with the same status. Examples of valid strings: `8h`, `2d8h5m20s`, `2m4s` 104 | # If missing, default to `0s` 105 | # Overwrite with arguments: `--resend Time string` 106 | resend_emails_after=3d 107 | 108 | # Attach text output file with raw WPScan output when sending a report. 109 | # Useful with when using WPScan arguments "--format cli" 110 | # Overwrite with arguments: `--attach` 111 | attach_wpscan_output=No 112 | ``` 113 | 114 | ## Sample email report 115 | 116 | ![WPWatcher Report](https://github.com/tristanlatr/WPWatcher/raw/master/docs/source/_static/wpwatcher-report.png "WPWatcher Report") 117 | -------------------------------------------------------------------------------- /wpwatcher/syslog.py: -------------------------------------------------------------------------------- 1 | """ 2 | CEF Syslog output support. 3 | """ 4 | from typing import Dict, Any, List 5 | import socket 6 | import logging 7 | from wpwatcher import log 8 | from wpwatcher.__version__ import __version__ 9 | 10 | 11 | class SyslogOutput: 12 | """ 13 | Send CEF messages based on reports. 14 | """ 15 | def __init__(self, conf: Dict[str, Any]): 16 | # Keep syslog dependency optionnal by importing at init time 17 | from rfc5424logging import Rfc5424SysLogHandler 18 | 19 | sh: Rfc5424SysLogHandler = Rfc5424SysLogHandler( 20 | address=(conf["syslog_server"], conf["syslog_port"]), 21 | socktype=getattr(socket, conf["syslog_stream"]), # Use TCP or UDP 22 | appname="WPWatcher", 23 | **conf["syslog_kwargs"], 24 | ) 25 | self.syslog = logging.getLogger("wpwatcher-syslog") 26 | self.syslog.setLevel(logging.DEBUG) 27 | self.syslog.addHandler(sh) 28 | 29 | DEVICE_VENDOR = "Github" 30 | DEVICE_PRODUCT = "WPWatcher" 31 | DEVICE_VERSION = __version__ 32 | 33 | # Dict of # report_key: (signatureId, name, severiry) 34 | # This definition must not change! 35 | EVENTS = { 36 | "infos": ("100", "WPScan INFO", 4), 37 | "fixed": ("101", "WPScan issue FIXED", 4), 38 | "error": ("102", "WPScan ERROR", 6), 39 | "warnings": ("103", "WPScan WARNING", 6), 40 | "alerts": ("104", "WPScan ALERT", 9), 41 | } 42 | 43 | def emit_messages(self, wp_report: Dict[str, Any]) -> None: 44 | """ 45 | Sends the CEF syslog messages for the report. 46 | """ 47 | log.debug(f"Sending Syslog messages for site {wp_report['site']}") 48 | for m in self.get_messages(wp_report): 49 | self.syslog.info(m) 50 | 51 | def get_messages(self, wp_report: Dict[str, Any]) -> List[str]: 52 | """ 53 | Return a list of CEF formatted messages 54 | """ 55 | from cefevent import CEFEvent 56 | 57 | messages = [] 58 | for v in self.EVENTS.keys(): 59 | # make sure items is a list, cast error string to list 60 | items = wp_report[v] if isinstance(wp_report[v], list) else [wp_report[v]] 61 | for msg_data in items: 62 | if msg_data: 63 | log.debug(f"Message data: {msg_data}") 64 | c = CEFEvent() 65 | # WPWatcher related fields 66 | c.set_prefix("deviceVendor", self.DEVICE_VENDOR) 67 | c.set_prefix("deviceProduct", self.DEVICE_PRODUCT) 68 | c.set_prefix("deviceVersion", self.DEVICE_VERSION) 69 | # Message common fields 70 | c.set_prefix("signatureId", self.EVENTS[v][0]) 71 | c.set_prefix("name", self.EVENTS[v][1]) 72 | c.set_prefix("severity", self.EVENTS[v][2]) 73 | # Message supp infos 74 | c.set_field("message", msg_data[:1022]) 75 | c.set_field("sourceHostName", wp_report["site"][:1022]) 76 | msg = c.build_cef() 77 | log.debug(f"Message CEF: {msg}") 78 | messages.append(msg) 79 | return messages 80 | 81 | def emit_test_messages(self) -> None: 82 | wp_report = { 83 | "site": "https://exemple.com", 84 | "error": "WPScan Failed ... (TESTING)", 85 | "infos": [ 86 | "Plugin: wpdatatables\nThe version could not be determined (latest is 2.1.2) (TESTING)" 87 | ], 88 | "warnings": [ 89 | "Outdated Wordpress version: 5.1.1\nRelease Date: 2019-03-13 (TESTING)" 90 | ], 91 | "alerts": [ 92 | "Vulnerability: WooCommerce < 4.1.0 - Unescaped Metadata when Duplicating Products (TESTING)" 93 | ], 94 | "fixed": [ 95 | "Issue regarding component 123 has been fixed since last report. (TESTING)" 96 | ], 97 | } 98 | self.emit_messages(wp_report) 99 | -------------------------------------------------------------------------------- /docs/source/quickstart.rst: -------------------------------------------------------------------------------- 1 | 2 | 3 | Quickstart 4 | ========== 5 | 6 | Prerequisites 7 | ^^^^^^^^^^^^^ 8 | 9 | - `WPScan `_ (itself requires Ruby and some libraries). 10 | - Python 3.6 or later 11 | 12 | Install 13 | ^^^^^^^ 14 | 15 | :: 16 | 17 | pip install -U 'wpwatcher' 18 | 19 | *Installs WPWatcher without syslog output support* 20 | 21 | ``wpwatcher`` should be in your `PATH`. 22 | 23 | Try it out 24 | ^^^^^^^^^^ 25 | 26 | **Simple usage** 27 | 28 | Scan 2 sites with default config:: 29 | 30 | wpwatcher --url exemple.com exemple1.com 31 | 32 | **More complete exemple** 33 | 34 | Load sites from text file , add WPScan arguments , follow redirection if WPScan fails , use 5 asynchronous workers , email custom recepients if any alerts with full WPScan output attached. If you reach your API limit, it will wait and continue 24h later. 35 | 36 | :: 37 | 38 | wpwatcher --urls sites.txt \ 39 | --wpscan_args "--force --stealthy --api-token " \ 40 | --follow_redirect \ 41 | --workers 5 \ 42 | --send --attach \ 43 | --email_to you@office.ca me@office.ca \ 44 | --api_limit_wait 45 | 46 | 47 | WPWatcher must read a configuration file to send mail reports. 48 | *This exemple assume you have filled your config file with mail server setings*. 49 | 50 | Configure 51 | ^^^^^^^^^ 52 | 53 | Select config file with ``--conf Path``. You can specify multiple files. Will overwrites the keys with each successive file. 54 | 55 | Create and edit a new config file from template. 56 | 57 | :: 58 | 59 | wpwatcher --template_conf > wpwatcher.conf 60 | vim wpwatcher.conf 61 | 62 | 63 | To load the config file by default, move the file to the following location: 64 | - For Windows: ``%APPDATA%\.wpwatcher\wpwatcher.conf`` or ``%APPDATA%\wpwatcher.conf`` 65 | - For Mac/Linux : ``$HOME/.wpwatcher/wpwatcher.conf`` or ``$HOME/wpwatcher.conf`` 66 | 67 | **Configuration exemple** 68 | 69 | Sample configuration file with full featured ``wp_sites`` entry, custom WPScan path and arguments, vuln DB api limit handling, email and syslog reporting 70 | 71 | .. code:: ini 72 | 73 | [wpwatcher] 74 | wp_sites= [ { 75 | "url":"exemple.com", 76 | "email_to":["site_owner@domain.com"], 77 | "false_positive_strings":[ 78 | "Yoast SEO 1.2.0-11.5 - Authenticated Stored XSS", 79 | "Yoast SEO <= 9.1 - Authenticated Race Condition"], 80 | "wpscan_args":["--stealthy"] 81 | }, 82 | { "url":"exemple2.com" } ] 83 | wpscan_path=/usr/local/rvm/gems/default/wrappers/wpscan 84 | wpscan_args=[ "--format", "json", 85 | "--no-banner", 86 | "--random-user-agent", 87 | "--disable-tls-checks", 88 | "--api-token", "YOUR_API_TOKEN" ] 89 | api_limit_wait=Yes 90 | send_email_report=Yes 91 | email_to=["me@gmail.com"] 92 | from_email=me@gmail.com 93 | smtp_user=me@gmail.com 94 | smtp_server=smtp.gmail.com:587 95 | smtp_ssl=Yes 96 | smtp_auth=Yes 97 | smtp_pass=P@assW0rd 98 | syslog_server=syslogserver.ca 99 | syslog_port=514 100 | 101 | Return non zero status code if... 102 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 103 | 104 | - A WPScan command failed 105 | - Unable to parse a WPScan output 106 | - Unable to send a email report 107 | - Other errors 108 | 109 | .. note:: Returns a non-zero status code only on errors. 110 | If a site is vulnerable it will still return zero. 111 | Search for ``ALERT`` or ``WARNING`` keywords in stdout to check for issues or configure email or syslog reports. 112 | 113 | Notes about WPScan API token 114 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 115 | 116 | You need a WPScan API token in order to show vulnerability data and be alerted of vulnerable WordPress or plugin. 117 | 118 | If you have large number of sites to scan, you'll probably can't scan all your sites because of the limited amount of daily API request. 119 | Set ``api_limit_wait=Yes`` to wait 24h and contuinue scans when API limit si reached. 120 | 121 | .. note:: 122 | If no API token is provided to WPScan, scans will still WARNING emails if outdated plugin or WordPress version is detected. 123 | 124 | .. attention:: 125 | Please make sure you respect the `WPScan license `_. 126 | 127 | 128 | -------------------------------------------------------------------------------- /wpwatcher/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | A few quick static methods. 3 | """ 4 | from typing import Iterable, Optional, Tuple, Dict, Any, Callable, List 5 | import re 6 | import threading 7 | import sys 8 | import copy 9 | import threading 10 | import queue 11 | from datetime import timedelta 12 | from wpwatcher import log 13 | 14 | # Few static helper methods ------------------- 15 | 16 | 17 | def remove_color(string: str) -> str: 18 | """ 19 | Remove ansi colors from string. 20 | """ 21 | return re.sub(r"(\x1b|\[[0-9][0-9]?m)", "", string) 22 | 23 | 24 | def timeout( 25 | timeout: float, 26 | func: Callable[..., Any], 27 | args: Tuple[Any, ...] = (), 28 | kwargs: Dict[str, Any] = {}, 29 | ) -> Any: 30 | """Run func with the given timeout. 31 | 32 | :raise TimeoutError: If func didn't finish running within the given timeout. 33 | """ 34 | 35 | class FuncThread(threading.Thread): 36 | def __init__(self, bucket: queue.Queue) -> None: # type: ignore [type-arg] 37 | threading.Thread.__init__(self) 38 | self.result: Any = None 39 | self.bucket: queue.Queue = bucket # type: ignore [type-arg] 40 | self.err: Optional[Exception] = None 41 | 42 | def run(self) -> None: 43 | try: 44 | self.result = func(*args, **kwargs) 45 | except Exception as err: 46 | self.bucket.put(sys.exc_info()) 47 | self.err = err 48 | 49 | bucket: queue.Queue = queue.Queue() # type: ignore [type-arg] 50 | it = FuncThread(bucket) 51 | it.start() 52 | it.join(timeout) 53 | if it.is_alive(): 54 | raise TimeoutError() 55 | else: 56 | try: 57 | _, _, exc_trace = bucket.get(block=False) 58 | except queue.Empty: 59 | return it.result 60 | else: 61 | raise it.err.with_traceback(exc_trace) # type: ignore [union-attr] 62 | 63 | 64 | def safe_log_wpscan_args(wpscan_args: Iterable[str]) -> List[str]: 65 | """Replace `--api-token` param with `"***"` for safer logging""" 66 | args = [val.strip() for val in copy.deepcopy(wpscan_args)] 67 | if "--api-token" in args: 68 | args[args.index("--api-token") + 1] = "***" 69 | return args 70 | 71 | 72 | def oneline(string: str) -> str: 73 | """Helper method that transform multiline string to one line for grepable output""" 74 | return " ".join(line.strip() for line in string.splitlines()) 75 | 76 | 77 | def get_valid_filename(s: str) -> str: 78 | """Return the given string converted to a string that can be used for a clean filename. Stolen from Django I think""" 79 | s = str(s).strip().replace(" ", "_") 80 | return re.sub(r"(?u)[^-\w.]", "", s) 81 | 82 | 83 | def print_progress_bar(count: int, total: int) -> None: 84 | """Helper method to print progress bar. Stolen on the web""" 85 | size = 0.3 # size of progress bar 86 | percent = int(float(count) / float(total) * 100) 87 | log.info( 88 | f"Progress - [{'=' * int(int(percent) * size)}{' ' * int((100 - int(percent)) * size)}] {percent}% - {count} / {total}" 89 | ) 90 | 91 | 92 | def parse_timedelta(time_str: str) -> timedelta: 93 | """ 94 | Parse a time string e.g. (2h13m) into a timedelta object. Stolen on the web 95 | """ 96 | regex = re.compile( 97 | r"^((?P[\.\d]+?)d)?((?P[\.\d]+?)h)?((?P[\.\d]+?)m)?((?P[\.\d]+?)s)?$" 98 | ) 99 | time_str = replace( 100 | time_str, 101 | { 102 | "sec": "s", 103 | "second": "s", 104 | "seconds": "s", 105 | "minute": "m", 106 | "minutes": "m", 107 | "min": "m", 108 | "mn": "m", 109 | "days": "d", 110 | "day": "d", 111 | "hours": "h", 112 | "hour": "h", 113 | }, 114 | ) 115 | parts = regex.match(time_str) 116 | if parts is None: 117 | raise ValueError( 118 | f"Could not parse any time information from '{time_str}'. Examples of valid strings: '8h', '2d8h5m20s', '2m4s'" 119 | ) 120 | time_params = { 121 | name: float(param) for name, param in parts.groupdict().items() if param 122 | } 123 | return timedelta(**time_params) 124 | 125 | 126 | def replace(text: str, conditions: Dict[str, str]) -> str: 127 | """Multiple replacements helper method. Stolen on the web""" 128 | rep = conditions 129 | rep = dict((re.escape(k), rep[k]) for k in rep) 130 | pattern = re.compile("|".join(rep.keys())) 131 | text = pattern.sub(lambda m: rep[re.escape(m.group(0))], text) 132 | return text 133 | -------------------------------------------------------------------------------- /tests/scan_random_sites.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # 3 | # Wordpress Watcher test script 4 | # 5 | # DISCLAIMER - USE AT YOUR OWN RISK. 6 | # 7 | # THIS TEST MIGHT BE ILLEGAL IN YOUR COUNTRY 8 | 9 | import json 10 | import os 11 | import requests 12 | import asyncore 13 | import smtpd 14 | import unittest 15 | import random 16 | import linecache 17 | import concurrent.futures 18 | 19 | from wpwatcher.wpscan import WPScanWrapper 20 | from wpwatcher.core import WPWatcher 21 | from wpwatcher.config import Config 22 | 23 | # WORDPRESS SITES SOURCE LIST FILE 24 | SOURCE="https://gist.githubusercontent.com/ahmadawais/e6cd20acdc4f7ad304a3e90ad44a663c/raw/ca95a83bc6e45f018189f8f73bc0b73d310a31f7/wordpress-sites.csv" 25 | 26 | # How many radom potential WordPress site to scan 27 | HOW_MANY=15 28 | 29 | class WPWatcherScanTests(unittest.TestCase): 30 | 31 | def test_scan_radom_sites(self): 32 | # This test might be illegal in your country 33 | 34 | # Get list of Wordpress sites if not already downloaded 35 | filename='/tmp/wp_sites' 36 | if not os.path.isfile(filename): 37 | myfile = requests.get(SOURCE) 38 | open(filename, 'wb').write(myfile.content) 39 | 40 | # Select X from the 50M 41 | idxs = random.sample(range(50000), HOW_MANY) 42 | urls=[linecache.getline(filename, i) for i in idxs] 43 | 44 | # Prepare scan config 45 | CONFIG1=""" 46 | [wpwatcher] 47 | wp_sites=%s 48 | smtp_server=localhost:1025 49 | from_email=testing-wpwatcher@exemple.com 50 | email_to=["test@mail.com"] 51 | wpscan_args=["--rua", "--stealthy", "--format", "cli", "--no-banner", "--disable-tls-checks"] 52 | false_positive_strings=["You can get a free API token with 50 daily requests by registering at https://wpvulndb.com/users/sign_up"] 53 | send_email_report=Yes 54 | log_file=./TEST-wpwatcher.log.conf 55 | wp_reports=./TEST-wp_reports.json.conf 56 | asynch_workers=10 57 | follow_redirect=Yes 58 | wpscan_output_folder=./TEST-wpscan-results/ 59 | send_infos=Yes 60 | """%json.dumps([{'url':s.strip()} for s in urls]) 61 | 62 | # Select X from the 50M 63 | idxs = random.sample(range(50000), HOW_MANY) 64 | urls=[linecache.getline(filename, i) for i in idxs] 65 | 66 | # Prepare scan config 67 | CONFIG2=""" 68 | [wpwatcher] 69 | wp_sites=%s 70 | smtp_server=localhost:1025 71 | from_email=testing-wpwatcher@exemple.com 72 | email_to=["test@mail.com"] 73 | wpscan_args=["--rua", "--stealthy", "--format", "json", "--no-banner", "--disable-tls-checks"] 74 | false_positive_strings=["You can get a free API token with 50 daily requests by registering at https://wpvulndb.com/users/sign_up"] 75 | send_email_report=Yes 76 | log_file=./TEST-wpwatcher.log.conf 77 | wp_reports=./TEST-wp_reports.json.conf 78 | asynch_workers=10 79 | follow_redirect=Yes 80 | wpscan_output_folder=./TEST-wpscan-results/ 81 | attach_wpscan_output=Yes 82 | send_infos=Yes 83 | send_errors=Yes 84 | email_errors_to=["admins@domain"] 85 | # prescan_without_api_token=Yes 86 | """%json.dumps([{'url':s.strip()} for s in urls]) 87 | 88 | # Select X from the 50M 89 | idxs = random.sample(range(50000), HOW_MANY) 90 | urls=[linecache.getline(filename, i) for i in idxs] 91 | 92 | # Prepare scan config 93 | CONFIG3=""" 94 | [wpwatcher] 95 | wp_sites=%s 96 | smtp_server=localhost:1025 97 | from_email=testing-wpwatcher@exemple.com 98 | email_to=["test@mail.com"] 99 | wpscan_args=["--rua", "--stealthy", "--format", "json", "--no-banner", "--disable-tls-checks"] 100 | false_positive_strings=["You can get a free API token with 50 daily requests by registering at https://wpvulndb.com/users/sign_up"] 101 | send_email_report=Yes 102 | log_file=./TEST-wpwatcher.log.conf 103 | wp_reports=./TEST-wp_reports.json.conf 104 | asynch_workers=10 105 | follow_redirect=Yes 106 | wpscan_output_folder=./TEST-wpscan-results/ 107 | attach_wpscan_output=Yes 108 | send_warnings=No 109 | send_errors=Yes 110 | fail_fast=Yes 111 | """%json.dumps([{'url':s.strip()} for s in urls]) 112 | 113 | # Launch SMPT debbug server 114 | smtpd.DebuggingServer(('localhost',1025), None ) 115 | executor = concurrent.futures.ThreadPoolExecutor(1) 116 | executor.submit(asyncore.loop) 117 | 118 | # Init WPWatcher 119 | w1 = WPWatcher(Config.fromstring(CONFIG1)) 120 | 121 | # Run scans 122 | res1=w1.run_scans_and_notify() 123 | 124 | # Init WPWatcher 125 | w2 = WPWatcher(Config.fromstring(CONFIG2)) 126 | 127 | # Run scans 128 | res2=w2.run_scans_and_notify() 129 | 130 | # Init WPWatcher 131 | w3 = WPWatcher(Config.fromstring(CONFIG3)) 132 | 133 | # Run scans 134 | res3=w3.run_scans_and_notify() 135 | 136 | # Close mail server 137 | asyncore.close_all() 138 | 139 | self.assertEqual(type(res1), tuple, "run_scans_and_notify returned an invalied result") 140 | -------------------------------------------------------------------------------- /docs/source/all-options.rst: -------------------------------------------------------------------------------- 1 | All configuration options 2 | ========================= 3 | 4 | .. list-table:: List of all WPWatcher configuration options 5 | 6 | * - Option 7 | - Accepted values in config file 8 | - CLI argument 9 | - Accepted values in CLI argument 10 | - Default value 11 | 12 | * - ``wpscan_path`` 13 | - Strings 14 | - NA 15 | - NA 16 | - ``wpscan`` 17 | 18 | * - ``wpscan_args`` 19 | - Json string 20 | - ``--wpargs "WPScan arguments"`` 21 | - String 22 | - ``--random-user-agent --format json`` 23 | 24 | * - ``wp_sites`` 25 | - Json string (Fully configurable) 26 | - ``--url URL [URL...]`` 27 | - Strings 28 | - None 29 | 30 | * - Load ``wp_sites`` URLs from file 31 | - NA 32 | - ``--urls Path`` 33 | - String 34 | - None 35 | 36 | * - ``false_positive_strings`` 37 | - Json string 38 | - ``--fpstr String [String...]`` 39 | - Strings 40 | - None 41 | 42 | * - ``send_email_report`` 43 | - Boolean Yes/No 44 | - ``--send`` 45 | - No value 46 | - No 47 | 48 | * - ``email_to`` 49 | - Json string 50 | - ``--email_to Email [Email ...]`` 51 | - Strings 52 | - No one 53 | 54 | * - ``email_errors_to`` 55 | - Json string 56 | - NA 57 | - NA 58 | - Same as ``email_to`` 59 | 60 | * - ``send_infos`` 61 | - Boolean Yes /No 62 | - ``--infos`` 63 | - No value 64 | - No 65 | 66 | * - ``send_warnings`` 67 | - Boolean Yes/No 68 | - NA 69 | - NA 70 | - Yes 71 | 72 | * - ``send_errors`` 73 | - Boolean Yes/No 74 | - ``--errors`` 75 | - No value 76 | - No 77 | 78 | * - ``attach_wpscan_output`` 79 | - Boolean Yes/No 80 | - ``--attach`` 81 | - No value 82 | - No 83 | 84 | * - ``resend_emails_after`` 85 | - String 86 | - ``--resend String`` 87 | - String 88 | - ``0s`` 89 | 90 | * - ``api_limit_wait`` 91 | - Boolean Yes/No 92 | - ``--wait`` 93 | - No value 94 | - No 95 | 96 | * - ``daemon`` 97 | - Boolean Yes/No 98 | - ``--daemon`` 99 | - No value 100 | - No 101 | 102 | * - ``daemon_loop_sleep`` 103 | - String 104 | - ``--loop`` 105 | - String 106 | - ``0s`` 107 | 108 | * - ``log_file`` 109 | - String 110 | - ``--log Path`` 111 | - String 112 | - None 113 | 114 | * - ``quiet`` 115 | - Boolean Yes/No 116 | - ``--quiet`` 117 | - No value 118 | - No 119 | 120 | * - ``verbose`` 121 | - Boolean Yes/No 122 | - ``--verbose`` 123 | - No value 124 | - No 125 | 126 | * - ``wpscan_output_folder`` 127 | - String 128 | - ``--wpout Path`` 129 | - String 130 | - None 131 | 132 | * - ``wp_reports`` 133 | - String 134 | - ``--reports Path`` 135 | - String 136 | - ``~/.wpwatcher/wp_reports.json`` 137 | 138 | * - ``fail_fast`` 139 | - Boolean Yes/No 140 | - ``--ff`` 141 | - No value 142 | - No 143 | 144 | * - ``asynch_workers`` 145 | - Int 146 | - ``--workers Number`` 147 | - Int 148 | - 1 149 | 150 | * - ``follow_redirect`` 151 | - Boolean Yes/No 152 | - ``--follow`` 153 | - No value 154 | - No 155 | 156 | * - ``scan_timeout`` 157 | - String 158 | - NA 159 | - NA 160 | - ``15m`` 161 | 162 | * - ``from_email`` 163 | - String 164 | - NA 165 | - NA 166 | - None 167 | 168 | * - ``smtp_server`` 169 | - String 170 | - NA 171 | - NA 172 | - None 173 | 174 | * - ``smtp_ssl`` 175 | - Boolean Yes/No 176 | - NA 177 | - NA 178 | - No 179 | 180 | * - ``smtp_auth`` 181 | - String 182 | - NA 183 | - NA 184 | - No 185 | 186 | * - ``smtp_user`` 187 | - String 188 | - NA 189 | - NA 190 | - None 191 | 192 | * - ``smtp_pass`` 193 | - String 194 | - NA 195 | - NA 196 | - None 197 | 198 | * - ``use_monospace_font`` 199 | - Boolean 200 | - ``--monospace`` 201 | - No value 202 | - No 203 | 204 | * - ``syslog_server`` 205 | - String 206 | - NA 207 | - NA 208 | - None 209 | 210 | * - ``syslog_port`` 211 | - Int 212 | - NA 213 | - NA 214 | - 514 215 | 216 | * - ``syslog_stream`` 217 | - String 218 | - NA 219 | - NA 220 | - ``SOCK_STREAM`` (TCP) 221 | 222 | * - ``syslog_kwargs`` 223 | - Json String 224 | - NA 225 | - NA 226 | - ``{"enterprise_id":42, "msg_as_utf8":true, "utc_timestamp":true}`` 227 | 228 | * - Test syslog 229 | - NA 230 | - ``--syslog_test`` 231 | - No value 232 | - No 233 | 234 | * - Dump database summary 235 | - NA 236 | - ``--wprs`` 237 | - File path or None 238 | - None 239 | 240 | * - Print a report in database 241 | - NA 242 | - ``--show`` 243 | - String 244 | - None 245 | 246 | * - Print a report in database in HTML format, use with --quiet to print only HTML content 247 | - NA 248 | - ``--show_html`` 249 | - String 250 | - None 251 | 252 | * - Print a report in database in JSON format, use with --quiet to print only JSON content 253 | - NA 254 | - ``--show_json`` 255 | - String 256 | - None -------------------------------------------------------------------------------- /wpwatcher/db.py: -------------------------------------------------------------------------------- 1 | """ 2 | Interface to JSON file storing scan results. 3 | """ 4 | 5 | from typing import Iterable, List, Dict, Any, Optional 6 | import os 7 | import json 8 | import time 9 | import threading 10 | from wpwatcher import log 11 | from wpwatcher.config import Config 12 | from wpwatcher.report import ScanReport, ReportCollection 13 | 14 | from filelock import FileLock, Timeout 15 | 16 | # Database default files 17 | DEFAULT_REPORTS = ".wpwatcher/wp_reports.json" 18 | DEFAULT_REPORTS_DAEMON = ".wpwatcher/wp_reports.daemon.json" 19 | 20 | class DataBase: 21 | """ 22 | Interface to JSON database file. 23 | Write all reports in a thread safe way. 24 | """ 25 | 26 | def __repr__(self) -> str: 27 | return repr(self._data) 28 | 29 | def __init__(self, filepath: Optional[str] = None, daemon: bool = False): 30 | 31 | if not filepath: 32 | filepath = self._find_db_file(daemon=daemon) 33 | 34 | self.no_local_storage: bool = filepath == "null" 35 | "True if the DB is disabled" 36 | self.filepath = filepath 37 | 38 | self._data = ReportCollection() 39 | self._data.extend(self._build_db(self.filepath)) 40 | 41 | # Writing into the database file is thread safe 42 | self._wp_report_lock: threading.Lock = threading.Lock() 43 | 44 | try: 45 | lock = FileLock(f"{self.filepath}.lock", thread_local=False) 46 | except: 47 | lock = FileLock(f"{self.filepath}.lock") 48 | 49 | # Only one instance of WPWatcher can use a database file at a time. 50 | self._wp_report_file_lock: FileLock = lock 51 | 52 | def open(self) -> None: 53 | """ 54 | Acquire the file lock for the DB file. 55 | """ 56 | try: 57 | self._wp_report_file_lock.acquire(timeout=1) 58 | except Timeout as err: 59 | raise RuntimeError(f"Could not use the database file '{self.filepath}' because another instance of WPWatcher is using it. ") from err 60 | log.debug(f"Acquired DB lock file '{self.filepath}.lock'") 61 | try: 62 | self.write() 63 | except: 64 | log.error( 65 | f"Could not write wp_reports database: {self.filepath}. Use '--reports null' to ignore local Json database." 66 | ) 67 | raise 68 | 69 | def close(self) -> None: 70 | """ 71 | Release the file lock. 72 | """ 73 | self._wp_report_file_lock.release() 74 | log.debug(f"Released DB lock file '{self.filepath}.lock'") 75 | 76 | @staticmethod 77 | def _find_db_file(daemon: bool = False) -> str: 78 | files = [DEFAULT_REPORTS] if not daemon else [DEFAULT_REPORTS_DAEMON] 79 | env = ["HOME", "PWD", "XDG_CONFIG_HOME", "APPDATA"] 80 | return Config.find_files(env, files, "[]", create=True)[0] 81 | 82 | # Read wp_reports database 83 | def _build_db(self, filepath: str) -> ReportCollection: 84 | """Load reports database and return the complete structure""" 85 | wp_reports = ReportCollection() 86 | if self.no_local_storage: 87 | return wp_reports 88 | 89 | if os.path.isfile(filepath): 90 | try: 91 | with open(filepath, "r") as reportsfile: 92 | wp_reports.extend( 93 | ScanReport(item) for item in json.load(reportsfile) 94 | ) 95 | log.info(f"Load wp_reports database: {filepath}") 96 | except Exception: 97 | log.error( 98 | f"Could not read wp_reports database: {filepath}. Use '--reports null' to ignore local Json database" 99 | ) 100 | raise 101 | else: 102 | log.info(f"The database file {filepath} do not exist. It will be created.") 103 | return wp_reports 104 | 105 | def write( 106 | self, wp_reports: Optional[Iterable[ScanReport]] = None 107 | ) -> bool: 108 | """ 109 | Write the reports to the database. 110 | 111 | Returns `True` if the reports have been successfully written. 112 | """ 113 | 114 | if not self._wp_report_file_lock.is_locked: 115 | raise RuntimeError("The file lock must be acquired before writing data. ") 116 | 117 | if not wp_reports: 118 | wp_reports = self._data 119 | 120 | for newr in wp_reports: 121 | new = True 122 | for r in self._data: 123 | if r["site"] == newr["site"]: 124 | self._data[self._data.index(r)] = newr 125 | new = False 126 | break 127 | if new: 128 | self._data.append(newr) 129 | # Write to file if not null 130 | if not self.no_local_storage: 131 | # Write method thread safe 132 | while self._wp_report_lock.locked(): 133 | time.sleep(0.01) 134 | self._wp_report_lock.acquire() 135 | with open(self.filepath, "w") as reportsfile: 136 | json.dump(self._data, reportsfile, indent=4) 137 | self._wp_report_lock.release() 138 | return True 139 | else: 140 | return False 141 | 142 | def find(self, wp_report: ScanReport) -> Optional[ScanReport]: 143 | """ 144 | Find the pre-existing report if any. 145 | """ 146 | last_wp_reports = [r for r in self._data if r["site"] == wp_report["site"]] 147 | last_wp_report: Optional[ScanReport] 148 | if len(last_wp_reports) > 0: 149 | last_wp_report = last_wp_reports[0] 150 | else: 151 | last_wp_report = None 152 | return last_wp_report 153 | -------------------------------------------------------------------------------- /tests/static/wordpress_one_vuln.txt: -------------------------------------------------------------------------------- 1 | [+] URL: http://wp.exemple.com/ [198.46.91.10] 2 | [+] Effective URL: https://wp.exemple.com/ 3 | [+] Started: Wed Apr 22 21:16:22 2020 4 | 5 | Interesting Finding(s): 6 | 7 | [+] Headers 8 | | Interesting Entries: 9 | | - server: Apache 10 | | - x-powered-by: PHP/7.2.24 11 | | Found By: Headers (Passive Detection) 12 | | Confidence: 100% 13 | 14 | [+] XML-RPC seems to be enabled: https://wp.exemple.com/xmlrpc.php 15 | | Found By: Link Tag (Passive Detection) 16 | | Confidence: 100% 17 | | Confirmed By: Direct Access (Aggressive Detection), 100% confidence 18 | | References: 19 | | - http://codex.wordpress.org/XML-RPC_Pingback_API 20 | | - https://www.rapid7.com/db/modules/auxiliary/scanner/http/wordpress_ghost_scanner 21 | | - https://www.rapid7.com/db/modules/auxiliary/dos/http/wordpress_xmlrpc_dos 22 | | - https://www.rapid7.com/db/modules/auxiliary/scanner/http/wordpress_xmlrpc_login 23 | | - https://www.rapid7.com/db/modules/auxiliary/scanner/http/wordpress_pingback_access 24 | 25 | [+] This site has 'Must Use Plugins': http://wp.exemple.com/wp-content/mu-plugins/ 26 | | Found By: Direct Access (Aggressive Detection) 27 | | Confidence: 80% 28 | | Reference: http://codex.wordpress.org/Must_Use_Plugins 29 | 30 | [+] WordPress version 4.7.2 identified (Insecure, released on 2017-01-26). 31 | | Found By: Rss Generator (Passive Detection) 32 | | - https://wp.exemple.com/feed/, https://wordpress.org/?v=4.7.2 33 | | - https://wp.exemple.com/comments/feed/, https://wordpress.org/?v=4.7.2 34 | | 35 | | [!] 1 vulnerabilities identified: 36 | | 37 | | [!] Title: WordPress 3.6.0-4.7.2 - Authenticated Cross-Site Scripting (XSS) via Media File Metadata 38 | | Fixed in: 4.7.3 39 | | References: 40 | | - https://wpvulndb.com/vulnerabilities/8765 41 | | - https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2017-6814 42 | | - https://wordpress.org/news/2017/03/wordpress-4-7-3-security-and-maintenance-release/ 43 | | - https://github.com/WordPress/WordPress/commit/28f838ca3ee205b6f39cd2bf23eb4e5f52796bd7 44 | | - https://sumofpwn.nl/advisory/2016/wordpress_audio_playlist_functionality_is_affected_by_cross_site_scripting.html 45 | | - https://seclists.org/oss-sec/2017/q1/563 46 | 47 | [+] WordPress theme in use: optimizer_pro 48 | | Location: http://wp.exemple.com/wp-content/themes/optimizer_pro/ 49 | | Style URL: https://wp.exemple.com/wp-content/themes/optimizer_pro/style.css 50 | | Style Name: Optimizer PRO 51 | | Style URI: https://optimizerwp.com/ 52 | | Description: Optimizer, an easy to customize multipurpose theme with lots of powerful features. This theme lets y... 53 | | Author: OptimizerWP 54 | | Author URI: https://optimizerwp.com/optimizer-pro/ 55 | | 56 | | Found By: Css Style In Homepage (Passive Detection) 57 | | Confirmed By: Css Style In 404 Page (Passive Detection) 58 | | 59 | | Version: 0.7.2 (80% confidence) 60 | | Found By: Style (Passive Detection) 61 | | - https://wp.exemple.com/wp-content/themes/optimizer_pro/style.css, Match: 'Version: 0.7.2' 62 | 63 | [+] Enumerating All Plugins (via Passive Methods) 64 | [+] Checking Plugin Versions (via Passive and Aggressive Methods) 65 | 66 | [i] Plugin(s) Identified: 67 | 68 | [+] all-in-one-event-calendar 69 | | Location: http://wp.exemple.com/wp-content/plugins/all-in-one-event-calendar/ 70 | | Latest Version: 2.6.2 71 | | Last Updated: 2020-02-06T18:49:00.000Z 72 | | 73 | | Found By: Urls In Homepage (Passive Detection) 74 | | Confirmed By: Urls In 404 Page (Passive Detection) 75 | | 76 | | The version could not be determined. 77 | 78 | [+] cookie-law-info 79 | | Location: http://wp.exemple.com/wp-content/plugins/cookie-law-info/ 80 | | Latest Version: 1.8.7 81 | | Last Updated: 2020-04-01T11:33:00.000Z 82 | | 83 | | Found By: Urls In Homepage (Passive Detection) 84 | | Confirmed By: Urls In 404 Page (Passive Detection) 85 | | 86 | | The version could not be determined. 87 | 88 | [+] our-team-enhanced 89 | | Location: http://wp.exemple.com/wp-content/plugins/our-team-enhanced/ 90 | | Latest Version: 4.4.2 91 | | Last Updated: 2018-08-10T16:15:00.000Z 92 | | 93 | | Found By: Urls In Homepage (Passive Detection) 94 | | Confirmed By: Urls In 404 Page (Passive Detection) 95 | | 96 | | The version could not be determined. 97 | 98 | [+] smartcat_our_team 99 | | Location: http://wp.exemple.com/wp-content/plugins/smartcat_our_team/ 100 | | 101 | | Found By: Urls In Homepage (Passive Detection) 102 | | Confirmed By: Urls In 404 Page (Passive Detection) 103 | | 104 | | The version could not be determined. 105 | 106 | [+] wordpress-seo 107 | | Location: http://wp.exemple.com/wp-content/plugins/wordpress-seo/ 108 | | Last Updated: 2020-04-14T10:12:00.000Z 109 | | [!] The version is out of date, the latest version is 13.5 110 | | 111 | | Found By: Comment (Passive Detection) 112 | | 113 | | Version: 5.7.1 (60% confidence) 114 | | Found By: Comment (Passive Detection) 115 | | - https://wp.exemple.com/, Match: 'optimized with the Yoast SEO plugin v5.7.1 -' 116 | 117 | [+] Enumerating Config Backups (via Passive and Aggressive Methods) 118 | 119 | Checking Config Backups -: |============================================================================================================================================================================================================================================| 120 | 121 | [i] No Config Backups Found. 122 | 123 | [+] WPVulnDB API OK 124 | | Plan: free 125 | | Requests Done (during the scan): 7 126 | | Requests Remaining: 0 127 | 128 | [+] Finished: Wed Apr 22 21:17:19 2020 129 | [+] Requests Done: 82 130 | [+] Cached Requests: 19 131 | [+] Data Sent: 18.831 KB 132 | [+] Data Received: 750.373 KB 133 | [+] Memory used: 194.023 MB 134 | [+] Elapsed time: 00:00:56 135 | -------------------------------------------------------------------------------- /tests/scan_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import os 3 | import shutil 4 | import http.server 5 | import concurrent.futures 6 | from wpwatcher.core import WPWatcher 7 | from wpwatcher.scan import Scanner 8 | from wpwatcher.config import Config 9 | from wpwatcher.site import Site 10 | from wpwatcher.report import ScanReport 11 | from wpwatcher.utils import get_valid_filename 12 | from . import WP_SITES, DEFAULT_CONFIG 13 | 14 | from wpscan_out_parse import WPScanJsonParser 15 | 16 | executor=None 17 | server=None 18 | class T(unittest.TestCase): 19 | 20 | @classmethod 21 | def setUpClass(cls): 22 | # Launch SMPT debbug server 23 | global executor 24 | global server 25 | server=http.server.HTTPServer(('localhost',8080), http.server.BaseHTTPRequestHandler ) 26 | executor=concurrent.futures.ThreadPoolExecutor(1) 27 | executor.submit(server.serve_forever) 28 | 29 | @classmethod 30 | def tearDownClass(cls): 31 | # Close mail server 32 | global executor 33 | global server 34 | server.shutdown() 35 | executor.shutdown() 36 | 37 | def test_update_report(self): 38 | # Init Scanner 39 | scanner = Scanner(Config.fromstring(DEFAULT_CONFIG)) 40 | for s in WP_SITES: 41 | old=ScanReport({ 42 | "site": s['url'], 43 | "status": "WARNING", 44 | "datetime": "2020-04-08T16-05-16", 45 | "last_email": "2020-04-08T16-05-17", 46 | "error": '', 47 | "infos": [ 48 | "[+]","blablabla"], 49 | "warnings": [ 50 | "[+] WordPress version 5.2.2 identified (Insecure, released on 2019-06-18).\nblablabla\n", 51 | "[!] No WPVulnDB API Token given, as a result vulnerability data has not been output.\n[!] You can get a free API token with 50 daily requests by registering at https://wpvulndb.com/users/sign_up" 52 | ], 53 | "alerts": [], 54 | "fixed": ["This issue was fixed"], 55 | "summary":None, 56 | "wpscan_output":"" 57 | }) 58 | parser = WPScanJsonParser(data={}) 59 | new=ScanReport({ 60 | "site": s['url'], 61 | "status": "", 62 | "datetime": "2020-04-10T16-00-00", 63 | "last_email": None, 64 | "error": '', 65 | "infos": [ 66 | "[+]","blablabla"], 67 | "warnings": [ 68 | "[!] No WPVulnDB API Token given, as a result vulnerability data has not been output.\n[!] You can get a free API token with 50 daily requests by registering at https://wpvulndb.com/users/sign_up" 69 | ], 70 | "alerts": [], 71 | "fixed": [], 72 | "summary":None, 73 | "wpscan_parser": parser, 74 | "wpscan_output":"" 75 | }) 76 | 77 | expected=ScanReport({ 78 | "site": s['url'], 79 | "status": "WARNING", 80 | "datetime": "2020-04-10T16-00-00", 81 | "last_email": "2020-04-08T16-05-17", 82 | "error": '', 83 | "infos": [ 84 | "[+]","blablabla"], 85 | "warnings": [ 86 | "[!] No WPVulnDB API Token given, as a result vulnerability data has not been output.\n[!] You can get a free API token with 50 daily requests by registering at https://wpvulndb.com/users/sign_up\nThis issue is unfixed since 2020-04-08T16-05-16" 87 | ], 88 | "alerts": [], 89 | "fixed": [ 90 | "This issue was fixed", 91 | 'Issue regarding component "%s" has been fixed since the last scan.'%("[+] WordPress version 5.2.2 identified (Insecure, released on 2019-06-18).") 92 | ], 93 | "summary":None, 94 | "wpscan_parser": parser, 95 | "wpscan_output":"" 96 | }) 97 | 98 | new.update_report(old) 99 | self.assertEqual(dict(new), dict(expected), "There is an issue with fixed issues feature: the expected report do not match the report returned by update_report()") 100 | 101 | def test_wpscan_output_folder(self): 102 | RESULTS_FOLDER="./results/" 103 | WPSCAN_OUTPUT_CONFIG = DEFAULT_CONFIG+"\nwpscan_output_folder=%s"%RESULTS_FOLDER 104 | scanner=Scanner(Config.fromstring(WPSCAN_OUTPUT_CONFIG)) 105 | self.assertTrue(os.path.isdir(RESULTS_FOLDER),"WPscan results folder doesn't seem to have been init") 106 | for s in WP_SITES: 107 | report={ 108 | "site": s['url'], 109 | "status": "WARNING", 110 | "datetime": "2020-04-08T16-05-16", 111 | "last_email": None, 112 | "error": '', 113 | "infos": [ 114 | "[+]","blablabla"], 115 | "warnings": [ 116 | "[+] WordPress version 5.2.2 identified (Insecure, released on 2019-06-18).\n| Found By: Emoji Settings (Passive Detection)\n", 117 | "[!] No WPVulnDB API Token given, as a result vulnerability data has not been output.\n[!] You can get a free API token with 50 daily requests by registering at https://wpvulndb.com/users/sign_up" 118 | ], 119 | "alerts": [], 120 | "fixed": [], 121 | "summary":None, 122 | "wpscan_output":"This is real%s"%(s) 123 | } 124 | f=scanner.write_wpscan_output(report) 125 | f1=os.path.join(RESULTS_FOLDER, 'warning/', get_valid_filename('WPScan_output_%s_%s.txt' % (s['url'], "2020-04-08T16-05-16"))) 126 | self.assertEqual(f, f1, "Inconsistent WPScan output filenames") 127 | self.assertTrue(os.path.isfile(f1),"WPscan output file doesn't exist") 128 | with open(f1, 'r') as out: 129 | self.assertEqual(out.read(), "This is real%s"%(s)) 130 | shutil.rmtree(RESULTS_FOLDER) 131 | 132 | 133 | 134 | def test_handle_wpscan_err(self): 135 | # test API wait, test Follow redirect 136 | # TODO 137 | pass 138 | 139 | def test_scan_localhost_error_not_wordpress(self): 140 | # test info, warnings and alerts 141 | scanner=Scanner(Config.fromstring(DEFAULT_CONFIG)) 142 | report=scanner.scan_site(Site({'url':'http://localhost:8080'})) 143 | self.assertEqual(report['status'], 'ERROR') 144 | self.assertRegex(report['error'], 'does not seem to be running WordPress') 145 | 146 | # def test_scan_localhost_error_not_wordpress_old_way(self): 147 | # # test info, warnings and alerts 148 | # scanner=Scanner(Config(string=DEFAULT_CONFIG).build_config()[0]) 149 | # report=scanner.scan_site(Site({'url':'http://localhost:8080'})) 150 | # self.assertEqual(report['status'], 'ERROR') 151 | # self.assertRegex(report['error'], 'does not seem to be running WordPress') 152 | -------------------------------------------------------------------------------- /tests/static/wordpress_no_vuln.json: -------------------------------------------------------------------------------- 1 | { 2 | "banner": { 3 | "description": "WordPress Security Scanner by the WPScan Team", 4 | "version": "3.5.4", 5 | "authors": [ 6 | "@_WPScan_", 7 | "@ethicalhack3r", 8 | "@erwan_lr", 9 | "@_FireFart_" 10 | ], 11 | "sponsored_by": "Sucuri - https://sucuri.net" 12 | }, 13 | "start_time": 1562094676, 14 | "start_memory": 40243200, 15 | "target_url": "https://www.sample-owasp-wp.com/", 16 | "effective_url": "https://www.sample-owasp-wp.com/", 17 | "interesting_findings": [ 18 | { 19 | "url": "https://www.sample-owasp-wp.com/", 20 | "to_s": "https://www.sample-owasp-wp.com/", 21 | "type": "headers", 22 | "found_by": "Headers (Passive Detection)", 23 | "confidence": 100, 24 | "confirmed_by": { 25 | 26 | }, 27 | "references": { 28 | 29 | }, 30 | "interesting_entries": [ 31 | "server: nginx/1.14.1" 32 | ] 33 | }, 34 | { 35 | "url": "https://www.sample-owasp-wp.com/robots.txt", 36 | "to_s": "https://www.sample-owasp-wp.com/robots.txt", 37 | "type": "robots_txt", 38 | "found_by": "Robots Txt (Aggressive Detection)", 39 | "confidence": 100, 40 | "confirmed_by": { 41 | 42 | }, 43 | "references": { 44 | 45 | }, 46 | "interesting_entries": [ 47 | 48 | ] 49 | }, 50 | { 51 | "url": "https://www.sample-owasp-wp.com/xmlrpc.php", 52 | "to_s": "https://www.sample-owasp-wp.com/xmlrpc.php", 53 | "type": "xmlrpc", 54 | "found_by": "Link Tag (Passive Detection)", 55 | "confidence": 100, 56 | "confirmed_by": { 57 | "Direct Access (Aggressive Detection)": { 58 | "confidence": 100 59 | } 60 | }, 61 | "references": { 62 | "url": [ 63 | "http://codex.wordpress.org/XML-RPC_Pingback_API" 64 | ], 65 | "metasploit": [ 66 | "auxiliary/scanner/http/wordpress_ghost_scanner", 67 | "auxiliary/dos/http/wordpress_xmlrpc_dos", 68 | "auxiliary/scanner/http/wordpress_xmlrpc_login", 69 | "auxiliary/scanner/http/wordpress_pingback_access" 70 | ] 71 | }, 72 | "interesting_entries": [ 73 | 74 | ] 75 | }, 76 | { 77 | "url": "https://www.sample-owasp-wp.com/readme.html", 78 | "to_s": "https://www.sample-owasp-wp.com/readme.html", 79 | "type": "readme", 80 | "found_by": "Direct Access (Aggressive Detection)", 81 | "confidence": 100, 82 | "confirmed_by": { 83 | 84 | }, 85 | "references": { 86 | 87 | }, 88 | "interesting_entries": [ 89 | 90 | ] 91 | }, 92 | { 93 | "url": "https://www.sample-owasp-wp.com/wp-content/mu-plugins/", 94 | "to_s": "This site has 'Must Use Plugins': https://www.sample-owasp-wp.com/wp-content/mu-plugins/", 95 | "type": "mu_plugins", 96 | "found_by": "Direct Access (Aggressive Detection)", 97 | "confidence": 80, 98 | "confirmed_by": { 99 | 100 | }, 101 | "references": { 102 | "url": [ 103 | "http://codex.wordpress.org/Must_Use_Plugins" 104 | ] 105 | }, 106 | "interesting_entries": [ 107 | 108 | ] 109 | }, 110 | { 111 | "url": "https://www.sample-owasp-wp.com/wp-content/uploads/", 112 | "to_s": "Upload directory has listing enabled: https://www.sample-owasp-wp.com/wp-content/uploads/", 113 | "type": "upload_directory_listing", 114 | "found_by": "Direct Access (Aggressive Detection)", 115 | "confidence": 100, 116 | "confirmed_by": { 117 | 118 | }, 119 | "references": { 120 | 121 | }, 122 | "interesting_entries": [ 123 | 124 | ] 125 | }, 126 | { 127 | "url": "https://www.sample-owasp-wp.com/wp-cron.php", 128 | "to_s": "https://www.sample-owasp-wp.com/wp-cron.php", 129 | "type": "wp_cron", 130 | "found_by": "Direct Access (Aggressive Detection)", 131 | "confidence": 60, 132 | "confirmed_by": { 133 | 134 | }, 135 | "references": { 136 | "url": [ 137 | "https://www.iplocation.net/defend-wordpress-from-ddos", 138 | "https://github.com/wpscanteam/wpscan/issues/1299" 139 | ] 140 | }, 141 | "interesting_entries": [ 142 | 143 | ] 144 | } 145 | ], 146 | "version": { 147 | "number": "5.2.2", 148 | "release_date": "2019-06-18", 149 | "status": "latest", 150 | "found_by": "Rss Generator (Passive Detection)", 151 | "confidence": 100, 152 | "interesting_entries": [ 153 | "https://www.sample-owasp-wp.com/feed/, https://wordpress.org/?v=5.2.2", 154 | "https://www.sample-owasp-wp.com/comments/feed/, https://wordpress.org/?v=5.2.2" 155 | ], 156 | "confirmed_by": { 157 | 158 | }, 159 | "vulnerabilities": [ 160 | 161 | ] 162 | }, 163 | "main_theme": { 164 | "slug": "customizr", 165 | "location": "https://www.sample-owasp-wp.com/wp-content/themes/customizr/", 166 | "latest_version": "4.1.42", 167 | "last_updated": "2019-06-30T00:00:00.000Z", 168 | "outdated": false, 169 | "readme_url": "https://www.sample-owasp-wp.com/wp-content/themes/customizr/readme.txt", 170 | "directory_listing": false, 171 | "error_log_url": "https://www.sample-owasp-wp.com/wp-content/themes/customizr/error_log", 172 | "style_url": "https://www.sample-owasp-wp.com/wp-content/themes/customizr/style.css?ver=4.1.42", 173 | "style_name": null, 174 | "style_uri": null, 175 | "description": null, 176 | "author": null, 177 | "author_uri": null, 178 | "template": null, 179 | "license": null, 180 | "license_uri": null, 181 | "tags": null, 182 | "text_domain": null, 183 | "found_by": "Css Style (Passive Detection)", 184 | "confidence": 70, 185 | "interesting_entries": [ 186 | 187 | ], 188 | "confirmed_by": { 189 | 190 | }, 191 | "vulnerabilities": [ 192 | ], 193 | "version": { 194 | "number": "4.1.42", 195 | "confidence": 80, 196 | "found_by": "Style (Passive Detection)", 197 | "interesting_entries": [ 198 | "https://www.sample-owasp-wp.com/wp-content/themes/customizr/style.css?ver=4.1.42, Match: 'Version: 4.1.42'" 199 | ], 200 | "confirmed_by": { 201 | 202 | } 203 | }, 204 | "parents": [ 205 | 206 | ] 207 | }, 208 | "plugins": { 209 | "youtube-embed-plus": { 210 | "slug": "youtube-embed-plus", 211 | "location": "https://www.sample-owasp-wp.com/wp-content/plugins/youtube-embed-plus/", 212 | "latest_version": "13.1", 213 | "last_updated": "2019-05-11T14:32:00.000Z", 214 | "outdated": false, 215 | "readme_url": null, 216 | "directory_listing": null, 217 | "error_log_url": null, 218 | "found_by": "Urls In Homepage (Passive Detection)", 219 | "confidence": 100, 220 | "interesting_entries": [ 221 | 222 | ], 223 | "confirmed_by": { 224 | "Javascript Var (Passive Detection)": { 225 | "confidence": 60, 226 | "interesting_entries": [ 227 | 228 | ] 229 | } 230 | }, 231 | "vulnerabilities": [ 232 | ], 233 | "version": null 234 | } 235 | }, 236 | "config_backups": { 237 | 238 | }, 239 | "stop_time": 1562094699, 240 | "elapsed": 22, 241 | "requests_done": 58, 242 | "cached_requests": 6, 243 | "data_sent": 12055, 244 | "data_sent_humanised": "11.772 KB", 245 | "data_received": 142206, 246 | "data_received_humanised": "138.873 KB", 247 | "used_memory": 198258688, 248 | "used_memory_humanised": "189.074 MB" 249 | } -------------------------------------------------------------------------------- /tests/static/wordpress_one_vuln.json: -------------------------------------------------------------------------------- 1 | { 2 | "banner": { 3 | "description": "WordPress Security Scanner by the WPScan Team", 4 | "version": "3.5.4", 5 | "authors": [ 6 | "@_WPScan_", 7 | "@ethicalhack3r", 8 | "@erwan_lr", 9 | "@_FireFart_" 10 | ], 11 | "sponsored_by": "Sucuri - https://sucuri.net" 12 | }, 13 | "start_time": 1562094676, 14 | "start_memory": 40243200, 15 | "target_url": "https://www.sample-owasp-wp.com/", 16 | "effective_url": "https://www.sample-owasp-wp.com/", 17 | "interesting_findings": [ 18 | { 19 | "url": "https://www.sample-owasp-wp.com/", 20 | "to_s": "https://www.sample-owasp-wp.com/", 21 | "type": "headers", 22 | "found_by": "Headers (Passive Detection)", 23 | "confidence": 100, 24 | "confirmed_by": { 25 | 26 | }, 27 | "references": { 28 | 29 | }, 30 | "interesting_entries": [ 31 | "server: nginx/1.14.1" 32 | ] 33 | }, 34 | { 35 | "url": "https://www.sample-owasp-wp.com/robots.txt", 36 | "to_s": "https://www.sample-owasp-wp.com/robots.txt", 37 | "type": "robots_txt", 38 | "found_by": "Robots Txt (Aggressive Detection)", 39 | "confidence": 100, 40 | "confirmed_by": { 41 | 42 | }, 43 | "references": { 44 | 45 | }, 46 | "interesting_entries": [ 47 | 48 | ] 49 | }, 50 | { 51 | "url": "https://www.sample-owasp-wp.com/xmlrpc.php", 52 | "to_s": "https://www.sample-owasp-wp.com/xmlrpc.php", 53 | "type": "xmlrpc", 54 | "found_by": "Link Tag (Passive Detection)", 55 | "confidence": 100, 56 | "confirmed_by": { 57 | "Direct Access (Aggressive Detection)": { 58 | "confidence": 100 59 | } 60 | }, 61 | "references": { 62 | "url": [ 63 | "http://codex.wordpress.org/XML-RPC_Pingback_API" 64 | ], 65 | "metasploit": [ 66 | "auxiliary/scanner/http/wordpress_ghost_scanner", 67 | "auxiliary/dos/http/wordpress_xmlrpc_dos", 68 | "auxiliary/scanner/http/wordpress_xmlrpc_login", 69 | "auxiliary/scanner/http/wordpress_pingback_access" 70 | ] 71 | }, 72 | "interesting_entries": [ 73 | 74 | ] 75 | }, 76 | { 77 | "url": "https://www.sample-owasp-wp.com/readme.html", 78 | "to_s": "https://www.sample-owasp-wp.com/readme.html", 79 | "type": "readme", 80 | "found_by": "Direct Access (Aggressive Detection)", 81 | "confidence": 100, 82 | "confirmed_by": { 83 | 84 | }, 85 | "references": { 86 | 87 | }, 88 | "interesting_entries": [ 89 | 90 | ] 91 | }, 92 | { 93 | "url": "https://www.sample-owasp-wp.com/wp-content/mu-plugins/", 94 | "to_s": "This site has 'Must Use Plugins': https://www.sample-owasp-wp.com/wp-content/mu-plugins/", 95 | "type": "mu_plugins", 96 | "found_by": "Direct Access (Aggressive Detection)", 97 | "confidence": 80, 98 | "confirmed_by": { 99 | 100 | }, 101 | "references": { 102 | "url": [ 103 | "http://codex.wordpress.org/Must_Use_Plugins" 104 | ] 105 | }, 106 | "interesting_entries": [ 107 | 108 | ] 109 | }, 110 | { 111 | "url": "https://www.sample-owasp-wp.com/wp-content/uploads/", 112 | "to_s": "Upload directory has listing enabled: https://www.sample-owasp-wp.com/wp-content/uploads/", 113 | "type": "upload_directory_listing", 114 | "found_by": "Direct Access (Aggressive Detection)", 115 | "confidence": 100, 116 | "confirmed_by": { 117 | 118 | }, 119 | "references": { 120 | 121 | }, 122 | "interesting_entries": [ 123 | 124 | ] 125 | }, 126 | { 127 | "url": "https://www.sample-owasp-wp.com/wp-cron.php", 128 | "to_s": "https://www.sample-owasp-wp.com/wp-cron.php", 129 | "type": "wp_cron", 130 | "found_by": "Direct Access (Aggressive Detection)", 131 | "confidence": 60, 132 | "confirmed_by": { 133 | 134 | }, 135 | "references": { 136 | "url": [ 137 | "https://www.iplocation.net/defend-wordpress-from-ddos", 138 | "https://github.com/wpscanteam/wpscan/issues/1299" 139 | ] 140 | }, 141 | "interesting_entries": [ 142 | 143 | ] 144 | } 145 | ], 146 | "version": { 147 | "number": "5.2.2", 148 | "release_date": "2019-06-18", 149 | "status": "latest", 150 | "found_by": "Rss Generator (Passive Detection)", 151 | "confidence": 100, 152 | "interesting_entries": [ 153 | "https://www.sample-owasp-wp.com/feed/, https://wordpress.org/?v=5.2.2", 154 | "https://www.sample-owasp-wp.com/comments/feed/, https://wordpress.org/?v=5.2.2" 155 | ], 156 | "confirmed_by": { 157 | 158 | }, 159 | "vulnerabilities": [ 160 | 161 | ] 162 | }, 163 | "main_theme": { 164 | "slug": "customizr", 165 | "location": "https://www.sample-owasp-wp.com/wp-content/themes/customizr/", 166 | "latest_version": "4.1.42", 167 | "last_updated": "2019-06-30T00:00:00.000Z", 168 | "outdated": false, 169 | "readme_url": "https://www.sample-owasp-wp.com/wp-content/themes/customizr/readme.txt", 170 | "directory_listing": false, 171 | "error_log_url": "https://www.sample-owasp-wp.com/wp-content/themes/customizr/error_log", 172 | "style_url": "https://www.sample-owasp-wp.com/wp-content/themes/customizr/style.css?ver=4.1.42", 173 | "style_name": null, 174 | "style_uri": null, 175 | "description": null, 176 | "author": null, 177 | "author_uri": null, 178 | "template": null, 179 | "license": null, 180 | "license_uri": null, 181 | "tags": null, 182 | "text_domain": null, 183 | "found_by": "Css Style (Passive Detection)", 184 | "confidence": 70, 185 | "interesting_entries": [ 186 | 187 | ], 188 | "confirmed_by": { 189 | 190 | }, 191 | "vulnerabilities": [ 192 | 193 | ], 194 | "version": { 195 | "number": "4.1.42", 196 | "confidence": 80, 197 | "found_by": "Style (Passive Detection)", 198 | "interesting_entries": [ 199 | "https://www.sample-owasp-wp.com/wp-content/themes/customizr/style.css?ver=4.1.42, Match: 'Version: 4.1.42'" 200 | ], 201 | "confirmed_by": { 202 | 203 | } 204 | }, 205 | "parents": [ 206 | 207 | ] 208 | }, 209 | "plugins": { 210 | "youtube-embed-plus": { 211 | "slug": "youtube-embed-plus", 212 | "location": "https://www.sample-owasp-wp.com/wp-content/plugins/youtube-embed-plus/", 213 | "latest_version": "13.1", 214 | "last_updated": "2019-05-11T14:32:00.000Z", 215 | "outdated": false, 216 | "readme_url": null, 217 | "directory_listing": null, 218 | "error_log_url": null, 219 | "found_by": "Urls In Homepage (Passive Detection)", 220 | "confidence": 100, 221 | "interesting_entries": [ 222 | 223 | ], 224 | "confirmed_by": { 225 | "Javascript Var (Passive Detection)": { 226 | "confidence": 60, 227 | "interesting_entries": [ 228 | 229 | ] 230 | } 231 | }, 232 | "vulnerabilities": [ 233 | { 234 | "title": "YouTube Embed <= 11.8.1 - Cross-Site Request Forgery (CSRF)", 235 | "fixed_in": "11.8.2", 236 | "references": { 237 | "url": [ 238 | "https://security.dxw.com/advisories/csrf-in-youtube-plugin/", 239 | "http://seclists.org/fulldisclosure/2017/Jul/64" 240 | ], 241 | "wpvulndb": [ 242 | "8873" 243 | ] 244 | } 245 | } 246 | ], 247 | "version": null 248 | } 249 | }, 250 | "config_backups": { 251 | 252 | }, 253 | "stop_time": 1562094699, 254 | "elapsed": 22, 255 | "requests_done": 58, 256 | "cached_requests": 6, 257 | "data_sent": 12055, 258 | "data_sent_humanised": "11.772 KB", 259 | "data_received": 142206, 260 | "data_received_humanised": "138.873 KB", 261 | "used_memory": 198258688, 262 | "used_memory_humanised": "189.074 MB" 263 | } -------------------------------------------------------------------------------- /tests/static/wordpress_many_vuln.json: -------------------------------------------------------------------------------- 1 | { 2 | "banner": { 3 | "description": "WordPress Security Scanner by the WPScan Team", 4 | "version": "3.5.4", 5 | "authors": [ 6 | "@_WPScan_", 7 | "@ethicalhack3r", 8 | "@erwan_lr", 9 | "@_FireFart_" 10 | ], 11 | "sponsored_by": "Sucuri - https://sucuri.net" 12 | }, 13 | "start_time": 1562094676, 14 | "start_memory": 40243200, 15 | "target_url": "https://www.sample-owasp-wp.com/", 16 | "effective_url": "https://www.sample-owasp-wp.com/", 17 | "interesting_findings": [ 18 | { 19 | "url": "https://www.sample-owasp-wp.com/", 20 | "to_s": "https://www.sample-owasp-wp.com/", 21 | "type": "headers", 22 | "found_by": "Headers (Passive Detection)", 23 | "confidence": 100, 24 | "confirmed_by": { 25 | 26 | }, 27 | "references": { 28 | 29 | }, 30 | "interesting_entries": [ 31 | "server: nginx/1.14.1" 32 | ] 33 | }, 34 | { 35 | "url": "https://www.sample-owasp-wp.com/robots.txt", 36 | "to_s": "https://www.sample-owasp-wp.com/robots.txt", 37 | "type": "robots_txt", 38 | "found_by": "Robots Txt (Aggressive Detection)", 39 | "confidence": 100, 40 | "confirmed_by": { 41 | 42 | }, 43 | "references": { 44 | 45 | }, 46 | "interesting_entries": [ 47 | 48 | ] 49 | }, 50 | { 51 | "url": "https://www.sample-owasp-wp.com/xmlrpc.php", 52 | "to_s": "https://www.sample-owasp-wp.com/xmlrpc.php", 53 | "type": "xmlrpc", 54 | "found_by": "Link Tag (Passive Detection)", 55 | "confidence": 100, 56 | "confirmed_by": { 57 | "Direct Access (Aggressive Detection)": { 58 | "confidence": 100 59 | } 60 | }, 61 | "references": { 62 | "url": [ 63 | "http://codex.wordpress.org/XML-RPC_Pingback_API" 64 | ], 65 | "metasploit": [ 66 | "auxiliary/scanner/http/wordpress_ghost_scanner", 67 | "auxiliary/dos/http/wordpress_xmlrpc_dos", 68 | "auxiliary/scanner/http/wordpress_xmlrpc_login", 69 | "auxiliary/scanner/http/wordpress_pingback_access" 70 | ] 71 | }, 72 | "interesting_entries": [ 73 | 74 | ] 75 | }, 76 | { 77 | "url": "https://www.sample-owasp-wp.com/readme.html", 78 | "to_s": "https://www.sample-owasp-wp.com/readme.html", 79 | "type": "readme", 80 | "found_by": "Direct Access (Aggressive Detection)", 81 | "confidence": 100, 82 | "confirmed_by": { 83 | 84 | }, 85 | "references": { 86 | 87 | }, 88 | "interesting_entries": [ 89 | 90 | ] 91 | }, 92 | { 93 | "url": "https://www.sample-owasp-wp.com/wp-content/mu-plugins/", 94 | "to_s": "This site has 'Must Use Plugins': https://www.sample-owasp-wp.com/wp-content/mu-plugins/", 95 | "type": "mu_plugins", 96 | "found_by": "Direct Access (Aggressive Detection)", 97 | "confidence": 80, 98 | "confirmed_by": { 99 | 100 | }, 101 | "references": { 102 | "url": [ 103 | "http://codex.wordpress.org/Must_Use_Plugins" 104 | ] 105 | }, 106 | "interesting_entries": [ 107 | 108 | ] 109 | }, 110 | { 111 | "url": "https://www.sample-owasp-wp.com/wp-content/uploads/", 112 | "to_s": "Upload directory has listing enabled: https://www.sample-owasp-wp.com/wp-content/uploads/", 113 | "type": "upload_directory_listing", 114 | "found_by": "Direct Access (Aggressive Detection)", 115 | "confidence": 100, 116 | "confirmed_by": { 117 | 118 | }, 119 | "references": { 120 | 121 | }, 122 | "interesting_entries": [ 123 | 124 | ] 125 | }, 126 | { 127 | "url": "https://www.sample-owasp-wp.com/wp-cron.php", 128 | "to_s": "https://www.sample-owasp-wp.com/wp-cron.php", 129 | "type": "wp_cron", 130 | "found_by": "Direct Access (Aggressive Detection)", 131 | "confidence": 60, 132 | "confirmed_by": { 133 | 134 | }, 135 | "references": { 136 | "url": [ 137 | "https://www.iplocation.net/defend-wordpress-from-ddos", 138 | "https://github.com/wpscanteam/wpscan/issues/1299" 139 | ] 140 | }, 141 | "interesting_entries": [ 142 | 143 | ] 144 | } 145 | ], 146 | "version": { 147 | "number": "5.2.2", 148 | "release_date": "2019-06-18", 149 | "status": "latest", 150 | "found_by": "Rss Generator (Passive Detection)", 151 | "confidence": 100, 152 | "interesting_entries": [ 153 | "https://www.sample-owasp-wp.com/feed/, https://wordpress.org/?v=5.2.2", 154 | "https://www.sample-owasp-wp.com/comments/feed/, https://wordpress.org/?v=5.2.2" 155 | ], 156 | "confirmed_by": { 157 | 158 | }, 159 | "vulnerabilities": [ 160 | 161 | ] 162 | }, 163 | "main_theme": { 164 | "slug": "customizr", 165 | "location": "https://www.sample-owasp-wp.com/wp-content/themes/customizr/", 166 | "latest_version": "4.1.42", 167 | "last_updated": "2019-06-30T00:00:00.000Z", 168 | "outdated": false, 169 | "readme_url": "https://www.sample-owasp-wp.com/wp-content/themes/customizr/readme.txt", 170 | "directory_listing": false, 171 | "error_log_url": "https://www.sample-owasp-wp.com/wp-content/themes/customizr/error_log", 172 | "style_url": "https://www.sample-owasp-wp.com/wp-content/themes/customizr/style.css?ver=4.1.42", 173 | "style_name": null, 174 | "style_uri": null, 175 | "description": null, 176 | "author": null, 177 | "author_uri": null, 178 | "template": null, 179 | "license": null, 180 | "license_uri": null, 181 | "tags": null, 182 | "text_domain": null, 183 | "found_by": "Css Style (Passive Detection)", 184 | "confidence": 70, 185 | "interesting_entries": [ 186 | 187 | ], 188 | "confirmed_by": { 189 | 190 | }, 191 | "vulnerabilities": [ 192 | { 193 | "title": "YouTube Embed <= 13.8.1 - Cross-Site Request Forgery (CSRF)", 194 | "fixed_in": "11.8.2", 195 | "references": { 196 | "url": [ 197 | "https://security.dxw.com/advisories/csrf-in-youtube-plugin/", 198 | "http://seclists.org/fulldisclosure/2017/Jul/64" 199 | ], 200 | "wpvulndb": [ 201 | "8873" 202 | ] 203 | } 204 | } 205 | 206 | ], 207 | "version": { 208 | "number": "4.1.42", 209 | "confidence": 80, 210 | "found_by": "Style (Passive Detection)", 211 | "interesting_entries": [ 212 | "https://www.sample-owasp-wp.com/wp-content/themes/customizr/style.css?ver=4.1.42, Match: 'Version: 4.1.42'" 213 | ], 214 | "confirmed_by": { 215 | 216 | } 217 | }, 218 | "parents": [ 219 | 220 | ] 221 | }, 222 | "plugins": { 223 | "youtube-embed-plus": { 224 | "slug": "youtube-embed-plus", 225 | "location": "https://www.sample-owasp-wp.com/wp-content/plugins/youtube-embed-plus/", 226 | "latest_version": "13.1", 227 | "last_updated": "2019-05-11T14:32:00.000Z", 228 | "outdated": false, 229 | "readme_url": null, 230 | "directory_listing": null, 231 | "error_log_url": null, 232 | "found_by": "Urls In Homepage (Passive Detection)", 233 | "confidence": 100, 234 | "interesting_entries": [ 235 | 236 | ], 237 | "confirmed_by": { 238 | "Javascript Var (Passive Detection)": { 239 | "confidence": 60, 240 | "interesting_entries": [ 241 | 242 | ] 243 | } 244 | }, 245 | "vulnerabilities": [ 246 | { 247 | "title": "YouTube Embed <= 11.8.1 - Cross-Site Request Forgery (CSRF)", 248 | "fixed_in": "11.8.2", 249 | "references": { 250 | "url": [ 251 | "https://security.dxw.com/advisories/csrf-in-youtube-plugin/", 252 | "http://seclists.org/fulldisclosure/2017/Jul/64" 253 | ], 254 | "wpvulndb": [ 255 | "8873" 256 | ] 257 | } 258 | }, 259 | { 260 | "title": "YouTube Embed <= 12.8.1 - Cross-Site Request Forgery (CSRF)", 261 | "fixed_in": "11.8.2", 262 | "references": { 263 | "url": [ 264 | "https://security.dxw.com/advisories/csrf-in-youtube-plugin/", 265 | "http://seclists.org/fulldisclosure/2017/Jul/64" 266 | ], 267 | "wpvulndb": [ 268 | "8873" 269 | ] 270 | } 271 | } 272 | ], 273 | "version": null 274 | } 275 | }, 276 | "config_backups": { 277 | 278 | }, 279 | "stop_time": 1562094699, 280 | "elapsed": 22, 281 | "requests_done": 58, 282 | "cached_requests": 6, 283 | "data_sent": 12055, 284 | "data_sent_humanised": "11.772 KB", 285 | "data_received": 142206, 286 | "data_received_humanised": "138.873 KB", 287 | "used_memory": 198258688, 288 | "used_memory_humanised": "189.074 MB" 289 | } -------------------------------------------------------------------------------- /wpwatcher/report.py: -------------------------------------------------------------------------------- 1 | """ 2 | Containers for scan results data stucture. 3 | """ 4 | 5 | from typing import Dict, Any, List, Iterable, Tuple, Optional, overload 6 | from wpwatcher import log 7 | from wpscan_out_parse.parser.base import Parser 8 | class ScanReport(Dict[str, Any]): 9 | """ 10 | Dict-Like object to store and process scan results. 11 | 12 | Keys: 13 | 14 | - "site" 15 | - "status" 16 | - "datetime" 17 | - "last_email" 18 | - "error" 19 | - "infos" 20 | - "warnings" 21 | - "alerts" 22 | - "fixed" 23 | - "summary" 24 | - "wpscan_output" 25 | - "wpscan_parser" 26 | 27 | """ 28 | 29 | DEFAULT_REPORT: Dict[str, Any] = { 30 | "site": "", 31 | "status": "", 32 | "datetime": None, 33 | "last_email": None, 34 | "error": "", 35 | "infos": [], 36 | "warnings": [], 37 | "alerts": [], 38 | "fixed": [], 39 | "summary": {}, 40 | "wpscan_output": "", 41 | "wpscan_parser": None, 42 | } 43 | 44 | FIELDS: Iterable[str] = list(DEFAULT_REPORT.keys()) 45 | 46 | def __init__(self, *args, **kwargs) -> None: # type: ignore [no-untyped-def] 47 | super().__init__(*args, **kwargs) 48 | for key in self.FIELDS: 49 | self.setdefault(key, self.DEFAULT_REPORT[key]) 50 | 51 | def fail(self, reason: str) -> None: 52 | """ 53 | Mark the scan as failed. 54 | """ 55 | log.error(reason) 56 | if self["error"]: 57 | self["error"] += "\n\n" 58 | self["error"] += reason 59 | 60 | def load_parser(self, parser: Parser) -> None: 61 | """ 62 | Load parser results into the report. 63 | """ 64 | # Save parser object 65 | self["wpscan_parser"] = parser 66 | 67 | # Save WPScan result dict 68 | results = parser.get_results() 69 | ( 70 | self["infos"], 71 | self["warnings"], 72 | self["alerts"], 73 | self["summary"], 74 | ) = ( 75 | results["infos"], 76 | results["warnings"], 77 | results["alerts"], 78 | results["summary"], 79 | ) 80 | 81 | # Including error if not None 82 | if results["error"]: 83 | self.fail(results["error"]) 84 | 85 | self.status() 86 | 87 | def __getitem__(self, key: str) -> Any: 88 | if key == "status": 89 | return self.status() 90 | else: 91 | return super().__getitem__(key) 92 | 93 | def status(self) -> str: 94 | """Get report status. """ 95 | status = "" 96 | if len(self["error"]) > 0: 97 | status = "ERROR" 98 | elif len(self["warnings"]) > 0 and len(self["alerts"]) == 0: 99 | status = "WARNING" 100 | elif len(self["alerts"]) > 0: 101 | status = "ALERT" 102 | else: 103 | status = "INFO" 104 | self['status'] = status 105 | return status 106 | 107 | def update_report(self, last_wp_report: Optional['ScanReport']) -> None: 108 | """ 109 | Update the report considering last report. 110 | """ 111 | if last_wp_report: 112 | # Save already fixed issues but not reported yet 113 | self["fixed"] = last_wp_report["fixed"] 114 | 115 | # Fill out last_email datetime if any 116 | if last_wp_report["last_email"]: 117 | self["last_email"] = last_wp_report["last_email"] 118 | 119 | # Fill out fixed issues if the scan is not an error 120 | if self["status"] != "ERROR": 121 | fixed, unfixed = self._get_fixed_n_unfixed_issues( 122 | last_wp_report, issue_type="alerts" 123 | ) 124 | self["fixed"].extend(fixed) 125 | self._add_unfixed_warnings( 126 | last_wp_report, unfixed, issue_type="alerts" 127 | ) 128 | 129 | fixed, unfixed = self._get_fixed_n_unfixed_issues( 130 | last_wp_report, issue_type="warnings" 131 | ) 132 | self["fixed"].extend(fixed) 133 | self._add_unfixed_warnings( 134 | last_wp_report, unfixed, issue_type="warnings" 135 | ) 136 | 137 | def _add_unfixed_warnings( 138 | self, 139 | last_wp_report: 'ScanReport', 140 | unfixed_items: List[str], 141 | issue_type: str, 142 | ) -> None: 143 | """ 144 | A line will be added at the end of the warning like: 145 | "This issue is unfixed since {date}" 146 | """ 147 | 148 | for unfixed_item in unfixed_items: 149 | try: 150 | # Get unfixd issue 151 | issue_index = [ 152 | alert.splitlines()[0] for alert in self[issue_type] 153 | ].index(unfixed_item.splitlines()[0]) 154 | except ValueError as e: 155 | log.error(e) 156 | else: 157 | self[issue_type][issue_index] += "\n" 158 | try: 159 | # Try to get older issue if it exists 160 | older_issue_index = [ 161 | alert.splitlines()[0] for alert in last_wp_report[issue_type] 162 | ].index(unfixed_item.splitlines()[0]) 163 | except ValueError as e: 164 | log.error(e) 165 | else: 166 | older_warn_last_line = last_wp_report[issue_type][ 167 | older_issue_index 168 | ].splitlines()[-1] 169 | if "This issue is unfixed" in older_warn_last_line: 170 | self[issue_type][issue_index] += older_warn_last_line 171 | else: 172 | self[issue_type][ 173 | issue_index 174 | ] += f"This issue is unfixed since {last_wp_report['datetime']}" 175 | 176 | def _get_fixed_n_unfixed_issues( 177 | self, last_wp_report: 'ScanReport', issue_type: str 178 | ) -> Tuple[List[str], List[str]]: 179 | """Return list of fixed issue texts to include in mails""" 180 | fixed_issues = [] 181 | unfixed_issues = [] 182 | for last_alert in last_wp_report[issue_type]: 183 | if (self["wpscan_parser"] and 184 | not self["wpscan_parser"].is_false_positive(last_alert) ): 185 | 186 | if last_alert.splitlines()[0] not in [ 187 | alert.splitlines()[0] for alert in self[issue_type] 188 | ]: 189 | fixed_issues.append( 190 | f'Issue regarding component "{last_alert.splitlines()[0]}" has been fixed since the last scan.' 191 | ) 192 | else: 193 | unfixed_issues.append(last_alert) 194 | 195 | return fixed_issues, unfixed_issues 196 | 197 | 198 | class ReportCollection(List[ScanReport]): 199 | """ 200 | List-Like object to store reports. 201 | """ 202 | 203 | def __repr__(self) -> str: 204 | """ 205 | Get the summary string. 206 | 207 | :Return: Summary table of all sites contained in the collection. 208 | Columns are: "Site", "Status", "Last scan", "Last email", "Issues", "Problematic component(s)" 209 | """ 210 | results = [ item for item in self if item ] 211 | if not results: 212 | return "No scan report to show" 213 | string = "Scan reports summary\n" 214 | header = ( 215 | "Site", 216 | "Status", 217 | "Last scan", 218 | "Last email", 219 | "Issues", 220 | "Problematic component(s)", 221 | ) 222 | sites_w = 20 223 | # Determine the longest width for site column 224 | for r in results: 225 | sites_w = len(r["site"]) + 4 if r and len(r["site"]) > sites_w else sites_w 226 | frow = "{:<%d} {:<8} {:<20} {:<20} {:<8} {}" % sites_w 227 | string += frow.format(*header) 228 | for row in results: 229 | pb_components = [] 230 | for m in row["alerts"] + row["warnings"]: 231 | pb_components.append(m.splitlines()[0]) 232 | # 'errors' key is deprecated. 233 | if row.get("error", None) or row.get("errors", []): 234 | err = row.get("error", "").splitlines() 235 | if err: 236 | pb_components.append(err[0]) 237 | # 'errors' key is deprecated, this part would be removed in the future 238 | for m in row.get("errors", []): 239 | pb_components.append(m.splitlines()[0]) 240 | string += "\n" 241 | string += frow.format( 242 | str(row["site"]), 243 | str(row["status"]), 244 | str(row["datetime"]), 245 | str(row["last_email"]), 246 | len(row["alerts"] + row["warnings"]), 247 | ", ".join(pb_components), 248 | ) 249 | return string 250 | -------------------------------------------------------------------------------- /tests/static/wordpress_many_vuln.txt: -------------------------------------------------------------------------------- 1 | [+] URL: http://wp.exemple.com/ [166.62.111.84] 2 | [+] Started: Wed Apr 22 21:16:06 2020 3 | 4 | Interesting Finding(s): 5 | 6 | [+] Headers 7 | | Interesting Entries: 8 | | - X-Cacheable: YES:Forced 9 | | - X-Cache-Hit: MISS 10 | | - X-Backend: all_requests 11 | | Found By: Headers (Passive Detection) 12 | | Confidence: 100% 13 | 14 | [+] http://wp.exemple.com/robots.txt 15 | | Found By: Robots Txt (Aggressive Detection) 16 | | Confidence: 100% 17 | 18 | [+] XML-RPC seems to be enabled: http://wp.exemple.com/xmlrpc.php 19 | | Found By: Direct Access (Aggressive Detection) 20 | | Confidence: 100% 21 | | References: 22 | | - http://codex.wordpress.org/XML-RPC_Pingback_API 23 | | - https://www.rapid7.com/db/modules/auxiliary/scanner/http/wordpress_ghost_scanner 24 | | - https://www.rapid7.com/db/modules/auxiliary/dos/http/wordpress_xmlrpc_dos 25 | | - https://www.rapid7.com/db/modules/auxiliary/scanner/http/wordpress_xmlrpc_login 26 | | - https://www.rapid7.com/db/modules/auxiliary/scanner/http/wordpress_pingback_access 27 | 28 | [+] http://wp.exemple.com/readme.html 29 | | Found By: Direct Access (Aggressive Detection) 30 | | Confidence: 100% 31 | 32 | [+] This site has 'Must Use Plugins': http://wp.exemple.com/wp-content/mu-plugins/ 33 | | Found By: Direct Access (Aggressive Detection) 34 | | Confidence: 80% 35 | | Reference: http://codex.wordpress.org/Must_Use_Plugins 36 | 37 | [+] The external WP-Cron seems to be enabled: http://wp.exemple.com/wp-cron.php 38 | | Found By: Direct Access (Aggressive Detection) 39 | | Confidence: 60% 40 | | References: 41 | | - https://www.iplocation.net/defend-wordpress-from-ddos 42 | | - https://github.com/wpscanteam/wpscan/issues/1299 43 | 44 | [+] WordPress version 5.4 identified (Latest, released on 2020-03-31). 45 | | Found By: Rss Generator (Passive Detection) 46 | | - http://wp.exemple.com/feed/, https://wordpress.org/?v=5.4 47 | | - http://wp.exemple.com/comments/feed/, https://wordpress.org/?v=5.4 48 | 49 | [+] WordPress theme in use: stylish 50 | | Location: http://wp.exemple.com/wp-content/themes/stylish/ 51 | | Readme: http://wp.exemple.com/wp-content/themes/stylish/readme.txt 52 | | [!] An error log file has been found: http://wp.exemple.com/wp-content/themes/stylish/error_log 53 | | Style URL: http://wp.exemple.com/wp-content/themes/stylish/style.css 54 | | Style Name: Stylish 55 | | Style URI: http://smthemes.com/stylish/ 56 | | Description: Template by SMThemes.com... 57 | | Author: The Smart Magazine Themes 58 | | Author URI: http://smthemes.com/ 59 | | 60 | | Found By: Css Style In Homepage (Passive Detection) 61 | | Confirmed By: Css Style In 404 Page (Passive Detection) 62 | | 63 | | Version: 2.3 (80% confidence) 64 | | Found By: Style (Passive Detection) 65 | | - http://wp.exemple.com/wp-content/themes/stylish/style.css, Match: 'Version: 2.3' 66 | 67 | [+] Enumerating All Plugins (via Passive Methods) 68 | [+] Checking Plugin Versions (via Passive and Aggressive Methods) 69 | 70 | [i] Plugin(s) Identified: 71 | 72 | [+] contact-form-7 73 | | Location: http://wp.exemple.com/wp-content/plugins/contact-form-7/ 74 | | Last Updated: 2020-03-07T10:12:00.000Z 75 | | [!] The version is out of date, the latest version is 5.1.7 76 | | 77 | | Found By: Urls In Homepage (Passive Detection) 78 | | Confirmed By: Urls In 404 Page (Passive Detection) 79 | | 80 | | [!] 1 vulnerability identified: 81 | | 82 | | [!] Title: Contact Form 7 <= 5.0.3 - register_post_type() Privilege Escalation 83 | | Fixed in: 5.0.4 84 | | References: 85 | | - https://wpvulndb.com/vulnerabilities/9127 86 | | - https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2018-20979 87 | | - https://contactform7.com/2018/09/04/contact-form-7-504/ 88 | | - https://plugins.trac.wordpress.org/changeset/1935726/contact-form-7 89 | | - https://plugins.trac.wordpress.org/changeset/1934594/contact-form-7 90 | | - https://plugins.trac.wordpress.org/changeset/1934343/contact-form-7 91 | | - https://plugins.trac.wordpress.org/changeset/1934327/contact-form-7 92 | | - https://www.ripstech.com/php-security-calendar-2018/#day-18 93 | | 94 | | Version: 4.1.1 (100% confidence) 95 | | Found By: Query Parameter (Passive Detection) 96 | | - http://wp.exemple.com/wp-content/plugins/contact-form-7/includes/css/styles.css?ver=4.1.1 97 | | - http://wp.exemple.com/wp-content/plugins/contact-form-7/includes/js/scripts.js?ver=4.1.1 98 | | Confirmed By: 99 | | Readme - Stable Tag (Aggressive Detection) 100 | | - http://wp.exemple.com/wp-content/plugins/contact-form-7/readme.txt 101 | | Readme - ChangeLog Section (Aggressive Detection) 102 | | - http://wp.exemple.com/wp-content/plugins/contact-form-7/readme.txt 103 | 104 | [+] ml-slider 105 | | Location: http://wp.exemple.com/wp-content/plugins/ml-slider/ 106 | | Last Updated: 2020-04-16T10:28:00.000Z 107 | | [!] The version is out of date, the latest version is 3.16.4 108 | | 109 | | Found By: Urls In Homepage (Passive Detection) 110 | | Confirmed By: Urls In 404 Page (Passive Detection) 111 | | 112 | | Version: 3.3.1 (80% confidence) 113 | | Found By: Readme - Stable Tag (Aggressive Detection) 114 | | - http://wp.exemple.com/wp-content/plugins/ml-slider/readme.txt 115 | 116 | [+] wordpress-seo 117 | | Location: http://wp.exemple.com/wp-content/plugins/wordpress-seo/ 118 | | Last Updated: 2020-04-14T10:12:00.000Z 119 | | [!] The version is out of date, the latest version is 13.5 120 | | 121 | | Found By: Comment (Passive Detection) 122 | | 123 | | [!] 7 vulnerabilities identified: 124 | | 125 | | [!] Title: Yoast SEO <= 2.1.1 - Authenticated Stored DOM XSS 126 | | Fixed in: 2.2 127 | | References: 128 | | - https://wpvulndb.com/vulnerabilities/8045 129 | | - https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2012-6692 130 | | - https://packetstormsecurity.com/files/132294/ 131 | | 132 | | [!] Title: Yoast SEO <= 3.2.4 - Subscriber Settings Sensitive Data Exposure 133 | | Fixed in: 3.2.5 134 | | References: 135 | | - https://wpvulndb.com/vulnerabilities/8487 136 | | - https://www.wordfence.com/blog/2016/05/yoast-seo-vulnerability/ 137 | | 138 | | [!] Title: Yoast SEO <= 3.2.5 - Unspecified Cross-Site Scripting (XSS) 139 | | Fixed in: 3.3.0 140 | | References: 141 | | - https://wpvulndb.com/vulnerabilities/8569 142 | | - https://wordpress.org/plugins/wordpress-seo/changelog/ 143 | | 144 | | [!] Title: Yoast SEO <= 3.4.0 - Authenticated Stored Cross-Site Scripting (XSS) 145 | | Fixed in: 3.4.1 146 | | References: 147 | | - https://wpvulndb.com/vulnerabilities/8583 148 | | - https://plugins.trac.wordpress.org/changeset/1466243/wordpress-seo 149 | | 150 | | [!] Title: Yoast SEO <= 5.7.1 - Authenticated Cross-Site Scripting (XSS) 151 | | Fixed in: 5.8 152 | | References: 153 | | - https://wpvulndb.com/vulnerabilities/8960 154 | | - https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2017-16842 155 | | - https://plugins.trac.wordpress.org/changeset/1766831/wordpress-seo/trunk/admin/google_search_console/class-gsc-table.php 156 | | - https://packetstormsecurity.com/files/145080/ 157 | | 158 | | [!] Title: Yoast SEO <= 9.1 - Authenticated Race Condition 159 | | Fixed in: 9.2 160 | | References: 161 | | - https://wpvulndb.com/vulnerabilities/9150 162 | | - https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2018-19370 163 | | - https://plugins.trac.wordpress.org/changeset/1977260/wordpress-seo 164 | | - https://packetstormsecurity.com/files/150497/ 165 | | - https://github.com/Yoast/wordpress-seo/pull/11502/commits/3bfa70a143f5ea3ee1934f3a1703bb5caf139ffa 166 | | 167 | | [!] Title: Yoast SEO 1.2.0-11.5 - Authenticated Stored XSS 168 | | Fixed in: 11.6 169 | | References: 170 | | - https://wpvulndb.com/vulnerabilities/9445 171 | | - https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2019-13478 172 | | - https://gist.github.com/sybrew/2f53625104ee013d2f599ac254f635ee 173 | | - https://github.com/Yoast/wordpress-seo/pull/13221 174 | | - https://yoast.com/yoast-seo-11.6/ 175 | | 176 | | Version: 1.7.4 (100% confidence) 177 | | Found By: Comment (Passive Detection) 178 | | - http://wp.exemple.com/, Match: 'optimized with the Yoast WordPress SEO plugin v1.7.4 -' 179 | | Confirmed By: 180 | | Readme - Stable Tag (Aggressive Detection) 181 | | - http://wp.exemple.com/wp-content/plugins/wordpress-seo/readme.txt 182 | | Readme - ChangeLog Section (Aggressive Detection) 183 | | - http://wp.exemple.com/wp-content/plugins/wordpress-seo/readme.txt 184 | 185 | [+] Enumerating Config Backups (via Passive and Aggressive Methods) 186 | 187 | Checking Config Backups -: |============================================================================================================================================================================================================================================| 188 | 189 | [i] No Config Backups Found. 190 | 191 | [+] WPVulnDB API OK 192 | | Plan: free 193 | | Requests Done (during the scan): 5 194 | | Requests Remaining: 20 195 | 196 | [+] Finished: Wed Apr 22 21:16:51 2020 197 | [+] Requests Done: 65 198 | [+] Cached Requests: 7 199 | [+] Data Sent: 21.555 KB 200 | [+] Data Received: 279.695 KB 201 | [+] Memory used: 184.758 MB 202 | [+] Elapsed time: 00:00:44 203 | -------------------------------------------------------------------------------- /wpwatcher/core.py: -------------------------------------------------------------------------------- 1 | """ 2 | Wordpress Watcher core object. 3 | """ 4 | from typing import Dict, Iterable, List, Sequence, Tuple, Any, Optional 5 | import os 6 | import threading 7 | import shutil 8 | import concurrent.futures 9 | import traceback 10 | import signal 11 | import sys 12 | 13 | 14 | from wpwatcher import log, _init_log 15 | from wpwatcher.db import DataBase 16 | from wpwatcher.scan import Scanner 17 | from wpwatcher.utils import safe_log_wpscan_args, print_progress_bar, timeout 18 | from wpwatcher.site import Site 19 | from wpwatcher.report import ReportCollection, ScanReport 20 | from wpwatcher.config import Config 21 | 22 | # Date format used everywhere 23 | DATE_FORMAT = "%Y-%m-%dT%H-%M-%S" 24 | 25 | # WPWatcher class --------------------------------------------------------------------- 26 | class WPWatcher: 27 | """WPWatcher object 28 | 29 | Usage exemple: 30 | 31 | .. python:: 32 | 33 | from wpwatcher.config import Config 34 | from wpwatcher.core import WPWatcher 35 | config = Config.fromenv() 36 | config.update({ 'send_infos': True, 37 | 'wp_sites': [ {'url':'exemple1.com'}, 38 | {'url':'exemple2.com'} ], 39 | 'wpscan_args': ['--format', 'json', '--stealthy'] 40 | }) 41 | watcher = WPWatcher(config) 42 | exit_code, reports = watcher.run_scans() 43 | for r in reports: 44 | print("%s\t\t%s"%( r['site'], r['status'] )) 45 | """ 46 | 47 | # WPWatcher must use a configuration dict 48 | def __init__(self, conf: Config): 49 | """ 50 | Arguments: 51 | - `conf`: the configuration dict. Required 52 | """ 53 | # (Re)init logger with config 54 | _init_log(verbose=conf["verbose"], quiet=conf["quiet"], logfile=conf["log_file"]) 55 | 56 | self._delete_tmp_wpscan_files() 57 | 58 | # Init DB interface 59 | self.wp_reports: DataBase = DataBase(filepath=conf["wp_reports"], daemon=conf['daemon']) 60 | 61 | # Update config before passing it to WPWatcherScanner 62 | conf.update({"wp_reports": self.wp_reports.filepath}) 63 | 64 | # Init scanner 65 | self.scanner: Scanner = Scanner(conf) 66 | 67 | # Save sites 68 | conf["wp_sites"] = [ 69 | Site(site_conf) for site_conf in conf["wp_sites"] 70 | ] 71 | self.wp_sites: List[Site] = conf["wp_sites"] 72 | 73 | 74 | # Asynchronous executor 75 | self._executor: concurrent.futures.ThreadPoolExecutor = ( 76 | concurrent.futures.ThreadPoolExecutor(max_workers=conf["asynch_workers"]) 77 | ) 78 | 79 | # List of conccurent futures 80 | self._futures: List[concurrent.futures.Future] = [] # type: ignore [type-arg] 81 | 82 | # Register the signals to be caught ^C , SIGTERM (kill) , service restart , will trigger interrupt() 83 | signal.signal(signal.SIGINT, self.interrupt) 84 | signal.signal(signal.SIGTERM, self.interrupt) 85 | 86 | self.new_reports = ReportCollection() 87 | """New reports, reset when running `run_scans`.""" 88 | 89 | # Dump config 90 | log.debug(f"Configuration:{repr(conf)}") 91 | 92 | @staticmethod 93 | def _delete_tmp_wpscan_files() -> None: 94 | """Delete temp wpcan files""" 95 | # Try delete temp files. 96 | if os.path.isdir("/tmp/wpscan"): 97 | try: 98 | shutil.rmtree("/tmp/wpscan") 99 | log.info("Deleted temp WPScan files in /tmp/wpscan/") 100 | except (FileNotFoundError, OSError, Exception): 101 | log.info( 102 | f"Could not delete temp WPScan files in /tmp/wpscan/\n{traceback.format_exc()}" 103 | ) 104 | 105 | def _cancel_pending_futures(self) -> None: 106 | """Cancel all asynchronous jobs""" 107 | for f in self._futures: 108 | if not f.done(): 109 | f.cancel() 110 | 111 | def interrupt_scans(self) -> None: 112 | """ 113 | Interrupt the scans and append finished scan reports to self.new_reports 114 | """ 115 | # Cancel all scans 116 | self._cancel_pending_futures() # future scans 117 | self.scanner.interrupt() # running scans 118 | self._rebuild_rew_reports() 119 | 120 | def _rebuild_rew_reports(self) -> None: 121 | "Recover reports from futures results" 122 | self.new_reports = ReportCollection() 123 | for f in self._futures: 124 | if f.done(): 125 | try: 126 | self.new_reports.append(f.result()) 127 | except Exception: 128 | pass 129 | 130 | def interrupt(self, sig=None, frame=None) -> None: # type: ignore [no-untyped-def] 131 | """Interrupt the program and exit. """ 132 | log.error("Interrupting...") 133 | # If called inside ThreadPoolExecutor, raise Exeception 134 | if not isinstance(threading.current_thread(), threading._MainThread): # type: ignore [attr-defined] 135 | raise InterruptedError() 136 | 137 | self.interrupt_scans() 138 | 139 | # Give a 5 seconds timeout to buggy WPScan jobs to finish or ignore them 140 | try: 141 | timeout(5, self._executor.shutdown, kwargs=dict(wait=True)) 142 | except TimeoutError: 143 | pass 144 | 145 | # Display results 146 | log.info(repr(self.new_reports)) 147 | log.info("Scans interrupted.") 148 | 149 | # and quit 150 | sys.exit(-1) 151 | 152 | 153 | def _log_db_reports_infos(self) -> None: 154 | if len(self.new_reports) > 0 and repr(self.new_reports) != "No scan report to show": 155 | if self.wp_reports.filepath != "null": 156 | log.info(f"Updated reports in database: {self.wp_reports.filepath}") 157 | else: 158 | log.info("Local database disabled, no reports updated.") 159 | 160 | 161 | def _scan_site(self, wp_site: Site) -> Optional[ScanReport]: 162 | """ 163 | Helper method to wrap the scanning process of `WPWatcherScanner.scan_site` and add the following: 164 | 165 | - Find the last report in the database and launch the scan 166 | - Write it in DB after scan. 167 | - Print progress bar 168 | 169 | This function can be called asynchronously. 170 | """ 171 | 172 | last_wp_report = self.wp_reports.find(ScanReport(site=wp_site["url"])) 173 | 174 | # Launch scanner 175 | wp_report = self.scanner.scan_site(wp_site, last_wp_report) 176 | 177 | # Save report in global instance database and to file when a site has been scanned 178 | if wp_report: 179 | self.wp_reports.write([wp_report]) 180 | else: 181 | log.info(f"No report saved for site {wp_site['url']}") 182 | 183 | # Print progress 184 | print_progress_bar(len(self.scanner.scanned_sites), len(self.wp_sites)) 185 | return wp_report 186 | 187 | def _run_scans(self, wp_sites: List[Site]) -> ReportCollection: 188 | """ 189 | Helper method to deal with : 190 | 191 | - executor, concurent futures 192 | - Trigger self.interrupt() on InterruptedError (raised if fail fast enabled) 193 | - Append result to `self.new_reports` list. 194 | """ 195 | 196 | log.info(f"Starting scans on {len(wp_sites)} configured sites") 197 | 198 | # reset new reports and scanned sites list. 199 | self._futures.clear() 200 | self.new_reports.clear() 201 | self.scanner.scanned_sites.clear() 202 | 203 | for wp_site in wp_sites: 204 | self._futures.append(self._executor.submit(self._scan_site, wp_site)) 205 | for f in self._futures: 206 | try: 207 | self.new_reports.append(f.result()) 208 | except concurrent.futures.CancelledError: 209 | pass 210 | # Ensure everything is down 211 | self._cancel_pending_futures() 212 | return self.new_reports 213 | 214 | def run_scans(self) -> Tuple[int, ReportCollection]: 215 | """ 216 | Run WPScan on defined websites and send notifications. 217 | 218 | :Returns: `tuple (exit code, reports)` 219 | """ 220 | 221 | # Check sites are in the config 222 | if len(self.wp_sites) == 0: 223 | log.error( 224 | "No sites configured, please provide wp_sites in config file or use arguments --url URL [URL...] or --urls File path" 225 | ) 226 | return (-1, self.new_reports) 227 | 228 | self.wp_reports.open() 229 | try: 230 | self._run_scans(self.wp_sites) 231 | # Handle interruption from inside threads when using --ff 232 | except InterruptedError: 233 | self.interrupt() 234 | finally: 235 | self.wp_reports.close() 236 | 237 | # Print results and finish 238 | log.info(repr(self.new_reports)) 239 | 240 | if not any([r["status"] == "ERROR" for r in self.new_reports if r]) and not self.scanner.broken_syslog: 241 | log.info("Scans finished successfully.") 242 | return (0, self.new_reports) 243 | else: 244 | log.info("Scans finished with errors.") 245 | return (-1, self.new_reports) 246 | 247 | # run_scans_and_notify = run_scans -------------------------------------------------------------------------------- /docs/source/output.md: -------------------------------------------------------------------------------- 1 | # Output 2 | 3 | Log file and stdout outputs are **easily grepable** with the following log levels and keywords: 4 | 5 | - `CRITICAL`: Only used for `WPScan ALERT` 6 | - `ERROR`: WPScan failed, send report failed or other errors 7 | - `WARNING`: Only used for `WPScan WARNING` 8 | - `INFO`: Used for info output , `WPScan INFO` and `FIXED` issues 9 | - `DEBUG`: Used for debug output and raw WPScan output. 10 | 11 | In addition to log messages, the readable report, and raw WPScan output can be printed with `--verbose`. 12 | 13 | ## Output configuration 14 | 15 | - Local log file 16 | ```ini 17 | log_file=/home/user/.wpwatcher/wpwatcher.log 18 | ``` 19 | Overwrite with argument: `--log File path` 20 | 21 | - Save WPScan output to files 22 | ```ini 23 | wpscan_output_folder=/home/user/Documents/WPScanResults/ 24 | ``` 25 | Overwrite with argument: `--wpout Folder path` 26 | 27 | - Quiet 28 | Print only errors and WPScan ALERTS 29 | ```ini 30 | quiet=No 31 | ``` 32 | Overwrite with arguments: `--quiet` 33 | 34 | - Verbose terminal output and logging. 35 | Print WPScan raw output and parsed WPScan results. 36 | ```ini 37 | verbose=No 38 | ``` 39 | Overwrite with arguments: `--verbose` 40 | 41 | ## Output sample 42 | 43 | ``` 44 | % wpwatcher --url www.exemple.com www.exemple2.com 45 | INFO - WPWatcher - Automating WPscan to scan and report vulnerable Wordpress sites 46 | INFO - Load config file(s) : ['/Users/user/Documents/WPWatcher/wpwatcher.conf'] 47 | INFO - Deleted temp WPScan files in /tmp/wpscan/ 48 | INFO - Load wp_reports database: /Users/user/.wpwatcher/wp_reports.json 49 | INFO - Starting scans on 2 configured sites 50 | INFO - Scanning site http://www.exemple.com 51 | INFO - ** WPScan INFO http://www.exemple.com ** [+] URL: http://www.exemple.com/ [167.71.91.231] [+] Effective URL: https://www.exemple.com/ [+] Started: Tue Apr 28 19:30:39 2020 52 | INFO - ** WPScan INFO http://www.exemple.com ** Interesting Finding(s): 53 | INFO - ** WPScan INFO http://www.exemple.com ** [+] Headers | Interesting Entry: server: nginx | Found By: Headers (Passive Detection) | Confidence: 100% 54 | INFO - ** WPScan INFO http://www.exemple.com ** [+] XML-RPC seems to be enabled: https://www.exemple.com/xmlrpc.php | Found By: Link Tag (Passive Detection) | Confidence: 100% | Confirmed By: Direct Access (Aggressive Detection), 100% confidence | References: | - http://codex.wordpress.org/XML-RPC_Pingback_API | - https://www.rapid7.com/db/modules/auxiliary/scanner/http/wordpress_ghost_scanner | - https://www.rapid7.com/db/modules/auxiliary/dos/http/wordpress_xmlrpc_dos | - https://www.rapid7.com/db/modules/auxiliary/scanner/http/wordpress_xmlrpc_login | - https://www.rapid7.com/db/modules/auxiliary/scanner/http/wordpress_pingback_access 55 | INFO - ** WPScan INFO http://www.exemple.com ** [+] This site seems to be a multisite | Found By: Direct Access (Aggressive Detection) | Confidence: 100% | Reference: http://codex.wordpress.org/Glossary#Multisite 56 | INFO - ** WPScan INFO http://www.exemple.com ** [+] This site has 'Must Use Plugins': http://www.exemple.com/wp-content/mu-plugins/ | Found By: Direct Access (Aggressive Detection) | Confidence: 80% | Reference: http://codex.wordpress.org/Must_Use_Plugins 57 | INFO - ** WPScan INFO http://www.exemple.com ** [+] WordPress version 4.9.13 identified (Latest, released on 2019-12-12). | Found By: Rss Generator (Passive Detection) | - https://www.exemple.com/feed/, https://wordpress.org/?v=4.9.13 | - https://www.exemple.com/comments/feed/, https://wordpress.org/?v=4.9.13 58 | INFO - ** WPScan INFO http://www.exemple.com ** [+] WordPress theme in use: bb-theme-child | Location: http://www.exemple.com/wp-content/themes/bb-theme-child/ | Style URL: https://www.exemple.com/wp-content/themes/bb-theme-child/style.css?ver=4.9.13 | Style Name: Beaver Builder Child Theme | Style URI: http://www.wpbeaverbuilder.com | Description: An example child theme that can be used as a starting point for custom development.... | Author: The Beaver Builder Team | Author URI: http://www.fastlinemedia.com | Found By: Css Style In Homepage (Passive Detection) | Confirmed By: Css Style In 404 Page (Passive Detection) | Version: 1.0 (80% confidence) | Found By: Style (Passive Detection) | - https://www.exemple.com/wp-content/themes/bb-theme-child/style.css?ver=4.9.13, Match: 'Version: 1.0' 59 | INFO - ** WPScan INFO http://www.exemple.com ** [+] Enumerating All Plugins (via Passive Methods) [+] Checking Plugin Versions (via Passive and Aggressive Methods) 60 | INFO - ** WPScan INFO http://www.exemple.com ** [i] Plugin(s) Identified: 61 | INFO - ** WPScan INFO http://www.exemple.com ** [+] bb-plugin | Location: http://www.exemple.com/wp-content/plugins/bb-plugin/ | Found By: Urls In Homepage (Passive Detection) | Confirmed By: Urls In 404 Page (Passive Detection) | The version could not be determined. 62 | INFO - ** WPScan INFO http://www.exemple.com ** [+] bbpowerpack | Location: http://www.exemple.com/wp-content/plugins/bbpowerpack/ | Found By: Urls In Homepage (Passive Detection) | Confirmed By: Urls In 404 Page (Passive Detection) | The version could not be determined. 63 | INFO - ** WPScan INFO http://www.exemple.com ** [+] Enumerating Config Backups (via Passive and Aggressive Methods) 64 | INFO - ** WPScan INFO http://www.exemple.com ** Checking Config Backups -: |================================================================================================================================================================================| 65 | INFO - ** WPScan INFO http://www.exemple.com ** [i] No Config Backups Found. 66 | INFO - ** WPScan INFO http://www.exemple.com ** [+] Finished: Tue Apr 28 19:30:47 2020 [+] Requests Done: 71 [+] Cached Requests: 4 [+] Data Sent: 19.451 KB [+] Data Received: 3.523 MB [+] Memory used: 247.629 MB [+] Elapsed time: 00:00:07 67 | INFO - ** WPScan INFO http://www.exemple.com ** [False positive] [!] No WPVulnDB API Token given, as a result vulnerability data has not been output. [!] You can get a free API token with 50 daily requests by registering at https://wpvulndb.com/users/sign_up 68 | WARNING - ** WPScan WARNING http://www.exemple.com ** [+] stream | Location: http://www.exemple.com/wp-content/plugins/stream/ | Last Updated: 2020-03-19T21:55:00.000Z | [!] The version is out of date, the latest version is 3.4.3 | Found By: Comment (Passive Detection) | Version: 3.2.3 (100% confidence) | Found By: Comment (Passive Detection) | - https://www.exemple.com/, Match: 'Stream WordPress user activity plugin v3.2.3' | Confirmed By: Readme - Stable Tag (Aggressive Detection) | - http://www.exemple.com/wp-content/plugins/stream/readme.txt 69 | INFO - Not sending WPWatcher WARNING email report for site http://www.exemple.com. To receive emails, setup mail server settings in the config and enable send_email_report or use --send. 70 | INFO - Progress - [=============== ] 50% - 1 / 2 71 | INFO - Scanning site http://www.exemple2.com 72 | INFO - ** WPScan INFO http://www.exemple2.com ** [+] URL: http://www.exemple2.com/ [104.31.71.16] [+] Effective URL: https://www.exemple2.com/ [+] Started: Tue Apr 28 19:30:51 2020 73 | INFO - ** WPScan INFO http://www.exemple2.com ** Interesting Finding(s): 74 | INFO - ** WPScan INFO http://www.exemple2.com ** [+] Headers | Interesting Entries: | - cf-cache-status: DYNAMIC | - expect-ct: max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct" | - server: cloudflare | - cf-ray: 58b492d1ec18ca67-YUL | - cf-request-id: 0264ba172d0000ca67e8275200000001 | Found By: Headers (Passive Detection) | Confidence: 100% 75 | INFO - ** WPScan INFO http://www.exemple2.com ** [+] This site seems to be a multisite | Found By: Direct Access (Aggressive Detection) | Confidence: 100% | Reference: http://codex.wordpress.org/Glossary#Multisite 76 | INFO - ** WPScan INFO http://www.exemple2.com ** [+] WordPress theme in use: julesr-aeets | Location: http://www.exemple2.com/wordpress/wp-content/themes/julesr-aeets/ | Style URL: http://www.exemple2.com/wordpress/wp-content/themes/julesr-aeets/style.css | Found By: Urls In Homepage (Passive Detection) | Confirmed By: Urls In 404 Page (Passive Detection) | The version could not be determined. 77 | INFO - ** WPScan INFO http://www.exemple2.com ** [+] Enumerating All Plugins (via Passive Methods) 78 | INFO - ** WPScan INFO http://www.exemple2.com ** [i] No plugins Found. 79 | INFO - ** WPScan INFO http://www.exemple2.com ** [+] Enumerating Config Backups (via Passive and Aggressive Methods) 80 | INFO - ** WPScan INFO http://www.exemple2.com ** Checking Config Backups -: |================================================================================================================================================================================| 81 | INFO - ** WPScan INFO http://www.exemple2.com ** [i] No Config Backups Found. 82 | INFO - ** WPScan INFO http://www.exemple2.com ** [+] Finished: Tue Apr 28 19:30:58 2020 [+] Requests Done: 55 [+] Cached Requests: 4 [+] Data Sent: 19.047 KB [+] Data Received: 156.492 KB [+] Memory used: 214.516 MB [+] Elapsed time: 00:00:06 83 | INFO - ** WPScan INFO http://www.exemple2.com ** [False positive] [!] No WPVulnDB API Token given, as a result vulnerability data has not been output. [!] You can get a free API token with 50 daily requests by registering at https://wpvulndb.com/users/sign_up 84 | WARNING - ** WPScan WARNING http://www.exemple2.com ** [+] WordPress version 5.1.1 identified (Insecure, released on 2019-03-13). | Found By: Meta Generator (Passive Detection) | - https://www.exemple2.com/, Match: 'WordPress 5.1.1' | Confirmed By: Most Common Wp Includes Query Parameter In Homepage (Passive Detection) | - https://www.exemple2.com/wordpress/wp-includes/css/dist/block-library/style.min.css?ver=5.1.1 85 | INFO - Not sending WPWatcher WARNING email report for site http://www.exemple2.com. To receive emails, setup mail server settings in the config and enable send_email_report or use --send. 86 | INFO - Progress - [==============================] 100% - 2 / 2 87 | INFO - Results summary 88 | Site Status Last scan Last email Issues Problematic component(s) 89 | http://www.exemple.com WARNING 2020-04-28T19-30-32 None 1 [+] stream 90 | http://www.exemple2.com WARNING 2020-04-28T19-30-47 None 1 [+] WordPress version 5.1.1 identified (Insecure, released on 2019-03-13). 91 | INFO - Updated 2 reports in database: /Users/user/.wpwatcher/wp_reports.json 92 | INFO - Scans finished successfully. 93 | ``` 94 | 95 | 96 | 97 | 98 | -------------------------------------------------------------------------------- /wpwatcher/wpscan.py: -------------------------------------------------------------------------------- 1 | from typing import List, Tuple, Optional 2 | import shlex 3 | import subprocess 4 | import json 5 | import time 6 | import re 7 | import threading 8 | from datetime import datetime, timedelta 9 | from wpwatcher import log 10 | from wpwatcher.utils import safe_log_wpscan_args, timeout 11 | 12 | # Wait when API limit reached 13 | API_WAIT_SLEEP = timedelta(hours=24) 14 | "24h" 15 | 16 | UPDATE_DB_INTERVAL: timedelta = timedelta(hours=1) 17 | "1h" 18 | 19 | 20 | INTERRUPT_TIMEOUT: int = 5 21 | "Send kill signal after 5 seconds when interrupting." 22 | 23 | # WPScan helper class ----------- 24 | class WPScanWrapper: 25 | """ 26 | Process level wrapper for WPScan with a few additions: 27 | 28 | - Auto-update the WPSCan database on interval 29 | - Supports multi-threading (update is done with a lock) 30 | - Use a timeout for the scans, kills the process and raise error if reached 31 | """ 32 | 33 | 34 | _NO_VAL = datetime(year=2000, month=1, day=1) 35 | _NO_VERSION = '0.0.0' 36 | 37 | def __init__(self, wpscan_path: str, scan_timeout: Optional[timedelta] = None, 38 | api_limit_wait: bool = False, follow_redirect: bool = False) -> None: 39 | """ 40 | :param wpscan_path: Path to WPScan executable. 41 | Exemple: ``'/usr/local/rvm/gems/default/wrappers/wpscan'`` 42 | :param scan_timeout: Timeout 43 | """ 44 | self.processes: List[subprocess.Popen[bytes]] = [] 45 | "List of running WPScan processes" 46 | 47 | self._wpscan_path: List[str] = shlex.split(wpscan_path) 48 | self._scan_timeout: Optional[timedelta] = scan_timeout 49 | 50 | self._update_lock: threading.Lock = threading.Lock() 51 | self._lazy_last_db_update: Optional[datetime] = self._NO_VAL 52 | self._lazy_wpscan_version: Optional[str] = None 53 | 54 | self._api_limit_wait = api_limit_wait 55 | self._follow_redirect = follow_redirect 56 | 57 | self._api_wait: threading.Event = threading.Event() 58 | 59 | self._interrupting = False 60 | 61 | def wpscan(self, *args: str) -> subprocess.CompletedProcess: # type: ignore [type-arg] 62 | """ 63 | Run WPScan and return process results. Automatically update WPScan database. 64 | 65 | :param args: Sequence of arguments to pass to WPScan. 66 | Exemple: ``"--update", "--format", "json", "--no-banner"`` 67 | 68 | :returns: Custom `subprocess.CompletedProcess` instance with decoded output. 69 | """ 70 | if self._needs_update(): # for lazy update 71 | while self._update_lock.locked(): 72 | time.sleep(0.01) 73 | with self._update_lock: 74 | if self._needs_update(): # Re-check in case of concurrent scanning 75 | self._update_wpscan() 76 | p = self._wpscan(*args) 77 | if p.returncode not in [0, 5]: 78 | return self._handle_wpscan_err(p) 79 | else: 80 | return p 81 | 82 | # safe_log_wpscan_args 83 | 84 | def interrupt(self) -> None: 85 | "Send SIGTERM to all currently running WPScan processes. Unlock api wait. " 86 | self._interrupting = True 87 | self._api_wait.set() 88 | for p in self.processes: 89 | p.terminate() 90 | # Wait for all processes to finish , kill after timeout 91 | try: 92 | timeout(INTERRUPT_TIMEOUT, self._wait_all_wpscan_process) 93 | except TimeoutError: 94 | for p in self.processes: 95 | p.kill() 96 | 97 | def _wait_all_wpscan_process(self) -> None: 98 | """ 99 | Wait all WPScan processes. 100 | Should be called with timeout() function 101 | """ 102 | while len(self.processes) > 0: 103 | time.sleep(0.5) 104 | 105 | 106 | @property 107 | def _last_db_update(self) -> Optional[datetime]: 108 | if self._lazy_last_db_update == self._NO_VAL: 109 | self._init_lazy_attributes() 110 | return self._lazy_last_db_update 111 | 112 | @property 113 | def _wpscan_version(self) -> Optional[str]: 114 | if self._lazy_wpscan_version == self._NO_VERSION: 115 | self._init_lazy_attributes() 116 | return self._lazy_wpscan_version 117 | 118 | 119 | def _init_lazy_attributes(self) -> None: 120 | 121 | wp_version_args = ["--version", "--format", "json", "--no-banner"] 122 | try: 123 | process = self._wpscan(*wp_version_args) 124 | except FileNotFoundError as err: 125 | raise FileNotFoundError( 126 | "Could not find WPScan executale. " 127 | "Make sure wpscan in you PATH or configure full path to executable in config file. " 128 | "If you're using RVM+bundler, the path should point to the WPScan wrapper like '/usr/local/rvm/gems/default/wrappers/wpscan'" 129 | ) from err 130 | else: 131 | if process.returncode != 0: 132 | raise RuntimeError( 133 | f"There is an issue with WPScan. Non-zero exit code when requesting 'wpscan {' '.join(wp_version_args)}' \nOutput:\n{process.stdout}\nError:\n{process.stderr}" 134 | ) 135 | 136 | version_info = json.loads(process.stdout) 137 | 138 | if isinstance(version_info.get("last_db_update"), str): 139 | self._lazy_last_db_update = datetime.strptime( 140 | version_info["last_db_update"].split(".")[0], "%Y-%m-%dT%H:%M:%S" 141 | ) 142 | else: 143 | self._lazy_last_db_update = None 144 | 145 | try: 146 | self._lazy_wpscan_version = version_info['version'] 147 | except KeyError: 148 | self._lazy_wpscan_version = None 149 | 150 | def _update_wpscan(self) -> None: 151 | # Update wpscan database 152 | log.info("Updating WPScan") 153 | process = self._wpscan( 154 | "--update", "--format", "json", "--no-banner" 155 | ) 156 | if process.returncode != 0: 157 | raise RuntimeError(f"Error updating WPScan.\nOutput:{process.stdout}\nError:\n{process.stderr}") 158 | self._lazy_last_db_update = datetime.now() 159 | 160 | 161 | def _needs_update(self) -> bool: 162 | return ( 163 | self._last_db_update == None or 164 | ( datetime.now() # type: ignore [operator] 165 | - self._last_db_update 166 | > UPDATE_DB_INTERVAL ) 167 | ) 168 | 169 | 170 | # Helper method: actually wraps wpscan 171 | def _wpscan(self, *args: str) -> subprocess.CompletedProcess: # type: ignore [type-arg] 172 | # WPScan arguments 173 | arguments = list(args) 174 | if arguments[0] == 'wpscan': 175 | arguments.pop(0) 176 | cmd = self._wpscan_path + arguments 177 | # Log wpscan command without api token 178 | log.debug(f"Running WPScan command: {' '.join(safe_log_wpscan_args(cmd))}") 179 | # Run wpscan 180 | process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 181 | # Append process to current process list and launch 182 | self.processes.append(process) 183 | 184 | if self._scan_timeout: 185 | try: 186 | stdout, stderr = timeout(self._scan_timeout.total_seconds(), 187 | process.communicate) 188 | except TimeoutError as err: 189 | process.kill() 190 | # Raise error 191 | err_str = f"WPScan process '{safe_log_wpscan_args(cmd)}' timed out after {self._scan_timeout.total_seconds()} seconds. Setup 'scan_timeout' to allow more time. " 192 | raise RuntimeError(err_str) from err 193 | else: 194 | stdout, stderr = process.communicate() 195 | 196 | self.processes.remove(process) 197 | try: 198 | out_decoded = stdout.decode("utf-8") 199 | err_decoded = stderr.decode("utf-8") 200 | except UnicodeDecodeError: 201 | out_decoded = stdout.decode("latin1", errors="replace") 202 | err_decoded = stderr.decode("latin1", errors="replace") 203 | finally: 204 | return subprocess.CompletedProcess( 205 | args = cmd, 206 | returncode = process.returncode, 207 | stdout = out_decoded, 208 | stderr = err_decoded) 209 | 210 | def _handle_wpscan_err_api_wait( 211 | self, failed_process: subprocess.CompletedProcess ) -> subprocess.CompletedProcess: # type: ignore [type-arg] 212 | """ 213 | Sleep 24 hours and retry. 214 | """ 215 | log.info( 216 | f"API limit has been reached, sleeping 24h and continuing the scans..." 217 | ) 218 | self._api_wait.wait(API_WAIT_SLEEP.total_seconds()) 219 | if self._interrupting: 220 | return failed_process 221 | return self._wpscan(*failed_process.args) 222 | 223 | def _handle_wpscan_err_follow_redirect( 224 | self, failed_process: subprocess.CompletedProcess) -> subprocess.CompletedProcess: # type: ignore [type-arg] 225 | """Parse URL in WPScan output and retry. 226 | """ 227 | if "The URL supplied redirects to" in failed_process.stdout: 228 | urls = re.findall( 229 | r"http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+", 230 | failed_process.stdout.split("The URL supplied redirects to")[1], 231 | ) 232 | 233 | if len(urls) >= 1: 234 | url = urls[0].strip() 235 | log.info(f"Following redirection to {url}") 236 | cmd = failed_process.args 237 | cmd[cmd.index('--url')+1] = url 238 | return self._wpscan(*cmd) 239 | 240 | else: 241 | 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}") 242 | else: 243 | return failed_process 244 | 245 | 246 | def _handle_wpscan_err(self, failed_process: subprocess.CompletedProcess) -> subprocess.CompletedProcess: # type: ignore [type-arg] 247 | """Handle API limit and Follow redirection errors based on output strings. 248 | """ 249 | if ( 250 | "API limit has been reached" in str(failed_process.stdout) 251 | and self._api_limit_wait 252 | ): 253 | return self._handle_wpscan_err_api_wait(failed_process) 254 | 255 | # Handle Following redirection 256 | elif ( 257 | "The URL supplied redirects to" in str(failed_process.stdout) 258 | and self._follow_redirect 259 | ): 260 | return self._handle_wpscan_err_follow_redirect(failed_process) 261 | 262 | else: 263 | return failed_process 264 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | Copyright 2020 Tristan Landes 179 | 180 | Licensed under the Apache License, Version 2.0 (the "License"); 181 | you may not use this file except in compliance with the License. 182 | You may obtain a copy of the License at 183 | 184 | http://www.apache.org/licenses/LICENSE-2.0 185 | 186 | Unless required by applicable law or agreed to in writing, software 187 | distributed under the License is distributed on an "AS IS" BASIS, 188 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 189 | See the License for the specific language governing permissions and 190 | limitations under the License. 191 | 192 | -------------------------------------------------------------------------------- /wpwatcher/cli.py: -------------------------------------------------------------------------------- 1 | """ 2 | Command line arguments and specific options. 3 | """ 4 | 5 | # Main program, parse the args, read config and launch scans 6 | from typing import Dict, Optional, Any, List, Sequence, Text 7 | import argparse 8 | import shlex 9 | import sys 10 | from wpwatcher import log, _init_log 11 | from wpwatcher.__version__ import __version__, __author__, __url__ 12 | from wpwatcher.utils import parse_timedelta 13 | from wpwatcher.config import Config 14 | from wpwatcher.core import WPWatcher 15 | from wpwatcher.db import DataBase 16 | from wpwatcher.daemon import Daemon 17 | from wpwatcher.syslog import SyslogOutput 18 | from wpwatcher.report import ReportCollection 19 | from wpscan_out_parse import format_results 20 | 21 | 22 | def main(_args: Optional[Sequence[Text]] = None) -> None: 23 | """Main program entrypoint""" 24 | # Parse arguments 25 | args: argparse.Namespace = get_arg_parser().parse_args(_args) 26 | 27 | # Init logger with CLi arguments 28 | _init_log(args.verbose, args.quiet) 29 | 30 | # If template conf , print and exit 31 | if args.template_conf: 32 | template_conf() 33 | 34 | # Print "banner" 35 | log.info( 36 | "WPWatcher - Automating WPscan to scan and report vulnerable Wordpress sites" 37 | ) 38 | 39 | if args.version: 40 | # Print and exit 41 | version() 42 | 43 | if args.wprs != False: 44 | # Init WPWatcherDataBase object and dump reports 45 | wprs(filepath=args.wprs, daemon=args.daemon) 46 | 47 | # Read config 48 | configuration = Config.fromcliargs(args) 49 | 50 | if args.show: 51 | # Init WPWatcherDataBase object and dump cli formatted report 52 | show( 53 | urlpart=args.show, 54 | filepath=configuration["wp_reports"], 55 | daemon=args.daemon, 56 | ) 57 | if args.show_html: 58 | show( 59 | urlpart=args.show_html, 60 | filepath=configuration["wp_reports"], 61 | daemon=args.daemon, 62 | format='html', 63 | ) 64 | if args.show_json: 65 | show( 66 | urlpart=args.show_json, 67 | filepath=configuration["wp_reports"], 68 | daemon=args.daemon, 69 | format='json', 70 | ) 71 | 72 | # Launch syslog test 73 | if args.syslog_test: 74 | syslog_test(configuration) 75 | 76 | # If daemon lopping 77 | if configuration["daemon"]: 78 | 79 | # Run 4 ever 80 | daemon = Daemon(configuration) 81 | daemon.loop() 82 | 83 | else: 84 | # Run scans and quit 85 | wpwatcher = WPWatcher(configuration) 86 | exit_code, reports = wpwatcher.run_scans() 87 | exit(exit_code) 88 | 89 | 90 | def wprs(filepath: Optional[str] = None, daemon: bool = False) -> None: 91 | """Generate JSON file database summary""" 92 | db = DataBase(filepath, daemon=daemon) 93 | print(repr(db)) 94 | exit(0) 95 | 96 | 97 | def show(urlpart: str, filepath: Optional[str] = None, daemon: bool = False, format:str='cli') -> None: 98 | """Inspect a report in database""" 99 | db = DataBase(filepath, daemon=daemon) 100 | matching_reports = [r for r in db._data if urlpart in r["site"]] 101 | eq_reports = [r for r in db._data if urlpart == r["site"]] 102 | if len(eq_reports): 103 | print( 104 | format_results(eq_reports[0], format=format) 105 | ) 106 | elif len(matching_reports) == 1: 107 | print( 108 | format_results(matching_reports[0], format=format) 109 | ) 110 | elif len(matching_reports) > 1: 111 | print( 112 | "The following sites match your search: \n" 113 | ) 114 | print( 115 | repr(ReportCollection(matching_reports)) 116 | ) 117 | print("\nPlease be more specific. \n") 118 | exit(1) 119 | else: 120 | print("No report found") 121 | exit(1) 122 | exit(0) 123 | 124 | 125 | def version() -> None: 126 | """Print version and contributors""" 127 | print(f"Version:\t\t{__version__}") 128 | print(f"Authors:\t\t{__author__}") 129 | exit(0) 130 | 131 | 132 | def template_conf() -> None: 133 | """Print template configuration""" 134 | print(Config.TEMPLATE_FILE) 135 | exit(0) 136 | 137 | 138 | def syslog_test(conf: Config) -> None: 139 | """Launch the emit_test_messages() method""" 140 | syslog = SyslogOutput(conf) 141 | syslog.emit_test_messages() 142 | exit(0) 143 | 144 | 145 | def get_arg_parser() -> argparse.ArgumentParser: 146 | """Parse CLI arguments, arguments can overwrite config file values""" 147 | 148 | parser = argparse.ArgumentParser( 149 | description=f"""WordPress Watcher is a Python wrapper for WPScan that manages scans on multiple sites and reports by email. 150 | Some config arguments can be passed to the command. 151 | It will overwrite previous values from config file(s). 152 | Check {__url__} for more informations.""" 153 | ) 154 | parser.add_argument( 155 | "--conf", 156 | "-c", 157 | metavar="File path", 158 | help="""Configuration file. You can specify multiple files, it will overwrites the keys with each successive file. 159 | If not specified, will try to load config from file `~/.wpwatcher/wpwatcher.conf`, `~/wpwatcher.conf` and `./wpwatcher.conf`. 160 | All options can be missing from config file.""", 161 | nargs="+", 162 | default=None, 163 | ) 164 | parser.add_argument( 165 | "--template_conf", 166 | "--tmpconf", 167 | help="""Print a template config file.""", 168 | action="store_true", 169 | ) 170 | 171 | # Declare arguments 172 | parser.add_argument( 173 | "--wp_sites", 174 | "--url", 175 | metavar="URL", 176 | help="Site(s) to scan, you can pass multiple values", 177 | nargs="+", 178 | default=None, 179 | ) 180 | parser.add_argument( 181 | "--wp_sites_list", 182 | "--urls", 183 | metavar="Path", 184 | help="Read URLs from a text file. File must contain one URL per line", 185 | default=None, 186 | ) 187 | parser.add_argument( 188 | "--send_email_report", 189 | "--send", 190 | help="Enable email report sending", 191 | action="store_true", 192 | ) 193 | parser.add_argument( 194 | "--email_to", 195 | "--em", 196 | metavar="Email", 197 | help="Email the specified receipent(s) you can pass multiple values", 198 | nargs="+", 199 | default=None, 200 | ) 201 | parser.add_argument( 202 | "--send_infos", "--infos", help="Email INFO reports", action="store_true" 203 | ) 204 | parser.add_argument( 205 | "--send_errors", "--errors", help="Email ERROR reports", action="store_true" 206 | ) 207 | parser.add_argument( 208 | "--attach_wpscan_output", 209 | "--attach", 210 | help="Attach WPScan output to emails", 211 | action="store_true", 212 | ) 213 | parser.add_argument( 214 | "--use_monospace_font", 215 | "--monospace", 216 | help="Use Courrier New monospaced font in emails", 217 | action="store_true", 218 | ) 219 | parser.add_argument( 220 | "--fail_fast", 221 | "--ff", 222 | help="Interrupt scans if any WPScan or sendmail failure", 223 | action="store_true", 224 | ) 225 | parser.add_argument( 226 | "--api_limit_wait", 227 | "--wait", 228 | help="Sleep 24h if API limit reached", 229 | action="store_true", 230 | ) 231 | parser.add_argument("--daemon", help="Loop and scan for ever", action="store_true") 232 | parser.add_argument( 233 | "--daemon_loop_sleep", 234 | "--loop", 235 | metavar="Time string", 236 | help="Time interval to sleep in daemon loop", 237 | default=None, 238 | ) 239 | parser.add_argument( 240 | "--wp_reports", 241 | "--reports", 242 | metavar="Path", 243 | help="Database Json file", 244 | default=None, 245 | ) 246 | parser.add_argument( 247 | "--resend_emails_after", 248 | "--resend", 249 | metavar="Time string", 250 | help="Minimum time interval to resend email report with same status", 251 | default=None, 252 | ) 253 | parser.add_argument( 254 | "--asynch_workers", 255 | "--workers", 256 | metavar="Number", 257 | help="Number of asynchronous workers", 258 | type=int, 259 | default=None, 260 | ) 261 | parser.add_argument( 262 | "--log_file", 263 | "--log", 264 | metavar="Path", 265 | help="Logfile replicates all output with timestamps", 266 | default=None, 267 | ) 268 | parser.add_argument( 269 | "--follow_redirect", 270 | "--follow", 271 | help="Follow site redirection if causes WPscan failure", 272 | action="store_true", 273 | ) 274 | parser.add_argument( 275 | "--wpscan_output_folder", 276 | "--wpout", 277 | metavar="Path", 278 | help="Write all WPScan results in sub directories 'info', 'warning', 'alert' and 'error'", 279 | default=None, 280 | ) 281 | parser.add_argument( 282 | "--wpscan_args", 283 | "--wpargs", 284 | metavar="Arguments", 285 | help="WPScan arguments as string. See 'wpscan --help' for more infos", 286 | default=None, 287 | ) 288 | parser.add_argument( 289 | "--false_positive_strings", 290 | "--fpstr", 291 | metavar="String", 292 | help="False positive strings, you can pass multiple values", 293 | nargs="+", 294 | default=None, 295 | ) 296 | parser.add_argument( 297 | "--verbose", 298 | "-v", 299 | help="Verbose output, print WPScan raw output and parsed WPScan results.", 300 | action="store_true", 301 | ) 302 | parser.add_argument( 303 | "--quiet", 304 | "-q", 305 | help="Print only errors and WPScan ALERTS", 306 | action="store_true", 307 | ) 308 | parser.add_argument( 309 | "--version", "-V", help="Print WPWatcher version", action="store_true" 310 | ) 311 | parser.add_argument( 312 | "--syslog_test", 313 | help="Sends syslog testing packets of all possible sorts to the configured syslog server.", 314 | action="store_true", 315 | ) 316 | parser.add_argument( 317 | "--wprs", 318 | metavar="Path", 319 | help="Print all reports summary. Leave path blank to find default file. Can be used with --daemon to print default daemon databse.", 320 | nargs="?", 321 | default=False, 322 | ) 323 | parser.add_argument( 324 | "--show", metavar="Site", help="Print a report in the Database in text format." 325 | ) 326 | parser.add_argument( 327 | "--show_html", metavar="Site", help="Print a report in the Database in HTML format, use with --quiet to print only HTML content." 328 | ) 329 | parser.add_argument( 330 | "--show_json", metavar="Site", help="Print a report in the Database in JSON format, use with --quiet to print only JSON content." 331 | ) 332 | return parser 333 | 334 | 335 | """Main program if called with wpwatcher/cli.py""" 336 | if __name__ == "__main__": 337 | main() 338 | -------------------------------------------------------------------------------- /wpwatcher/scan.py: -------------------------------------------------------------------------------- 1 | """ 2 | Scanner utility. 3 | """ 4 | from typing import Optional, BinaryIO, List, Tuple, Dict, Any, Union 5 | import threading 6 | import re 7 | import os 8 | import traceback 9 | import json 10 | from smtplib import SMTPException 11 | from datetime import timedelta, datetime 12 | 13 | from wpscan_out_parse import WPScanJsonParser, WPScanCliParser 14 | 15 | from wpwatcher import log 16 | from wpwatcher.__version__ import __version__ 17 | from wpwatcher.utils import ( 18 | get_valid_filename, 19 | safe_log_wpscan_args, 20 | oneline, 21 | remove_color, 22 | ) 23 | from wpwatcher.email import EmailSender 24 | from wpwatcher.wpscan import WPScanWrapper 25 | from wpwatcher.syslog import SyslogOutput 26 | from wpwatcher.report import ScanReport 27 | from wpwatcher.site import Site 28 | from wpwatcher.config import Config 29 | 30 | 31 | # Date format used everywhere 32 | DATE_FORMAT = "%Y-%m-%dT%H-%M-%S" 33 | 34 | 35 | class Scanner: 36 | """Scanner class create reports and handles the scan process. """ 37 | 38 | def __init__(self, conf: Config): 39 | 40 | # Create (lazy) wpscan link 41 | self.wpscan = WPScanWrapper( 42 | wpscan_path=conf["wpscan_path"], 43 | scan_timeout=conf["scan_timeout"], 44 | api_limit_wait=conf["api_limit_wait"], 45 | follow_redirect=conf["follow_redirect"], ) 46 | 47 | # Init mail client 48 | self.mail: EmailSender = EmailSender(conf) 49 | 50 | # Toogle if aborting so other errors doesnt get triggerred and exit faster 51 | self.interrupting: bool = False 52 | # List of scanned URLs 53 | self.scanned_sites: List[Optional[str]] = [] 54 | 55 | # Save required config options 56 | self.wpscan_output_folder: str = conf["wpscan_output_folder"] 57 | self.wpscan_args: List[str] = conf["wpscan_args"] 58 | self.fail_fast: bool = conf["fail_fast"] 59 | self.false_positive_strings: List[str] = conf["false_positive_strings"] 60 | 61 | # Init wpscan output folder 62 | if self.wpscan_output_folder: 63 | os.makedirs(self.wpscan_output_folder, exist_ok=True) 64 | os.makedirs( 65 | os.path.join(self.wpscan_output_folder, "error/"), exist_ok=True 66 | ) 67 | os.makedirs( 68 | os.path.join(self.wpscan_output_folder, "alert/"), exist_ok=True 69 | ) 70 | os.makedirs( 71 | os.path.join(self.wpscan_output_folder, "warning/"), exist_ok=True 72 | ) 73 | os.makedirs(os.path.join(self.wpscan_output_folder, "info/"), exist_ok=True) 74 | 75 | self.syslog: Optional[SyslogOutput] = None 76 | self.broken_syslog = False 77 | if conf["syslog_server"]: 78 | try: 79 | self.syslog = SyslogOutput(conf) 80 | except Exception as e: 81 | self.broken_syslog = True 82 | log.error(f"Broken Syslog: {e}\n{traceback.format_exc()}") 83 | 84 | 85 | 86 | def interrupt(self) -> None: 87 | """ 88 | Call `WPScanWrapper.interrupt`. 89 | 90 | This do NOT raise SystemExit. 91 | """ 92 | self.interrupting = True 93 | self.wpscan.interrupt() 94 | 95 | # Scan process 96 | 97 | @staticmethod 98 | def _write_wpscan_output(wp_report: ScanReport, fpwpout: BinaryIO) -> None: 99 | """Helper method to write output to file""" 100 | nocolor_output = remove_color(wp_report["wpscan_output"]) 101 | try: 102 | fpwpout.write(nocolor_output.encode("utf-8")) 103 | except UnicodeEncodeError: 104 | fpwpout.write(nocolor_output.encode("latin1", errors='replace')) 105 | 106 | def write_wpscan_output(self, wp_report: ScanReport) -> Optional[str]: 107 | """Write WPScan output to configured place with `wpscan_output_folder` if configured""" 108 | # Subfolder 109 | folder = f"{wp_report['status'].lower()}/" 110 | # Write wpscan output 111 | if self.wpscan_output_folder: 112 | wpscan_results_file = os.path.join( 113 | self.wpscan_output_folder, 114 | folder, 115 | get_valid_filename( 116 | f"WPScan_output_{wp_report['site']}_{wp_report['datetime']}.txt" 117 | ), 118 | ) 119 | log.info(f"Saving WPScan output to file {wpscan_results_file}") 120 | with open(wpscan_results_file, "wb") as wpout: 121 | self._write_wpscan_output(wp_report, wpout) 122 | return wpout.name 123 | else: 124 | return None 125 | 126 | 127 | def log_report_results(self, wp_report: ScanReport) -> None: 128 | """Print WPScan findings""" 129 | for info in wp_report["infos"]: 130 | log.info(oneline(f"** WPScan INFO {wp_report['site']} ** {info}")) 131 | for fix in wp_report["fixed"]: 132 | log.info(oneline(f"** FIXED Issue {wp_report['site']} ** {fix}")) 133 | for warning in wp_report["warnings"]: 134 | log.warning(oneline(f"** WPScan WARNING {wp_report['site']} ** {warning}")) 135 | for alert in wp_report["alerts"]: 136 | log.critical(oneline(f"** WPScan ALERT {wp_report['site']} ** {alert}")) 137 | 138 | def _scan_site( 139 | self, wp_site: Site, wp_report: ScanReport 140 | ) -> Optional[ScanReport]: 141 | """ 142 | Handled WPScan scanning , parsing, errors and reporting. 143 | Returns filled wp_report, None if interrupted or killed. 144 | Can raise `RuntimeError` if any errors. 145 | """ 146 | 147 | # WPScan arguments 148 | wpscan_arguments = ( 149 | self.wpscan_args + wp_site["wpscan_args"] + ["--url", wp_site["url"]] 150 | ) 151 | 152 | # Output 153 | log.info(f"Scanning site {wp_site['url']}") 154 | 155 | # Launch WPScan 156 | wpscan_process = self.wpscan.wpscan( 157 | *wpscan_arguments 158 | ) 159 | wp_report["wpscan_output"] = wpscan_process.stdout 160 | 161 | log.debug(f"WPScan raw output:\n{wp_report['wpscan_output']}") 162 | log.debug("Parsing WPScan output") 163 | 164 | try: 165 | # Use wpscan_out_parse module 166 | try: 167 | parser = WPScanJsonParser( 168 | json.loads(wp_report["wpscan_output"]), 169 | self.false_positive_strings 170 | + wp_site["false_positive_strings"] 171 | + ["No WPVulnDB API Token given", "No WPScan API Token given"] 172 | ) 173 | except ValueError as err: 174 | parser = WPScanCliParser( 175 | wp_report["wpscan_output"], 176 | self.false_positive_strings 177 | + wp_site["false_positive_strings"] 178 | + ["No WPVulnDB API Token given", "No WPScan API Token given"] 179 | ) 180 | finally: 181 | wp_report.load_parser(parser) 182 | 183 | except Exception as err: 184 | raise RuntimeError( 185 | f"Could not parse WPScan output for site {wp_site['url']}\nOutput:\n{wp_report['wpscan_output']}" 186 | ) from err 187 | 188 | # Exit code 0: all ok. Exit code 5: Vulnerable. Other exit code are considered as errors 189 | if wpscan_process.returncode in [0, 5]: 190 | return wp_report 191 | 192 | # Quick return if interrupting and/or if user cancelled scans 193 | if self.interrupting or wpscan_process.returncode in [2, -2, -9]: 194 | return None 195 | 196 | # Other errors codes : 127, etc, simply raise error 197 | 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}" 198 | raise RuntimeError(err_str) 199 | 200 | 201 | def _fail_scan(self, wp_report: ScanReport, err_str: str) -> None: 202 | """ 203 | Common manipulations when a scan fail. This will not stop the scans 204 | unless --fail_fast if enabled. 205 | 206 | Triger InterruptedError if fail_fast and not already interrupting. 207 | """ 208 | 209 | wp_report.fail(err_str) 210 | if self.fail_fast and not self.interrupting: 211 | raise InterruptedError() 212 | 213 | def scan_site( 214 | self, wp_site: Site, last_wp_report: Optional[ScanReport] = None 215 | ) -> Optional[ScanReport]: 216 | """ 217 | Orchestrate the scanning of a site. 218 | 219 | :Return: The scan report or `None` if something happened. 220 | """ 221 | 222 | # Init report variables 223 | wp_report: ScanReport = ScanReport( 224 | {"site": wp_site["url"], "datetime": datetime.now().strftime(DATE_FORMAT)} 225 | ) 226 | 227 | # Launch WPScan 228 | try: 229 | # If report is None, return None right away 230 | if not self._scan_site(wp_site, wp_report): 231 | return None 232 | 233 | except RuntimeError: 234 | 235 | self._fail_scan( 236 | wp_report, 237 | f"Could not scan site {wp_site['url']} \n{traceback.format_exc()}", 238 | ) 239 | 240 | 241 | # Updating report entry with data from last scan 242 | wp_report.update_report(last_wp_report) 243 | 244 | self.log_report_results(wp_report) 245 | 246 | wpscan_command = " ".join( 247 | safe_log_wpscan_args( 248 | ["wpscan"] 249 | + self.wpscan_args 250 | + wp_site["wpscan_args"] 251 | + ["--url", wp_site["url"]] 252 | ) 253 | ) 254 | 255 | try: 256 | 257 | # Notify recepients if match triggers 258 | if self.mail.notify( 259 | wp_site, wp_report, 260 | last_wp_report, 261 | wpscan_command=wpscan_command, 262 | wpscan_version=self.wpscan._wpscan_version or '??', 263 | ): 264 | # Store report time 265 | wp_report["last_email"] = wp_report["datetime"] 266 | # Discard fixed items because infos have been sent 267 | wp_report["fixed"] = [] 268 | 269 | # Handle sendmail errors 270 | except (SMTPException, ConnectionRefusedError, TimeoutError): 271 | self._fail_scan( 272 | wp_report, 273 | f"Could not send mail report for site {wp_site['url']}\n{traceback.format_exc()}", 274 | ) 275 | 276 | 277 | # Send syslog if self.syslog is not None 278 | if self.syslog: 279 | try: 280 | self.syslog.emit_messages(wp_report) 281 | except Exception: 282 | self._fail_scan( 283 | wp_report, 284 | f"Could not send syslog messages for site {wp_site['url']}\n{traceback.format_exc()}", 285 | ) 286 | 287 | # Save scanned site 288 | self.scanned_sites.append(wp_site["url"]) 289 | 290 | # Discard wpscan_output from report 291 | if "wpscan_output" in wp_report: 292 | del wp_report["wpscan_output"] 293 | 294 | # Discard wpscan_parser from report 295 | if "wpscan_parser" in wp_report: 296 | del wp_report["wpscan_parser"] 297 | 298 | 299 | return wp_report 300 | --------------------------------------------------------------------------------