Documentation
WPSniffer is a command-line malware scanner for WordPress and PHP sites. It runs on your own server, compares every file against community YARA rules from YARA Forge using the YARA-X engine, and writes a report. This guide takes you from a bare server to a scheduled scan with an email summary.
Requirements
- Python 3.8 or newer with
pipand thevenvmodule. Most Linux distributions ship these; on Debian and Ubuntu installpython3-venvif the virtualenv step fails. - Read access to the files you want to scan. WPSniffer only reads files. It never writes to, moves or deletes anything in the scanned directory.
- Outbound HTTPS to GitHub for the first run, so the YARA Forge rule package can be downloaded. Air-gapped servers can skip this with
--yara-forge noneand local rules (see Rules). - Shell access such as SSH. WPSniffer is not a WordPress plugin and does not need WordPress, PHP or a database to be running.
Installation
WPSniffer is published on PyPI. Install it into a virtual environment so its dependencies stay separate from the system Python. The commands below create one in your home directory:
$ python3 -m venv ~/wpsniffer
$ ~/wpsniffer/bin/pip install wpsniffer
The wpsniffer command now lives at ~/wpsniffer/bin/wpsniffer. Either call it by that path, add the bin directory to your PATH, or activate the environment first:
$ source ~/wpsniffer/bin/activate
$ wpsniffer --help
To upgrade later:
$ ~/wpsniffer/bin/pip install --upgrade wpsniffer
Every release is also published as a wheel and source archive on the GitHub releases page. On a server without access to PyPI, download the wheel there and install it with pip install ./wpsniffer-*.whl.
Your first scan
Point malware-scan at the document root of a site. On the first run WPSniffer downloads the YARA Forge core package, compiles the rules and caches both, so later runs start faster.
$ wpsniffer malware-scan --progress /var/www/html
Add --progress for a live display of files scanned, matches and throughput. Each suspicious file is reported with the path and the YARA rule it matched, for example:
File at /var/www/html/wp-content/uploads/2026/10/cache.php matched YARA rule SIGNATURE_BASE_WEBSHELL_PHP_GENERIC
File at /var/www/html/wp-includes/images/wp-img.php matched YARA rule SIGNATURE_BASE_WEBSHELL_PHP_OBFUSC
Rule names come from YARA Forge and encode the source collection and the rule author, so you can look up what each detection means. Paths and rule names above are illustrative.
By default only files with code-like extensions are scanned; add -a to scan every file regardless of extension, or --images to include images. A good first target on a shared server is everything under /var/www; a good first target on a single-site server is the WordPress root. Scanning wp-content/uploads on its own is a fast way to find dropped webshells, because that directory should never contain PHP at all. The guides walk through each of these scenarios step by step.
Commands
wpsniffer malware-scan
Scans one or more paths, or paths read from standard input, against the selected rules and prints or writes a report. This is the command you will use most. Directories are walked recursively.
$ wpsniffer malware-scan [options] PATH [PATH ...]
wpsniffer count-sites
Walks a directory tree and counts the WordPress installations it finds. Useful on hosting servers to see how many sites live under a path before you scan them all, or to feed an inventory into monitoring.
$ wpsniffer count-sites /var/www/
Every command accepts --help, which prints the complete and authoritative option list for the version you have installed.
Options reference
The options below are the ones you will reach for most often. Run wpsniffer malware-scan --help for the full list and the exact defaults of your installed version.
| Option | What it does |
|---|---|
--yara-forge core|extended|full|none | Which YARA Forge package to download and use. core is the default and has the fewest false positives. none skips the download so you can scan with your own rules only. |
--rules PATH | Add your own .yar or .yara file, or a directory of them. Can be combined with a YARA Forge package. Each file gets its own namespace, so rule names never collide. See custom rules. |
--yara-forge-release TAG | Pin a YARA Forge release such as 20261018 instead of latest, for reproducible scans. See pinning a release. |
-e RULE, --exclude-signatures | Ignore rules by name. Shell-style wildcards work (-e 'SIGNATURE_BASE_SUSP_*'), values can be comma-separated, and the flag can repeat. Rules from your own files are named namespace:RULE. See handling false positives. |
-i RULE, --include-signatures | Report only the named rules, with the same wildcard and comma syntax. |
--match-all | Report every rule a file matches instead of stopping at the first match. Useful when you want the full picture for a report, at the cost of a slightly slower scan. |
--output-path FILE | Write the report to a file instead of standard output. With -E the file is attached to the email report. |
-m FORMAT, --output-format | csv (default), tsv, null-delimited, line-delimited or human. Add --output-headers for a header row. |
--output-columns a,b,c | Choose which columns appear in the report. See reports and columns. |
-w N, --workers | Number of worker processes, default 1. Raise it on machines with spare cores, keep it low on busy shared hosts. |
--progress | Live progress display in the terminal. |
--scan-timeout SECONDS | Maximum time per file, default 10 seconds. A file that exceeds it is reported as timed out instead of stalling the run. |
-M SIZE, --scanned-content-limit | How much of each file is scanned, default 50m. Files are scanned whole up to this limit, so YARA filesize conditions work and matches are never split across chunk boundaries. |
-a, --images | By default only files with code-like extensions are scanned. -a scans every file; --images adds image files. |
-N REGEX, -X REGEX | Only scan, or never scan, filenames matching a Python regular expression, for example -N '.*\.php$'. -n and -x do the same for exact filenames. |
-E ADDRESS, --email | Email the report summary, attaching the output file when --output-path is set. Sends through sendmail by default, or SMTP with --smtp-host and related options. See email reports. |
--no-read-stdin | Do not read paths from standard input. Required under cron and CI, where stdin is not a terminal and the scan would otherwise wait for input. |
--pre-compile | Download and compile the rules into the cache without scanning, for example right after an upgrade. --refresh-rules forces a new download; --purge-cache clears the cache. |
-c FILE, --configuration | Read options from an INI file. Without it, ~/.config/wpsniffer/wpsniffer.ini or /etc/wpsniffer/wpsniffer.ini is used, which is the right place for SMTP passwords. wpsniffer configure writes one interactively. |
Reports and columns
Choose the format with -m: csv (the default), tsv, line-delimited or null-delimited text for scripts, or human for reading in the terminal. Pick the columns with --output-columns; the default set is filename,signature_id,signature_description,rule_score,matched_text. The columns available are:
| Column | Contents |
|---|---|
filename | Full path of the matched file. |
signature_id | Name of the YARA rule that matched. |
signature_name | The rule's display name. |
signature_description | The rule's description, from its meta section. |
rule_score | The rule's score or quality rating, where the rule provides one. Higher means a more specific, higher-confidence rule. |
rule_author | Who wrote the rule. WPSniffer credits every rule author in its output. |
rule_reference | A URL or reference the author supplied, often a write-up of the malware family. |
matched_text | The first matched string, up to 256 bytes, so you can see what was flagged without opening the file. |
rule_tags | The rule's YARA tags. |
A full CSV report with every column looks like this:
$ wpsniffer malware-scan --match-all --output-path results.csv \
--output-columns filename,signature_id,signature_description,rule_score,rule_author,rule_reference,matched_text \
/var/www/html
Email reports
Pass -E with one or more addresses to get a summary by email at the end of a scan: the suspicious files, the number of files scanned, bytes processed and elapsed time. When --output-path is set, the report file is attached. Mail goes through the local sendmail by default; set --smtp-host, --smtp-port, --smtp-user and --smtp-tls-mode to use an SMTP server instead, and keep --smtp-password in the INI file rather than on the command line.
$ wpsniffer malware-scan -E ops@example.com --email-from scanner@example.com \
--output-path /var/log/wpsniffer/report.csv /var/www
Scanning paths from stdin
When standard input is not a terminal, malware-scan reads file paths from it, in addition to any paths on the command line. That lets you combine it with find or any other tool that produces a file list. Use -print0, because the default separator is the null byte (change it with -s). Under cron or CI, where stdin is open but empty, pass --no-read-stdin so the scan does not wait.
Scan only files changed in the last hour, which is the fastest way to check a site right after you suspect a break-in:
$ find /var/www -cmin -60 -type f -print0 | wpsniffer malware-scan
Scan only PHP files under uploads, where no PHP should ever exist:
$ find /var/www/html/wp-content/uploads -type f -name '*.php' -print0 | wpsniffer malware-scan
Scheduled scans with cron
Because WPSniffer has no daemon and no account, a scheduled scan is one crontab line. This example scans all sites under /var/www every night at 03:00, writes a dated CSV, and keeps the terminal output quiet.
0 3 * * * /home/deploy/wpsniffer/bin/wpsniffer malware-scan --no-read-stdin -w 2 \
--output-path /var/log/wpsniffer/$(date +\%F).csv /var/www
Notes for cron:
- Pass
--no-read-stdin. Under cron, standard input is not a terminal, and without the flag the scan waits for a file list that never comes. - Use the full path to the
wpsnifferbinary inside the virtualenv, because cron does not load your shell profile. - Escape
%as\%in crontab, as shown above. - The rule package cache is refreshed daily, so a nightly job always scans with current rules without any extra step.
- Add
-E you@example.comto receive the summary by email with the CSV attached, instead of reading log files.
Running in CI
WPSniffer runs cleanly in a pipeline: it needs only Python, prints machine-readable output, and can scan a checked-out repository or a build artifact before it is deployed. A minimal job installs the tool, scans the workspace and fails the build when a report is non-empty.
$ python3 -m venv .venv && .venv/bin/pip install wpsniffer
$ .venv/bin/wpsniffer malware-scan --no-read-stdin --output-path report.csv .
$ test ! -s report.csv
Pass --no-read-stdin, since CI runners leave standard input open. For reproducible results across runs, pin the YARA Forge release with --yara-forge-release rather than taking the daily refresh (see pinning a release), or ship your own rules with --rules and --yara-forge none.
After a scan finds matches
WPSniffer detects; it does not clean. When a scan reports files (the cleanup guide covers each step in depth):
- Do not delete anything yet. Open the
matched_textand the rule'srule_referenceto confirm the match is real. Some broad rules fire on legitimate minified or obfuscated plugin code. - Check the file's origin. A PHP file inside
wp-content/uploads, a core file whose checksum differs from the official release, or a file with a recent change time on an otherwise static site is very likely malicious. - Look for siblings. Attackers rarely drop one file. Re-run the scan with
--yara-forge extendedand--match-all, and list recently changed files withfind -cminas shown above. - Clean and close the door. Remove or restore the files from a known-good backup, update WordPress, themes and plugins, rotate passwords and salts, and check for unknown admin users and scheduled tasks.
- Re-scan. A clean second scan is your confirmation. Then put the scan on a schedule.
Troubleshooting
The rule download fails
The only network request WPSniffer makes is fetching the YARA Forge package from GitHub. Check that the server can reach github.com over HTTPS. If it cannot, download the package on another machine, copy the rules over and use --rules with --yara-forge none.
A scan is slow
Raise -w on multi-core machines (the default is a single worker), restrict the scan with -N '.*\.php$', or feed it a narrower file list from find. Scanning a media library full of large images and videos adds time without adding detections.
The scan hangs under cron or CI
It is waiting for file paths on standard input. Add --no-read-stdin, or redirect < /dev/null.
The same rule matches on every site
That is a false positive for your environment. Exclude the rule with -e (wildcards such as -e 'SIGNATURE_BASE_SUSP_*' work), and consider reporting it upstream so the rule can be improved. See handling false positives.
Python is too old
WPSniffer needs Python 3.8 or newer. On older distributions install a newer interpreter from your distribution's backports or a tool such as pyenv, then create the virtualenv with it.