├── wordfence ├── cli │ ├── __init__.py │ ├── banner │ │ ├── __init__.py │ │ └── banner.py │ ├── malwarescan │ │ └── __init__.py │ ├── config │ │ ├── typing.py │ │ ├── defaults.py │ │ └── config.py │ ├── exceptions.py │ ├── version │ │ ├── version.py │ │ └── definition.py │ ├── vulnscan │ │ └── exceptions.py │ ├── help │ │ ├── definition.py │ │ └── help.py │ ├── terms │ │ ├── definition.py │ │ └── terms.py │ ├── mailing_lists.py │ ├── configure │ │ ├── configure.py │ │ └── definition.py │ ├── countsites │ │ ├── countsites.py │ │ └── definition.py │ ├── io.py │ ├── test_malwarescan_filter.py │ ├── remediate │ │ ├── remediate.py │ │ └── definition.py │ ├── licensing.py │ ├── auto_complete.py │ ├── subcommands.py │ ├── email.py │ ├── terms_management.py │ └── dbscan │ │ └── reporting.py ├── intel │ ├── __init__.py │ ├── signatures.py │ └── database_rules.py ├── util │ ├── __init__.py │ ├── pcre │ │ ├── __init__.py │ │ └── pcre.py │ ├── vectorscan │ │ ├── __init__.py │ │ └── vectorscan.py │ ├── text.py │ ├── unicode.py │ ├── encoding.py │ ├── signals.py │ ├── platform.py │ ├── url.py │ ├── terminal.py │ ├── library.py │ ├── json.py │ ├── serialization.py │ ├── direct_io.py │ ├── timing.py │ ├── test_versioning.py │ ├── updater.py │ ├── units.py │ ├── input.py │ ├── html.py │ └── versioning.py ├── php │ └── __init__.py ├── version.py ├── wordpress │ ├── __init__.py │ ├── exceptions.py │ ├── theme.py │ ├── plugin.py │ ├── database.py │ ├── remediator.py │ └── extension.py ├── scanning │ ├── matching │ │ └── __init__.py │ ├── __init__.py │ ├── exceptions.py │ └── filtering.py ├── databasescanning │ ├── __init__.py │ └── scanner.py ├── __init__.py ├── api │ ├── __init__.py │ ├── exceptions.py │ ├── noc4.py │ ├── licensing.py │ └── noc_client.py └── logging │ ├── formatting.py │ └── __init__.py ├── docker └── build │ ├── volumes │ └── output │ │ └── .gitignore │ ├── build-deb.Dockerfile.dockerignore │ ├── build-standalone.Dockerfile │ ├── build-deb.Dockerfile │ ├── build-rpm.Dockerfile │ ├── host-refresh.sh │ ├── host-build.sh │ └── entrypoint.sh ├── main.py ├── .gitignore ├── docs ├── count-sites │ ├── Examples.md │ ├── README.md │ └── Configuration.md ├── remediate │ ├── README.md │ ├── Examples.md │ └── Configuration.md ├── vuln-scan │ ├── README.md │ ├── Examples.md │ └── Configuration.md ├── db-scan │ ├── README.md │ ├── Examples.md │ └── Configuration.md ├── Subcommands.md ├── malware-scan │ ├── README.md │ ├── Remediation.md │ ├── Examples.md │ ├── Vectorscan.md │ └── Configuration.md ├── Updating.md ├── Output.md ├── Email.md ├── README.md ├── FAQs.md └── Autocomplete.md ├── debian ├── rules └── control ├── requirements.txt ├── .github └── workflows │ ├── unit-testing.yml │ ├── validate-code-styles.yml │ ├── release.yml │ └── build.yml ├── SECURITY.md ├── .dockerignore ├── scripts ├── complete.bash └── transform-readme.py ├── Dockerfile ├── pyproject.toml ├── wordfence.spec ├── CONTRIBUTING.md └── README.md /wordfence/cli/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /wordfence/intel/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /wordfence/util/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /wordfence/php/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ['parsing'] 2 | -------------------------------------------------------------------------------- /docker/build/volumes/output/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore -------------------------------------------------------------------------------- /wordfence/util/pcre/__init__.py: -------------------------------------------------------------------------------- 1 | from .pcre import * # noqa: F401, F403 2 | -------------------------------------------------------------------------------- /wordfence/version.py: -------------------------------------------------------------------------------- 1 | __version__ = '5.0.2' 2 | __version_name__ = None 3 | -------------------------------------------------------------------------------- /wordfence/wordpress/__init__.py: -------------------------------------------------------------------------------- 1 | from . import site 2 | 3 | __all__ = ['site'] 4 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from wordfence.cli import cli 3 | 4 | cli.main() 5 | -------------------------------------------------------------------------------- /wordfence/cli/banner/__init__.py: -------------------------------------------------------------------------------- 1 | from . import banner 2 | 3 | __all__ = ['banner'] 4 | -------------------------------------------------------------------------------- /wordfence/scanning/matching/__init__.py: -------------------------------------------------------------------------------- 1 | from .matching import * # noqa: 401, 403 2 | -------------------------------------------------------------------------------- /wordfence/util/vectorscan/__init__.py: -------------------------------------------------------------------------------- 1 | from .vectorscan import * # noqa: F401, F403 2 | -------------------------------------------------------------------------------- /wordfence/databasescanning/__init__.py: -------------------------------------------------------------------------------- 1 | from . import scanner 2 | 3 | __all__ = ['scanner'] 4 | -------------------------------------------------------------------------------- /wordfence/util/text.py: -------------------------------------------------------------------------------- 1 | def yes_no(value: bool) -> str: 2 | return 'Yes' if value else 'No' 3 | -------------------------------------------------------------------------------- /wordfence/__init__.py: -------------------------------------------------------------------------------- 1 | from wordfence.version import __version__ 2 | 3 | __all__ = ['__version__'] 4 | -------------------------------------------------------------------------------- /wordfence/api/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ['licensing', 'exceptions', 'noc4', 'noc1', 'intelligence'] 2 | -------------------------------------------------------------------------------- /wordfence/cli/malwarescan/__init__.py: -------------------------------------------------------------------------------- 1 | from . import malwarescan 2 | 3 | __all__ = ['malwarescan'] 4 | -------------------------------------------------------------------------------- /wordfence/scanning/__init__.py: -------------------------------------------------------------------------------- 1 | from . import scanner, exceptions 2 | 3 | __all__ = ['scanner', 'exceptions'] 4 | -------------------------------------------------------------------------------- /wordfence/cli/config/typing.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Any 2 | 3 | ConfigDefinitions = Dict[str, Dict[str, Any]] 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .idea 3 | *.py[cod] 4 | /build/ 5 | /dist/ 6 | deb_dist 7 | .vscode 8 | wordfence.egg-info/ 9 | /venv/ -------------------------------------------------------------------------------- /wordfence/cli/exceptions.py: -------------------------------------------------------------------------------- 1 | class CliException(Exception): 2 | pass 3 | 4 | 5 | class ConfigurationException(CliException): 6 | pass 7 | -------------------------------------------------------------------------------- /wordfence/cli/config/defaults.py: -------------------------------------------------------------------------------- 1 | INI_DEFAULT_FILENAME = b'wordfence-cli.ini' 2 | INI_DEFAULT_PATH = b'~/.config/wordfence/' + INI_DEFAULT_FILENAME 3 | -------------------------------------------------------------------------------- /wordfence/scanning/exceptions.py: -------------------------------------------------------------------------------- 1 | class ScanningException(Exception): 2 | pass 3 | 4 | 5 | class ScanningIoException(ScanningException): 6 | pass 7 | -------------------------------------------------------------------------------- /docs/count-sites/Examples.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | ## Count the number of WordPress sites under /var/www 4 | 5 | ``` 6 | wordfence count-sites /var/www 7 | ``` 8 | -------------------------------------------------------------------------------- /docs/remediate/README.md: -------------------------------------------------------------------------------- 1 | ## Remediation Contents 2 | 3 | Documentation for the `wordfence remediate` subcommand 4 | 5 | - [Subcommand Configuration](Configuration.md) 6 | - [Examples](Examples.md) 7 | -------------------------------------------------------------------------------- /docs/count-sites/README.md: -------------------------------------------------------------------------------- 1 | ## Site Counting Contents 2 | 3 | Documentation for the `wordfence count-sites` subcommand 4 | 5 | - [Subcommand Configuration](Configuration.md) 6 | - [Examples](Examples.md) 7 | -------------------------------------------------------------------------------- /debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | 3 | #export DH_VERBOSE = 1 4 | export PYBUILD_NAME=wordfence 5 | 6 | %: 7 | dh $@ --with python3 --buildsystem=pybuild 8 | 9 | override_dh_builddeb: 10 | dh_builddeb -- -Zxz 11 | -------------------------------------------------------------------------------- /wordfence/util/unicode.py: -------------------------------------------------------------------------------- 1 | import unicodedata 2 | 3 | 4 | def filter_control_characters(string: str) -> str: 5 | return "".join( 6 | ch for ch in string if unicodedata.category(ch)[0] != "C" 7 | ) 8 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Runtime dependencies 2 | packaging >= 21.0 3 | requests >= 2.3 4 | pymysql >= 0.9.3 5 | # Build requirements 6 | build ~= 0.10 7 | setuptools ~= 65.0 8 | # Helpful development tools 9 | pyinstaller ~= 5.13.0 10 | -------------------------------------------------------------------------------- /wordfence/cli/version/version.py: -------------------------------------------------------------------------------- 1 | from ..subcommands import Subcommand 2 | 3 | 4 | class VersionSubcommand(Subcommand): 5 | 6 | def invoke(self) -> int: 7 | self.context.display_version() 8 | return 0 9 | 10 | 11 | factory = VersionSubcommand 12 | -------------------------------------------------------------------------------- /.github/workflows/unit-testing.yml: -------------------------------------------------------------------------------- 1 | name: "Unit Testing" 2 | on: [push] 3 | jobs: 4 | test: 5 | runs-on: ubuntu-24.04 6 | steps: 7 | - uses: actions/checkout@v3 8 | - run: sudo apt-get update && sudo apt-get install -y python3-pymysql python3-requests 9 | - run: python3 -m unittest 10 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Reporting security vulnerabilities 2 | 3 | Please report any security-related issues by emailing [security@wordfence.com](mailto:security@wordfence.com). See *Reporting Security Issues in Wordfence Products to Wordfence* at [wordfence.com/security](https://www.wordfence.com/security/) for additional guidelines. 4 | -------------------------------------------------------------------------------- /wordfence/wordpress/exceptions.py: -------------------------------------------------------------------------------- 1 | class WordpressException(Exception): 2 | pass 3 | 4 | 5 | class ExtensionException(WordpressException): 6 | pass 7 | 8 | 9 | class WordpressDatabaseException(Exception): 10 | 11 | def __init__(self, database, message): # noqa: B042 12 | self.database = database 13 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .git 3 | __pycache__ 4 | *.pyc 5 | *.pyo 6 | *.pyd 7 | .Python 8 | env 9 | pip-log.txt 10 | pip-delete-this-directory.txt 11 | .tox 12 | .coverage 13 | .coverage.* 14 | .cache 15 | nosetests.xml 16 | coverage.xml 17 | *.cover 18 | *.log 19 | .mypy_cache 20 | .pytest_cache 21 | .hypothesis -------------------------------------------------------------------------------- /wordfence/cli/vulnscan/exceptions.py: -------------------------------------------------------------------------------- 1 | from ..exceptions import CliException, ConfigurationException 2 | 3 | 4 | class VulnScanningException(CliException): 5 | pass 6 | 7 | 8 | class VulnScanningConfigurationException( 9 | VulnScanningException, 10 | ConfigurationException 11 | ): 12 | pass 13 | -------------------------------------------------------------------------------- /docker/build/build-deb.Dockerfile.dockerignore: -------------------------------------------------------------------------------- 1 | .idea 2 | __pycache__ 3 | *.pyc 4 | *.pyo 5 | *.pyd 6 | .Python 7 | env 8 | pip-log.txt 9 | pip-delete-this-directory.txt 10 | .tox 11 | .coverage 12 | .coverage.* 13 | .cache 14 | nosetests.xml 15 | coverage.xml 16 | *.cover 17 | *.log 18 | .mypy_cache 19 | .pytest_cache 20 | .hypothesis 21 | -------------------------------------------------------------------------------- /wordfence/util/encoding.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | 4 | def bytes_to_str(value: Optional[bytes]) -> Optional[str]: 5 | if value is None: 6 | return None 7 | return value.decode('latin1', 'replace') 8 | 9 | 10 | def str_to_bytes(value: Optional[str]) -> Optional[bytes]: 11 | if value is None: 12 | return None 13 | return value.encode('latin1', 'replace') 14 | -------------------------------------------------------------------------------- /docker/build/build-standalone.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:18.04 2 | 3 | ENV DEBIAN_FRONTEND=noninteractive 4 | RUN apt-get update && apt-get install -y \ 5 | python3.8 \ 6 | python3.8-dev \ 7 | python3-pip \ 8 | libffi-dev 9 | 10 | COPY ./docker/build/entrypoint.sh /root/entrypoint.sh 11 | COPY ./ /root/wordfence-cli 12 | 13 | RUN chmod +x /root/entrypoint.sh 14 | 15 | ENTRYPOINT ["/bin/bash"] 16 | CMD ["/root/entrypoint.sh"] 17 | -------------------------------------------------------------------------------- /docs/vuln-scan/README.md: -------------------------------------------------------------------------------- 1 | ## Vulnerability Scanning Contents 2 | 3 | Documentation for the `wordfence vuln-scan` subcommand 4 | 5 | - [Subcommand Configuration](Configuration.md) 6 | - [Examples](Examples.md) 7 | - [Scanning a single WordPress installation for vulnerabilities](Examples.md#scanning-a-single-wordpress-installation-for-vulnerabilities) 8 | - [Running the vulnerability scan in a cron](Examples.md#running-the-vulnerability-scan-in-a-cron) 9 | -------------------------------------------------------------------------------- /.github/workflows/validate-code-styles.yml: -------------------------------------------------------------------------------- 1 | name: "Validate Code Styles" 2 | on: [push] 3 | jobs: 4 | flake8: 5 | runs-on: ubuntu-24.04 6 | steps: 7 | - uses: actions/checkout@v3 8 | - run: sudo apt-get update && sudo apt-get install -y python3-full 9 | - run: python3 -m venv ./venv 10 | - run: ./venv/bin/pip install flake8 flake8-bugbear 11 | - run: ./venv/bin/python -m flake8 --exclude venv --require-plugins pycodestyle,flake8-bugbear 12 | -------------------------------------------------------------------------------- /wordfence/util/vectorscan/vectorscan.py: -------------------------------------------------------------------------------- 1 | from ..library import LibraryNotAvailableException 2 | 3 | 4 | class VectorscanException(Exception): 5 | pass 6 | 7 | 8 | class VectorscanLibraryNotAvailableException( 9 | VectorscanException, 10 | LibraryNotAvailableException 11 | ): 12 | pass 13 | 14 | 15 | try: 16 | from .bindings import * # noqa: F401, F403 17 | AVAILABLE = True 18 | except VectorscanLibraryNotAvailableException: 19 | AVAILABLE = False 20 | -------------------------------------------------------------------------------- /wordfence/cli/help/definition.py: -------------------------------------------------------------------------------- 1 | from ..subcommands import SubcommandDefinition 2 | from ..config.typing import ConfigDefinitions 3 | 4 | config_definitions: ConfigDefinitions = {} 5 | 6 | cacheable_types = set() 7 | 8 | definition = SubcommandDefinition( 9 | name='help', 10 | usage='[SUBCOMMAND]', 11 | description='Display help', 12 | config_definitions=config_definitions, 13 | config_section='DEFAULT', 14 | cacheable_types=cacheable_types, 15 | requires_config=False 16 | ) 17 | -------------------------------------------------------------------------------- /wordfence/cli/help/help.py: -------------------------------------------------------------------------------- 1 | from ..subcommands import Subcommand 2 | 3 | 4 | class HelpSubcommand(Subcommand): 5 | 6 | def invoke(self) -> int: 7 | subcommand = None 8 | for argument in self.config.trailing_arguments: 9 | if subcommand is not None: 10 | raise Exception('Please specify a single subcommand') 11 | subcommand = argument 12 | self.helper.display_help(subcommand) 13 | return 0 14 | 15 | 16 | factory = HelpSubcommand 17 | -------------------------------------------------------------------------------- /wordfence/cli/version/definition.py: -------------------------------------------------------------------------------- 1 | from ..subcommands import SubcommandDefinition 2 | from ..config.typing import ConfigDefinitions 3 | 4 | config_definitions: ConfigDefinitions = {} 5 | 6 | cacheable_types = set() 7 | 8 | definition = SubcommandDefinition( 9 | name='version', 10 | usage='', 11 | description='Display the version of Wordfence CLI', 12 | config_definitions=config_definitions, 13 | config_section='DEFAULT', 14 | cacheable_types=cacheable_types, 15 | requires_config=False 16 | ) 17 | -------------------------------------------------------------------------------- /docker/build/build-deb.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:22.04 2 | 3 | ENV DEBIAN_FRONTEND=noninteractive 4 | RUN apt-get update && apt-get install -y \ 5 | devscripts \ 6 | debhelper \ 7 | dh-python \ 8 | python3 \ 9 | python3-all \ 10 | python3-pip \ 11 | pybuild-plugin-pyproject \ 12 | pkg-config 13 | 14 | COPY ./docker/build/entrypoint.sh /root/entrypoint.sh 15 | COPY ./ /root/wordfence-cli 16 | 17 | RUN chmod +x /root/entrypoint.sh 18 | 19 | ENTRYPOINT ["/bin/bash"] 20 | CMD ["/root/entrypoint.sh"] 21 | -------------------------------------------------------------------------------- /wordfence/cli/terms/definition.py: -------------------------------------------------------------------------------- 1 | from ..subcommands import SubcommandDefinition 2 | from ..config.typing import ConfigDefinitions 3 | 4 | config_definitions: ConfigDefinitions = {} 5 | 6 | cacheable_types = set() 7 | 8 | definition = SubcommandDefinition( 9 | name='terms', 10 | usage='', 11 | description='Display the license terms for Wordfence CLI', 12 | config_definitions=config_definitions, 13 | config_section='DEFAULT', 14 | cacheable_types=cacheable_types, 15 | requires_config=False 16 | ) 17 | -------------------------------------------------------------------------------- /wordfence/api/exceptions.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | 4 | class ApiException(Exception): 5 | 6 | def __init__( # noqa: B042 7 | self, 8 | internal_message: str, 9 | public_message: Optional[str] = None 10 | ): 11 | if public_message is not None: 12 | message = f'{internal_message}: {public_message}' 13 | else: 14 | message = internal_message 15 | super().__init__(message) 16 | self.public_message = public_message 17 | -------------------------------------------------------------------------------- /wordfence/api/noc4.py: -------------------------------------------------------------------------------- 1 | from .noc_client import NocClient 2 | 3 | NOC4_BASE_URL = 'https://noc4.wordfence.com/v1.11/' 4 | 5 | 6 | class Client(NocClient): 7 | 8 | def get_default_base_url(self) -> str: 9 | return NOC4_BASE_URL 10 | 11 | def build_query(self, action: str, base_query: dict = None) -> dict: 12 | query = super().build_query(action, base_query) 13 | # TODO: How should site parameters be handled for CLI requests 14 | query['s'] = 'http://www.example.com' 15 | query['h'] = 'http://www.example.com' 16 | return query 17 | -------------------------------------------------------------------------------- /docs/db-scan/README.md: -------------------------------------------------------------------------------- 1 | ## Database Scanning Contents 2 | 3 | Documentation for the `wordfence db-scan` subcommand 4 | 5 | - [Subcommand Configuration](Configuration.md) 6 | - [Examples](Examples.md) 7 | - [Scanning a single WordPress database](Examples.md#scanning-a-single-wordpress-database) 8 | - [Automatically locating WordPress installations](Examples.md#automatically-locating-wordpress-installations) 9 | - [Scanning databases listed in a JSON file](Examples.md#scanning-databases-listed-in-a-json-file) 10 | - [Writing database scan results to a CSV](Examples.md#writing-database-scan-results-to-a-csv) 11 | -------------------------------------------------------------------------------- /docs/Subcommands.md: -------------------------------------------------------------------------------- 1 | # Wordfence CLI Subcommands 2 | 3 | Wordfence CLI provides a variety of subcommands to support different use cases. The following subcommands are currently available: 4 | 5 | - **[malware-scan](malware-scan/)** (scan files for malware) 6 | - **[vuln-scan](vuln-scan/)** (scan one or more WordPress installations for known vulnerabilities 7 | - **[db-scan](db-scan/)** (scan one or more WordPress databases for malicious content) 8 | - **[remediate](remediate/)** (remediate malware by restoring known files) 9 | - **[count-sites](count-sites/)** (count the number of WordPress sites at one or more paths) 10 | -------------------------------------------------------------------------------- /wordfence/util/signals.py: -------------------------------------------------------------------------------- 1 | import signal 2 | 3 | 4 | HANDLED_SIGNALS = [ 5 | signal.SIGINT 6 | ] 7 | 8 | 9 | _handlers = None 10 | 11 | 12 | def reset(): 13 | global _handlers 14 | if _handlers is None: 15 | _handlers = {} 16 | for signal_type in HANDLED_SIGNALS: 17 | _handlers[signal_type] = signal.getsignal(signal_type) 18 | signal.signal(signal_type, signal.SIG_DFL) 19 | 20 | 21 | def restore(): 22 | global _handlers 23 | for signal_type, handler in _handlers.items(): 24 | signal.signal(signal_type, handler) 25 | _handlers = None 26 | -------------------------------------------------------------------------------- /scripts/complete.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | function _wordfence_complete() { 4 | result_string=$(python3 -m wordfence.cli.auto_complete ${COMP_WORDS[*]} ${COMP_CWORD}) 5 | result=() 6 | while IFS= read -r line 7 | do 8 | result+=("$line") 9 | done <<< "$result_string" 10 | words="${result[0]}" 11 | flags="" 12 | if [ "${result[1]}" ] 13 | then 14 | flags="${flags}-f " 15 | fi 16 | if [ "${result[2]}" ] 17 | then 18 | flags="${flags}-d " 19 | fi 20 | latest="${COMP_WORDS[$COMP_CWORD]}" 21 | COMPREPLY=($(compgen ${flags}-W "$words" -- "$latest")) 22 | return 0 23 | } 24 | 25 | complete -F _wordfence_complete wordfence 26 | -------------------------------------------------------------------------------- /docs/remediate/Examples.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | ## Restore the original contents of a plugin file 4 | 5 | ``` 6 | wordfence remediate /var/www/html/wp-content/plugins/hello.php 7 | ``` 8 | 9 | ## Restore all files in a theme directory and output the results to a CSV file 10 | 11 | ``` 12 | wordfence remediate --output-format csv --output-path /tmp/wfcli-remediation-results.csv --output-headers /var/www/html/wp-content/themes/twentytwentythree 13 | ``` 14 | 15 | ## Automatically deletect and remediate malware under /var/www/wordpress 16 | 17 | ``` 18 | wordfence malware-scan --output-columns filename -m null-delimited /var/www/wordpress | wordfence remediate 19 | ``` 20 | -------------------------------------------------------------------------------- /wordfence/util/platform.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from platform import machine 3 | from typing import Set 4 | 5 | 6 | class UnknownPlatform(Exception): 7 | pass 8 | 9 | 10 | class Platform(Enum): 11 | 12 | AMD64 = ('amd64', {'amd64', 'x86_64'}) 13 | ARM64 = ('arm64', {'arm64', 'aarch64'}) 14 | 15 | def __init__(self, key: str, machine_names: Set[str]): 16 | self.key = key 17 | self.machine_names = machine_names 18 | 19 | @classmethod 20 | def detect(cls): 21 | machine_name = machine() 22 | for platform in cls: 23 | if machine_name in platform.machine_names: 24 | return platform 25 | raise UnknownPlatform() 26 | -------------------------------------------------------------------------------- /docker/build/build-rpm.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM almalinux:9.3 2 | 3 | RUN dnf -y upgrade && \ 4 | dnf -y install dnf-plugins-core && \ 5 | dnf config-manager --set-enabled crb && \ 6 | dnf -y install epel-release && \ 7 | dnf -y install rpmdevtools \ 8 | rpm-build \ 9 | python3.11-devel \ 10 | pyproject-rpm-macros \ 11 | python3.11-pip \ 12 | python3.11-wheel \ 13 | python3.11-pytest \ 14 | python3.11-requests \ 15 | python3.11-PyMySQL \ 16 | python3.11-setuptools 17 | 18 | RUN pip-3.11 19 | 20 | COPY ./docker/build/entrypoint.sh /root/entrypoint.sh 21 | COPY ./ /root/wordfence-cli 22 | 23 | RUN chmod +x /root/entrypoint.sh 24 | 25 | ENTRYPOINT ["/bin/bash"] 26 | CMD ["/root/entrypoint.sh"] 27 | -------------------------------------------------------------------------------- /wordfence/util/url.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urlparse, urlunparse, parse_qs, urlencode 2 | 3 | 4 | class Url: 5 | 6 | def __init__(self, url: str): 7 | self._parts = urlparse(url)._asdict() 8 | 9 | def get_hostname(self) -> str: 10 | return self._parts['netloc'] 11 | 12 | def get_query(self) -> str: 13 | return self._parts['query'] 14 | 15 | def set_query(self, query: str) -> None: 16 | self._parts['query'] = query 17 | 18 | def set_query_parameter(self, key, value) -> None: 19 | parameters = parse_qs(self.get_query()) 20 | parameters[key] = value 21 | self.set_query(urlencode(parameters, doseq=True)) 22 | 23 | def __str__(self) -> str: 24 | return urlunparse(self._parts.values()) 25 | -------------------------------------------------------------------------------- /wordfence/cli/mailing_lists.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class MailingList(Enum): 5 | 6 | TERMS = ( 7 | 'https://www.wordfence.com/products/wordfence-cli/#terms' 8 | ) 9 | WORDPRESS_SECURITY = ( 10 | 'https://www.wordfence.com/subscribe-to-the-wordfence-email-list/' 11 | ) 12 | 13 | def __init__(self, registration_url): 14 | self.registration_url = registration_url 15 | 16 | 17 | EMAIL_SIGNUP_MESSAGE = ( 18 | "Register to receive updated Wordfence CLI Terms of Service via email " 19 | f"at {MailingList.TERMS.registration_url}. Join our WordPress Security " 20 | f"mailing list at {MailingList.WORDPRESS_SECURITY.registration_url} " 21 | "to get security alerts, news, and research directly to your inbox." 22 | ) 23 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-slim as base 2 | 3 | # install OS packages 4 | RUN apt-get update && apt-get install -y \ 5 | libpcre3 \ 6 | && rm -rf /var/lib/apt/lists/* 7 | 8 | FROM base as build 9 | WORKDIR /app 10 | 11 | # copy source code, build wheel, set up venv, and install 12 | COPY wordfence wordfence 13 | COPY pyproject.toml pyproject.toml 14 | RUN pip install build~=0.10 15 | RUN python -m build --wheel 16 | RUN python -m venv /venv 17 | ENV VIRTUAL_ENV="/venv" 18 | ENV PATH="/venv/bin:${PATH}" 19 | RUN pip install dist/wordfence-*.whl 20 | 21 | FROM base as final 22 | WORKDIR /venv 23 | 24 | # copy venv from build stage 25 | COPY --from=build /venv . 26 | ENV VIRTUAL_ENV="/venv" 27 | ENV PATH="/venv/bin:${PATH}" 28 | # run the application, bringing in command line arguments 29 | ENTRYPOINT [ "wordfence" ] 30 | -------------------------------------------------------------------------------- /wordfence/util/terminal.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from enum import IntEnum 3 | 4 | 5 | def supports_colors() -> bool: 6 | # TODO: Implement more detailed checks for color support 7 | return sys.stdout is not None and sys.stdout.isatty() 8 | 9 | 10 | class Color(IntEnum): 11 | BLACK = 30 12 | RED = 31 13 | GREEN = 32 14 | YELLOW = 33 15 | BLUE = 34 16 | MAGENTA = 35 17 | CYAN = 36 18 | WHITE = 37 19 | RESET = 0 20 | 21 | 22 | ESC = '\x1b' 23 | 24 | 25 | def escape(color: Color, bold: bool = False) -> str: 26 | fields = [] 27 | if bold: 28 | fields.append('1') 29 | else: 30 | fields.append('22') 31 | fields.append(str(color.value)) 32 | sequence = ';'.join(fields) 33 | return f'{ESC}[{sequence}m' 34 | 35 | 36 | RESET = escape(color=Color.RESET) 37 | -------------------------------------------------------------------------------- /wordfence/cli/terms/terms.py: -------------------------------------------------------------------------------- 1 | from ...util import caching 2 | from ...logging import log 3 | from ..subcommands import Subcommand 4 | from ..mailing_lists import EMAIL_SIGNUP_MESSAGE 5 | 6 | 7 | class TermsSubcommand(Subcommand): 8 | 9 | def fetch_terms(self) -> str: 10 | client = self.context.create_noc1_client(use_hooks=False) 11 | return client.get_terms() 12 | 13 | def get_terms(self) -> str: 14 | cacheable = caching.Cacheable( 15 | 'terms', 16 | self.fetch_terms, 17 | caching.DURATION_ONE_DAY 18 | ) 19 | return cacheable.get(self.cache) 20 | 21 | def invoke(self) -> int: 22 | terms = self.get_terms() 23 | print(terms) 24 | log.info(EMAIL_SIGNUP_MESSAGE) 25 | return 0 26 | 27 | 28 | factory = TermsSubcommand 29 | -------------------------------------------------------------------------------- /wordfence/util/library.py: -------------------------------------------------------------------------------- 1 | from ctypes import cdll, CDLL 2 | from ctypes.util import find_library 3 | from importlib import import_module 4 | 5 | 6 | class LibraryNotAvailableException(Exception): 7 | pass 8 | 9 | 10 | def load_library(name: str) -> CDLL: 11 | pathname = find_library(name) 12 | if pathname is None: 13 | raise LibraryNotAvailableException() 14 | try: 15 | library = cdll.LoadLibrary(pathname) 16 | return library 17 | except OSError: 18 | raise LibraryNotAvailableException 19 | 20 | 21 | class OptionalUtility: 22 | 23 | def __init__(self, name): 24 | try: 25 | self.module = import_module('.' + name, 'wordfence.util') 26 | except LibraryNotAvailableException: 27 | self.module = None 28 | 29 | def is_available(self) -> bool: 30 | return self.module is not None 31 | -------------------------------------------------------------------------------- /docs/remediate/Configuration.md: -------------------------------------------------------------------------------- 1 | # Remediation Configuration 2 | 3 | Remediation can be configured using either command line arguments, the [INI file](../Configuration.md#wordfence-cliini), or a combination of both. 4 | 5 | ## Command Line Arguments 6 | 7 | - `--read-stdin`: Read paths from stdin 8 | - `-s`, `--path-separator`: Path separator when reading from stdin, defaults to null byte 9 | - `--require-path`: Require at least one path be provided for scanning 10 | - `--allow-nested`: Allow nested WordPress installations 11 | - `--allow-io-errors`: Allow counting to continue if IO errors are encountered 12 | 13 | # INI Options 14 | 15 | ```ini 16 | [COUNT_SITES] 17 | # Read paths from stdin 18 | read_stdin = [on|off] 19 | # Separator string when reading paths from stdin, defaults to null byte 20 | path_separator = 21 | allow_nested = [on|off] 22 | allow_io_errors = [on|off] 23 | ``` 24 | -------------------------------------------------------------------------------- /docs/count-sites/Configuration.md: -------------------------------------------------------------------------------- 1 | # Site Counting Configuration 2 | 3 | Site counting can be configured using either command line arguments, the [INI file](../Configuration.md#wordfence-cliini), or a combination of both. 4 | 5 | ## Command Line Arguments 6 | 7 | - `--read-stdin`: Read paths from stdin 8 | - `-s`, `--path-separator`: Path separator when reading from stdin, defaults to null byte 9 | - `--require-path`: Require at least one path be provided for scanning 10 | - `--allow-nested`: Allow nested WordPress installations 11 | - `--allow-io-errors`: Allow counting to continue if IO errors are encountered 12 | 13 | # INI Options 14 | 15 | ```ini 16 | [COUNT_SITES] 17 | # Read paths from stdin 18 | read_stdin = [on|off] 19 | # Separator string when reading paths from stdin, defaults to null byte 20 | path_separator = 21 | allow_nested = [on|off] 22 | allow_io_errors = [on|off] 23 | ``` 24 | -------------------------------------------------------------------------------- /docs/malware-scan/README.md: -------------------------------------------------------------------------------- 1 | ## Malware Scanning Contents 2 | 3 | Documentation for the `wordfence malware-scan` subcommand 4 | 5 | - [Subcommand Configuration](Configuration.md) 6 | - [Automatic Remediation of discovered malware](Remediation.md) 7 | - [Faster Scanning with Vectorscan](malware-scan/Vectorscan.md) 8 | - [Examples](Examples.md) 9 | - [Scanning a directory for malware](Examples.md#scanning-a-directory-for-malware) 10 | - [Running Wordfence CLI in a cron](Examples.md#running-wordfence-cli-in-a-cron) 11 | - [Piping files from find to Wordfence CLI](Examples.md#piping-files-from-find-to-wordfence-cli) 12 | - [Automatically repairing known files belonging to WordPress](Examples.md#automatically-repairing-known-files-belonging-to-wordpress) 13 | - [Automatically repairing known files, and automatically deleting unknown files](Examples.md#automatically-repairing-known-files-and-automatically-deleting-unknown-files) 14 | 15 | -------------------------------------------------------------------------------- /wordfence/cli/configure/configure.py: -------------------------------------------------------------------------------- 1 | from ..subcommands import Subcommand 2 | from ..configurer import MIN_WORKERS 3 | 4 | 5 | class ConfigureSubcommand(Subcommand): 6 | 7 | def invoke(self) -> int: 8 | configurer = self.context.configurer 9 | configurer.overwrite = self.config.overwrite 10 | configurer.request_license = self.config.request_license 11 | if self.config.workers is not None \ 12 | and self.config.workers < MIN_WORKERS: 13 | if self.config.is_from_cli('workers'): 14 | raise ValueError( 15 | 'The number of workers cannot be less than ' 16 | f'{MIN_WORKERS}' 17 | ) 18 | self.config.workers = MIN_WORKERS 19 | configurer.workers = self.config.workers 20 | configurer.default = self.config.default 21 | configurer.prompt_for_config() 22 | return 0 23 | 24 | 25 | factory = ConfigureSubcommand 26 | -------------------------------------------------------------------------------- /debian/control: -------------------------------------------------------------------------------- 1 | Source: wordfence 2 | Maintainer: Wordfence 3 | Section: utils 4 | Priority: optional 5 | Build-Depends: debhelper-compat (= 13), 6 | dh-python, 7 | python3-all, 8 | python3-setuptools, 9 | pybuild-plugin-pyproject 10 | Standards-Version: 4.6.2 11 | Homepage: https://www.wordfence.com/products/wordfence-cli/ 12 | Vcs-Browser: https://github.com/wordfence/wordfence-cli 13 | Vcs-Git: https://github.com/wordfence/wordfence-cli.git 14 | X-Python3-Version: >= 3.8 15 | 16 | Package: wordfence 17 | Architecture: all 18 | Depends: ${python3:Depends}, libpcre3 19 | Recommends: libhyperscan5 20 | Description: Command-line malware scanner powered by Wordfence 21 | Wordfence CLI is a multi-process malware scanner written in Python. It's 22 | designed to have low memory overhead while being able to utilize multiple 23 | cores for scanning large filesystems for malware. Wordfence CLI uses libpcre 24 | over Python's existing regex libraries for speed and compatibility with our 25 | signature set. 26 | -------------------------------------------------------------------------------- /scripts/transform-readme.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import re 3 | import sys 4 | from urllib.parse import urljoin 5 | 6 | 7 | MARKDOWN_LINK_PATTERN = re.compile(r'\[([^]]*)\]\(([^)]*)\)') 8 | 9 | 10 | def make_links_absolute(content: str, link_base: str) -> str: 11 | 12 | def make_absolute(match) -> str: 13 | text = match.group(1) 14 | link = match.group(2) 15 | link = urljoin(link_base, link) 16 | return f'[{text}]({link})' 17 | 18 | return MARKDOWN_LINK_PATTERN.sub(make_absolute, content) 19 | 20 | 21 | def transform_readme(path: str, link_base: str): 22 | with open(path, 'r+') as file: 23 | content = file.read() 24 | content = make_links_absolute(content, link_base) 25 | file.seek(0) 26 | file.truncate() 27 | file.write(content) 28 | 29 | 30 | if __name__ == '__main__': 31 | try: 32 | path = sys.argv[1] 33 | link_base = sys.argv[2] 34 | transform_readme(path, link_base) 35 | except KeyError: 36 | raise Exception( 37 | 'A README path and link base must be provided' 38 | ) 39 | -------------------------------------------------------------------------------- /docs/db-scan/Examples.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | ## Scanning a single WordPress database 4 | 5 | Scan a WordPress database using explicit connection settings and prompt for the password at runtime. 6 | 7 | wordfence db-scan -H db.example.com -P 3306 -u wordpress -D wordpress --prompt-for-password 8 | 9 | ## Automatically locating WordPress installations 10 | 11 | Search the supplied directories for `wp-config.php`, extract the database credentials, and scan each discovered site. 12 | 13 | wordfence db-scan -S /var/www/wordpress /srv/wordpress 14 | 15 | ## Scanning databases listed in a JSON file 16 | 17 | Provide a JSON file that lists one or more database configurations. Each entry must include `name`, `user`, `password`, and `host`, with optional `port`, `collation`, and `prefix` values. Multiple JSON files can be supplied. 18 | 19 | wordfence db-scan /etc/wordfence/databases.json 20 | 21 | ## Writing database scan results to a CSV 22 | 23 | Output scan results to a CSV file with headers and explicitly selected columns. 24 | 25 | wordfence db-scan -S /var/www/wordpress --output-format csv --output-columns table,rule_description,row --output-headers --output-path /home/username/db-scan-results.csv 26 | -------------------------------------------------------------------------------- /wordfence/util/json.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Any 3 | from base64 import b64encode 4 | 5 | 6 | UNFILTERED_TYPES = { 7 | bool, 8 | int, 9 | float, 10 | str 11 | } 12 | 13 | 14 | def encode_invalid_data(data) -> Any: 15 | for unfiltered_type in UNFILTERED_TYPES: 16 | if isinstance(data, unfiltered_type): 17 | return data 18 | if isinstance(data, dict): 19 | filtered = {} 20 | for key, value in data.items(): 21 | filtered[encode_invalid_data(key)] = encode_invalid_data(value) 22 | return filtered 23 | elif isinstance(data, list): 24 | filtered = [] 25 | for value in data: 26 | filtered.append(encode_invalid_data(value)) 27 | return filtered 28 | elif isinstance(data, bytes): 29 | return b64encode(data).decode('utf-8') 30 | else: 31 | try: 32 | json.dumps(data) 33 | except Exception: 34 | return None 35 | 36 | 37 | # Encode any data that cannot be represented as valid JSON 38 | # prior to attempting to encode data as JSON 39 | def safe_json_encode(data) -> str: 40 | return json.dumps(encode_invalid_data(data)) 41 | -------------------------------------------------------------------------------- /docker/build/host-refresh.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ -z ${1:+x} ]; then 4 | echo "You must provide the path to the project (if called from within the root project dir, pass in '.')" 5 | exit 1 6 | elif [ ! -d "$1" ]; then 7 | echo "The first argument must point to the project directory" 8 | exit 1 9 | elif [ -z ${2:+x} ]; then 10 | echo "You must provide the architecture as the second argument" 11 | exit 1 12 | elif [ "$2" != "amd64" ] && [ "$2" != "arm64" ]; then 13 | echo "Invalid architecture (must be amd64 or arm64)" 14 | exit 1 15 | elif [ -z ${3:+x} ]; then 16 | echo "You must provide the package type as the third argument" 17 | exit 1 18 | elif [ "$3" != "deb" ] && [ "$3" != "rpm" ] && [ "$3" != "standalone" ]; then 19 | echo "Invalid package type (must be deb or standalone)" 20 | exit 1 21 | fi 22 | 23 | PROJECT_DIR=$(realpath "$1") 24 | ARCHITECTURE="$2" 25 | PACKAGE_TYPE="$3" 26 | 27 | docker rmi -f "wfcli-build-$ARCHITECTURE" 2>/dev/null 28 | docker build \ 29 | --no-cache \ 30 | -t "wfcli-build-${ARCHITECTURE}" \ 31 | --platform "linux/${ARCHITECTURE}" \ 32 | -f "${PROJECT_DIR}/docker/build/build-${PACKAGE_TYPE}.Dockerfile" \ 33 | "$PROJECT_DIR" 34 | -------------------------------------------------------------------------------- /wordfence/logging/formatting.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from ..util.terminal import Color, escape, RESET 4 | 5 | 6 | class ConfigurableFormatter(logging.Formatter): 7 | 8 | def __init__(self, colored: bool = False, prefixed: bool = False): 9 | super().__init__() 10 | self.colored = colored 11 | self.prefixed = prefixed 12 | self.reset = RESET if colored else '' 13 | 14 | def get_style(self, level) -> str: 15 | if not self.colored: 16 | return '' 17 | if level >= logging.ERROR: 18 | return escape(color=Color.RED) 19 | if level >= logging.WARNING: 20 | return escape(color=Color.YELLOW) 21 | if level <= logging.DEBUG: 22 | return escape(color=Color.WHITE) 23 | return escape(color=Color.GREEN) 24 | 25 | def get_prefix(self, level) -> str: 26 | if not self.prefixed or not level: 27 | return '' 28 | return f'{level}: ' 29 | 30 | def format(self, record) -> str: 31 | style = self.get_style(record.levelno) 32 | prefix = self.get_prefix(record.levelname) 33 | message = super().format(record) 34 | return f'{style}{prefix}{message}{self.reset}' 35 | -------------------------------------------------------------------------------- /docker/build/host-build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ -z ${1:+x} ]; then 4 | echo "You must provide the path to the project (if called from within the root project dir, pass in '.')" 5 | exit 1 6 | elif [ ! -d "$1" ]; then 7 | echo "The first argument must point to the project directory" 8 | exit 1 9 | elif [ -z ${2:+x} ]; then 10 | echo "You must provide the architecture as the second argument" 11 | exit 1 12 | elif [ "$2" != "amd64" ] && [ "$2" != "arm64" ]; then 13 | echo "Invalid architecture (must be amd64 or arm64)" 14 | exit 1 15 | elif [ -z ${3:+x} ]; then 16 | echo "You must provide the package type as the third argument" 17 | exit 1 18 | elif [ "$3" != "deb" ] && [ "$3" != "rpm" ] && [ "$3" != "standalone" ]; then 19 | echo "Invalid package type (must be deb or standalone)" 20 | exit 1 21 | fi 22 | 23 | PROJECT_DIR=$(realpath "$1") 24 | echo "output path: $PROJECT_DIR/docker/build/volumes/output" 25 | ARCHITECTURE="$2" 26 | PACKAGE_TYPE="$3" 27 | docker run \ 28 | --name "wfcli-build-container-${ARCHITECTURE}" \ 29 | --platform "linux/${ARCHITECTURE}" \ 30 | -v "${PROJECT_DIR}/docker/build/volumes/output/:/root/output:rw" \ 31 | -e "PACKAGE_TYPE=${PACKAGE_TYPE}" \ 32 | "wfcli-build-$ARCHITECTURE" 33 | -------------------------------------------------------------------------------- /wordfence/util/serialization.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | from io import BytesIO 3 | from typing import Any, Set, Optional 4 | 5 | 6 | class SerializationException(Exception): 7 | pass 8 | 9 | 10 | class ProhibitedTypeException(SerializationException): 11 | pass 12 | 13 | 14 | class UnexpectedTypeException(SerializationException): 15 | pass 16 | 17 | 18 | class LimitedDeserializer(pickle.Unpickler): 19 | 20 | def __init__(self, data: bytes, allowed: Set[str]): 21 | super().__init__(BytesIO(data)) 22 | self.allowed = allowed 23 | 24 | def find_class(self, module, name): 25 | full_name = f'{module}.{name}' 26 | if full_name in self.allowed: 27 | return super().find_class(module, name) 28 | else: 29 | raise ProhibitedTypeException(f'Global {full_name} is not allowed') 30 | 31 | 32 | def limited_deserialize( 33 | data: bytes, 34 | allowed: Optional[Set[str]] = None, 35 | expected: Any = None 36 | ) -> Any: 37 | if allowed is None: 38 | allowed = set() 39 | result = LimitedDeserializer(data, allowed).load() 40 | if expected is not None and not isinstance(result, expected): 41 | raise UnexpectedTypeException('Unexpected type: ' + type(expected)) 42 | return result 43 | -------------------------------------------------------------------------------- /wordfence/api/licensing.py: -------------------------------------------------------------------------------- 1 | from typing import Union, Optional 2 | 3 | from .exceptions import ApiException 4 | 5 | 6 | LICENSE_URL = 'https://www.wordfence.com/products/wordfence-cli/' 7 | 8 | 9 | class License: 10 | 11 | def __init__(self, key: str): 12 | self.key = key 13 | self.paid = False 14 | 15 | def __eq__(self, other): 16 | return other.key == self.key 17 | 18 | def __str__(self) -> str: 19 | return self.key 20 | 21 | 22 | def to_license(license: Union[License, str]) -> License: 23 | if isinstance(license, License): 24 | return license 25 | return License(license) 26 | 27 | 28 | class LicenseRequiredException(ApiException): 29 | 30 | def __init__(self): 31 | super().__init__( 32 | 'License required', 33 | 'A valid Wordfence CLI license is required' 34 | ) 35 | 36 | 37 | class LicenseSpecific: 38 | 39 | def __init__(self, license: Optional[License]): 40 | self.license = license 41 | 42 | def is_compatible_with_license(self, license: License): 43 | return self.license is None or self.license == license 44 | 45 | def assign_license(self, license: Optional[License]): 46 | self.license = license 47 | 48 | def clear_license(self): 49 | self.assign_license(None) 50 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "setuptools.build_meta" 3 | requires = [ "setuptools>=65" ] 4 | 5 | [project] 6 | name = "wordfence" 7 | authors = [ 8 | { name = "Wordfence", email = "opensource@wordfence.com" } 9 | ] 10 | maintainers = [ 11 | { name = "Wordfence", email = "opensource@wordfence.com" } 12 | ] 13 | description = "Command-line malware scanner powered by Wordfence" 14 | readme = "README.md" 15 | license = { file = "LICENSE" } 16 | requires-python = ">=3.8" 17 | classifiers = [ 18 | "Programming Language :: Python :: 3", 19 | "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", 20 | "Operating System :: OS Independent", 21 | "Environment :: Console", 22 | "Intended Audience :: Information Technology", 23 | "Intended Audience :: System Administrators", 24 | "Topic :: Security" 25 | ] 26 | dependencies = [ 27 | "packaging>=21.0", 28 | "requests>=2.3", 29 | "pymysql>=0.9.3" 30 | ] 31 | dynamic = [ "version" ] 32 | 33 | [tool.setuptools.packages.find] 34 | include = [ "wordfence*" ] 35 | 36 | [tool.setuptools.dynamic] 37 | version = { attr = "wordfence.version.__version__" } 38 | 39 | [project.urls] 40 | Homepage = "https://www.wordfence.com/products/wordfence-cli/" 41 | Documentation = "https://www.wordfence.com/help/wordfence-cli/" 42 | Repository = "https://github.com/wordfence/wordfence-cli" 43 | 44 | [project.scripts] 45 | wordfence = "wordfence.cli.cli:main" 46 | -------------------------------------------------------------------------------- /wordfence/util/pcre/pcre.py: -------------------------------------------------------------------------------- 1 | from ctypes import c_int 2 | 3 | from ..library import LibraryNotAvailableException 4 | 5 | 6 | class PcreException(Exception): 7 | pass 8 | 9 | 10 | class PcreLibraryNotAvailableException( 11 | PcreException, 12 | LibraryNotAvailableException 13 | ): 14 | pass 15 | 16 | 17 | PCRE_CASELESS = 0x00000001 18 | PCRE_DEFAULT_MATCH_LIMIT = 1000000 19 | PCRE_DEFAULT_MATCH_LIMIT_RECURSION = 100000 20 | 21 | 22 | class PcreOptions: 23 | 24 | def __init__( 25 | self, 26 | caseless: bool = False, 27 | match_limit: int = PCRE_DEFAULT_MATCH_LIMIT, 28 | match_limit_recursion: int = PCRE_DEFAULT_MATCH_LIMIT_RECURSION 29 | ): 30 | self.caseless = caseless 31 | self.match_limit = match_limit 32 | self.match_limit_recursion = match_limit_recursion 33 | self._compilation_options = None 34 | 35 | def _get_compilation_options(self) -> c_int: 36 | if self._compilation_options is None: 37 | options = 0 38 | if self.caseless: 39 | options |= PCRE_CASELESS 40 | self._compilation_options = c_int(options) 41 | return self._compilation_options 42 | 43 | 44 | PCRE_DEFAULT_OPTIONS = PcreOptions() 45 | 46 | 47 | try: 48 | from .bindings import * # noqa: F401, F403 49 | AVAILABLE = True 50 | except PcreLibraryNotAvailableException: 51 | AVAILABLE = False 52 | -------------------------------------------------------------------------------- /docs/vuln-scan/Examples.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | ## Scanning a single WordPress installation for vulnerabilities 4 | 5 | A basic example of scanning the `/var/www/wordpress` directory for vulnerabilities. 6 | 7 | wordfence vuln-scan /var/www/wordpress 8 | 9 | ## Writing vulnerability scan results to a CSV 10 | 11 | A basic example of scanning the `/var/www/wordpress` directory for vulnerabilities and writing the results to `/home/username/wordfence-cli-vuln-scan.csv`. 12 | 13 | wordfence vuln-scan --output-format csv --output-path /home/username/wordfence-cli-vuln-scan.csv /var/www/wordpress 14 | 15 | ## Running the vulnerability scan in a cron 16 | 17 | Run Wordfence CLI in a cron job daily to scan `/var/www/wordpress` and write the results to `/home/username/wordfence-cli-vuln-scan.csv` as the `username` user. This would be similar to how a scheduled scan works within the Wordfence plugin. 18 | 19 | 0 0 * * * username /usr/bin/flock -w 0 /tmp/wordfence-cli-vuln-scan.lock /usr/local/bin/wordfence vuln-scan --output-format csv --output-path /home/username/wordfence-cli-vuln-scan.csv /var/www/wordpress 2>&1 /var/log/wordfence/vuln-scan.log; /usr/bin/rm /tmp/wordfence-cli-vuln-scan.lock 20 | 21 | The cronjob uses a lock file at `/tmp/wordfence-cli-vuln-scan.lock` to prevent duplicate vulnerability scans from running at the same time. Any output and errors are logged to `/var/log/wordfence/vuln-scan.log`. Please update the paths from this example based on the system this is intended to run on. 22 | 23 | -------------------------------------------------------------------------------- /wordfence/util/direct_io.py: -------------------------------------------------------------------------------- 1 | import os 2 | import mmap 3 | import math 4 | 5 | from typing import Optional 6 | 7 | 8 | class DirectIoBuffer: 9 | 10 | def __init__(self, max_chunk_size: int = mmap.PAGESIZE): 11 | self.max_chunk_size = max_chunk_size 12 | self.buffer_size = ( 13 | math.ceil(max_chunk_size / mmap.PAGESIZE) * mmap.PAGESIZE 14 | ) 15 | self.buffer = mmap.mmap(-1, self.buffer_size) 16 | self.buffer_view = memoryview(self.buffer) 17 | self.buffers = [self.buffer] 18 | 19 | def seek(self, position: int = 0) -> list: 20 | self.buffer.seek(position) 21 | 22 | def read(self, length: int) -> bytes: 23 | return self.buffer.read(length) 24 | 25 | 26 | class DirectIoReader: 27 | 28 | def __init__(self, path: str, buffer: DirectIoBuffer): 29 | self.fd = os.open(path, os.O_RDONLY | os.O_DIRECT) 30 | self.buffer = buffer 31 | self.offset = 0 32 | 33 | def read(self, limit: Optional[int] = None) -> bytes: 34 | read_offset = math.floor(self.offset / mmap.PAGESIZE) * mmap.PAGESIZE 35 | skip = self.offset % mmap.PAGESIZE 36 | read_length = os.preadv(self.fd, self.buffer.buffers, read_offset) 37 | read_length -= skip 38 | read_length = min(read_length, limit) 39 | self.offset += read_length 40 | self.buffer.seek(skip) 41 | return self.buffer.read(read_length) 42 | 43 | def __enter__(self): 44 | return self 45 | 46 | def __exit__(self, exc_type, exc_value, traceback): 47 | os.close(self.fd) 48 | -------------------------------------------------------------------------------- /wordfence/util/timing.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | 4 | def unit_nanoseconds(ns: int) -> int: 5 | return ns 6 | 7 | 8 | def unit_seconds(ns: int) -> int: 9 | return ns / 1000000000 10 | 11 | 12 | def unit_milliseconds(ns: int) -> int: 13 | return ns / 1000000 14 | 15 | 16 | class Timer: 17 | 18 | def __init__(self, start: bool = True): 19 | if start: 20 | self.start() 21 | else: 22 | self.start_time = None 23 | self.end_time = None 24 | self.previous_time = 0 25 | 26 | def _capture_time(self) -> int: 27 | return time.monotonic_ns() 28 | 29 | def start(self) -> None: 30 | self.start_time = self._capture_time() 31 | self.end_time = None 32 | 33 | def reset(self) -> None: 34 | self.start() 35 | 36 | def stop(self) -> None: 37 | self.end_time = self._capture_time() 38 | 39 | def resume(self) -> None: 40 | if self.start_time is not None: 41 | self.previous_time += self.get_elapsed( 42 | unit=unit_nanoseconds, 43 | total=False 44 | ) 45 | self.start() 46 | 47 | def _get_current_elapsed(self) -> int: 48 | if self.start_time is None: 49 | return 0 50 | end_time = \ 51 | self.end_time if self.end_time is not None \ 52 | else self._capture_time() 53 | return end_time - self.start_time 54 | 55 | def get_elapsed(self, unit=unit_seconds, total: bool = True) -> int: 56 | previous_time = self.previous_time if total else 0 57 | return unit(previous_time + self._get_current_elapsed()) 58 | -------------------------------------------------------------------------------- /docs/Updating.md: -------------------------------------------------------------------------------- 1 | # Updating your Installation 2 | 3 | ## Updating with `pip` 4 | 5 | Use the following to update to the latest release of Wordfence CLI: 6 | 7 | pip install --upgrade wordfence 8 | 9 | ## Binaries 10 | 11 | The releases page in GitHub will have the most recently available binaries for download: 12 | 13 | [https://github.com/wordfence/wordfence-cli/releases](https://github.com/wordfence/wordfence-cli/releases) 14 | 15 | The binary files are in the format `wordfence_yyyy.tar.gz` where `yyyy` is the CPU architecture. The following example uses `AMD64` as the architecture. 16 | 17 | We recommend verifying the authenticity of the download prior to extracting the archive. You can do this by following [the steps outlined in the installation document](Installation.md#verifying-the-authenticity-of-a-release-asset) prior to following the rest of these updating steps. 18 | 19 | Extract the binary: 20 | 21 | tar xvzf wordfence_amd64.tar.gz 22 | 23 | Verify the binary works correctly on your system: 24 | 25 | ./wordfence version 26 | 27 | You should see output similar to this: 28 | 29 | Wordfence CLI 2.0.1 30 | 31 | Copy the binary to the path you've installed your existing Wordfence CLI binary. 32 | 33 | ## Docker 34 | 35 | If you've followed our [installation instructions for Docker](Installation.md#docker), you can `cd` into the source code directory and run `git pull` to fetch the latest version. The `main` branch is kept up-to-date with the most recent stable release. 36 | 37 | ## Manual Installation 38 | 39 | Updating a manual/development installation can be done by using `git pull` in the source code directory. The `main` branch is kept up-to-date with the most recent stable release. -------------------------------------------------------------------------------- /wordfence/util/test_versioning.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from .versioning import compare_php_versions 4 | 5 | 6 | class TestPhpVersions(unittest.TestCase): 7 | 8 | def _expect_comparison( 9 | self, 10 | a: str, 11 | b: str, 12 | expected: int 13 | ) -> None: 14 | self.assertEqual( 15 | compare_php_versions(a, b), 16 | expected 17 | ) 18 | 19 | def test_numeric(self): 20 | self._expect_comparison('1.0.0', '1.0.0', 0) 21 | self._expect_comparison('2.0.0', '3.0.0', -1) 22 | self._expect_comparison('5.0.0', '4.0.0', 1) 23 | 24 | def test_short(self): 25 | self._expect_comparison('1.0', '1.0.0', 0) 26 | self._expect_comparison('2.0.0', '2', 0) 27 | self._expect_comparison('1', '2', -1) 28 | 29 | def test_dev_versions(self): 30 | self._expect_comparison('1.0.0-rc1', '1.0.0', -1) 31 | self._expect_comparison('2.0.dev', '2.0', -1) 32 | self._expect_comparison('1.0.0-test', '1.0.0-dev', -1) 33 | self._expect_comparison('1.0.0-alpha', '1.0.0-dev', 1) 34 | self._expect_comparison('3.45.beta', '3.45.alpha', 1) 35 | self._expect_comparison('5.0.0.beta', '5.0.0.rc', -1) 36 | self._expect_comparison('9.0.0', '9.0.0rc1', 1) 37 | self._expect_comparison('10.0.0', '10.0.0pl', -1) 38 | self._expect_comparison('1.0-dev', '1.0.0-dev', -1) 39 | self._expect_comparison('1.0.0-rc', '1.0.0-RC', 0) 40 | self._expect_comparison('1.0.0-alpha', '1.0.0-a', 0) 41 | self._expect_comparison('1.0.0-beta', '1.0.0b', 0) 42 | self._expect_comparison('1.0.0-pl', '1.0.0-p', 0) 43 | -------------------------------------------------------------------------------- /wordfence/cli/countsites/countsites.py: -------------------------------------------------------------------------------- 1 | import os 2 | from ...wordpress.site import WordpressLocator 3 | from ...logging import log 4 | from ..subcommands import Subcommand 5 | from ..io import IoManager 6 | from ..exceptions import ConfigurationException 7 | 8 | 9 | class CountSitesSubcommand(Subcommand): 10 | 11 | def count_sites(self, path: bytes) -> int: 12 | count = 0 13 | locator = WordpressLocator( 14 | path=path, 15 | allow_nested=self.config.allow_nested, 16 | allow_io_errors=self.config.allow_io_errors 17 | ) 18 | for core in locator.locate_core_paths(): 19 | log.debug('Located WordPress site at ' + os.fsdecode(core)) 20 | count += 1 21 | return count 22 | 23 | def invoke(self) -> int: 24 | count = 0 25 | paths_counted = 0 26 | io_manager = IoManager( 27 | self.config.read_stdin, 28 | self.config.path_separator, 29 | binary=True 30 | ) 31 | for path in self.config.trailing_arguments: 32 | count += self.count_sites(path) 33 | paths_counted += 1 34 | if io_manager.should_read_stdin(): 35 | for path in io_manager.get_input_reader().read_all_entries(): 36 | count += self.count_sites(path) 37 | paths_counted += 1 38 | if self.context.requires_input(self.config.require_path) \ 39 | and paths_counted == 0: 40 | raise ConfigurationException( 41 | 'At least one path must be specified' 42 | ) 43 | log.info(f'Located {count} WordPress site(s)') 44 | print(count) 45 | return 0 46 | 47 | 48 | factory = CountSitesSubcommand 49 | -------------------------------------------------------------------------------- /wordfence/cli/config/config.py: -------------------------------------------------------------------------------- 1 | from argparse import ArgumentParser 2 | from types import SimpleNamespace 3 | from typing import Any, Dict, Optional 4 | 5 | from .config_items import ConfigItemDefinition, Context 6 | 7 | 8 | class Config(SimpleNamespace): 9 | 10 | def __init__( 11 | self, 12 | definitions, 13 | parser: ArgumentParser, 14 | subcommand: Optional[str], 15 | ini_path: Optional[str] = None 16 | ): 17 | super().__init__() 18 | self._definitions = definitions 19 | self._parser = parser 20 | self.subcommand = subcommand 21 | self.ini_path = ini_path 22 | self.trailing_arguments = None 23 | self.defaulted_options = set() 24 | self.sources = {} 25 | 26 | def values(self) -> Dict[str, Any]: 27 | result: Dict[str, Any] = dict() 28 | for prop, value in vars(self).items(): 29 | if (prop.startswith('_') or callable(value) or 30 | isinstance(value, classmethod)): 31 | continue 32 | result[prop] = value 33 | return result 34 | 35 | def get(self, property_name, default=None) -> Any: 36 | return getattr(self, property_name, default) 37 | 38 | def define(self, property_name) -> ConfigItemDefinition: 39 | return self._definitions[property_name] 40 | 41 | def has_ini_file(self) -> bool: 42 | return self.ini_path is not None 43 | 44 | def display_help(self) -> None: 45 | self._parser.print_help() 46 | print() 47 | 48 | def is_specified(self, option: str) -> bool: 49 | return option not in self.defaulted_options 50 | 51 | def is_from_cli(self, option: str) -> bool: 52 | return option in self.sources \ 53 | and self.sources[option] is Context.CLI 54 | -------------------------------------------------------------------------------- /wordfence/wordpress/theme.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Optional, Dict 3 | 4 | from .extension import Extension, ExtensionLoader 5 | 6 | 7 | THEME_HEADER_FIELDS = { 8 | 'Name': 'Theme Name', 9 | 'ThemeURI': 'Theme URI', 10 | 'Description': 'Description', 11 | 'Author': 'Author', 12 | 'AuthorURI': 'Author URI', 13 | 'Version': 'Version', 14 | 'Template': 'Template', 15 | 'Status': 'Status', 16 | 'Tags': 'Tags', 17 | 'TextDomain': 'Text Domain', 18 | 'DomainPath': 'Domain Path', 19 | 'RequiresWP': 'Requires at least', 20 | 'RequiresPHP': 'Requires PHP' 21 | } 22 | 23 | 24 | class Theme(Extension): 25 | pass 26 | 27 | 28 | class ThemeLoader(ExtensionLoader): 29 | 30 | def __init__(self, directory: str, allow_io_errors: bool = False): 31 | super().__init__( 32 | 'theme', 33 | directory=directory, 34 | header_fields=THEME_HEADER_FIELDS, 35 | allow_io_errors=allow_io_errors 36 | ) 37 | 38 | def _initialize_extension( 39 | self, 40 | slug: str, 41 | version: Optional[str], 42 | header: Dict[str, str], 43 | path: bytes 44 | ): 45 | return Theme( 46 | slug=slug, 47 | version=version, 48 | header=header, 49 | path=path 50 | ) 51 | 52 | def _process_entry(self, entry: os.DirEntry) -> Optional[Theme]: 53 | if not entry.is_dir(): 54 | return None 55 | path = os.path.join(entry.path, b'style.css') 56 | if not os.path.isfile(path): 57 | return None 58 | slug = os.fsdecode(entry.name) 59 | return self.load(slug, path, base_path=entry.path) 60 | -------------------------------------------------------------------------------- /wordfence/cli/io.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from typing import Optional, Union 3 | 4 | from ..util.io import StreamReader 5 | 6 | 7 | class IoManager: 8 | 9 | def __init__( 10 | self, 11 | read_stdin: Optional[bool], 12 | input_delimiter: Union[str, bytes], 13 | write_stdout: Optional[bool] = None, 14 | output_path: Optional[str] = None, 15 | encode_paths: bool = False, 16 | binary: bool = False 17 | ): 18 | self.read_stdin = read_stdin 19 | if binary: 20 | self.input_delimiter = input_delimiter \ 21 | if isinstance(input_delimiter, bytes) \ 22 | else input_delimiter.encode('utf-8') 23 | else: 24 | self.input_delimiter = input_delimiter \ 25 | if isinstance(input_delimiter, str) \ 26 | else input_delimiter.decode('utf-8') 27 | self.write_stdout = write_stdout 28 | self.output_path = output_path 29 | self.binary = binary 30 | 31 | def should_read_stdin(self) -> bool: 32 | if sys.stdin is None: 33 | return False 34 | if self.read_stdin is None: 35 | return not sys.stdin.isatty() 36 | else: 37 | return self.read_stdin 38 | 39 | def get_input_reader(self) -> StreamReader: 40 | if not hasattr(self, 'input_reader'): 41 | self.input_reader = StreamReader( 42 | sys.stdin, 43 | self.input_delimiter, 44 | binary=self.binary 45 | ) 46 | return self.input_reader 47 | 48 | def should_write_stdout(self) -> bool: 49 | if sys.stdout is None or self.write_stdout is False: 50 | return False 51 | return self.write_stdout or self.output_path is None 52 | -------------------------------------------------------------------------------- /wordfence/util/updater.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from packaging import version 3 | from typing import Optional 4 | 5 | from . import caching 6 | from .caching import NoCachedValueException 7 | from ..version import __version__ 8 | from ..logging import log 9 | 10 | API = 'https://api.github.com/repos/wordfence/wordfence-cli/releases/latest' 11 | 12 | 13 | class Version: 14 | 15 | @staticmethod 16 | def get_latest() -> Optional[str]: 17 | try: 18 | response = requests.get(API).json() 19 | if 'tag_name' in response.keys(): 20 | latest = response['tag_name'] 21 | if latest[0] == 'v': 22 | latest = latest[1:] 23 | return latest 24 | else: 25 | return None 26 | except requests.exceptions.RequestException: 27 | return None 28 | 29 | @staticmethod 30 | def check(cache: caching.Cache): 31 | try: 32 | latest_version = caching.Cache.get( 33 | cache, 34 | 'latest_version', 35 | 86400 # Latest version is valid for 24 hours 36 | ) 37 | except NoCachedValueException: 38 | latest_version = Version.get_latest() 39 | if latest_version is None: 40 | log.error('Unable to fetch the latest version. ' 41 | 'The version you are using may be out of date!') 42 | return 43 | else: 44 | caching.Cache.put(cache, 'latest_version', latest_version) 45 | 46 | if latest_version is None: 47 | return 48 | 49 | if version.parse(__version__) < version.parse(latest_version): 50 | log.warning('A newer version of the Wordfence CLI is available! ' 51 | 'Updating to ' + latest_version + ' is recommended.') 52 | -------------------------------------------------------------------------------- /wordfence.spec: -------------------------------------------------------------------------------- 1 | %global python3_pkgversion 3.11 2 | 3 | Name: python-wordfence 4 | Version: %{wordfence_version} 5 | Release: 1%{?dist} 6 | Summary: Wordfence malware and vulnerability scanner command line utility 7 | 8 | License: GPLv3 9 | URL: https://www.wordfence.com/products/wordfence-cli/ 10 | Source0: https://github.com/wordfence/wordfence-cli/archive/refs/tags/v%{version}.tar.gz 11 | 12 | BuildArch: noarch 13 | BuildRequires: python%{python3_pkgversion}-devel 14 | BuildRequires: python%{python3_pkgversion}-setuptools 15 | BuildRequires: pyproject-rpm-macros 16 | 17 | %global _description %{expand: 18 | Wordfence CLI is an open source, high performance, multi-process security scanner, written in Python, that quickly scans local and network filesystems to detect PHP malware and WordPress vulnerabilities.} 19 | 20 | 21 | %description %_description 22 | 23 | 24 | %package -n python%{python3_pkgversion}-wordfence 25 | Summary: %{summary} 26 | Requires: pcre python%{python3_pkgversion}-packaging python%{python3_pkgversion}-requests python%{python3_pkgversion}-PyMySQL 27 | 28 | 29 | %description -n python%{python3_pkgversion}-wordfence %_description 30 | # Don't build debuginfo package: 31 | # https://docs.fedoraproject.org/en-US/packaging-guidelines/Debuginfo/#_missing_debuginfo_packages 32 | %global debug_package %{nil} 33 | 34 | 35 | # Some build macros require https://src.fedoraproject.org/rpms/pyproject-rpm-macros 36 | %prep 37 | %setup -q -n wordfence-cli 38 | %generate_buildrequires 39 | %pyproject_buildrequires 40 | 41 | 42 | %build 43 | %pyproject_wheel 44 | 45 | 46 | %install 47 | %pyproject_install 48 | %pyproject_save_files wordfence 49 | 50 | 51 | # %check 52 | # %{pytest} 53 | 54 | 55 | %files -n python%{python3_pkgversion}-wordfence -f %{pyproject_files} 56 | %doc README.md 57 | %license LICENSE 58 | %{_bindir}/wordfence 59 | -------------------------------------------------------------------------------- /wordfence/cli/test_malwarescan_filter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | import types 5 | import unittest 6 | from types import SimpleNamespace 7 | 8 | 9 | class MalwareScanFilterTests(unittest.TestCase): 10 | 11 | @classmethod 12 | def setUpClass(cls): 13 | super().setUpClass() 14 | if 'pymysql' not in sys.modules: 15 | sys.modules['pymysql'] = types.ModuleType('pymysql') 16 | from wordfence.cli.malwarescan.malwarescan import MalwareScanSubcommand 17 | from wordfence.scanning.filtering import FileFilter 18 | 19 | cls._subcommand_cls = MalwareScanSubcommand 20 | cls._file_filter_cls = FileFilter 21 | 22 | def _build_filter(self, **overrides): 23 | defaults = { 24 | 'include_all_files': False, 25 | 'include_files': None, 26 | 'include_files_pattern': None, 27 | 'exclude_files': None, 28 | 'exclude_files_pattern': None, 29 | 'images': False, 30 | } 31 | defaults.update(overrides) 32 | subcommand = object.__new__(self._subcommand_cls) 33 | subcommand.config = SimpleNamespace(**defaults) 34 | return subcommand._initialize_file_filter() 35 | 36 | def test_initialize_file_filter_with_include_all_files(self): 37 | filter_instance = self._build_filter(include_all_files=True) 38 | self.assertIsInstance(filter_instance, self._file_filter_cls) 39 | self.assertTrue(filter_instance.filter(b'/tmp/allowed.php')) 40 | self.assertTrue(filter_instance.filter(b'/tmp/image.jpg')) 41 | 42 | def test_initialize_file_filter_images_flag(self): 43 | filter_instance = self._build_filter( 44 | include_all_files=True, 45 | images=True, 46 | ) 47 | self.assertTrue(filter_instance.filter(b'/tmp/image.jpg')) 48 | 49 | 50 | if __name__ == '__main__': 51 | unittest.main() 52 | -------------------------------------------------------------------------------- /wordfence/logging/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Optional 3 | from enum import IntEnum 4 | from dataclasses import dataclass 5 | 6 | from .formatting import ConfigurableFormatter 7 | 8 | DEFAULT_LOGGER_NAME = 'wordfence' 9 | VERBOSE = 15 10 | 11 | 12 | class LogLevel(IntEnum): 13 | DEBUG = logging.DEBUG 14 | VERBOSE = VERBOSE 15 | INFO = logging.INFO 16 | WARNING = logging.WARNING 17 | ERROR = logging.ERROR 18 | CRITICAL = logging.CRITICAL 19 | 20 | 21 | logging.addLevelName(LogLevel.VERBOSE.value, LogLevel.VERBOSE.name) 22 | 23 | logging.basicConfig(format='%(message)s') 24 | log = logging.getLogger(DEFAULT_LOGGER_NAME) 25 | root_log = logging.getLogger() 26 | 27 | initial_handler: Optional[logging.Handler] = None 28 | 29 | 30 | def remove_initial_handler() -> None: 31 | global initial_handler 32 | if initial_handler is not None: 33 | return 34 | initial_handler = root_log.handlers[0] 35 | root_log.removeHandler(initial_handler) 36 | 37 | 38 | def restore_initial_handler(error_if_not_set: bool = False) -> None: 39 | global initial_handler 40 | if initial_handler is None: 41 | if error_if_not_set: 42 | raise ValueError("Unknown initial handler") 43 | return 44 | root_log.addHandler(initial_handler) 45 | initial_handler = None 46 | 47 | 48 | def set_log_format(colored: bool = False, prefixed: bool = False) -> None: 49 | for handler in root_log.handlers: 50 | handler.setFormatter( 51 | ConfigurableFormatter(colored, prefixed) 52 | ) 53 | 54 | 55 | @dataclass 56 | class LogSettings: 57 | level: LogLevel = LogLevel.WARNING 58 | colored: bool = False 59 | prefixed: bool = False 60 | 61 | def apply(self) -> None: 62 | log.setLevel(self.level) 63 | set_log_format( 64 | colored=self.colored, 65 | prefixed=self.prefixed 66 | ) 67 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Wordfence CLI 2 | 3 | Thank you for your interest in contributing to a Wordfence open source project! We welcome contributions from the community and ask that contributors follow the below guidelines. 4 | 5 | ## Reporting bugs 6 | 7 | If you think you found a bug or noticed a mistake in our documentation, please [check for an existing issue](https://github.com/wordfence/wordfence-cli/issues). If you don’t find one, [open a new issue here](https://github.com/wordfence/wordfence-cli/issues/new). If applicable, include all steps that are necessary to reproduce the bug, any relevant command input and output (stripped of sensitive information), and the expected output or behavior. 8 | 9 | ## Reporting security vulnerabilities 10 | 11 | See [this project’s security policy](https://github.com/wordfence/wordfence-cli/security). 12 | 13 | ## Submitting pull requests 14 | 15 | Pull requests should be submitted from a feature branch of your own fork of this repository. Unless otherwise noted, the base branch for all pull requests should be the `main` branch of this repository. 16 | 17 | Before working on or submitting a pull request that adds significant functionality or changes this repository’s code substantially, we encourage you to discuss the proposed changes with us by [opening an issue](https://github.com/wordfence/wordfence-cli/issues/new). This will greatly increase the likelihood that your pull request is merged. 18 | 19 | We may also require that you sign a contributor license agreement (CLA) in order to verify that your contribution is an original creation and that you have the appropriate rights to grant us the use of it. The CLA signing process is generally done via PR comments and email. You can [view our individual CLA here](https://www.wordfence.com/wp-content/uploads/2024/06/Defiant-Individual-Contributor-License-Agreement-2024.6.10.pdf) and [view our corporate CLA here](https://www.wordfence.com/wp-content/uploads/2024/06/Defiant-Corporate-Contributor-License-Agreement-2024.6.10.pdf). 20 | -------------------------------------------------------------------------------- /wordfence/util/units.py: -------------------------------------------------------------------------------- 1 | import re 2 | from dataclasses import dataclass 3 | from enum import Enum 4 | 5 | KIBIBYTE = 1024 6 | MEBIBYTE = 1024 * 1024 7 | 8 | sizings_map = { 9 | 'b': 1, 10 | 'k': KIBIBYTE, 11 | 'kb': KIBIBYTE, 12 | 'kib': KIBIBYTE, 13 | 'm': MEBIBYTE, 14 | 'mb': MEBIBYTE, 15 | 'mib': MEBIBYTE 16 | } 17 | """maps suffixes to byte multipliers; k/kb/kib are synonyms, as are m/mb/mib""" 18 | 19 | 20 | def byte_length(conversion_value: str) -> int: 21 | match = re.search(r"(\d+)([^0-9].*)", conversion_value) 22 | if not match: 23 | raise ValueError("Invalid format for byte length type") 24 | suffix = match.group(2).lower() 25 | if not sizings_map.get(suffix, False): 26 | raise ValueError("Unrecognized byte length suffix") 27 | return int(match.group(1)) * sizings_map.get(suffix) 28 | 29 | 30 | class ByteUnit(Enum): 31 | BYTE = (1, 'B') 32 | KIBIBYTE = (pow(2, 10), 'KiB') 33 | MEBIBYTE = (pow(2, 20), 'MiB') 34 | GIBIBYTE = (pow(2, 30), 'GiB') 35 | TEBIBYTE = (pow(2, 40), 'TiB') 36 | 37 | def __init__( 38 | self, 39 | size: int, 40 | abbreviation: str 41 | ): 42 | self.size = size 43 | self.abbreviation = abbreviation 44 | 45 | 46 | @dataclass 47 | class ByteUnitValue: 48 | value: float 49 | unit: ByteUnit 50 | 51 | def __str__(self) -> str: 52 | if self.unit.size == 1: 53 | value = int(self.value) 54 | else: 55 | value = round(self.value, 1) 56 | return f'{value} {self.unit.abbreviation}' 57 | 58 | 59 | def scale_byte_unit(byte_count: int) -> ByteUnitValue: 60 | scaled_unit = ByteUnit.BYTE 61 | for unit in ByteUnit: 62 | if byte_count >= unit.size \ 63 | and ( 64 | scaled_unit is None 65 | or unit.size > scaled_unit.size 66 | ): 67 | scaled_unit = unit 68 | scaled_value = byte_count / scaled_unit.size 69 | return ByteUnitValue(scaled_value, scaled_unit) 70 | -------------------------------------------------------------------------------- /docs/Output.md: -------------------------------------------------------------------------------- 1 | # Output 2 | 3 | By default Wordfence CLI will print a banner followed by which paths it was supplied for scanning: 4 | 5 | 6 | ▓▓▓ 7 | ▓▓▓▓▓ ▓▓▓▓▓ _ __ __ ____ 8 | ▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓ | | / /___ _________/ / / __/__ ____ ________ 9 | ▓▓ ▓ ▓▓ | | /| / / __ \/ ___/ __ /_/ /_/ _ \/ __ \/ ___/ _ \ 10 | ▓▓ ▓▓ ▓▓▓ ▓▓ ▓▓ | |/ |/ / /_/ / / / /_/ /_ __/ __/ / / / /__/ __/ 11 | ▓▓ ▓ ▓ ▓ ▓▓ |__/|__/\____/_/ \____/ /_/ \___/_/ /_/\___/\___/ 12 | ▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓ ____ _ ___ 13 | ▓▓ ▓▓▓ ▓ ▓▓▓ ▓ ▓▓▓ ▓▓ / ___| | |_ _| 14 | ▓▓▓▓▓ ▓ ▓▓▓ ▓ ▓▓▓▓▓ | | | | | | 15 | ▓▓ ▓ ▓▓ ▓▓ ▓ ▓▓ | |___| |___ | | 16 | ▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓ \____|_____|___| 17 | 18 | Scanning path: /var/www/ 19 | Scanning path: /home/user/ 20 | 21 | Files found that match a signature matches are printed in the output as well. There are additionally the `--verbose` and `--debug` options that can be used to get further information about what Wordfence CLI is doing under the hood. 22 | 23 | ## Report Formats 24 | 25 | The report is a separate output stream generated by Wordfence CLI that will contain a list of the files detected by one of our signatures. Use `--output-columns` to configure which columns are written to the report. Available columns: `filename`, `signature_id`, `signature_name`, `signature_description`, `matched_text`. 26 | 27 | - CSV (Comma-Separated Values): Tabular formatted file containing file names, and signature details about what was matched. 28 | - TSV (Tab-Separated Values): Tabular formatted file containing file names, and signature details about what was matched. 29 | - NULL-byte delimited: A NULL-byte delimited list of file paths only (does not contain signature or match data). 30 | 31 | -------------------------------------------------------------------------------- /wordfence/api/noc_client.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from typing import Optional 3 | from urllib.parse import urlencode 4 | 5 | from .licensing import License 6 | from .exceptions import ApiException 7 | from ..util.validation import Validator, ValidationException 8 | 9 | DEFAULT_TIMEOUT = 30 10 | 11 | 12 | class NocClient: 13 | 14 | def __init__( 15 | self, 16 | license: Optional[License] = None, 17 | base_url: str = None, 18 | timeout: int = DEFAULT_TIMEOUT 19 | ): 20 | self.license = license 21 | self.base_url = base_url \ 22 | if base_url is not None \ 23 | else self.get_default_base_url() 24 | self.timeout = timeout 25 | 26 | def get_default_base_url(self) -> str: 27 | raise ApiException('No default base URL is defined') 28 | 29 | def build_query(self, action: str, base_query: dict = None) -> dict: 30 | if base_query is None: 31 | query = {} 32 | else: 33 | query = base_query.copy() 34 | query['action'] = action 35 | if self.license is not None: 36 | query['k'] = self.license.key 37 | query['cli'] = 1 38 | return query 39 | 40 | def request( 41 | self, 42 | action: str, 43 | query: Optional[dict] = None, 44 | body: Optional[dict] = None, 45 | json: bool = True 46 | ): 47 | query = self.build_query(action, query) 48 | url = self.base_url + '?' + urlencode(query) 49 | try: 50 | if body is None: 51 | response = requests.get(url, timeout=self.timeout) 52 | else: 53 | response = requests.post(url, timeout=self.timeout, data=body) 54 | if json: 55 | return response.json() 56 | else: 57 | return response.content 58 | except Exception as error: 59 | raise ApiException('Request failed') from error 60 | 61 | def validate_response(self, response, validator: Validator) -> None: 62 | try: 63 | validator.validate(response) 64 | except ValidationException as exception: 65 | raise ApiException('Response validation failed') from exception 66 | -------------------------------------------------------------------------------- /docs/malware-scan/Remediation.md: -------------------------------------------------------------------------------- 1 | # Remediation 2 | 3 | Wordfence CLI, as of version 3.0.1, comes with a new subcommand, `wordfence remediate`, to automatically repair known files belonging to a WordPress installation. Any file part of all versions of WordPress core, as well as all versions of plugins and themes available for download on [wordpress.org](https://wordpress.org/) can be automatically downloaded to restore a file infected with malware. 4 | 5 | To see `remediate` in action, visit the [examples page](Examples.md). 6 | 7 | ## Remediate Command Line Arguments 8 | 9 | **General Options:** 10 | 11 | - `-c`, `--configuration`: Path to a configuration INI file to use. (default: `~/.config/wordfence/wordfence-cli.ini`) 12 | - `-l`, `--license`: Specify the license to use. 13 | - `--version`: Display the version of Wordfence CLI. 14 | - `-h`, `--help`: Display help information. 15 | - `--accept-terms`: Automatically accept the terms required to invoke the specified command. 16 | - `--read-stdin`: Read paths from stdin. If not specified, paths will automatically be read from stdin when input is not from a TTY. (use `--no-read-stdin` to disable) 17 | - `-s`, `--path-separator`: Separator used to delimit paths when reading from stdin. Defaults to the null byte. 18 | 19 | **Output Control:** 20 | 21 | - `--banner`: Display the Wordfence banner in command output when running in a TTY/terminal. (use `--no-banner` to disable) 22 | - `--color`: Enable ANSI escape sequences in output. 23 | - `--output`: Write results to stdout. This is the default behavior when `--output-path` is not specified. (use `--no-output` to disable) 24 | - `--output-path`: Path to which to write results. 25 | - `--output-columns`: An ordered, comma-delimited list of columns to include in the output. Available columns: `path`, `type`, `site`, `target_path`, `wordpress_version`, `extension_slug`, `extension_name`, `extension_version`. Compatible formats: `csv`, `tsv`, `null-delimited`, `line-delimited`. (default: `all`) 26 | - `-m`, `--output-format`: Output format used for result data. Options: `csv`, `tsv`, `null-delimited`, `line-delimited`, `human`. (default: `human`) 27 | - `--output-headers`: Include column headers in output. Compatible formats: `csv`, `tsv`, `null-delimited`, `line-delimited`. (use `--no-output-headers` to disable) 28 | - `-u`, `--output-unremediated`: Only include unremediated paths in the output. 29 | 30 | -------------------------------------------------------------------------------- /wordfence/cli/configure/definition.py: -------------------------------------------------------------------------------- 1 | from ..subcommands import SubcommandDefinition, UsageExample 2 | from ..config.typing import ConfigDefinitions 3 | 4 | config_definitions: ConfigDefinitions = { 5 | "overwrite": { 6 | "short_name": "o", 7 | "description": "Overwrite any existing configuration file without " 8 | "prompting", 9 | "context": "CLI", 10 | "argument_type": "OPTIONAL_FLAG", 11 | "default": None 12 | }, 13 | "request-license": { 14 | "short_name": "r", 15 | "description": "Automatically request a free license without " 16 | "prompting", 17 | "context": "CLI", 18 | "argument_type": "OPTIONAL_FLAG", 19 | "default": None 20 | }, 21 | "workers": { 22 | "short_name": "w", 23 | "description": "Specify the number of worker processes to " 24 | "use for malware scanning", 25 | "context": "CLI", 26 | "argument_type": "OPTION", 27 | "default": None, 28 | "meta": { 29 | "value_type": int 30 | } 31 | }, 32 | "default": { 33 | "short_name": "D", 34 | "description": "Automatically accept the default values for any " 35 | "options that are not explicitly specified. This will " 36 | "also result in a free license being requested when " 37 | "terms are accepted.", 38 | "context": "CLI", 39 | "argument_type": "FLAG", 40 | "default": False 41 | } 42 | } 43 | 44 | cacheable_types = set() 45 | 46 | examples = [ 47 | UsageExample( 48 | 'Interactively configure Wordfence CLI', 49 | 'wordfence configure' 50 | ), 51 | UsageExample( 52 | 'Non-interactively configure Wordfence CLI to use 4 worker processes ' 53 | 'and the default values for all other options, automatically ' 54 | 'accepting the terms and requesting a free license', 55 | 'wordfence configure --default --workers 4 --accept-terms' 56 | ) 57 | ] 58 | 59 | definition = SubcommandDefinition( 60 | name='configure', 61 | usage='', 62 | description='Configure Wordfence CLI', 63 | config_definitions=config_definitions, 64 | config_section='DEFAULT', 65 | cacheable_types=cacheable_types, 66 | requires_config=False, 67 | examples=examples 68 | ) 69 | -------------------------------------------------------------------------------- /wordfence/cli/remediate/remediate.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from ...wordpress.remediator import Remediator, Noc1RemediationSource 4 | from ...logging import log 5 | from ..subcommands import Subcommand 6 | from ..exceptions import ConfigurationException 7 | from .reporting import RemediationReportManager, RemediationReport 8 | 9 | 10 | class RemediateSubcommand(Subcommand): 11 | 12 | def prepare(self) -> None: 13 | self.remediator = Remediator( 14 | Noc1RemediationSource(self.context.get_noc1_client()) 15 | ) 16 | 17 | def process_path(self, path: bytes, report: RemediationReport) -> None: 18 | log.debug(f'Attempting to remediate {path}...') 19 | for result in self.remediator.remediate(os.fsencode(path)): 20 | report.add_result(result) 21 | 22 | def invoke(self) -> int: 23 | report_manager = RemediationReportManager(self.context) 24 | io_manager = report_manager.get_io_manager() 25 | with report_manager.open_output_file() as output_file: 26 | report = report_manager.initialize_report(output_file) 27 | for path in self.config.trailing_arguments: 28 | self.process_path(path, report) 29 | if io_manager.should_read_stdin(): 30 | reader = io_manager.get_input_reader() 31 | for path in reader.read_all_entries(): 32 | self.process_path(path, report) 33 | if self.remediator.input_count == 0 and \ 34 | self.context.requires_input(self.config.require_path): 35 | raise ConfigurationException( 36 | 'At least one path to remediate must be specified' 37 | ) 38 | report.complete() 39 | if report.counts.remediated == report.counts.total: 40 | log.info( 41 | f'{report.counts.remediated} file(s) were successfully' 42 | ' remediated' 43 | ) 44 | else: 45 | log.error( 46 | f'{report.counts.remediated} of {report.counts.total} ' 47 | 'file(s) were successfully remediated, ' 48 | f'{report.counts.unsuccessful} file(s) could not be ' 49 | 'remediated' 50 | ) 51 | return 1 52 | return 0 53 | 54 | 55 | factory = RemediateSubcommand 56 | -------------------------------------------------------------------------------- /docs/Email.md: -------------------------------------------------------------------------------- 1 | # Email 2 | 3 | Wordfence CLI can be configured to send a summary of scan results (both the malware scan and the vulnerability scan) to an email address. The email will only send when the scan actually finds something, for instance a file contains malware, or a WordPress plugin has a vulnerability. An email will also not be sent in the event a scan fails. We recommend reviewing scan findings regularly. 4 | 5 | ## Configuration 6 | 7 | The following command line arguments can be supplied to either the malware scan or the vulnerability scan to tell CLI how to send email. If using SMTP, we recommend storing the configuration in the INI rather than supplying credentials as command line parameters. 8 | 9 | - `-E`, `--email`: Email address(es) to which to send reports. 10 | * `--email-from`: The From address to use when sending emails. If not specified, the current username and hostname will be used. 11 | * `--smtp-host`: The host name of the SMTP server to use for sending email. 12 | * `--smtp-port`: The port of the SMTP server to use for sending email. 13 | * `--smtp-tls-mode`: The SSL/TLS mode to use when communicating with the SMTP server. none disables TLS entirely. smtps requires TLS for all communication while starttls will negotiate TLS if supported using the STARTTLS SMTP command. Options: `none`, `smtps`, `starttls` (default: `starttls`) 14 | * `--smtp-user`: The username for authenticating with the SMTP server. 15 | * `--smtp-password`: The password for authentication with the SMTP server. This should generally be specified in an INI file as including passwords as command line arguments can expose them to other users on the same system. 16 | * `--sendmail-path`: The path to the sendmail executable. This will be used to send email if SMTP is not configured. (default: `sendmail`) 17 | 18 | If you do not have access to an external SMTP server, `sendmail` can be used as a local relay. We recommend going through the process of authenticating your server as an email sender using SPF, DKIM, and DMARC to prevent emails from CLI from being flagged as spam. 19 | 20 | ## Attaching Output Files 21 | 22 | Using the `--output-path` option in conjunction with `--email` will cause the output file to be added as an attachment to the emailed report. 23 | 24 | ## Further reading 25 | 26 | - [Email Authentication](https://en.wikipedia.org/wiki/Email_authentication) 27 | - [SPF](https://en.wikipedia.org/wiki/Sender_Policy_Framework) 28 | - [DKIM](https://en.wikipedia.org/wiki/DomainKeys_Identified_Mail) 29 | - [DMARC](https://en.wikipedia.org/wiki/DMARC) 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Wordfence CLI 2 | 3 | Wordfence CLI is an open source, high performance, multi-process security scanner, written in Python, that quickly scans network filesystems to detect PHP/other malware and WordPress vulnerabilities. CLI is parallelizable, can be scheduled, can accept input via pipe, and can pipe output to other commands. 4 | 5 | ## Installation 6 | 7 | We have a number of installation methods to install Wordfence CLI in our [installation documentation](docs/Installation.md) which we'd recommend reviewing to get you scanning for malware in as few steps as possible. 8 | 9 | We recommend installing using `pip`: 10 | 11 | pip install wordfence 12 | 13 | If you'd like to install Wordfence CLI manually or use CLI for development, you can clone the GitHub repo to your local environment: 14 | 15 | git clone git@github.com:wordfence/wordfence-cli.git 16 | cd ./wordfence-cli 17 | pip install . 18 | python main.py version 19 | 20 | ### Requirements 21 | 22 | - Python >= 3.8 23 | - The C library `libpcre` >= 8.38 24 | - Python packages: 25 | - `packaging` >= 21.0 26 | - `requests` >= 2.3 27 | - `mysql-connector-python` >= 8.0 28 | 29 | ### Obtaining a license 30 | 31 | Visit [https://www.wordfence.com/products/wordfence-cli/](https://www.wordfence.com/products/wordfence-cli/) to obtain a license to download our signature set. 32 | 33 | ## Usage 34 | 35 | You can run `wordfence help` for a full list of options that can be passed to Wordfence CLI. Read more about the [configuration options](docs/Configuration.md) that can be passed to Wordfence CLI. 36 | 37 | #### Scanning a directory for malware 38 | 39 | Recursively scanning the `/var/www` directory for malware: 40 | 41 | wordfence malware-scan /var/www 42 | 43 | A [full list of examples for the malware scan](docs/malware-scan/Examples.md) is included in our documentation. 44 | 45 | #### Scanning a WordPress installation for vulnerabilities 46 | 47 | Scanning the `/var/www/wordpress` directory for vulnerabilities. 48 | 49 | wordfence vuln-scan /var/www/wordpress 50 | 51 | A [full list of examples for the vulnerability scan](docs/vuln-scan/Examples.md) is included in our documentation. 52 | 53 | ## Documentation 54 | 55 | The full documentation for Wordfence CLI can be found [here](docs/) which includes installation instructions, configuration options, detailed examples, and frequently asked questions. 56 | 57 | ## License 58 | 59 | Wordfence CLI is open source, licensed under GPLv3. The license can be found [here](LICENSE). 60 | 61 | ## Contributing 62 | 63 | See [our contribution guidelines](CONTRIBUTING.md). 64 | -------------------------------------------------------------------------------- /wordfence/cli/countsites/definition.py: -------------------------------------------------------------------------------- 1 | from ..subcommands import SubcommandDefinition, UsageExample 2 | from ..config.typing import ConfigDefinitions 3 | 4 | config_definitions: ConfigDefinitions = { 5 | "read-stdin": { 6 | "description": "Read paths from stdin. If not specified, paths will " 7 | "automatically be read from stdin when input is not " 8 | "from a TTY.", 9 | "context": "ALL", 10 | "argument_type": "OPTIONAL_FLAG", 11 | "default": None 12 | }, 13 | "path-separator": { 14 | "short_name": "s", 15 | "description": "Separator used to delimit paths when reading from " 16 | "stdin. Defaults to the null byte.", 17 | "context": "ALL", 18 | "argument_type": "OPTION", 19 | "default": "AA==", 20 | "default_type": "base64" 21 | }, 22 | "require-path": { 23 | "description": "When enabled, invoking the count command without " 24 | "specifying at least one path will trigger an error. " 25 | "This is the default behavior when running in a " 26 | "terminal.", 27 | "context": "CLI", 28 | "argument_type": "OPTIONAL_FLAG", 29 | "default": None 30 | }, 31 | "allow-nested": { 32 | "description": "When enabled (the default), WordPress installations " 33 | "nested below other installations will be included in " 34 | "the count.", 35 | "context": "ALL", 36 | "argument_type": "FLAG", 37 | "default": True 38 | }, 39 | "allow-io-errors": { 40 | "description": "Allow counting to continue if IO errors are " 41 | "encountered. Sites that cannot be identified due to " 42 | "IO errors will be omitted from the count. This is the " 43 | "default behavior.", 44 | "context": "ALL", 45 | "argument_type": "FLAG", 46 | "default": True 47 | } 48 | } 49 | 50 | examples = [ 51 | UsageExample( 52 | 'Count the number of WordPress installations under /var/www/', 53 | 'wordfence count-sites /var/www/' 54 | ) 55 | ] 56 | 57 | definition = SubcommandDefinition( 58 | name='count-sites', 59 | usage='[OPTIONS] [PATH]...', 60 | description='Count the total number of WordPress installations under ' 61 | 'the specified path(s)', 62 | config_definitions=config_definitions, 63 | config_section='COUNT_SITES', 64 | cacheable_types=set(), 65 | examples=examples, 66 | accepts_directories=True 67 | ) 68 | -------------------------------------------------------------------------------- /wordfence/cli/licensing.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Union 2 | 3 | from ..api.licensing import License, LicenseSpecific, to_license 4 | from ..api.exceptions import ApiException 5 | from ..api import noc1 6 | from ..util.caching import NoCachedValueException, InvalidCachedValueException 7 | from .context import CliContext 8 | 9 | 10 | CACHE_KEY = 'license' 11 | CACHEABLE_TYPES = { 12 | 'wordfence.api.licensing.LicenseSpecific' 13 | } 14 | 15 | 16 | class LicenseValidationFailure(Exception): 17 | 18 | def __init__(self, message: str): # noqa: B042 19 | self.message = message 20 | 21 | 22 | class LicenseManager: 23 | 24 | def __init__(self, context: CliContext): 25 | self.context = context 26 | 27 | def _create_noc1_client( 28 | self, 29 | license: Optional[License] = None 30 | ) -> noc1.Client: 31 | return self.context.create_noc1_client(license) 32 | 33 | def request_free_license(self, terms_accepted: bool = False) -> License: 34 | client = self.context.create_noc1_client() 35 | return License(client.get_cli_api_key(accept_terms=terms_accepted)) 36 | 37 | def validate_license(self, license: Union[License, str]) -> License: 38 | license = to_license(license) 39 | client = self.context.create_noc1_client(license) 40 | try: 41 | if not client.ping_api_key(): 42 | raise LicenseValidationFailure('Invalid license') 43 | except ApiException as exception: 44 | if exception.public_message is None: 45 | raise LicenseValidationFailure( 46 | 'License validation failed.' 47 | ) 48 | else: 49 | raise LicenseValidationFailure( 50 | f'Invalid license: {exception.public_message}' 51 | ) 52 | return license 53 | 54 | def set_license(self, license: Union[License, str]) -> str: 55 | license = to_license(license) 56 | self.context.cache.put(CACHE_KEY, LicenseSpecific(license)) 57 | 58 | def check_license(self) -> License: 59 | current = self.context.get_license() 60 | try: 61 | cached = self.context.cache.get(CACHE_KEY) 62 | if cached.is_compatible_with_license(current): 63 | return cached.license 64 | except NoCachedValueException: 65 | pass 66 | except InvalidCachedValueException: 67 | pass 68 | self.validate_license(current) 69 | self.set_license(current) 70 | return current 71 | 72 | def update_license(self, license: License) -> None: 73 | self.set_license(license) 74 | -------------------------------------------------------------------------------- /docs/malware-scan/Examples.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | ## Scanning a single directory for malware 4 | 5 | A basic example of recursively scanning the `/var/www` directory for malware. 6 | 7 | wordfence malware-scan /var/www 8 | 9 | ## Writing malware scan results to a CSV 10 | 11 | A basic example of recursively scanning the `/var/www` directory for malware and writing the results to `/home/username/wordfence-cli-scan.csv`. 12 | 13 | wordfence malware-scan --output-format csv --output-path /home/username/wordfence-cli-scan.csv /var/www 14 | 15 | ## Running Wordfence CLI in a cron 16 | 17 | Run Wordfence CLI in a cron job daily to scan `/var/www` and email the results to example@example.com. The results are also written to `/home/username/wordfence-cli-scan.csv` as the `username` user. This would be similar to how a scheduled scan works within the Wordfence plugin. 18 | 19 | 0 0 * * * username /usr/bin/flock -w 0 /tmp/wordfence-cli-scan.lock /usr/local/bin/wordfence malware-scan --output-format csv --output-path /home/username/wordfence-cli-scan.csv --email example@example.com /var/www 2>&1 /var/log/wordfence/malware-scan.log; /usr/bin/rm /tmp/wordfence-cli-scan.lock 20 | 21 | The cronjob uses a lock file at `/tmp/wordfence-cli-scan.lock` to prevent duplicate scans from running at the same time. Any output and errors are logged to `/var/log/wordfence/malware-scan.log`. Please update the paths from this example based on the system this is intended to run on. 22 | 23 | ## Piping files from `find` to Wordfence CLI 24 | 25 | Find files under the directory `/var/www/` that have changed in the last hour and scan them with Wordfence CLI: 26 | 27 | find /var/www/ -cmin -60 -type f -print0 | wordfence malware-scan 28 | 29 | We recommend that you use `ctime` over `mtime` and `atime` as changing the `ctime` of a file requires root access to the file system. `mtime` and `atime` can be arbitrarily set by the file owner using the `touch` command. 30 | 31 | ## Automatically repairing known files belonging to WordPress 32 | 33 | Run Wordfence CLI on a WordPress installation under `/var/www/wordpress` and automatically repair any files infected with malware: 34 | 35 | wordfence malware-scan --output-columns filename -m null-delimited /var/www/wordpress | wordfence remediate 36 | 37 | ## Automatically repairing known files, and automatically deleting unknown files 38 | 39 | Run Wordfence CLI on a WordPress installation under `/var/www/wordpress` and automatically repair any files infected with malware. For any additional files found that aren't automatically repaired, delete them: 40 | 41 | wordfence malware-scan --output-columns filename -m null-delimited /var/www/wordpress | wordfence remediate --output-columns path -m null-delimited -u | xargs -0 rm 42 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Wordfence CLI Documentation 2 | 3 | Wordfence CLI is a high performance, multi-process, command-line malware scanner written in Python. 4 | 5 | ## Contents 6 | 7 | - [Installation](Installation.md) 8 | - [Installation with `pip`](Installation.md#installation-with-pip) 9 | - [Install the Debian package](Installation.md#install-the-debian-package) 10 | - [Install the RPM package](Installation.md#install-the-rpm-package) 11 | - [Binaries](Installation.md#binaries) 12 | - [Docker](Installation.md#docker) 13 | - [Manual Installation](Installation.md#manual-installation) 14 | - [Updating](Updating.md) 15 | - [Configuration](Configuration.md) 16 | - [Output](Output.md) 17 | - [Configuring your server to send email](Email.md) 18 | - **Malware Scanning** 19 | - [Subcommand Configuration](malware-scan/Configuration.md) 20 | - [Automatic Remediation](malware-scan/Remediation.md) 21 | - [Faster Scanning with Vectorscan](malware-scan/Vectorscan.md) 22 | - [Examples](malware-scan/Examples.md) 23 | - [Scanning a single directory for malware](malware-scan/Examples.md#scanning-a-single-directory-for-malware) 24 | - [Writing malware scan results to a CSV](malware-scan/Examples.md#writing-malware-scan-results-to-a-csv) 25 | - [Running Wordfence CLI in a cron](malware-scan/Examples.md#running-wordfence-cli-in-a-cron) 26 | - [Piping files from `find` to Wordfence CLI](malware-scan/Examples.md#piping-files-from-find-to-wordfence-cli) 27 | - **Vulnerability Scanning** 28 | - [Subcommand Configuration](vuln-scan/Configuration.md) 29 | - [Examples](vuln-scan/Examples.md) 30 | - [Scanning a single WordPress installation for vulnerabilities](vuln-scan/Examples.md#scanning-a-single-wordpress-installation-for-vulnerabilities) 31 | - [Writing vulnerability scan results to a CSV](vuln-scan/Examples.md#writing-vulnerability-scan-results-to-a-csv) 32 | - [Running the vulnerability scan in a cron](vuln-scan/Examples.md#running-the-vulnerability-scan-in-a-cron) 33 | - **Database Scanning** 34 | - [Subcommand Configuration](db-scan/Configuration.md) 35 | - [Examples](db-scan/Examples.md) 36 | - [Scanning a single WordPress database](db-scan/Examples.md#scanning-a-single-wordpress-database) 37 | - [Automatically locating WordPress installations](db-scan/Examples.md#automatically-locating-wordpress-installations) 38 | - [Scanning databases listed in a JSON file](db-scan/Examples.md#scanning-databases-listed-in-a-json-file) 39 | - [Writing database scan results to a CSV](db-scan/Examples.md#writing-database-scan-results-to-a-csv) 40 | - **Remediation** 41 | - [Subcommand Configuration](remediate/Configuration.md) 42 | - [Examples](remediate/Examples.md) 43 | - [Autocomplete of CLI's subcommands and parameters](Autocomplete.md) 44 | - [Frequently Asked Questions](FAQs.md) 45 | -------------------------------------------------------------------------------- /wordfence/wordpress/plugin.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Optional, Dict 3 | 4 | from ..util.io import PathProperties 5 | from .extension import Extension, ExtensionLoader 6 | 7 | 8 | PLUGIN_HEADER_FIELDS = { 9 | 'Name': 'Plugin Name', 10 | 'PluginURI': 'Plugin URI', 11 | 'Version': 'Version', 12 | 'Description': 'Description', 13 | 'Author': 'Author', 14 | 'AuthorURI': 'Author URI', 15 | 'TextDomain': 'Test Domain', 16 | 'DomainPath': 'Domain Path', 17 | 'Network': 'Network', 18 | 'RequiresWP': 'Requires at least', 19 | 'RequiresPHP': 'Requires PHP', 20 | '_sitewide': 'Site Wide Only' 21 | } 22 | 23 | 24 | class Plugin(Extension): 25 | pass 26 | 27 | 28 | class PluginLoader(ExtensionLoader): 29 | 30 | def __init__(self, directory: str, allow_io_errors: bool = False): 31 | super().__init__( 32 | 'plugin', 33 | directory=directory, 34 | header_fields=PLUGIN_HEADER_FIELDS, 35 | allow_io_errors=allow_io_errors 36 | ) 37 | 38 | def _initialize_extension( 39 | self, 40 | slug: str, 41 | version: Optional[str], 42 | header: Dict[str, str], 43 | path: bytes 44 | ): 45 | return Plugin( 46 | slug=slug, 47 | version=version, 48 | header=header, 49 | path=path 50 | ) 51 | 52 | def _has_php_extension(self, properties: PathProperties) -> bool: 53 | return properties.extension == b'.php' 54 | 55 | def _process_entry(self, entry: os.DirEntry) -> Optional[Plugin]: 56 | # Ignore dot files 57 | if entry.name.find(b'.') == 0: 58 | return None 59 | if entry.is_file(): 60 | path_properties = PathProperties(entry.path) 61 | if self._has_php_extension(path_properties): 62 | return self.load( 63 | os.fsdecode(path_properties.stem), 64 | entry.path, 65 | ) 66 | elif entry.is_dir(): 67 | slug = os.fsdecode(entry.name) 68 | for child in os.scandir(entry.path): 69 | if child.is_file(): 70 | child_path = os.path.join(entry.path, child.name) 71 | if self._has_php_extension(PathProperties(child_path)): 72 | plugin = self.load( 73 | slug, 74 | child_path, 75 | base_path=entry.path 76 | ) 77 | if plugin is not None: 78 | return plugin 79 | return None 80 | -------------------------------------------------------------------------------- /wordfence/cli/auto_complete.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from typing import List, Iterable, Dict, Optional 3 | 4 | from .config import resolve_config_map 5 | from .config.config_items import ConfigItemDefinition 6 | from .subcommands import SubcommandDefinition, load_subcommand_definition, \ 7 | VALID_SUBCOMMANDS 8 | 9 | 10 | def _write_bool(value: bool) -> None: 11 | print('true' if value else '') 12 | 13 | 14 | def _write_options( 15 | options: Iterable[str], 16 | allow_files: bool = False, 17 | allow_directories: bool = False 18 | ) -> None: 19 | print(' '.join(options)) 20 | _write_bool(allow_files) 21 | _write_bool(allow_directories) 22 | 23 | 24 | def _write_completion_options( 25 | words: List[str], 26 | subcommand_definition: SubcommandDefinition, 27 | config_map: Dict[str, ConfigItemDefinition], 28 | previous: Optional[str] = None 29 | ) -> bool: 30 | options = [] 31 | allow_files = subcommand_definition.accepts_files 32 | allow_directories = subcommand_definition.accepts_directories 33 | if subcommand_definition.name == 'help': 34 | options.extend(VALID_SUBCOMMANDS) 35 | for item in config_map.values(): 36 | item_options = { 37 | f'--{item.name}' 38 | } 39 | if item.short_name is not None: 40 | item_options.add(f'-{item.short_name}') 41 | if item.is_flag(): 42 | item_options.add(f'--no-{item.name}') 43 | if previous in item_options and item.accepts_value(): 44 | options = [] if item.meta.valid_options is None \ 45 | else list(item.meta.valid_options) 46 | allow_files = item.meta.accepts_file 47 | allow_directories = item.meta.accepts_directory 48 | break 49 | options.extend(item_options) 50 | _write_options( 51 | options, 52 | allow_files, 53 | allow_directories 54 | ) 55 | 56 | 57 | def auto_complete(words: List[str], cursor_index: int) -> None: 58 | try: 59 | subcommand = words[1] 60 | except IndexError: 61 | subcommand = None 62 | # cursor_word = words[cursor_index] 63 | if cursor_index == 1 or subcommand is None: 64 | _write_options(VALID_SUBCOMMANDS) 65 | else: 66 | subcommand_definition = load_subcommand_definition(subcommand) 67 | config_map = resolve_config_map(subcommand_definition) 68 | try: 69 | previous = words[cursor_index - 1] 70 | except IndexError: 71 | previous = None 72 | _write_completion_options( 73 | words, 74 | subcommand_definition, 75 | config_map, 76 | previous 77 | ) 78 | 79 | 80 | if __name__ == '__main__': 81 | words = sys.argv[1:-1] 82 | index = sys.argv[-1] 83 | if not index.isdecimal(): 84 | raise Exception('Cursor index must be a number') 85 | index = int(index) 86 | auto_complete(words, index) 87 | -------------------------------------------------------------------------------- /wordfence/cli/remediate/definition.py: -------------------------------------------------------------------------------- 1 | from ..subcommands import SubcommandDefinition, UsageExample 2 | from ..config.typing import ConfigDefinitions 3 | from .reporting import REMEDIATION_REPORT_CONFIG_OPTIONS 4 | 5 | config_definitions: ConfigDefinitions = { 6 | "read-stdin": { 7 | "description": "Read paths from stdin. If not specified, paths will " 8 | "automatically be read from stdin when input is not " 9 | "from a TTY.", 10 | "context": "ALL", 11 | "argument_type": "OPTIONAL_FLAG", 12 | "default": None 13 | }, 14 | "path-separator": { 15 | "short_name": "s", 16 | "description": "Separator used to delimit paths when reading from " 17 | "stdin. Defaults to the null byte.", 18 | "context": "ALL", 19 | "argument_type": "OPTION", 20 | "default": "AA==", 21 | "default_type": "base64" 22 | }, 23 | **REMEDIATION_REPORT_CONFIG_OPTIONS, 24 | "output-unremediated": { 25 | "short_name": "u", 26 | "description": "Only include unremediated paths in the output.", 27 | "context": "CLI", 28 | "argument_type": "FLAG", 29 | "default": False, 30 | "category": "Output Control" 31 | }, 32 | "require-path": { 33 | "description": "When enabled, invoking the remediate command without " 34 | "specifying at least one path will trigger an error. " 35 | "This is the default behavior when running in a " 36 | "terminal.", 37 | "context": "CLI", 38 | "argument_type": "OPTIONAL_FLAG", 39 | "default": None 40 | }, 41 | } 42 | 43 | examples = [ 44 | UsageExample( 45 | 'Restore the original contents of a plugin file', 46 | 'wordfence remediate /var/www/html/wp-content/plugins/hello.php' 47 | ), 48 | UsageExample( 49 | 'Restore all files in a theme directory and output the results to a ' 50 | 'CSV file', 51 | 'wordfence remediate --output-format csv --output-path ' 52 | '/tmp/wfcli-remediation-results.csv --output-headers ' 53 | '/var/www/html/wp-content/themes/twentytwentythree' 54 | ), 55 | UsageExample( 56 | 'Automatically detect and remediate malware under /var/www/wordpress', 57 | 'wordfence malware-scan --output-columns filename -m null-delimited ' 58 | '/var/www/wordpress | wordfence remediate' 59 | ) 60 | ] 61 | 62 | definition = SubcommandDefinition( 63 | name='remediate', 64 | usage='[OPTIONS] [PATH]...', 65 | description='Remediate malware by restoring the content of known files', 66 | long_description='Known files will be overwritten with their original ' 67 | 'content from the WordPress.org repo. Any intentional ' 68 | 'modifications will be lost if files are remediated. ' 69 | 'Performing a backup of existing files prior to ' 70 | 'remediation is recommended.', 71 | config_definitions=config_definitions, 72 | config_section='REMEDIATE', 73 | cacheable_types=set(), 74 | examples=examples 75 | ) 76 | -------------------------------------------------------------------------------- /docs/malware-scan/Vectorscan.md: -------------------------------------------------------------------------------- 1 | # Faster Scanning with Vectorscan 2 | 3 | The malware scan currently has 2 separate scan signature engines, [`libpcre`](https://www.pcre.org/) and [Vectorscan](https://github.com/VectorCamp/vectorscan). While both of these scan engines are highly performant, Vectorscan is software designed to address the specific use-case we have with malware scanning, where we need to run a large number of regular expressions against a stream of data. 4 | 5 | ## Benchmarks for Performance Increase using Vectorscan 6 | 7 | Benchmarking has indicated that Vectorscan can be more than 30 times as fast as the exact same scan on the exact same hardware using PCRE. 8 | 9 | |Files|Matches|Data|Workers|PCRE Time|Vectorscan Time|Improvement|Library|CPU| 10 | |-----|-------|----|-------|---------|---------------|-----------|-------|---| 11 | |12,006|11,998|605.4 MiB|1|388s|16s|~25x|Hyperscan 5.2.1|AMD Ryzen 7 1700| 12 | |3,499|1|54.7 MiB|4|42s|3s|14x|Hyperscan 5.2.1|AMD Ryzen 7 1700| 13 | |25905|25001|1.7 GiB|32|38s|4s|~10x|Hyperscan 5.4.0|AMD Ryzen 9 5950X| 14 | |25905|25001|1.7 GiB|8|98s|3s|~32x|Hyperscan 5.4.0|AMD Ryzen 9 5950X| 15 | |25905|25001|1.7 GiB|4|191s|6s|~32x|Hyperscan 5.4.0|AMD Ryzen 9 5950X| 16 | |25905|25001|1.7 GiB|1|710s|21s|~34x|Hyperscan 5.4.0|AMD Ryzen 9 5950X| 17 | 18 | *(The above benchmarks were conducted using a free Wordfence CLI license)* 19 | 20 | 21 | ## Configuring CLI to use Vectorscan 22 | 23 | By default, CLI will use `libprce` for scanning. To configure CLI to use Vectorscan, you can use the following command-line argument: 24 | 25 | wordfence malware-scan --match-engine=vectorscan 26 | 27 | This can also be set in the INI file: 28 | 29 | [MALWARE_SCAN] 30 | match_engine=vectorscan 31 | 32 | ## Installing Vectorscan/Hyperscan 33 | 34 | On Debian-based distros, Vectorscan can be installed with `apt`: 35 | 36 | $ sudo apt install libvectorscan5 37 | 38 | Vectorscan is a fork of Hyperscan and maintains a compatible API, so installing Hyperscan on Intel-based systems will work as well. 39 | 40 | $ sudo apt install libhyperscan5 41 | 42 | We do currently support both technologies, but this may change over time if Vectorscan's API diverges from Hyperscan's. We will officially support Vectorscan going forward. 43 | 44 | Additional installation instructions can be found [in the Vectorscan Wiki](https://github.com/VectorCamp/vectorscan/wiki/Installation-from-package). 45 | 46 | ## Known issues 47 | 48 | ### Segfaults on Ubuntu versions 22.04 and 24.04 49 | 50 | The Vectorscan package provided by Ubuntu versions 22.04 and 24.04 will produce segfaults when performing malware scanning against certain files on some ARM systems. We've identified that this is an issue specifically with the packaged version. Compiling Vectorscan from scratch does not produce the same segfaults. We've created a [ticket on LaunchPad](https://bugs.launchpad.net/ubuntu/+source/vectorscan/+bug/2064951) for this issue. 51 | 52 | ### Negligible Performance Improvement over NFS 53 | 54 | CLI will oftentimes have to scan large filesystems of relatively small files. NFS is not setup particularly well to serve many small files. Vectorscan can scan these small files fast enough where there can be not much of discernible difference between using `libprce` and Vectorscan since the scanning processes are largely I/O bound while waiting on NFS to provide the file contents. 55 | 56 | We've observed in our own benchmarks that there is not a significant performance increase using Vectorscan over `libpcre` for filesystems over NFS shares. 57 | -------------------------------------------------------------------------------- /wordfence/scanning/filtering.py: -------------------------------------------------------------------------------- 1 | import re 2 | import os 3 | from typing import Optional, List, Callable, Pattern, AnyStr 4 | 5 | 6 | class FilterCondition: 7 | 8 | def __init__(self, test: Callable[[str], bool], allow: bool = True): 9 | self.test = test 10 | self.allow = allow 11 | 12 | def evaluate(self, path: bytes) -> bool: 13 | return self.test(path) 14 | 15 | 16 | class FileFilter: 17 | 18 | def __init__(self, conditions: Optional[List[FilterCondition]] = None): 19 | self._conditions = conditions if conditions is not None else [] 20 | 21 | def add_condition(self, condition: FilterCondition) -> None: 22 | self._conditions.append(condition) 23 | 24 | def add(self, test: Callable[[str], bool], allow: bool = True): 25 | self.add_condition(FilterCondition(test, allow)) 26 | 27 | def filter(self, path: bytes) -> bool: 28 | allowed = False 29 | for condition in self._conditions: 30 | if condition.allow and allowed: 31 | continue # Only a single allow condition needs to match 32 | matched = condition.evaluate(path) 33 | if matched: 34 | if condition.allow: 35 | allowed = True 36 | else: 37 | return False # Any disallowed condition takes precedence 38 | return allowed 39 | 40 | 41 | def matches_regex(regex: re.Pattern, string: bytes) -> bool: 42 | return regex.search(string) is not None 43 | 44 | 45 | def filter_any(path: bytes) -> bool: 46 | return True 47 | 48 | 49 | PATTERN_PHP = re.compile( 50 | br'\.(?:php(?:\d+)?|phtml)(\.|$)', 51 | re.IGNORECASE 52 | ) 53 | PATTERN_HTML = re.compile( 54 | br'\.(?:html?)(\.|$)', 55 | re.IGNORECASE 56 | ) 57 | PATTERN_JS = re.compile( 58 | br'\.(?:js|svg)(\.|$)', 59 | re.IGNORECASE 60 | ) 61 | PATTERN_IMAGES = re.compile( 62 | ( 63 | br'\.(?:jpg|jpeg|mp3|avi|m4v|mov|mp4|gif|png|tiff?|svg|sql|js|tbz2?' # noqa: E501 64 | br'|bz2?|xz|zip|tgz|gz|tar|log|err\d+)(\.|$)' 65 | ), 66 | re.IGNORECASE 67 | ) 68 | 69 | 70 | def filter_php(path: bytes) -> bool: 71 | return matches_regex(PATTERN_PHP, path) 72 | 73 | 74 | def filter_html(path: bytes) -> bool: 75 | return matches_regex(PATTERN_HTML, path) 76 | 77 | 78 | def filter_js(path: bytes) -> bool: 79 | return matches_regex(PATTERN_JS, path) 80 | 81 | 82 | def filter_images(path: bytes) -> bool: 83 | return matches_regex(PATTERN_IMAGES, path) 84 | 85 | 86 | class FilenameFilter: 87 | 88 | def __init__(self, value: bytes): 89 | self.value = value 90 | 91 | def __call__(self, path: bytes): 92 | filename = os.path.basename(path) 93 | return filename == self.value 94 | 95 | 96 | class Filter: 97 | 98 | def __init__(self, pattern: Pattern[AnyStr]): 99 | self.pattern = pattern 100 | 101 | def __call__(self, path: bytes) -> bool: 102 | return matches_regex(self.pattern, path) 103 | 104 | 105 | class InvalidPatternException(Exception): 106 | 107 | def __init__(self, pattern: bytes): # noqa: B042 108 | self.pattern = pattern 109 | 110 | 111 | def filter_pattern(regex: bytes) -> Callable[[bytes], bool]: 112 | try: 113 | pattern = re.compile(regex) 114 | return Filter(pattern) 115 | except re.error: 116 | raise InvalidPatternException(regex) 117 | -------------------------------------------------------------------------------- /docs/vuln-scan/Configuration.md: -------------------------------------------------------------------------------- 1 | # Vulnerability Scan Configuration 2 | 3 | Vulnerability scanning can be configured using either command line arguments, the [INI file](../Configuration.md#wordfence-cliini), or a combination of both. 4 | 5 | ## Command Line Arguments 6 | 7 | - `--read-stdin`: Read WordPress base paths from stdin. If not specified, paths will automatically be read from stdin when input is not from a TTY. 8 | - `-s`, `--path-separator`: Separator used to delimit paths when reading from stdin. Defaults to the null byte. 9 | - `-w`, `--wordpress-path`: Path to the root of a WordPress installation to scan for core vulnerabilities. 10 | - `-p`, `--plugin-directory`: Path to a directory containing WordPress plugins to scan for vulnerabilities. 11 | - `-t`, `--theme-directory`: Path to a directory containing WordPress themes to scan for vulnerabilities. 12 | - `-C`, `--relative-content-path`: Alternate path of the wp-content directory relative to the WordPress root. 13 | - `-P`, `--relative-plugins-path`: Alternate path of the wp-content/plugins directory relative to the WordPress root. 14 | - `-M`, `--relative-mu-plugins-path`: Alternate path of the wp-content/mu-plugins directory relative to the WordPress root. 15 | - `--output`: Write results to stdout. This is the default behavior when --output-path is not specified. 16 | - `--output-path`: Path to which to write results. 17 | - `--output-columns`: An ordered, comma-delimited list of columns to include in the output. Available columns: `software_type`, `slug`, `version`, `id`, `title`, `link`, `description`, `cve`, `cvss_vector`, `cvss_score`, `cvss_rating`, `cwe_id`, `cwe_name`, `cwe_description`, `patched`, `remediation`, `published`, `updated`, `scanned_path`. Compatible formats: csv, tsv, null-delimited, line-delimited. 18 | - `-m`, `--output-format`: Output format used for result data. 19 | - `--output-headers`: Include column headers in output. Compatible formats: csv, tsv, null-delimited, line-delimited. 20 | - `-e`, `--exclude-vulnerability`: Vulnerability UUIDs or CVE IDs to exclude from scan results. 21 | - `-i`, `--include-vulnerability`: Vulnerabilitiy UUIDs or CVE IDs to include in scan results. 22 | - `-I`, `--informational`: Include informational vulnerability records in results. 23 | - `-f`, `--feed`: The feed to use for vulnerability information. The production feed provides all available information fields. The scanner feed contains only the minimum fields necessary to conduct a scan and may be a better choice when detailed vulnerability information is not needed. 24 | - `--require-path`: When enabled, an error will be issued if at least one path to scan is not specified. This is the default behavior when running in a terminal. 25 | 26 | ## INI Options 27 | 28 | ```ini 29 | [VULN_SCAN] 30 | # Read WordPress paths from stdin 31 | read_stdin = [on|off] 32 | # Separator to use when reading paths from stdin, defaults to null byte 33 | path_separator = 34 | # Alternate relative path for wp-content 35 | relative_content_path = 36 | # Alternate relative path for wp-content/plugins 37 | relative_plugins_path = 38 | # Alternate relative path for wp-content/mu-plugins 39 | relative_mu_plugins_path = 40 | # Controls whether or not output is written to stdout 41 | output = [on|off] 42 | output_path = 43 | # Comma-delimited list of columns to include in output (`software_type`, `slug`, `version`, `id`, `title`, `link`, `description`, `cve`, `cvss_vector`, `cvss_score`, `cvss_rating`, `cwe_id`, `cwe_name`, `cwe_description`, `patched`, `remediation`, `published`, `updated`, `scanned_path`) 44 | output_columns = 45 | output_format = [human|csv|tsv|null-delimited|line-delimited] 46 | # Whether to include headers in output 47 | output_headers = [on|off] 48 | # Comma-delimited list of vulnerability UUIDs or CVE IDs 49 | exclude_vulnerability = 50 | include_vulnerability = 51 | # Toggle informational vulnerabilities 52 | informations = [on|off] 53 | feed = [production|scanner] 54 | allow_nested = [on|off] 55 | allow_io_errors = [on|off] 56 | ``` 57 | -------------------------------------------------------------------------------- /wordfence/cli/banner/banner.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | TEXT_BANNER = r""" 5 | _ __ __ ____ 6 | | | / /___ _________/ / / __/__ ____ ________ 7 | | | /| / / __ \/ ___/ __ /_/ /_/ _ \/ __ \/ ___/ _ \ 8 | | |/ |/ / /_/ / / / /_/ /_ __/ __/ / / / /__/ __/ 9 | |__/|__/\____/_/ \____/ /_/ \___/_/ /_/\___/\___/ 10 | ____ _ ___ 11 | / ___| | |_ _| 12 | | | | | | | 13 | | |___| |___ | | 14 | \____|_____|___| 15 | """ 16 | 17 | LOGO = r""" 18 | ▓▓▓ 19 | ▓▓▓▓▓ ▓▓▓▓▓ 20 | ▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓ 21 | ▓▓ ▓ ▓▓ 22 | ▓▓ ▓▓ ▓▓▓ ▓▓ ▓▓ 23 | ▓▓ ▓ ▓ ▓ ▓▓ 24 | ▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓ 25 | ▓▓ ▓▓▓ ▓ ▓▓▓ ▓ ▓▓▓ ▓▓ 26 | ▓▓▓▓▓ ▓ ▓▓▓ ▓ ▓▓▓▓▓ 27 | ▓▓ ▓ ▓▓ ▓▓ ▓ ▓▓ 28 | ▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓ 29 | """ 30 | 31 | 32 | class Banner: 33 | 34 | def __init__(self, content: str): 35 | self.content = content 36 | self.process_content() 37 | 38 | def process_content(self) -> None: 39 | self.row_count = 0 40 | self.column_count = 0 41 | rows = self.content.split('\n') 42 | for row in rows: 43 | self.column_count = max(self.column_count, len(row.rstrip())) 44 | self.row_count += 1 45 | for index, row in enumerate(rows): 46 | rows[index] = row.ljust(self.column_count) 47 | self.rows = rows 48 | 49 | def merge(self, banner, separator: str = ' ') -> None: 50 | height_difference = self.row_count - banner.row_count 51 | self_taller = height_difference > 0 52 | taller = self if self_taller else banner 53 | if self_taller: 54 | self_offset = 0 55 | banner_offset = -height_difference 56 | else: 57 | self_offset = -height_difference 58 | banner_offset = 0 59 | height_difference = abs(height_difference) 60 | new_rows = [] 61 | for index in range(0, height_difference): 62 | new_rows.append(taller.rows[index]) 63 | for index in range(height_difference, taller.row_count): 64 | new_rows.append( 65 | self.rows[index + self_offset] + 66 | separator + 67 | banner.rows[index + banner_offset] 68 | ) 69 | self.rows = new_rows 70 | self.row_count += height_difference 71 | self.column_count += len(separator) + banner.column_count 72 | 73 | def display(self) -> None: 74 | for row in self.rows: 75 | print(row) 76 | 77 | def __str__(self) -> str: 78 | return self.content 79 | 80 | 81 | def add_logo(banner) -> str: 82 | pass 83 | 84 | 85 | def get_welcome_banner(): 86 | terminal_columns = os.get_terminal_size().columns 87 | text = Banner(TEXT_BANNER) 88 | logo = Banner(LOGO) 89 | combined = Banner(LOGO) 90 | combined.merge(text) 91 | variants = [ 92 | combined, 93 | text, 94 | logo 95 | ] 96 | for banner in variants: 97 | if banner.column_count <= terminal_columns: 98 | return banner 99 | return None 100 | 101 | 102 | def show_welcome_banner(): 103 | banner = get_welcome_banner() 104 | if banner is not None: 105 | banner.display() 106 | 107 | 108 | def should_show_welcome_banner(banner_enabled): 109 | return banner_enabled \ 110 | and sys.stdout.isatty() \ 111 | and sys.stdout.encoding == 'utf-8' 112 | 113 | 114 | def show_welcome_banner_if_enabled(config) -> None: 115 | if should_show_welcome_banner(config.banner) and \ 116 | not config.get('quiet, False') and \ 117 | not config.get('progress', False): 118 | show_welcome_banner() 119 | -------------------------------------------------------------------------------- /docker/build/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | cd /root/wordfence-cli 5 | 6 | if [ "$PACKAGE_TYPE" = 'deb' ]; then 7 | 8 | # build deb package 9 | 10 | VERSION=$(python3 -c 'from wordfence import version; print(version.__version__)') 11 | 12 | # install build requirements 13 | python3 -m pip install --upgrade pip 14 | python3 -m pip install -r requirements.txt --force-reinstall 15 | 16 | export DEBFULLNAME='Wordfence' 17 | export DEBEMAIL='opensource@wordfence.com' 18 | echo 'Generating changelog' 19 | dch \ 20 | --distribution unstable \ 21 | --check-dirname-level 0 \ 22 | --package wordfence \ 23 | --newversion "$VERSION" \ 24 | --create \ 25 | "${VERSION} release. See https://github.com/wordfence/wordfence-cli/releases/latest for release notes." 26 | 27 | # build the package 28 | dpkg-buildpackage -us -uc -b -Zxz 29 | 30 | # copy to output volume 31 | pushd .. 32 | DEB_FILENAME="wordfence_${VERSION}_all" 33 | cp "${DEB_FILENAME}.deb" /root/output/wordfence.deb 34 | popd 35 | 36 | fi 37 | 38 | if [ "$PACKAGE_TYPE" = 'rpm' ]; then 39 | 40 | # build RPM package 41 | 42 | VERSION=$(python3 -c 'from wordfence import version; print(version.__version__)') 43 | SPECFILE="wordfence.spec" 44 | 45 | export PATH="${PATH}:/usr/local/bin" 46 | 47 | # setup directories for rpmbuild 48 | rpmdev-setuptree 49 | 50 | # create source archive for rpmbuild 51 | tar -C /root -czvf "/root/v${VERSION}.tar.gz" wordfence-cli 52 | cp "/root/v${VERSION}.tar.gz" /root/rpmbuild/SOURCES/ 53 | cp "$SPECFILE" /root/rpmbuild/SPECS/ 54 | 55 | # build RPM 56 | rpmbuild -bb \ 57 | -D "wordfence_version ${VERSION}" \ 58 | "/root/rpmbuild/SPECS/${SPECFILE}" 59 | 60 | # copy to output volume 61 | pushd /root/rpmbuild/RPMS/noarch/ 62 | RPM_FILENAME="python3.11-wordfence-${VERSION}-1.el9.noarch" 63 | cp "${RPM_FILENAME}.rpm" /root/output/wordfence-el9.rpm 64 | fi 65 | 66 | if [ "$PACKAGE_TYPE" = 'standalone' ]; then 67 | 68 | # build standalone executable 69 | 70 | ARCHITECTURE=$(dpkg --print-architecture) 71 | VERSION=$(python3.8 -c 'from wordfence import version; print(version.__version__)') 72 | 73 | # install build requirements 74 | python3.8 -m pip install --upgrade pip 75 | python3.8 -m pip install -r requirements.txt --force-reinstall 76 | # Ubuntu 18.04 requires this additional package (as well as the OS package libffi-dev) 77 | python3.8 -m pip install cffi 78 | 79 | pyinstaller \ 80 | --name wordfence \ 81 | --onefile \ 82 | --hidden-import wordfence.cli.configure.configure \ 83 | --hidden-import wordfence.cli.configure.definition \ 84 | --hidden-import wordfence.cli.malwarescan.malwarescan \ 85 | --hidden-import wordfence.cli.malwarescan.definition \ 86 | --hidden-import wordfence.cli.vulnscan.vulnscan \ 87 | --hidden-import wordfence.cli.vulnscan.definition \ 88 | --hidden-import wordfence.cli.help.help \ 89 | --hidden-import wordfence.cli.help.definition \ 90 | --hidden-import wordfence.cli.version.version \ 91 | --hidden-import wordfence.cli.version.definition \ 92 | --hidden-import wordfence.cli.terms.terms \ 93 | --hidden-import wordfence.cli.terms.definition \ 94 | --hidden-import wordfence.cli.remediate.remediate \ 95 | --hidden-import wordfence.cli.remediate.definition \ 96 | --hidden-import wordfence.cli.countsites.countsites \ 97 | --hidden-import wordfence.cli.countsites.definition \ 98 | --hidden-import wordfence.scanning.matching.pcre \ 99 | --hidden-import wordfence.scanning.matching.vectorscan \ 100 | --hidden-import wordfence.cli.dbscan.dbscan \ 101 | --hidden-import wordfence.cli.dbscan.definition \ 102 | main.py 103 | 104 | # compress and copy to output volume 105 | pushd /root/wordfence-cli/dist 106 | STANDALONE_FILENAME="wordfence_${VERSION}_${ARCHITECTURE}_linux_exec" 107 | tar -czvf "${STANDALONE_FILENAME}.tar.gz" wordfence 108 | cp "${STANDALONE_FILENAME}.tar.gz" "/root/output/wordfence_${ARCHITECTURE}.tar.gz" 109 | popd 110 | 111 | fi 112 | 113 | ls -lah /root/output 114 | -------------------------------------------------------------------------------- /wordfence/util/input.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from typing import Optional, Callable, Any 4 | 5 | 6 | class InputException(Exception): 7 | pass 8 | 9 | 10 | class InvalidInputException(InputException): 11 | 12 | def __init__(self, message: str): # noqa: B042 13 | self.message = message 14 | 15 | 16 | class NoInputException(InputException): 17 | pass 18 | 19 | 20 | class NoTerminalException(InputException): 21 | pass 22 | 23 | 24 | def has_terminal_output() -> bool: 25 | return sys.stdout is not None \ 26 | and sys.stdout.isatty() 27 | 28 | 29 | def has_terminal_input() -> bool: 30 | return sys.stdin is not None \ 31 | and sys.stdin.isatty() 32 | 33 | 34 | def has_terminal() -> bool: 35 | return has_terminal_input() and has_terminal_output() 36 | 37 | 38 | def prompt( 39 | message: str, 40 | default: Optional[str] = None, 41 | transformer: Optional[Callable[[str], Any]] = None, 42 | allow_empty: bool = False 43 | ) -> Any: 44 | if not has_terminal(): 45 | raise NoTerminalException('Interactive prompts require a terminal') 46 | default_message = '' 47 | if default is not None: 48 | default_message = f' (default: {default})' 49 | while True: 50 | try: 51 | response = input(f'{message}{default_message}: ') 52 | except EOFError as error: 53 | raise NoInputException('Unable to read input') from error 54 | if len(response) == 0 and not allow_empty: 55 | response = default 56 | if transformer is not None: 57 | try: 58 | return transformer(response) 59 | except InvalidInputException as e: 60 | print(e.message) 61 | else: 62 | return response 63 | 64 | 65 | def transform_yn_to_bool(response: str) -> Any: 66 | lower_response = response.lower() 67 | if lower_response == 'y': 68 | return True 69 | elif lower_response == 'n': 70 | return False 71 | else: 72 | raise InvalidInputException( 73 | f'Invalid response: "{response}", please enter "y" or "n"' 74 | ) 75 | 76 | 77 | def transform_str_to_int(response: str) -> int: 78 | try: 79 | if response.isascii() and response.isdigit(): 80 | return int(response) 81 | except ValueError: 82 | pass 83 | raise InvalidInputException( 84 | 'Please enter a valid integer' 85 | ) 86 | 87 | 88 | def initialize_str_to_int_transformer( 89 | min: Optional[int] = None, 90 | max: Optional[int] = None 91 | ): 92 | def transformer(response: str) -> int: 93 | value = transform_str_to_int(response) 94 | if min is not None and value < min: 95 | raise InvalidInputException( 96 | f'Please enter a value that is at least {min}' 97 | ) 98 | if max is not None and value > max: 99 | raise InvalidInputException( 100 | f'Please enter a value that is no greater than {max}' 101 | ) 102 | return value 103 | return transformer 104 | 105 | 106 | def prompt_yes_no(message: str, default: Optional[bool] = None) -> bool: 107 | default_string = None 108 | if default is not None: 109 | default_string = 'y' if default else 'n' 110 | return prompt( 111 | message=f'{message} [y/n]', 112 | default=default_string, 113 | transformer=transform_yn_to_bool 114 | ) 115 | 116 | 117 | def prompt_int( 118 | message: str, 119 | default: Optional[int] = None, 120 | min: Optional[int] = None, 121 | max: Optional[int] = None 122 | ) -> int: 123 | default_string = None 124 | if default is not None: 125 | default_string = str(default) 126 | transformer = initialize_str_to_int_transformer(min, max) 127 | return prompt( 128 | message, 129 | default=default_string, 130 | transformer=transformer 131 | ) 132 | -------------------------------------------------------------------------------- /wordfence/util/html.py: -------------------------------------------------------------------------------- 1 | from html import escape 2 | from typing import Dict, Optional, List, Union, Any 3 | 4 | 5 | class HtmlContent: 6 | 7 | def to_html(self) -> str: 8 | raise NotImplementedError( 9 | 'HTML content must be able to be converted to a string' 10 | ) 11 | 12 | def __str__(self) -> str: 13 | return self.to_html() 14 | 15 | 16 | class RawHtml(HtmlContent): 17 | 18 | def __init__(self, html: str): 19 | self.html = html 20 | 21 | def to_html(self) -> str: 22 | return self.html 23 | 24 | 25 | def to_html( 26 | content: Optional[List[Union[HtmlContent, str]]] = None 27 | ) -> str: 28 | string = '' 29 | for item in content: 30 | if isinstance(item, HtmlContent): 31 | string += item.to_html() 32 | else: 33 | string += escape(item) 34 | return string 35 | 36 | 37 | class Container(HtmlContent): 38 | 39 | def __init__( 40 | self, 41 | content: Optional[List[Union[HtmlContent, str]]] = None 42 | ): 43 | self.content = content if content is not None else [] 44 | 45 | def append(self, content: Any): 46 | if not (isinstance(content, HtmlContent) or isinstance(content, str)): 47 | content = str(content) 48 | self.content.append(content) 49 | return self 50 | 51 | def to_html(self) -> str: 52 | return to_html(self.content) 53 | 54 | 55 | class Tag(Container): 56 | 57 | def __init__( 58 | self, 59 | name: str, 60 | attributes: Optional[Dict[str, Optional[str]]] = None, 61 | content: Optional[List[Union[HtmlContent, str]]] = None 62 | ): 63 | self.name = name 64 | self.attributes = attributes if attributes is not None else {} 65 | super().__init__(content) 66 | 67 | def set_attribute(self, name: str, value: Optional[str] = None): 68 | self.attributes[name] = value 69 | return self 70 | 71 | def _format_attributes(self) -> str: 72 | attribute_string = '' 73 | for name, value in self.attributes.items(): 74 | name = escape(name) 75 | value = escape(value) 76 | attribute_string += f' {name}="{value}"' 77 | return attribute_string 78 | 79 | def to_html(self) -> str: 80 | attribute_string = self._format_attributes() 81 | string = f'<{self.name}{attribute_string}>' 82 | if len(self.content) > 0: 83 | string += super().to_html() 84 | string += f'' 85 | return string 86 | 87 | 88 | class Document(HtmlContent): 89 | 90 | def __init__(self): 91 | self.head = Tag('head') 92 | self.body = Tag('body') 93 | 94 | def to_html(self) -> str: 95 | html = Tag('html') 96 | html.append(self.head) 97 | html.append(self.body) 98 | return html.to_html() 99 | 100 | 101 | class Style: 102 | 103 | def __init__( 104 | self, 105 | selector: str, 106 | properties: Optional[Dict[str, str]] = None 107 | ): 108 | self.selector = selector 109 | self.properties = properties if properties is not None else {} 110 | 111 | def set(self, property: str, value): 112 | self.properties[property] = str(value) 113 | return self 114 | 115 | def __str__(self) -> str: 116 | css = self.selector + ' {\n' 117 | for name, value in self.properties.items(): 118 | css += f'\t{name}: {value};\n' 119 | css += '}' 120 | return css 121 | 122 | 123 | class Stylesheet(HtmlContent): 124 | 125 | def __init__( 126 | self, 127 | styles: Optional[List[Style]] = None 128 | ): 129 | self.styles = styles if styles is not None else [] 130 | 131 | def add(self, *styles: Style): 132 | self.styles.extend(styles) 133 | return self 134 | 135 | def to_html(self) -> str: 136 | tag = Tag('style') 137 | for style in self.styles: 138 | tag.append(str(style)) 139 | return tag.to_html() 140 | -------------------------------------------------------------------------------- /wordfence/wordpress/database.py: -------------------------------------------------------------------------------- 1 | import pymysql 2 | from typing import Optional, Generator, Dict, Any 3 | 4 | from .exceptions import WordpressDatabaseException 5 | 6 | 7 | DEFAULT_HOST = 'localhost' 8 | DEFAULT_PORT = 3306 9 | DEFAULT_USER = 'root' 10 | DEFAULT_PREFIX = 'wp_' 11 | DEFAULT_COLLATION = 'utf8mb4_unicode_ci' 12 | 13 | 14 | class WordpressDatabaseConnection: 15 | 16 | def __init__(self, database): 17 | self.database = database 18 | try: 19 | self.connection = pymysql.connect( 20 | host=database.server.host, 21 | port=database.server.port, 22 | user=database.server.user, 23 | password=database.server.password, 24 | database=database.name 25 | ) 26 | self.set_collation(database.collation) 27 | except pymysql.MySQLError: 28 | raise WordpressDatabaseException( 29 | database, 30 | f'Failed to connect to database: {database.debug_string}' 31 | ) 32 | 33 | def __enter__(self): 34 | return self 35 | 36 | def __exit__(self, exc_type, exc_val, exc_tb): 37 | return 38 | 39 | def prefix_table(self, table: str) -> str: 40 | return self.database.prefix_table(table) 41 | 42 | def query( 43 | self, 44 | query: str, 45 | parameters: tuple = () 46 | ) -> Generator[Dict[str, Any], None, None]: 47 | try: 48 | cursor = self.connection.cursor(pymysql.cursors.DictCursor) 49 | cursor.execute(query, parameters) 50 | for result in cursor: 51 | yield result 52 | cursor.close() 53 | except pymysql.MySQLError: 54 | raise WordpressDatabaseException( 55 | self.database, 56 | 'Failed to execute query' 57 | ) 58 | 59 | def query_literal( 60 | self, 61 | query: str 62 | ) -> Generator[Dict[str, Any], None, None]: 63 | return self.query( 64 | query.replace('%', '%%') 65 | ) 66 | 67 | def get_column_types( 68 | self, 69 | table: str, 70 | prefix: bool = False 71 | ) -> Dict[str, str]: 72 | if prefix: 73 | table = self.prefix_table(table) 74 | columns = {} 75 | for result in self.query(f'SHOW COLUMNS FROM {table}'): 76 | columns[result['Field'].lower()] = result['Type'] 77 | return columns 78 | 79 | def set_variable( 80 | self, 81 | variable: str, 82 | value: str 83 | ) -> None: 84 | self.query('SET %s = %s', (variable, value)) 85 | 86 | def set_collation(self, collation: str) -> None: 87 | self.set_variable('collation_connection', collation) 88 | 89 | 90 | class WordpressDatabaseServer: 91 | 92 | def __init__( 93 | self, 94 | host: str = DEFAULT_HOST, 95 | port: int = DEFAULT_PORT, 96 | user: str = DEFAULT_USER, 97 | password: Optional[str] = None 98 | ): 99 | self.host = host 100 | self.port = port 101 | self.user = user 102 | self.password = password 103 | 104 | 105 | class WordpressDatabase: 106 | 107 | def __init__( 108 | self, 109 | name: str, 110 | server: WordpressDatabaseServer, 111 | prefix: str = DEFAULT_PREFIX, 112 | collation: str = DEFAULT_COLLATION 113 | ): 114 | self.name = name 115 | self.server = server 116 | self.prefix = prefix 117 | self.collation = collation 118 | self.debug_string = self._build_debug_string() 119 | 120 | def connect(self) -> WordpressDatabaseConnection: 121 | return WordpressDatabaseConnection(self) 122 | 123 | def _build_debug_string(self) -> str: 124 | return ( 125 | f'{self.server.user}@{self.server.host}:' 126 | f'{self.server.port}/{self.name}' 127 | ) 128 | 129 | def prefix_table(self, table: str) -> str: 130 | return self.prefix + table 131 | -------------------------------------------------------------------------------- /docs/db-scan/Configuration.md: -------------------------------------------------------------------------------- 1 | # Database Scan Configuration 2 | 3 | Database scanning can be configured using either command line arguments, the [INI file](../Configuration.md#wordfence-cliini), or a combination of both. 4 | 5 | ## Command Line Arguments 6 | 7 | - `-H`, `--host`: Database hostname. Defaults to `localhost`. 8 | - `-P`, `--port`: Database port. Defaults to `3306`. 9 | - `-u`, `--user`: Database user. Defaults to `root`. 10 | - `--password`: Provide the database password via the command line. This is insecure and should be avoided in favor of prompting or environment variables. 11 | - `-p`, `--prompt-for-password`: Prompt for the database password on invocation. 12 | - `--password-env`: Environment variable containing the database password. Defaults to `WFCLI_DB_PASSWORD`. 13 | - `-x`, `--prefix`: WordPress database prefix. Defaults to `wp_`. 14 | - `-D`, `--database-name`: Name of the database to scan. 15 | - `-C`, `--collation`: Collation to use when connecting to MySQL. Defaults to `utf8mb4_unicode_ci`. 16 | - `--read-stdin`: Force reading database configuration paths from stdin. When stdin is not a terminal, the command automatically reads paths without specifying this flag. 17 | - `-s`, `--path-separator`: Separator used when reading paths from stdin. Defaults to the null byte (`AA==` in base64 form). 18 | - `--require-database`: Error if no databases are provided. This is automatically enforced when running interactively. 19 | - `-S`, `--locate-sites`: Scan one or more filesystem paths for `wp-config.php` files and extract database credentials automatically. 20 | - `--allow-nested`: Permit nested WordPress installations when locating sites. Enabled by default. 21 | - `--allow-io-errors`: Continue locating sites when IO errors occur. Enabled by default. 22 | - `--use-remote-rules`: Pull the latest database scanning rules from the Wordfence API. Enabled by default. 23 | - `-R`, `--rules-file`: Path to a JSON rules file to merge into the database rule set. May be provided multiple times. 24 | - `-e`, `--exclude-rules`: Rule IDs to ignore when scanning. Accepts comma-delimited lists and repeated flags. 25 | - `-i`, `--include-rules`: Rule IDs to include when scanning. Accepts comma-delimited lists and repeated flags. 26 | - `--output`: Write results to stdout (default when `--output-path` is not set). 27 | - `--output-path`: Destination file for scan results. 28 | - `--output-columns`: Comma-delimited list of columns to include in the output. Available columns: `table`, `rule_id`, `rule_description`, `row`. Column customization is supported for `csv`, `tsv`, `null-delimited`, and `line-delimited` formats. 29 | - `-m`, `--output-format`: Output format for results. Supported values: `human` (default), `csv`, `tsv`, `null-delimited`, `line-delimited`. 30 | - `--output-headers`: Include column headers in formats that support them (`csv`, `tsv`, `null-delimited`, `line-delimited`). 31 | 32 | # INI Options 33 | 34 | ```ini 35 | [DB_SCAN] 36 | # The name of an environment variable containing the database password to use 37 | password_env = 38 | # Read paths from stdin 39 | read_stdin = [on|off] 40 | # Separator string when reading paths from stdin, defaults to null byte 41 | path_separator = 42 | # Controls whether or not output is written to stdout 43 | output = [on|off] 44 | output_path = 45 | # Comma-delimited list of columns to include in output (`table`, `rule_id`, `rule_description`, `row`) 46 | output_columns = 47 | output_format = [human|csv|tsv|null-delimited|line-delimited] 48 | # Whether to include headers in output 49 | output_headers = [on|off] 50 | allow_nested = [on|off] 51 | allow_io_errors = [on|off] 52 | use_remote_rules = [on|off] 53 | rules_file = 54 | # Comma-delimited list of rule IDs 55 | exclude_rules = 56 | include_rules = 57 | ``` 58 | 59 | # JSON Configuration 60 | 61 | When `--locate-sites` is not used, each trailing argument should be a JSON file containing a list of database configurations in the following shape: 62 | 63 | [ 64 | { 65 | "name": "wordpress", 66 | "user": "wordpress", 67 | "password": "example", 68 | "host": "db.example.com", 69 | "port": 3306, 70 | "collation": "utf8mb4_unicode_ci", 71 | "prefix": "wp_" 72 | } 73 | ] 74 | 75 | Entries may omit `port`, `collation`, or `prefix`; defaults are applied automatically. 76 | 77 | When databases are discovered through `--locate-sites` or provided via JSON files, connection details from those sources override the command-line defaults for each database. 78 | 79 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: "Wordfence CLI release" 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | build_run_number: 7 | type: string 8 | description: The build workflow run number to release (without the preceding '#') 9 | required: true 10 | release_pypi: 11 | type: boolean 12 | description: Whether to upload a release to PyPI 13 | default: true 14 | pypi_repository: 15 | type: string 16 | description: The repository to upload Python distribution packages to (pypi, testpypi, or testwf) 17 | required: true 18 | release_github: 19 | type: boolean 20 | description: Whether to draft a GitHub release 21 | default: true 22 | release_tag: 23 | type: string 24 | description: The tag to create a release for (e.g., v1.0.0); will be created if it doesn't exist 25 | required: true 26 | 27 | jobs: 28 | pypi_release: 29 | if: ${{ inputs.release_pypi }} 30 | name: PyPI release 31 | runs-on: ubuntu-24.04 32 | steps: 33 | - name: Download artifact 34 | uses: dawidd6/action-download-artifact@v2.27.0 35 | with: 36 | workflow: build.yml 37 | run_number: ${{ inputs.build_run_number }} 38 | - name: Set up Python 39 | uses: actions/setup-python@v4 40 | with: 41 | python-version: '3.8' 42 | - name: Upload distribution packages 43 | run: | 44 | pip install twine~=5.1.1 45 | if [ "${{ github.event.inputs.pypi_repository }}" = "pypi" ]; then 46 | export TWINE_USERNAME="__token__" 47 | export TWINE_PASSWORD="${{ secrets.PYPI_TWINE_PASSWORD }}" 48 | export TWINE_REPOSITORY_URL="${{ secrets.PYPI_TWINE_REPOSITORY_URL }}" 49 | elif [ "${{ github.event.inputs.pypi_repository }}" = "testpypi" ]; then 50 | export TWINE_USERNAME="__token__" 51 | export TWINE_PASSWORD="${{ secrets.TEST_PYPI_TWINE_PASSWORD }}" 52 | export TWINE_REPOSITORY_URL="${{ secrets.TEST_PYPI_TWINE_REPOSITORY_URL }}" 53 | elif [ "${{ github.event.inputs.pypi_repository }}" = "testwf" ]; then 54 | export TWINE_USERNAME="wordfence" 55 | export TWINE_PASSWORD="${{ secrets.TEST_WF_TWINE_PASSWORD }}" 56 | export TWINE_REPOSITORY_URL="${{ secrets.TEST_WF_TWINE_REPOSITORY_URL }}" 57 | fi 58 | python -m twine \ 59 | upload \ 60 | --non-interactive \ 61 | wordfence_cli_python/*.whl \ 62 | wordfence_cli_python/*.tar.gz 63 | github_release: 64 | if: ${{ inputs.release_github }} 65 | name: GitHub release 66 | runs-on: ubuntu-24.04 67 | steps: 68 | - name: Download artifact 69 | uses: dawidd6/action-download-artifact@v2.27.0 70 | with: 71 | workflow: build.yml 72 | run_number: ${{ inputs.build_run_number }} 73 | - name: Get commit hash from build 74 | id: get-commit-hash 75 | run: | 76 | printf "Getting commit hash for build number %s\\n" "$BUILD_RUN_NUMBER" 77 | BUILD_COMMIT_HASH=$( 78 | gh run list \ 79 | --repo wordfence/wordfence-cli \ 80 | --workflow build.yml \ 81 | --json headBranch,headSha,number | jq -r ".[] | select(.number==$BUILD_RUN_NUMBER) | .headSha" 82 | ) 83 | if [ ! -z "$BUILD_COMMIT_HASH" ]; then 84 | printf "Found commit hash %s\\n" "$BUILD_COMMIT_HASH" 85 | else 86 | echo "Couldn't find commit hash" 87 | exit 1 88 | fi 89 | echo "BUILD_COMMIT_HASH=${BUILD_COMMIT_HASH}" >> "$GITHUB_OUTPUT" 90 | env: 91 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 92 | BUILD_RUN_NUMBER: ${{ inputs.build_run_number }} 93 | - name: Create draft release 94 | uses: softprops/action-gh-release@v1 95 | with: 96 | files: | 97 | wordfence_cli_amd64/*.tar.gz 98 | wordfence_cli_arm64/*.tar.gz 99 | wordfence_cli_deb/*.deb 100 | wordfence_cli_rpm_el9/*.rpm 101 | wordfence_cli_python/*.whl 102 | wordfence_cli_python/*.tar.gz 103 | wordfence_cli_checksums/checksums.txt 104 | wordfence_cli_checksums/checksums.txt.asc 105 | target_commitish: ${{ steps.get-commit-hash.outputs.BUILD_COMMIT_HASH }} 106 | tag_name: ${{ inputs.release_tag }} 107 | draft: true 108 | -------------------------------------------------------------------------------- /wordfence/databasescanning/scanner.py: -------------------------------------------------------------------------------- 1 | from typing import Union, Generator, List 2 | from wordfence.intel.database_rules import DatabaseRuleSet, DatabaseRule 3 | from wordfence.wordpress.database import WordpressDatabase, \ 4 | WordpressDatabaseConnection 5 | from wordfence.logging import log, VERBOSE 6 | from wordfence.util.timing import Timer 7 | 8 | 9 | class DatabaseScanResult: 10 | 11 | def __init__( 12 | self, 13 | rule: DatabaseRule, 14 | table: str, 15 | row: dict 16 | ): 17 | self.rule = rule 18 | self.table = table 19 | self.row = row 20 | 21 | 22 | class DatabaseScanner: 23 | 24 | def __init__( 25 | self, 26 | rule_set: DatabaseRuleSet 27 | ): 28 | self.rule_set = rule_set 29 | self.scan_count = 0 30 | self.timer = Timer(start=False) 31 | 32 | def _get_valid_columns( 33 | self, 34 | connection: WordpressDatabaseConnection, 35 | prefixed_table: str 36 | ) -> List: 37 | columns = connection.get_column_types(prefixed_table) 38 | try: 39 | del columns['rule_id'] 40 | except KeyError: 41 | pass # If the column doesn't exist, that's fine 42 | return list(columns.keys()) 43 | 44 | def _scan_table( 45 | self, 46 | connection: WordpressDatabaseConnection, 47 | table: str 48 | ) -> Generator[DatabaseScanResult, None, None]: 49 | prefixed_table = connection.prefix_table(table) 50 | conditions = [] 51 | rule_selects = [] 52 | for rule in self.rule_set.get_rules(table): 53 | conditions.append(f'({rule.condition})') 54 | rule_selects.append( 55 | f'WHEN {rule.condition} THEN {rule.identifier}' 56 | ) 57 | rule_case = 'CASE\n' + '\n'.join(rule_selects) + '\nEND' 58 | selected_columns = self._get_valid_columns(connection, prefixed_table) 59 | selected_columns.append(f'{rule_case} as rule_id') 60 | selected_columns = ', '.join(selected_columns) 61 | query = ( 62 | f'SELECT {selected_columns} FROM ' 63 | f'{prefixed_table} WHERE ' 64 | + ' OR '.join(conditions) 65 | ) 66 | # Using a dict as the query parameters avoids %s from being 67 | # interpreted as a placeholder (there is apparently no way 68 | # to escape "%s" ("%%s" doesn't work) 69 | for result in connection.query_literal(query): 70 | rule = self.rule_set.get_rule(result['rule_id']) 71 | del result['rule_id'] 72 | yield DatabaseScanResult( 73 | rule=rule, 74 | table=prefixed_table, 75 | row=result 76 | ) 77 | 78 | def _scan_connection( 79 | self, 80 | connection: WordpressDatabaseConnection 81 | ) -> Generator[DatabaseScanResult, None, None]: 82 | self.timer.resume() 83 | log.log( 84 | VERBOSE, 85 | f'Scanning database: {connection.database.debug_string}...' 86 | ) 87 | for table in self.rule_set.get_targeted_tables(): 88 | yield from self._scan_table(connection, table) 89 | log.log( 90 | VERBOSE, 91 | f'Scan completed for: {connection.database.debug_string}' 92 | ) 93 | self.timer.stop() 94 | 95 | def scan( 96 | self, 97 | database: Union[WordpressDatabase, WordpressDatabaseConnection] 98 | ) -> Generator[DatabaseScanResult, None, None]: 99 | self.scan_count += 1 100 | if isinstance(database, WordpressDatabaseConnection): 101 | yield from self._scan_connection(database) 102 | else: 103 | log.log( 104 | VERBOSE, 105 | f'Connecting to database: {database.debug_string}...' 106 | ) 107 | with database.connect() as connection: 108 | log.log( 109 | VERBOSE, 110 | 'Successfully connected to database: ' 111 | f'{database.debug_string}' 112 | ) 113 | yield from self._scan_connection(connection) 114 | 115 | def get_elapsed_time(self) -> int: 116 | return self.timer.get_elapsed() 117 | -------------------------------------------------------------------------------- /wordfence/intel/signatures.py: -------------------------------------------------------------------------------- 1 | from hashlib import sha256 2 | from typing import Optional 3 | 4 | from ..api.licensing import License, LicenseSpecific 5 | from ..util.serialization import limited_deserialize 6 | 7 | 8 | class CommonString: 9 | 10 | def __init__(self, string: str, signature_ids: list = None): 11 | self.string = string 12 | if signature_ids is None: 13 | signature_ids = [] 14 | self.signature_ids = signature_ids 15 | 16 | 17 | class Signature: 18 | 19 | def __init__( 20 | self, 21 | identifier: int, 22 | rule: str, 23 | name: str, 24 | description: str, 25 | common_strings: list = None 26 | ): 27 | self.identifier = identifier 28 | self.rule = rule 29 | self.name = name 30 | self.description = description 31 | self.common_strings = common_strings \ 32 | if common_strings is not None \ 33 | else [] 34 | 35 | def get_common_string_count(self) -> int: 36 | return len(self.common_strings) 37 | 38 | def has_common_strings(self) -> bool: 39 | return self.get_common_string_count() > 0 40 | 41 | 42 | class SignatureSet(LicenseSpecific): 43 | 44 | def __init__( 45 | self, 46 | common_strings: list, 47 | signatures: dict, 48 | license: Optional[License] = None 49 | ): 50 | super().__init__(license) 51 | self.common_strings = common_strings 52 | self.signatures = signatures 53 | 54 | def remove_signature(self, identifier: int) -> bool: 55 | if identifier not in self.signatures: 56 | return False 57 | signature = self.signatures[identifier] 58 | for index in signature.common_strings: 59 | self.common_strings[index].signature_ids.remove(identifier) 60 | del self.signatures[identifier] 61 | return True 62 | 63 | def get_signature(self, identifier: int) -> Signature: 64 | if identifier in self.signatures: 65 | return self.signatures[identifier] 66 | raise ValueError(f'Invalid signature identifier: {identifier}') 67 | 68 | def has_signature(self, identifier: int) -> bool: 69 | try: 70 | self.get_signature(identifier) 71 | return True 72 | except ValueError: 73 | return False 74 | 75 | def get_hash(self) -> str: 76 | hash = sha256() 77 | delimiter = ';' 78 | for signature in self.signatures.values(): 79 | hash.update(delimiter.join([ 80 | str(signature.identifier), 81 | signature.rule, 82 | delimiter.join( 83 | [self.common_strings[index].string for index 84 | in signature.common_strings] 85 | ) 86 | ]).encode('utf-8')) 87 | return hash.digest() 88 | 89 | 90 | class PrecompiledSignatureSet(LicenseSpecific): 91 | 92 | VERSION = 1 93 | 94 | def __init__( 95 | self, 96 | signature_set: SignatureSet, 97 | data: bytes, 98 | signature_hash: Optional[bytes] = None, 99 | license: Optional[License] = None 100 | ): 101 | super().__init__(license) 102 | self.signature_set = signature_set 103 | self.signature_hash = ( 104 | signature_set if isinstance(signature_set, bytes) 105 | else signature_set.get_hash() 106 | ) 107 | self.data = data 108 | self.version = self.VERSION 109 | 110 | def is_supported_version(self) -> bool: 111 | return hasattr(self, 'version') and self.version == self.VERSION 112 | 113 | def assign_license(self, license: Optional[License]): 114 | super().assign_license(license) 115 | self.signature_set.assign_license(license) 116 | 117 | 118 | def deserialize_precompiled_signature_set(data): 119 | signature_set = limited_deserialize( 120 | data, 121 | { 122 | 'wordfence.api.licensing.License', 123 | 'wordfence.intel.signatures.PrecompiledSignatureSet', 124 | 'wordfence.intel.signatures.SignatureSet', 125 | 'wordfence.intel.signatures.Signature', 126 | 'wordfence.intel.signatures.CommonString' 127 | }, 128 | PrecompiledSignatureSet 129 | ) 130 | return signature_set 131 | -------------------------------------------------------------------------------- /wordfence/intel/database_rules.py: -------------------------------------------------------------------------------- 1 | from wordfence.util.validation import ListValidator, DictionaryValidator, \ 2 | OptionalValueValidator 3 | from typing import Optional, Set, List 4 | import json 5 | 6 | 7 | class DatabaseRule: 8 | 9 | def __init__( 10 | self, 11 | identifier: int, 12 | tables: Optional[Set[str]] = None, 13 | condition: Optional[str] = None, 14 | description: Optional[str] = None 15 | ): 16 | self.identifier = identifier 17 | self.tables = tables 18 | self.condition = condition 19 | self.description = description 20 | 21 | def __hash__(self): 22 | return hash(self.identifier) 23 | 24 | def __eq__(self, other) -> bool: 25 | return ( 26 | type(other) is type(self) 27 | and other.identifier == self.identifier 28 | ) 29 | 30 | 31 | class DatabaseRuleSet: 32 | 33 | def __init__(self): 34 | self.rules = {} 35 | self.table_rules = {} 36 | self.global_rules = set() 37 | 38 | def add_rule(self, rule: DatabaseRule) -> None: 39 | if rule.identifier in self.rules: 40 | raise Exception('Duplicate rule ID: {rule.identifier}') 41 | self.rules[rule.identifier] = rule 42 | if rule.tables is None: 43 | self.global_rules.add(rule) 44 | else: 45 | for table in rule.tables: 46 | if table not in self.table_rules: 47 | self.table_rules[table] = set() 48 | self.table_rules[table].add(rule) 49 | 50 | def remove_rule(self, rule_id: int) -> None: 51 | try: 52 | rule = self.rules.pop(rule_id) 53 | if rule.tables is None: 54 | self.global_rules.discard(rule) 55 | else: 56 | for table in rule.tables: 57 | if table in list(self.table_rules.keys()): 58 | table_rules = self.table_rules[table] 59 | table_rules.discard(rule) 60 | if len(table_rules) == 0: 61 | del self.table_rules[table] 62 | except KeyError: 63 | pass # Rule doesn't exist, no need to remove 64 | 65 | def get_rules(self, table: str) -> List[DatabaseRule]: 66 | rules = [] 67 | try: 68 | rules.extend(self.table_rules[table]) 69 | except KeyError: 70 | pass # There are no table rules 71 | rules.extend(self.global_rules) 72 | return rules 73 | 74 | def get_targeted_tables(self) -> List[str]: 75 | return self.table_rules.keys() 76 | 77 | def get_rule(self, identifier: int) -> DatabaseRule: 78 | return self.rules[identifier] 79 | 80 | def filter_rules( 81 | self, 82 | included: Optional[Set[int]] = None, 83 | excluded: Optional[Set[int]] = None 84 | ): 85 | if included is not None: 86 | for rule_id in list(self.rules.keys()): 87 | if rule_id not in included: 88 | self.remove_rule(rule_id) 89 | if excluded is not None: 90 | for rule_id in excluded: 91 | self.remove_rule(rule_id) 92 | 93 | 94 | JSON_VALIDATOR = ListValidator( 95 | DictionaryValidator({ 96 | 'id': int, 97 | 'tables': ListValidator(str), 98 | 'condition': str, 99 | 'description': OptionalValueValidator(str) 100 | }, optional_keys={'description'}) 101 | ) 102 | 103 | 104 | def parse_database_rules( 105 | data, 106 | pre_validated: bool = False, 107 | rule_set: Optional[DatabaseRuleSet] = None 108 | ) -> DatabaseRuleSet: 109 | if not pre_validated: 110 | JSON_VALIDATOR.validate(data) 111 | if rule_set is None: 112 | rule_set = DatabaseRuleSet() 113 | for rule_data in data: 114 | rule = DatabaseRule( 115 | identifier=rule_data['id'], 116 | tables=rule_data['tables'], 117 | condition=rule_data['condition'], 118 | description=rule_data['description'] 119 | ) 120 | rule_set.add_rule(rule) 121 | return rule_set 122 | 123 | 124 | def load_database_rules( 125 | path: bytes, 126 | rule_set: Optional[DatabaseRuleSet] = None 127 | ) -> DatabaseRuleSet: 128 | with open(path, 'rb') as file: 129 | data = json.load(file) 130 | return parse_database_rules(data, rule_set=rule_set) 131 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: "Wordfence CLI build" 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*.*.*" 7 | workflow_dispatch: 8 | inputs: 9 | 10 | jobs: 11 | linux_standalone_build: 12 | name: Linux standalone build 13 | runs-on: ubuntu-24.04 14 | strategy: 15 | matrix: 16 | include: 17 | - arch: amd64 18 | - arch: arm64 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v4 22 | # Set up QEMU to support additional platforms 23 | - name: Set up QEMU 24 | uses: docker/setup-qemu-action@v2 25 | - name: Docker build 26 | run: ./docker/build/host-refresh.sh "$GITHUB_WORKSPACE" "$ARCHITECTURE" standalone 27 | env: 28 | ARCHITECTURE: ${{ matrix.arch }} 29 | - name: Docker run 30 | run: ./docker/build/host-build.sh "$GITHUB_WORKSPACE" "$ARCHITECTURE" standalone 31 | env: 32 | ARCHITECTURE: ${{ matrix.arch }} 33 | - name: Upload artifacts 34 | uses: actions/upload-artifact@v4 35 | with: 36 | name: wordfence_cli_${{ matrix.arch }} 37 | path: ${{ github.workspace }}/docker/build/volumes/output/wordfence_*.tar.gz 38 | deb_build: 39 | name: deb build 40 | runs-on: ubuntu-24.04 41 | steps: 42 | - name: Checkout 43 | uses: actions/checkout@v4 44 | - name: Docker build 45 | run: ./docker/build/host-refresh.sh "$GITHUB_WORKSPACE" amd64 deb 46 | - name: Docker run 47 | run: ./docker/build/host-build.sh "$GITHUB_WORKSPACE" amd64 deb 48 | - name: Upload artifacts 49 | uses: actions/upload-artifact@v4 50 | with: 51 | name: wordfence_cli_deb 52 | path: ${{ github.workspace }}/docker/build/volumes/output/wordfence.deb 53 | rpm_build: 54 | name: RPM build 55 | runs-on: ubuntu-24.04 56 | steps: 57 | - name: Checkout 58 | uses: actions/checkout@v4 59 | - name: Docker build 60 | run: ./docker/build/host-refresh.sh "$GITHUB_WORKSPACE" amd64 rpm 61 | - name: Docker run 62 | run: ./docker/build/host-build.sh "$GITHUB_WORKSPACE" amd64 rpm 63 | - name: Upload artifacts 64 | uses: actions/upload-artifact@v4 65 | with: 66 | name: wordfence_cli_rpm_el9 67 | path: ${{ github.workspace }}/docker/build/volumes/output/wordfence-el9.rpm 68 | python_build: 69 | name: Python build 70 | runs-on: ubuntu-24.04 71 | steps: 72 | - name: Checkout 73 | uses: actions/checkout@v4 74 | - name: Set up Python 75 | uses: actions/setup-python@v4 76 | with: 77 | python-version: '3.8' 78 | - name: Transform readme 79 | run: ./scripts/transform-readme.py README.md ${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/ 80 | - name: Python build 81 | run: | 82 | pip install build~=0.10 83 | python3 -m build 84 | - name: Upload artifacts 85 | uses: actions/upload-artifact@v4 86 | with: 87 | name: wordfence_cli_python 88 | path: | 89 | ${{ github.workspace }}/dist/*.tar.gz 90 | ${{ github.workspace }}/dist/*.whl 91 | ${{ github.workspace }}/dist/*.sha256 92 | ${{ github.workspace }}/dist/*.asc 93 | generate_checksums: 94 | name: Generate checksums 95 | runs-on: ubuntu-24.04 96 | needs: 97 | - linux_standalone_build 98 | - deb_build 99 | - rpm_build 100 | - python_build 101 | steps: 102 | - name: Download artifacts 103 | uses: actions/download-artifact@v4 104 | - name: Create checksums 105 | run: | 106 | touch checksums.txt 107 | for artifact in \ 108 | wordfence_cli_amd64 \ 109 | wordfence_cli_arm64 \ 110 | wordfence_cli_deb \ 111 | wordfence_cli_rpm_el9 \ 112 | wordfence_cli_python 113 | do 114 | pushd "$artifact" 115 | sha256sum * >> ../checksums.txt 116 | popd 117 | done 118 | cat checksums.txt 119 | - name: Import GPG key 120 | uses: crazy-max/ghaction-import-gpg@v6.3.0 121 | with: 122 | gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} 123 | passphrase: ${{ secrets.GPG_PASSPHRASE }} 124 | - name: Sign checksums file 125 | run: gpg --detach-sign --armor --local-user '=Wordfence ' checksums.txt 126 | - name: Upload checksums and signature 127 | uses: actions/upload-artifact@v4 128 | with: 129 | name: wordfence_cli_checksums 130 | path: | 131 | ${{ github.workspace }}/checksums.txt 132 | ${{ github.workspace }}/checksums.txt.asc 133 | -------------------------------------------------------------------------------- /wordfence/cli/subcommands.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | from collections import namedtuple 3 | from types import ModuleType 4 | from typing import Optional, Dict, Set, List, Union 5 | 6 | from .config.typing import ConfigDefinitions 7 | from .config.config_items import config_definitions_to_config_map, \ 8 | ConfigItemDefinition 9 | from .context import CliContext 10 | 11 | VALID_SUBCOMMANDS = { 12 | 'configure', 13 | 'malware-scan', 14 | 'vuln-scan', 15 | 'remediate', 16 | 'count-sites', 17 | 'db-scan', 18 | 'help', 19 | 'version', 20 | 'terms' 21 | } 22 | 23 | 24 | def map_subcommand_to_module_name(subcommand: str) -> str: 25 | return subcommand.replace('-', '') 26 | 27 | 28 | def import_subcommand_module( 29 | subcommand: str, 30 | submodule: Optional[str] = None 31 | ) -> ModuleType: 32 | name = map_subcommand_to_module_name(subcommand) 33 | if submodule is None: 34 | submodule = name 35 | target = f'.{name}.{submodule}' 36 | return importlib.import_module( 37 | target, 38 | package='wordfence.cli' 39 | ) 40 | 41 | 42 | class Subcommand: 43 | 44 | def __init__(self, context: CliContext): 45 | self.context = context 46 | # Aliases for shorter access to context properties 47 | self.config = context.config 48 | self.cache = context.cache 49 | self.helper = context.helper 50 | self.prepare() 51 | 52 | def prepare(self) -> None: 53 | pass 54 | 55 | def invoke(self) -> int: 56 | return 0 57 | 58 | def terminate(self) -> None: 59 | pass 60 | 61 | def generate_exception_message( 62 | self, 63 | exception: BaseException 64 | ) -> Optional[str]: 65 | return None 66 | 67 | 68 | UsageExample = namedtuple('UsageExample', ['description', 'command']) 69 | 70 | 71 | class SubcommandDefinition: 72 | 73 | def __init__( 74 | self, 75 | name: str, 76 | usage: Union[str, List[str]], 77 | description: str, 78 | config_definitions: ConfigDefinitions, 79 | config_section: str, 80 | cacheable_types: Set[str], 81 | requires_config: bool = True, 82 | previous_names: Set[str] = None, 83 | examples: List[UsageExample] = None, 84 | uses_license: bool = False, 85 | accepts_files: bool = False, 86 | accepts_directories: bool = False, 87 | long_description: Optional[str] = None 88 | ): 89 | self.name = name 90 | self.usage = usage 91 | self.description = description 92 | self.config_definitions = config_definitions 93 | self.config_section = config_section 94 | self.config_map = None 95 | self.cacheable_types = cacheable_types 96 | self.requires_config = requires_config 97 | self.previous_names = previous_names if previous_names is not None \ 98 | else set() 99 | self.examples = examples 100 | self.uses_license = uses_license 101 | self.accepts_files = accepts_files 102 | self.accepts_directories = accepts_directories 103 | self.long_description = long_description 104 | 105 | def get_config_map(self) -> Dict[str, ConfigItemDefinition]: 106 | if self.config_map is None: 107 | self.config_map = config_definitions_to_config_map( 108 | self.config_definitions 109 | ) 110 | return self.config_map 111 | 112 | def accepts_option(self, name: str) -> bool: 113 | return name in self.config_definitions 114 | 115 | def accepts_paths(self) -> bool: 116 | return self.accepts_files or self.accepts_directories 117 | 118 | def initialize_subcommand(self, context: CliContext) -> Subcommand: 119 | module = import_subcommand_module(self.name) 120 | assert hasattr(module, 'factory') 121 | assert callable(module.factory) 122 | subcommand = module.factory(context) 123 | assert isinstance(subcommand, Subcommand) 124 | return subcommand 125 | 126 | 127 | def load_subcommand_definition(subcommand: str) -> SubcommandDefinition: 128 | module = import_subcommand_module(subcommand, 'definition') 129 | assert hasattr(module, 'definition') 130 | assert isinstance(module.definition, SubcommandDefinition) 131 | return module.definition 132 | 133 | 134 | def load_subcommand_definitions() -> Dict[str, SubcommandDefinition]: 135 | definitions = dict() 136 | for subcommand in VALID_SUBCOMMANDS: 137 | definitions[subcommand] = load_subcommand_definition(subcommand) 138 | return definitions 139 | -------------------------------------------------------------------------------- /docs/malware-scan/Configuration.md: -------------------------------------------------------------------------------- 1 | # Malware Scan Configuration 2 | 3 | Malware scanning can be configured using either command line arguments, the [INI file](../Configuration.md#wordfence-cliini), or a combination of both. 4 | 5 | ## Command Line Arguments 6 | 7 | - `--read-stdin`: Read paths from stdin. If not specified, paths will automatically be read from stdin when input is not from a TTY. 8 | - `-s`, `--file-list-separator`: Separator used when listing files via stdin. Defaults to the null byte. 9 | - `--output`: Write results to stdout. This is the default behavior when --output-path is not specified. 10 | - `--output-path`: Path to which to write results. 11 | - `--output-columns`: An ordered, comma-delimited list of columns to include in the output. Available columns: `filename`, `signature_id`, `signature_name`, `signature_description`, `matched_text`. Compatible formats: csv, tsv, null-delimited, line-delimited, human. 12 | - `-m`, `--output-format`: Output format used for result data. 13 | - `--output-headers`: Include column headers in output. Compatible formats: csv, tsv, null-delimited, line-delimited. 14 | - `-e`, `--exclude-signatures`: Specify rule IDs to exclude from the scan. Can be comma-delimited and/or specified multiple times. 15 | - `-i`, `--include-signatures`: Specify rule IDs to include in the scan. Can be comma-delimited and/or specified multiple times. 16 | - `--images`: Include image files in the scan. 17 | - `-a`, `--include-all-files`: Scan all files. By default, only files matching certain extensions are scanned 18 | - `-n`, `--include-files`: Only scan filenames that are exact matches. Can be used multiple times. 19 | - `-x`, `--exclude-files`: Do not scan filenames that are exact matches. Can be used multiple times. Denials take precedence over allows. 20 | - `-N`, `--include-files-pattern`: Python regex allow pattern. Only matching filenames will be scanned. 21 | - `-X`, `--exclude-files-pattern`: Python regex deny pattern. Matching filenames will not be scanned. 22 | - `--allow-io-errors`: Allow scanning to continue if IO errors are encountered. Files that cannot be read will be skipped and a warning will be logged. 23 | - `-z`, `--chunk-size`: Size of file chunks that will be scanned. Use a whole number followed by one of the following suffixes: b (byte), k (kibibyte), m (mebibyte). Defaults to 3m. 24 | - `-M`, `--scanned-content-limit`: The maximum amount of data to scan in each file. Content beyond this limit will not be scanned. Defaults to 50 mebibytes. Use a whole number followed by one of the following suffixes: b (byte), k (kibibyte), m (mebibyte). 25 | - `--match-engine`: The regex engine to use for malware scanning. Options: `pcre`, `vectorscan` (default: `pcre`) 26 | - `--match-all`: If set, all possible signatures will be checked against each scanned file. Otherwise, only the first matching signature will be reported 27 | - `--pcre-backtrack-limit`: The regex backtracking limit for signature evaluation 28 | - `--pcre-recursion-limit`: The regex recursion limit for signature evaluation 29 | - `-w`, `--workers`: Number of worker processes used to perform scanning. Defaults to 1 worker process. 30 | - `--progress`: Display scan progress in the terminal with a curses interface 31 | 32 | ## INI Options 33 | 34 | ```ini 35 | [MALWARE_SCAN] 36 | # Read file paths to scan from stdin 37 | read_stdin = [on|off] 38 | file_list_separator = 39 | # Controls whether or not output is written to stdout 40 | output = [on|off] 41 | output_path = 42 | # Comma-delimited list of columns to include in output (`filename`, `signature_id`, `signature_name`, `signature_description`, `matched_text`) 43 | output_columns = 44 | output_format = [human|csv|tsv|null-delimited|line-delimited] 45 | # Whether to include headers in output 46 | output_headers = [on|off] 47 | # Comma-delimited list of signature IDs 48 | exclude_signatures = 49 | include_signatures = 50 | images = [on|off] 51 | include_all_files = [on|off] 52 | # Comma-delimited list of filenames 53 | include_files = 54 | exclude_files = 55 | # Comma-delimited list of Python regex patterns 56 | include_files_pattern = 57 | exclude_files_pattern = 58 | allow_io_errors = [on|off] 59 | # Files are scanned in chunks of this size, may use b, k, or m suffix 60 | chunk_size = 61 | # Maximum amount of data to scan for each file, may use b, k, or m suffix 62 | scanned_content_limit = 63 | match_engine = [pcre|vectorscan] 64 | match_all = [on|off] 65 | # Backtrack limit option for PCRE, should be an integer 66 | pcre_backtrack_limit = 67 | # Number of workers to use 68 | workers = 69 | # Alternate database path when using vectorscan 70 | pattern_database_path = 71 | # Whether or not to compile the vectorscan database locally 72 | compile_local = [on|off] 73 | # Toggle direct IO (avoids filesystem caching) 74 | direct_io = [on|off] 75 | ``` 76 | -------------------------------------------------------------------------------- /wordfence/cli/email.py: -------------------------------------------------------------------------------- 1 | import smtplib 2 | from email.message import Message 3 | from email.headerregistry import Address 4 | from enum import Enum 5 | from typing import Optional 6 | from os import popen, getuid 7 | from socket import gethostname 8 | from pwd import getpwuid 9 | 10 | from ..logging import log 11 | from .config.config import Config 12 | 13 | 14 | class EmailException(Exception): 15 | pass 16 | 17 | 18 | class SmtpTlsMode(Enum): 19 | NONE = 'none' 20 | SMTPS = 'smtps' 21 | STARTTLS = 'starttls' 22 | 23 | 24 | class Sender: 25 | 26 | def send(self, message: Message) -> None: 27 | pass 28 | 29 | def close(self) -> None: 30 | pass 31 | 32 | 33 | class SmtpSender(Sender): 34 | 35 | def __init__( 36 | self, 37 | host: str, 38 | port: Optional[int] = None, 39 | tls_mode: SmtpTlsMode = SmtpTlsMode.STARTTLS, 40 | user: Optional[str] = None, 41 | password: Optional[str] = None 42 | ): 43 | smtp_type = smtplib.SMTP_SSL if tls_mode is SmtpTlsMode.SMTPS \ 44 | else smtplib.SMTP 45 | port = 0 if port is None else port 46 | try: 47 | self.smtp = smtp_type( 48 | host=host, 49 | port=port 50 | ) 51 | if tls_mode is SmtpTlsMode.STARTTLS: 52 | log.debug('Starting SMTP TLS...') 53 | self.smtp.starttls() 54 | if user is not None: 55 | log.debug(f'Authenticating with SMTP server as {user}...') 56 | self.smtp.login(user, password) 57 | except smtplib.SMTPException as e: 58 | raise EmailException('SMTP client creation failed') from e 59 | 60 | def send(self, message: Message) -> None: 61 | try: 62 | log.debug(f"Sending email via SMTP to {message['To']}...") 63 | self.smtp.send_message(message) 64 | except smtplib.SMTPException as e: 65 | raise EmailException('Sending email via SMTP failed') from e 66 | 67 | def close(self) -> None: 68 | self.smtp.quit() 69 | 70 | 71 | class SendmailSender(Sender): 72 | 73 | def __init__(self, executable: str): 74 | self.executable = executable 75 | 76 | def send(self, message: Message): 77 | log.debug(f"Sending email via sendmail to {message['To']}...") 78 | command = f'{self.executable} -t -oi' 79 | try: 80 | sendmail = popen(command, 'w') 81 | sendmail.write(message.as_string()) 82 | result = sendmail.close() 83 | if result is not None: 84 | raise EmailException(f'Sendmail exited with code: {result}') 85 | except Exception as e: 86 | raise EmailException('Sendmail invocation failed') from e 87 | 88 | 89 | def initialize_sender(config: Config) -> Sender: 90 | if config.smtp_host is None: 91 | return SendmailSender(config.sendmail_path) 92 | else: 93 | return SmtpSender( 94 | config.smtp_host, 95 | config.smtp_port, 96 | SmtpTlsMode(config.smtp_tls_mode), 97 | config.smtp_user, 98 | config.smtp_password 99 | ) 100 | 101 | 102 | def generate_default_from_address(display_name: str) -> Address: 103 | username = getpwuid(getuid()).pw_name 104 | hostname = gethostname() 105 | address = Address( 106 | display_name=display_name, 107 | username=username, 108 | domain=hostname 109 | ) 110 | return address 111 | 112 | 113 | class Mailer(Sender): 114 | 115 | def __init__( 116 | self, 117 | config: Config, 118 | ): 119 | self.config = config 120 | self.sender = None 121 | self.from_address = None 122 | 123 | def get_sender(self) -> Sender: 124 | if self.sender is None: 125 | self.sender = initialize_sender(self.config) 126 | return self.sender 127 | 128 | def get_from_address(self) -> str: 129 | if self.from_address is None: 130 | address = self.config.email_from 131 | display_name = 'Wordfence CLI' 132 | if address is None: 133 | address = generate_default_from_address(display_name) 134 | else: 135 | address = Address( 136 | display_name=display_name, 137 | addr_spec=address 138 | ) 139 | self.from_address = str(address) 140 | return self.from_address 141 | 142 | def send(self, message: Message) -> None: 143 | message['From'] = self.get_from_address() 144 | self.get_sender().send(message) 145 | 146 | def close(self) -> None: 147 | if self.sender is not None: 148 | self.sender.close() 149 | -------------------------------------------------------------------------------- /wordfence/cli/terms_management.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from wordfence.util.input import prompt_yes_no, InputException 4 | from wordfence.util.caching import Cacheable, NoCachedValueException, \ 5 | InvalidCachedValueException, DURATION_ONE_DAY 6 | from wordfence.api.licensing import License, LicenseSpecific 7 | from .context import CliContext 8 | from .licensing import LicenseManager 9 | 10 | TERMS_URL = \ 11 | 'https://www.wordfence.com/wordfence-cli-license-terms-and-conditions/' 12 | TERMS_CACHE_KEY = 'terms' 13 | ACCEPTANCE_CACHE_KEY = 'terms-accepted' 14 | CACHEABLE_TYPES = { 15 | 'wordfence.cli.terms_management.LicenseTermsAcceptance' 16 | } 17 | 18 | 19 | class LicenseTermsAcceptance(LicenseSpecific): 20 | 21 | def __init__(self, license: License, accepted: bool = False): 22 | super().__init__(license) 23 | self.accepted = accepted 24 | 25 | 26 | class TermsManager: 27 | 28 | def __init__(self, context: CliContext, license_manager: LicenseManager): 29 | self.context = context 30 | self.license_manager = license_manager 31 | 32 | def prompt_acceptance_if_needed(self, use_api: bool = True): 33 | try: 34 | acceptance = self.context.cache.get(ACCEPTANCE_CACHE_KEY) 35 | if acceptance is True: 36 | self.record_acceptance(remote=False) 37 | return 38 | if acceptance.accepted: 39 | return 40 | except (NoCachedValueException, InvalidCachedValueException): 41 | if use_api: 42 | client = self.context.get_noc1_client() 43 | client.ping_api_key() 44 | self.prompt_acceptance_if_needed(False) 45 | return 46 | self.prompt_acceptance(license=self.license_manager.check_license()) 47 | 48 | def _cache_acceptance( 49 | self, 50 | license: License, 51 | accepted: bool = True 52 | ): 53 | self.context.cache.put( 54 | ACCEPTANCE_CACHE_KEY, 55 | LicenseTermsAcceptance(license, accepted) 56 | ) 57 | 58 | def record_acceptance( 59 | self, 60 | license: License = None, 61 | accepted: bool = True, 62 | remote: bool = True 63 | ) -> None: 64 | if license is None: 65 | license = self.license_manager.check_license() 66 | if remote: 67 | client = self.context.create_noc1_client(license) 68 | client.record_toupp() 69 | self._cache_acceptance(license, accepted) 70 | 71 | def trigger_update(self, updated: bool, license: License): 72 | if updated: 73 | self.context.cache.remove(TERMS_CACHE_KEY) 74 | self.record_acceptance( 75 | license=license, 76 | accepted=not updated, 77 | remote=False 78 | ) 79 | if updated: 80 | self.prompt_acceptance(license) 81 | 82 | def prompt_acceptance(self, license: License): 83 | if self.context.config.accept_terms: 84 | self.record_acceptance(license=license) 85 | return 86 | if license.paid: 87 | edition = '' 88 | else: 89 | edition = ' Free edition' 90 | terms_accepted = False 91 | try: 92 | terms_accepted = prompt_yes_no( 93 | f'Your access to and use of Wordfence CLI{edition} is ' 94 | 'subject to the updated Wordfence CLI License Terms and ' 95 | f'Conditions set forth at {TERMS_URL}. By entering "y" and ' 96 | 'selecting Enter, you agree that you have read and accept the ' 97 | 'updated Wordfence CLI License Terms and Conditions.', 98 | default=False 99 | ) 100 | except InputException: 101 | print( 102 | 'Wordfence CLI does not appear to be running interactively' 103 | ' and cannot prompt for agreement to the license terms. ' 104 | 'Please run Wordfence CLI in a terminal or use the ' 105 | '--accept-terms command line option instead.' 106 | ) 107 | if terms_accepted: 108 | self.record_acceptance(license=license) 109 | else: 110 | print( 111 | 'You must accept the terms in order to continue using' 112 | ' Wordfence CLI.' 113 | ) 114 | sys.exit(1) 115 | 116 | def _fetch_terms(self) -> str: 117 | client = self.context.get_noc1_client() 118 | return client.get_terms() 119 | 120 | def get_terms(self) -> str: 121 | cacheable = Cacheable( 122 | TERMS_CACHE_KEY, 123 | self._fetch_terms, 124 | DURATION_ONE_DAY 125 | ) 126 | return cacheable.get(self.context.cache) 127 | -------------------------------------------------------------------------------- /docs/FAQs.md: -------------------------------------------------------------------------------- 1 | # Frequently Asked Questions 2 | 3 | #### How do I get a license? 4 | 5 | Licenses can be obtained at [https://www.wordfence.com/products/wordfence-cli/](https://www.wordfence.com/products/wordfence-cli/). 6 | 7 | #### The scanner has identified malware. What do I do now? 8 | 9 | If you do not know what the file is, we recommend making a backup before you remove it, in case it was a false positive. We always recommend saving a backup copy of the file first, whether by making a full backup of the system or by saving only the file and the location where it belongs, so you can replace it if necessary. 10 | 11 | #### What permissions are required to install/run CLI? 12 | 13 | Wordfence CLI can be installed and run as any user, including `root`. When installing using `pip` as root, `pip` will return a warning about running as root. Using that installation method, we recommend installing using `pip` as a non-priveledged user. 14 | 15 | #### What the difference is between Wordfence CLI and WP-CLI? 16 | 17 | Wordfence CLI is a stand-alone, command-line, malware scanner written in Python. WP-CLI is a WordPress command-line utility for managing a WordPress installation written in PHP. They are 2 separate and distinct pieces of software and are unrelated. 18 | 19 | #### If I have Wordfence CLI, do I need the Wordfence plugin too? 20 | 21 | If you are a WordPress site owner who is looking for a security solution, the Wordfence plugin offers comprehensive protection and intrusion detection. Wordfence CLI can be used alongside the Wordfence plugin. Wordfence CLI would be used instead of the malware detection functionality of the Wordfence plugin's scan. 22 | 23 | #### If I want to run Wordfence CLI alongside the Wordfence plugin, as a replacement for the malware scanner, are there any scanner settings I can turn off in the plugin to reduce redundancy? 24 | 25 | Yes. Within the Wordfence plugin scan options, you can disable the "Scan file contents for backdoors, trojans and suspicious code" scan option and rely on Wordfence CLI to perform the malware scan. 26 | 27 | #### I got the error "Failed to locate libpcre". What do I do? 28 | 29 | Wordfence CLI uses the `libpcre` library to run our signatures against files. There's a few ways to install it depending on your system: 30 | 31 | For Debian/Ubuntu flavors of Linux: 32 | 33 | apt-get install libpcre 34 | 35 | Or 36 | 37 | apt-get install libpcre3 38 | 39 | For Red Hat/Fedora based Linux distributions: 40 | 41 | dnf install pcre 42 | 43 | #### How often are signatures refreshed locally? 44 | 45 | Once every 24 hours. You can use the `--purge-cache` command line argument to refresh the signature set. 46 | 47 | #### How many workers can I run on my system? 48 | 49 | That's really up to you, but we recommend for a fast scan two workers per CPU core. 50 | 51 | #### What is the most performant way recommended to run a scan? 52 | 53 | We recommend two workers per CPU core for the fastest possible scan. For busy production systems, we recommend limiting the worker count to reduce the impact on the availability of production services. 54 | 55 | #### What file types are scanned by default? 56 | 57 | The default scan will scan .php, .phtml, .html, .js, and .svg files by default. Using the `--images` option will expand the file list to .jpg, .jpeg, .mp3, .avi, .m4v, .mov, .mp4, .gif, .png, .tiff, .svg, .sql, .js, .tbz2, .bz2, .xz, .zip, .tgz, .gz, .tar, .log, .err. If there are specific file types you want to match, you can use the `--include-files` or `--include-files-pattern` to define a custom set of files/file types to scan. See the [configuration](Configuration.md#command-line-arguments) for more details. 58 | 59 | #### I got the error "Unable to locate content directory for site at /path/to/wordpress". What do I do? 60 | 61 | This is likely from a WordPress installation using a [custom `wp-content` directory](https://developer.wordpress.org/plugins/plugin-basics/determining-plugin-and-content-directories/#constants "Determining Plugin and Content Directories"). There are a number of things CLI will check to determine where plugin and theme files are stored: 62 | 63 | - We check if the relative path `wp-content/plugins` and `wp-content/themes` exists. 64 | - We look in the wp-config.php file for the `WP_CONTENT_DIR` and/or `WP_PLUGIN_DIR` constants. 65 | 66 | If we are unable to find a path from either of those checks, CLI will return this error. You can use one of the following command line parameters to tell CLI where to find plugins and themes: 67 | 68 | - `-p`, `--plugin-directory`: Path to a directory containing WordPress plugins to scan for vulnerabilities. 69 | - `-t`, `--theme-directory`: Path to a directory containing WordPress themes to scan for vulnerabilities. 70 | - `-C`, `--relative-content-path`: Alternate path of the wp-content directory relative to the WordPress root. 71 | - `-P`, `--relative-plugins-path`: Alternate path of the wp-content/plugins directory relative to the WordPress root. 72 | - `-M`, `--relative-mu-plugins-path`: Alternate path of the wp-content/plugins directory relative to the WordPress root. 73 | -------------------------------------------------------------------------------- /wordfence/wordpress/remediator.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from typing import Optional 4 | 5 | from ..util.io import iterate_files, resolve_path 6 | from ..logging import log 7 | from ..api import noc1 8 | from ..api.exceptions import ApiException 9 | from .identifier import FileIdentifier, FileType, FileIdentity, \ 10 | KnownFileIdentity, GroupIdentity 11 | 12 | 13 | class RemediationSource: 14 | 15 | def get_correct_content( 16 | self, 17 | identity: KnownFileIdentity 18 | ) -> Optional[bytes]: 19 | pass 20 | 21 | 22 | class Noc1RemediationSource(RemediationSource): 23 | 24 | def __init__(self, client: noc1.Client): 25 | self.client = client 26 | 27 | def get_correct_content( 28 | self, 29 | identity: KnownFileIdentity 30 | ) -> Optional[bytes]: 31 | try: 32 | type = identity.type.value 33 | path = os.fsdecode(identity.local_path) 34 | if identity.extension is None: 35 | return self.client.get_wp_file_content( 36 | type, 37 | path, 38 | identity.site.get_version(), 39 | ) 40 | else: 41 | return self.client.get_wp_file_content( 42 | type, 43 | path, 44 | identity.site.get_version(), 45 | identity.extension.get_name(), 46 | identity.extension.version 47 | ) 48 | except ApiException as e: 49 | log.warning( 50 | f'Unable to fetch correct content for {identity} - {e}' 51 | ) 52 | return None 53 | 54 | 55 | class RemediationResult: 56 | 57 | def __init__( 58 | self, 59 | path: bytes, 60 | identity: FileIdentity, 61 | known: bool = False, 62 | remediated: bool = False, 63 | target_path: Optional[bytes] = None 64 | ): 65 | self.path = path 66 | self.identity = identity 67 | self.known = known 68 | self.remediated = remediated 69 | self.target_path = target_path if target_path is not None else path 70 | 71 | def __bool__(self) -> bool: 72 | return self.remediated 73 | 74 | 75 | class Remediator: 76 | 77 | def __init__(self, source: RemediationSource): 78 | self.identifier = FileIdentifier() 79 | self.source = source 80 | self.input_count = 0 81 | 82 | def get_correct_content(self, identity: KnownFileIdentity) -> bytes: 83 | return self.source.get_correct_content(identity) 84 | 85 | def remediate_file( 86 | self, 87 | path: bytes, 88 | target_path: Optional[bytes] = None 89 | ) -> RemediationResult: 90 | identity = self.identifier.identify(path) 91 | result = RemediationResult(path, identity, target_path=target_path) 92 | if identity.type is FileType.UNKNOWN: 93 | log.warning(f'Unable to identify {path}') 94 | return result 95 | log.debug('Identified ' + os.fsdecode(path) + f' as {identity}') 96 | correct_content = self.get_correct_content(identity) 97 | if correct_content is None: 98 | log.warning( 99 | f'Unable to determine correct content for {identity}, ' 100 | 'skipping remediation...' 101 | ) 102 | return result 103 | result.known = True 104 | try: 105 | log.debug('Overwriting ' + os.fsdecode(path) + '...') 106 | with open(path, 'wb') as file: 107 | file.write(correct_content) 108 | result.remediated = True 109 | except OSError as error: 110 | log.error( 111 | f'An error occurred while attempting to remediate {path}: ' 112 | + str(error) 113 | ) 114 | return result 115 | 116 | def handle_symlink_loop(self, path: str) -> None: 117 | log.warning(f'Symlink loop detected at {path}') 118 | 119 | def remediate(self, path: bytes) -> RemediationResult: 120 | self.input_count += 1 121 | path = resolve_path(path) 122 | if os.path.isdir(path): 123 | file_found = False 124 | for file in iterate_files( 125 | path, 126 | loop_callback=self.handle_symlink_loop 127 | ): 128 | yield self.remediate_file(resolve_path(file), path) 129 | file_found = True 130 | if not file_found: 131 | yield RemediationResult( 132 | path, 133 | GroupIdentity(FileType.UNKNOWN, path) 134 | ) 135 | else: 136 | yield self.remediate_file(path) 137 | -------------------------------------------------------------------------------- /docs/Autocomplete.md: -------------------------------------------------------------------------------- 1 | # Autocomplete of CLI's Subcommands and Parameters 2 | 3 | The source code for Wordfence CLI [comes with a script](../scripts/) that can be used to automatically suggest or fill in subcommands and command line parameters. To use these autocompletions from `bash`, you can run `source ./scripts/complete.bash` from the base of the source code directory. If you are using `zsh`, you can run the following prior to running `source` on the bash completion file: 4 | 5 | autoload -Uz +X compinit && compinit 6 | autoload -Uz +X bashcompinit && bashcompinit 7 | 8 | Once this is done, you can use the `Tab` key to suggest subcommands or parameters when running `wordfence` from the command line. 9 | 10 | ## Examples 11 | 12 | $ source wordfence-cli/scripts/complete.bash 13 | $ wordfence vuln-scan - 14 | --accept-terms -E -l --no-debug --output --quiet --smtp-user 15 | --banner --email -L --no-help --output-columns --read-stdin -t 16 | -c --email-from --license --no-informational --output-format --relative-content-path --theme-directory 17 | -C --exclude-vulnerability --log-level --no-output --output-headers --relative-mu-plugins-path -v 18 | --cache -f -m --no-output-headers --output-path --relative-plugins-path --verbose 19 | --cache-directory --feed -M --no-prefix-log-levels -p --require-path --version 20 | --check-for-update -h --no-accept-terms --no-purge-cache -P -s -w 21 | --color --help --no-banner --no-quiet --path-separator --sendmail-path --wfi-url 22 | --configuration -i --noc1-url --no-read-stdin --plugin-directory --smtp-host --wordpress-path 23 | -d -I --no-cache --no-require-path --prefix-log-levels --smtp-password 24 | --debug --include-vulnerability --no-check-for-update --no-verbose --purge-cache --smtp-port 25 | -e --informational --no-color --no-version -q --smtp-tls-mode 26 | 27 | 28 | 29 | $ wordfence malware-scan - 30 | -a --debug --images -n --no-include-all-files --output-columns -s --wfi-url 31 | --accept-terms -e --include-all-files -N --no-match-all --output-format --scanned-content-limit --workers 32 | --allow-io-errors -E --include-files --no-accept-terms --no-output --output-headers --sendmail-path -x 33 | --banner --email --include-files-pattern --no-allow-io-errors --no-output-headers --output-path --smtp-host -X 34 | -c --email-from --include-signatures --no-banner --no-prefix-log-levels --pcre-backtrack-limit --smtp-password -z 35 | --cache --exclude-files -l --noc1-url --no-progress --pcre-recursion-limit --smtp-port 36 | --cache-directory --exclude-files-pattern -L --no-cache --no-purge-cache --prefix-log-levels --smtp-tls-mode 37 | --check-for-update --exclude-signatures --license --no-check-for-update --no-quiet --progress --smtp-user 38 | --chunk-size --file-list-separator --log-level --no-color --no-read-stdin --purge-cache -v 39 | --color -h -m --no-debug --no-verbose -q --verbose 40 | --configuration --help -M --no-help --no-version --quiet --version 41 | -d -i --match-all --no-images --output --read-stdin -w 42 | -------------------------------------------------------------------------------- /wordfence/cli/dbscan/reporting.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional, Dict 2 | 3 | from wordfence.databasescanning.scanner import DatabaseScanResult 4 | from wordfence.util.terminal import Color, escape, RESET 5 | from wordfence.util.json import safe_json_encode 6 | from ..reporting import ReportManager, ReportColumnEnum, ReportFormatEnum, \ 7 | ReportRecord, Report, ReportFormat, ReportColumn, ReportEmail, \ 8 | BaseHumanReadableWriter, \ 9 | get_config_options, generate_html_table, generate_report_email_html, \ 10 | REPORT_FORMAT_CSV, REPORT_FORMAT_TSV, REPORT_FORMAT_NULL_DELIMITED, \ 11 | REPORT_FORMAT_LINE_DELIMITED 12 | from ..context import CliContext 13 | from ..email import Mailer 14 | 15 | 16 | class DatabaseScanReportColumn(ReportColumnEnum): 17 | TABLE = 'table', lambda record: record.result.table 18 | RULE_ID = 'rule_id', lambda record: record.result.rule.identifier 19 | RULE_DESCRIPTION = 'rule_description', \ 20 | lambda record: record.result.rule.description 21 | ROW = 'row', lambda record: safe_json_encode(record.result.row) 22 | 23 | 24 | class HumanReadableWriter(BaseHumanReadableWriter): 25 | 26 | def format_record(self, record) -> str: 27 | result = record.result 28 | return ( 29 | escape(Color.YELLOW) 30 | + 'Suspicious database record found in table ' 31 | f'"{result.table}" matching rule "{result.rule.description}"' 32 | ': ' + safe_json_encode(record.result.row) + RESET 33 | ) 34 | 35 | 36 | REPORT_FORMAT_HUMAN = ReportFormat( 37 | 'human', 38 | lambda stream, columns: HumanReadableWriter(stream), 39 | allows_headers=False, 40 | allows_column_customization=False 41 | ) 42 | 43 | 44 | class DatabaseScanReportFormat(ReportFormatEnum): 45 | CSV = REPORT_FORMAT_CSV 46 | TSV = REPORT_FORMAT_TSV 47 | NULL_DELIMITED = REPORT_FORMAT_NULL_DELIMITED 48 | LINE_DELIMITED = REPORT_FORMAT_LINE_DELIMITED 49 | HUMAN = REPORT_FORMAT_HUMAN 50 | 51 | 52 | class DatabaseScanReportRecord(ReportRecord): 53 | 54 | def __init__(self, result: DatabaseScanResult): 55 | self.result = result 56 | 57 | 58 | class DatabaseScanReport(Report): 59 | 60 | def __init__( 61 | self, 62 | format: ReportFormat, 63 | columns: List[ReportColumn], 64 | email_addresses: List[str], 65 | mailer: Optional[Mailer], 66 | write_headers: bool = False, 67 | only_unremediated: bool = False 68 | ): 69 | super().__init__( 70 | format, 71 | columns, 72 | email_addresses, 73 | mailer, 74 | write_headers 75 | ) 76 | self.result_count = 0 77 | self.database_count = 0 78 | 79 | def add_result(self, result: DatabaseScanResult): 80 | self.result_count += 1 81 | self.write_record( 82 | DatabaseScanReportRecord(result) 83 | ) 84 | 85 | def generate_email( 86 | self, 87 | recipient: str, 88 | attachments: Dict[str, str], 89 | hostname: str 90 | ) -> ReportEmail: 91 | plain = ( 92 | 'Database Scan Complete\n\n' 93 | f'Scanned Databases: {self.database_count}\n\n' 94 | f'Results Found: {self.result_count}\n\n' 95 | ) 96 | 97 | results = { 98 | 'Scanned Databases': self.database_count, 99 | 'Results Found': self.result_count 100 | } 101 | 102 | table = generate_html_table(results) 103 | 104 | document = generate_report_email_html( 105 | table, 106 | 'Database Scan Results', 107 | hostname 108 | ) 109 | 110 | return ReportEmail( 111 | recipient=recipient, 112 | subject=f'Database Scan Results for {hostname}', 113 | plain_content=plain, 114 | html_content=document.to_html() 115 | ) 116 | 117 | 118 | class DatabaseScanReportManager(ReportManager): 119 | 120 | def __init__(self, context: CliContext): 121 | super().__init__( 122 | formats=DatabaseScanReportFormat, 123 | columns=DatabaseScanReportColumn, 124 | context=context, 125 | read_stdin=context.config.read_stdin, 126 | input_delimiter=context.config.path_separator, 127 | binary_input=True 128 | ) 129 | 130 | def _instantiate_report( 131 | self, 132 | format: ReportFormat, 133 | columns: List[ReportColumn], 134 | email_addresses: List[str], 135 | mailer: Optional[Mailer], 136 | write_headers: bool 137 | ) -> Report: 138 | return DatabaseScanReport( 139 | format, 140 | columns, 141 | email_addresses, 142 | mailer, 143 | write_headers 144 | ) 145 | 146 | 147 | DATABASE_SCAN_REPORT_CONFIG_OPTIONS = get_config_options( 148 | DatabaseScanReportFormat, 149 | DatabaseScanReportColumn, 150 | default_format='human' 151 | ) 152 | -------------------------------------------------------------------------------- /wordfence/wordpress/extension.py: -------------------------------------------------------------------------------- 1 | import re 2 | import os 3 | from typing import Optional, Dict, List 4 | 5 | from ..logging import log 6 | from ..util.io import SYMLINK_IO_ERRORS 7 | from ..util.encoding import str_to_bytes 8 | from .exceptions import ExtensionException 9 | 10 | 11 | HEADER_READ_SIZE = 8 * 1024 12 | HEADER_CLEANUP_PATTERN = re.compile(r'\s*(?:\*\/|\?>).*') 13 | CARRIAGE_RETURN_PATTERN = re.compile('\r') 14 | 15 | 16 | class Extension: 17 | 18 | def __init__( 19 | self, 20 | slug: str, 21 | version: Optional[bytes], 22 | header: Dict[str, str], 23 | path: bytes 24 | ): 25 | self.slug = slug 26 | self.version = version 27 | self.header = header 28 | self.path = path 29 | 30 | def get_name(self) -> str: 31 | try: 32 | return self.header['Name'] 33 | except KeyError: 34 | return self.slug 35 | 36 | def __str__(self) -> str: 37 | return f'{self.slug}({self.version})' 38 | 39 | 40 | class ExtensionLoader: 41 | 42 | def __init__( 43 | self, 44 | extension_type: str, 45 | directory: str, 46 | header_fields: Dict[str, str], 47 | allow_io_errors: bool = False, 48 | ): 49 | self.extension_type = extension_type 50 | self.directory = directory 51 | self.header_fields = header_fields 52 | self.allow_io_errors = allow_io_errors 53 | 54 | def _clean_up_header_value(self, value: str) -> str: 55 | return HEADER_CLEANUP_PATTERN.sub('', value).strip() 56 | 57 | def _read_header(self, path: bytes) -> str: 58 | try: 59 | with open(path, 'r', errors='replace') as stream: 60 | data = stream.read(HEADER_READ_SIZE) 61 | return data 62 | except OSError as error: 63 | raise ExtensionException( 64 | f'Unable to read {self.extension_type} header from ' 65 | + os.fsdecode(path) 66 | ) from error 67 | 68 | def _parse_header( 69 | self, 70 | data: str, 71 | ) -> Dict[str, str]: 72 | data = CARRIAGE_RETURN_PATTERN.sub('\n', data) 73 | values = {} 74 | for field, pattern in self.header_fields.items(): 75 | match = re.search( 76 | '^[ \t\\/*#@]*' + re.escape(pattern) + r':(.*)$', 77 | data, 78 | re.MULTILINE | re.IGNORECASE 79 | ) 80 | if match is not None: 81 | values[field] = self._clean_up_header_value(match.group(1)) 82 | return values 83 | 84 | def load( 85 | self, 86 | slug: str, 87 | path: bytes, 88 | base_path: Optional[bytes] = None 89 | ) -> Optional[Extension]: 90 | header_data = self._read_header(path) 91 | header = self._parse_header(header_data) 92 | if 'Name' not in header: 93 | return None 94 | try: 95 | version = header['Version'] 96 | if isinstance(version, str): 97 | version = str_to_bytes(version) 98 | except KeyError: 99 | version = None 100 | if base_path is None: 101 | base_path = path 102 | return self._initialize_extension(slug, version, header, base_path) 103 | 104 | def _initialize_extension( 105 | self, 106 | slug: str, 107 | version: Optional[str], 108 | header: Dict[str, str], 109 | path: bytes 110 | ): 111 | return Extension( 112 | slug=slug, 113 | version=version, 114 | header=header, 115 | path=path 116 | ) 117 | 118 | def _process_entry(entry: os.DirEntry) -> Optional[Extension]: 119 | return None 120 | 121 | def load_all(self) -> List[Extension]: 122 | extensions = [] 123 | try: 124 | for entry in os.scandir(self.directory): 125 | try: 126 | extension = self._process_entry(entry) 127 | if extension is not None: 128 | extensions.append(extension) 129 | except OSError as error: 130 | if error.errno in SYMLINK_IO_ERRORS: 131 | continue 132 | if self.allow_io_errors: 133 | log.warning( 134 | f'Unable to load {self.extension_type} from ' 135 | + os.fsdecode(entry.path) + f': {error}' 136 | ) 137 | else: 138 | raise 139 | except OSError as error: 140 | if error.errno not in SYMLINK_IO_ERRORS: 141 | message = ( 142 | f'Unable to scan {self.extension_type} directory at ' 143 | + os.fsdecode(self.directory) 144 | ) 145 | if self.allow_io_errors: 146 | log.warning(message) 147 | else: 148 | raise ExtensionException(message) from error 149 | return extensions 150 | -------------------------------------------------------------------------------- /wordfence/util/versioning.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import List, Dict, Union, Optional 3 | 4 | from .encoding import str_to_bytes, bytes_to_str 5 | 6 | 7 | PHP_VERSION_DELIMITER = b'.' 8 | PHP_VERSION_ALTERNATE_DELIMITERS = [b'_', b'-', b'+'] 9 | 10 | 11 | NON_NUMBER_PATTERN = re.compile(b'[^0-9.]+') 12 | NUMBER_PATTERN = re.compile(b'^[0-9]+$') 13 | REPEATED_DOT_PATTERN = re.compile(b'\\.{2,}') 14 | 15 | 16 | # TODO: Convert str to bytes 17 | def delimit_non_numbers(version: str) -> str: 18 | return NON_NUMBER_PATTERN.sub(b".\\g<0>.", version).strip(b'.') 19 | 20 | 21 | def is_number(string: bytes) -> bool: 22 | return NUMBER_PATTERN.match(string) is not None 23 | 24 | 25 | def strip_repeated_delimiters(version: bytes) -> bytes: 26 | return REPEATED_DOT_PATTERN.sub(b'.', version) 27 | 28 | 29 | LOWER_ALPHA_VERSIONS = [ 30 | [b'dev'], 31 | [b'alpha', b'a'], 32 | [b'beta', b'b'], 33 | [b'RC', b'rc'], 34 | ] 35 | HIGHER_ALPHA_VERSIONS = [ 36 | [b'pl', b'p'] 37 | ] 38 | TIER_OFFSET = 2 39 | TIER_NUMBER = len(LOWER_ALPHA_VERSIONS) + TIER_OFFSET 40 | 41 | 42 | def create_alpha_version_map(versions: List[List[bytes]]) -> Dict[bytes, int]: 43 | map = {} 44 | for index, tier in enumerate(versions): 45 | for version in tier: 46 | map[version] = index 47 | return map 48 | 49 | 50 | LOWER_ALPHA_VERSION_MAP = create_alpha_version_map(LOWER_ALPHA_VERSIONS) 51 | HIGHER_ALPHA_VERSIONS_MAP = create_alpha_version_map(HIGHER_ALPHA_VERSIONS) 52 | 53 | 54 | def get_alpha_tier(string: bytes, map: Dict[bytes, int]) -> Optional[int]: 55 | try: 56 | return map[string] 57 | except KeyError: 58 | return None 59 | 60 | 61 | def get_lower_alpha_tier(string: bytes) -> Optional[int]: 62 | return get_alpha_tier(string, LOWER_ALPHA_VERSION_MAP) 63 | 64 | 65 | def get_higher_alpha_tier(string: bytes) -> Optional[int]: 66 | return get_alpha_tier(string, HIGHER_ALPHA_VERSIONS_MAP) 67 | 68 | 69 | class PhpVersionComponent: 70 | 71 | def __init__(self, value: bytes): 72 | self.is_number = is_number(value) 73 | if self.is_number: 74 | self.value = int(value) 75 | else: 76 | self.value = value 77 | self.lower_alpha_tier = \ 78 | None if self.is_number else get_lower_alpha_tier(self.value) 79 | self.higher_alpha_tier = \ 80 | None if self.is_number else get_higher_alpha_tier(self.value) 81 | self.tier = self._evaluate_tier() 82 | 83 | def _evaluate_tier(self) -> int: 84 | if self.lower_alpha_tier is not None: 85 | return self.lower_alpha_tier + TIER_OFFSET 86 | if self.is_number: 87 | return TIER_NUMBER 88 | if self.higher_alpha_tier is not None: 89 | return TIER_NUMBER + 1 + self.higher_alpha_tier 90 | if not self.is_number and self.lower_alpha_tier is None \ 91 | and self.higher_alpha_tier is None: 92 | return 1 93 | return 0 94 | 95 | def __str__(self) -> str: 96 | return str(self.value) 97 | 98 | 99 | DefaultComponent = PhpVersionComponent(b'0') 100 | 101 | 102 | class PhpVersion: 103 | 104 | def __init__(self, version: Union[str, bytes]): 105 | if isinstance(version, str): 106 | version = str_to_bytes(version) 107 | self.version = version 108 | self._components = self.extract_components(version) 109 | 110 | def extract_components(self, version: bytes) -> List[bytes]: 111 | for character in PHP_VERSION_ALTERNATE_DELIMITERS: 112 | version = version.replace(character, PHP_VERSION_DELIMITER) 113 | # Note that this also strips leading/trailing delimiters 114 | version = strip_repeated_delimiters( 115 | delimit_non_numbers(version) 116 | ) 117 | return list(map(PhpVersionComponent, version.split(b'.'))) 118 | 119 | def _get_component(self, index: int) -> PhpVersionComponent: 120 | try: 121 | return self._components[index] 122 | except IndexError: 123 | return DefaultComponent 124 | 125 | 126 | def compare_version_components( 127 | a: Optional[PhpVersionComponent], 128 | b: Optional[PhpVersionComponent] 129 | ) -> int: 130 | if a.value == b.value: 131 | return 0 132 | if a.tier != b.tier: 133 | return -1 if a.tier < b.tier else 1 134 | if a.tier == 0 or a.tier == TIER_NUMBER: 135 | return -1 if a.value < b.value else 1 136 | return 0 137 | 138 | 139 | def compare_php_versions( 140 | a: Union[PhpVersion, str, bytes], 141 | b: Union[PhpVersion, str, bytes] 142 | ) -> int: 143 | """ This is intended to mirror PHP's version_compare function """ 144 | """ https://www.php.net/manual/en/function.version-compare.php """ 145 | if not isinstance(a, PhpVersion): 146 | a = PhpVersion(a) 147 | if not isinstance(b, PhpVersion): 148 | b = PhpVersion(b) 149 | component_count = max(len(a._components), len(b._components)) 150 | for i in range(0, component_count): 151 | comparison = compare_version_components( 152 | a._get_component(i), 153 | b._get_component(i) 154 | ) 155 | if comparison != 0: 156 | return comparison 157 | return 0 158 | 159 | 160 | def version_to_str(version: Optional[bytes]) -> str: 161 | if version is None: 162 | return 'unknown' 163 | return bytes_to_str(version) 164 | --------------------------------------------------------------------------------