--------------------------------------------------------------------------------
/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 |
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 | 
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 | 
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 |
--------------------------------------------------------------------------------