Skip to content

About

Offline, read-only scanner for WordPress database dumps and site files. Flags backdoors, injected scripts, rogue options and spam, and says plainly what it could not check.

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

wp-malware-scan

An offline, read-only malware scanner for WordPress database dumps and site files.

You give it a database dump and a copy of the site files. It reads them, applies 36 rules and prints a verdict with evidence: CLEAN, SPAM-ONLY, SUSPICIOUS, INFECTED or FULLY-COMPROMISED. It never connects to a site or a database, never changes what it reads, and needs nothing but Python.

Why offline

Some WordPress infections keep their code in the database, in options and posts, where a file scanner never looks. Some also change what the site serves when they spot a scanner, so a scan of the public URL sees a clean page. A dump and a copy of the files cannot react. They are the evidence.

What it is not

  • It is not a firewall, a cleaner or a monitor. It reports. It never deletes, edits or repairs anything.
  • It does not verify WordPress core, plugin or theme files against official checksums.
  • It is signature and heuristic based. A CLEAN verdict means the rules found nothing. It is never proof that a site is clean. See Limitations.

Install

You need Python 3.9 or later. There are no other runtime dependencies.

Install from GitHub with pipx or pip:

pipx install git+https://github.com/CodeVertex55/wp-malware-scan
pip install git+https://github.com/CodeVertex55/wp-malware-scan

For a server where you cannot install packages, download wp-malware-scan.pyz from the latest release or build it yourself, and copy it over. It runs with only Python installed:

python scripts/build_pyz.py
python3 wp-malware-scan.pyz --version

The build script writes dist/wp-malware-scan.pyz. Every command below also works as python3 wp-malware-scan.pyz COMMAND or python -m wp_malware_scan COMMAND.

Quick start

Take a copy of the site first (see docs/playbook.md). You need two things:

  • a database dump as plain SQL text, such as the output of mysqldump
  • a copy of the site files

A compressed dump (gzip, bzip2, zip, xz or zstandard) is refused with exit code 66 and a message to decompress it first. The tool does not decompress it. An empty dump is refused too. See Limitations.

Scan both:

wp-malware-scan scan --sql sites/shop.example/shop.sql --files sites/shop.example/public_html --site-name shop.example

--sql and --files are each optional, but you need at least one. A scan of one input is labelled as partial, and the report says what was left out. A partial scan never reads as plain CLEAN: the headline says CLEAN (in what was scanned). Without --site-name the report is named after the files folder, or after its parent when the folder is called public_html, htdocs, www, html, wordpress or httpdocs, or else after the dump file. This is the output for a small synthetic site that has a PHP file in the uploads folder, a must-use plugin and some spam in a form table:

wp-malware-scan 1.0.0
Site: shop.example

VERDICT: INFECTED
1 critical finding (FS-UPL-100) and none from an escalating rule.

Scope
-----
Scanned:
  database dump: sites/shop.example/shop.sql (2.0 KB, table prefix wp_, 4 tables)
  files folder: sites/shop.example/public_html (5 files seen, 5 PHP files)
  mode: full scan
Not scanned, or limited:
  - nothing was left out that the scanners could tell

Findings
--------
4 findings: 1 CRIT, 1 HIGH, 1 LOW, 1 INFO.

CRIT (1)
  FS-UPL-100  Executable PHP in uploads
      Where: wp-content/uploads/2024/cache.php
      Found: PHP file under the uploads directory

HIGH (1)
  FS-MU-110  Must-use plugin present
      Where: wp-content/mu-plugins/loader.php
      Found: PHP file in the must-use plugins directory

LOW (1)
  DB-SPAM-041  Spam in form-submission tables
      Where: wp_e_submissions
      Found: 2 rows; categories: link-spam, pharma

INFO (1)
  DB-USR-030  User and administrator counts
      Where: wp_users
      Found: 1 user

Next steps
----------
  1. Take a backup of the current state as evidence, then treat the site as
     compromised.
  2. Change every password and key: WordPress users, database, hosting, FTP
     and SSH, and the secret keys in the configuration file.
  3. Rebuild from clean WordPress, plugin and theme files and restore only
     reviewed content. Deleting the flagged items alone may not be enough,
     because a hidden backdoor can put them back.
  4. See docs/playbook.md for the full steps for this verdict.

Read the report in this order:

  1. The verdict line and the sentence under it.
  2. The scope. It lists what was scanned and, more important, what was not.
  3. The findings, worst first. Each has a rule id, a location and a short description. Look the rule up in docs/rules.md.
  4. The next steps, then docs/playbook.md.

The command exits with a code that matches the verdict, so you can use it in scripts. The table below lists them.

Verdicts and exit codes

Verdict Exit code What it means
CLEAN 0 No rule matched in what was scanned. This is a good sign and never proof. A scan of one input, a --fast scan, or a scan that could not read part of its input shows CLEAN (in what was scanned) in the report headline and in the sweep table.
SPAM-ONLY 0 Spam content was found, such as form spam or junk text, and nothing that runs code on the site. A nuisance that can hurt search rankings. A partial scan shows SPAM-ONLY (in what was scanned).
SUSPICIOUS 1 Something may be harmful and a person has to check it: a must-use plugin you do not recognise, a prepend setting, SEO spam in published posts.
INFECTED 2 Malicious code or content was found. Treat the site as compromised.
FULLY-COMPROMISED 3 A rule matched that on its own means an attacker has deep and lasting access, such as a known backdoor option or dangerous code planted after the settings include in wp-config.php. Rebuild the site.

Other exit codes:

Exit code What it means
64 Usage error: a missing or unknown option, a bad allowlist file, or an output path that points inside the scanned files or onto the dump or the allowlist.
66 An input could not be used: a missing file, a folder given as the dump, a file given as the files folder, a permission error, an empty dump, a compressed dump, or a dump with no WordPress tables when no files were given. A sweep returns 66 when a site could not be read, unless a worse code applies (see below).
70 An internal error. This is a bug in the scanner. The message names the input and asks you to report it. Add --debug to print the full traceback.

The verdict follows the worst finding that is not allowlisted. An escalating rule gives FULLY-COMPROMISED, then any CRIT gives INFECTED, then any HIGH gives SUSPICIOUS. The severity of each rule is in docs/rules.md, with a legend.

Sweep many sites

sweep scans every site under a folder and prints one summary table. Each immediate subfolder of ROOT is one site. A *.sql file in it is the dump. A folder that holds wp-config.php or wp-content is the files root: the site folder itself, or else one of its immediate subfolders. With several dumps it uses the largest and notes the rest.

wp-malware-scan sweep sites
Site          Verdict                          CRIT  HIGH  MED  LOW  INFO  Scope
------------  -------------------------------  ----  ----  ---  ---  ----  -------
blog.example  CLEAN                            0     0     0    0    1     full
news.example  SPAM-ONLY (in what was scanned)  0     0     0    1    1     partial
shop.example  INFECTED                         1     1     0    1    1     full

3 sites scanned. Worst verdict: INFECTED.
Scope "partial" means the result holds only in what was scanned. Run a full scan before relying on it.

Options:

  • --output-dir DIR writes one full report per site, named after the site folder reduced to safe characters, in the format you chose.
  • --jobs N scans with N worker processes. The default is 1. Results keep the order of the sites.
  • --allowlist FILE and --fast work as they do for scan.
  • --format text|json|markdown sets the format of the summary.

A site that cannot be read, such as one with an empty, compressed or non-WordPress dump and no files, does not stop the sweep. It is listed under the table. A site whose scan fails with an unexpected error is listed as failed, and the other sites still finish. The summary is printed before any per-site report file is written, so a failed write cannot hide it.

The exit code of a sweep keeps verdicts and errors apart. It is 3 when any site is FULLY-COMPROMISED. Otherwise it is 70 when any site failed with an unexpected error, otherwise 66 when any site could not be read, otherwise the worst verdict code of the sites. So 66 or 70 means the table is incomplete and has to be read, and 3 is never hidden.

Links are never followed. A linked folder directly under ROOT is not scanned. The summary says how many linked folders were not followed, and each one gets a line on standard error. A linked folder inside a site folder is noted in that site's report, and the site's result is then partial. If ROOT holds only links, the sweep exits with 66 and names them.

Allowlist

If you have checked a finding and it is legitimate, such as a hosting provider's must-use plugin, you can accept it with an allowlist file:

{
  "allow": [
    {
      "rule": "FS-MU-110",
      "location": "wp-content/mu-plugins/loader.php",
      "reason": "Cache loader from the hosting provider, checked against its documentation"
    }
  ]
}
wp-malware-scan scan --sql shop.sql --files public_html --allowlist allow.json

The reason is required. Rules that escalate to FULLY-COMPROMISED cannot be allowlisted, and the tool refuses a file that names one. A location of * alone is refused for rules of CRIT or HIGH severity: name a path or a prefix. Allowlisted findings stay in the report in their own section with the reason, and they are left out of the verdict. See docs/allowlist.md.

Output formats

--format takes text (the default), json or markdown. Use --output FILE to write the report to a file instead of standard output. The tool refuses an output path inside the scanned files folder, and one that would overwrite the dump or the allowlist file. Another existing file is overwritten.

  • text is for a terminal. It uses colour only when standard output is a terminal, and never with --output, --no-color or the NO_COLOR environment variable.
  • markdown is a report you can hand to a site owner. It opens with a plain language summary of the verdict, then the findings table, what was scanned and the next steps.
  • json is for scripts. It has a schema field, currently 1, and these keys: tool_version, site, verdict, partial, verdict_reason, exit_code, counts, scanned, not_scanned and findings. verdict is always the plain value such as CLEAN. partial is true when the scan did not cover everything, and then a CLEAN or SPAM-ONLY result holds only in what was scanned. A sweep with --format json prints schema, tool_version, worst_verdict, sites (each with site, verdict, exit_code, partial and counts), unreadable, failed and linked. Each finding has rule_id, severity, title, area, location, evidence, allowlisted and allow_reason. The output is plain ASCII.

Progress goes to standard error, and only when standard error is a terminal. Reports go to standard output or to --output.

wp-malware-scan rules prints the rule reference. docs/rules.md is the same reference in Markdown, generated from the rule registry.

How it stays safe on hostile input

A site you are checking may hold anything, including content written to attack the tools that read it. The scanner treats everything it reads as hostile.

  • Read only. Nothing is written inside the dump or the files folder. An output path inside the scanned folder is refused. The check asks the file system, so a link, a junction or a Windows extended-length path into the folder is refused as well.
  • Nothing from the target is run. The source does not use eval, exec, subprocess, sockets, pickle or dynamic imports. Tests check the syntax tree of every module for these.
  • No network. The tool never opens a connection.
  • Links are not followed. Symbolic links and junctions are counted and skipped, so a link cannot lead the scan out of the folder. A sweep reports the linked folders it skipped.
  • Everything is size capped. A file longer than 1.5 MB is read as its first 1.5 MB and its last 64 KB. A database value longer than 1.5 MB is read head and tail. Decoding of encoded values goes at most three layers deep, takes at most 8 MB in and produces at most 2 MB, and a compressed value cannot expand past that cap.
  • Patterns run in linear time. The tests run every pattern against large hostile inputs and check that time grows in proportion to size.
  • Output is sanitised. Anything taken from the target passes through a filter that replaces control, escape and bidirectional characters and cuts long text before it reaches a terminal or a file.
  • Secrets are never printed. If an option looks like harvested credentials, the report gives its location and a count of lines, not the values. Decoded payloads are summarised, not shown.
  • Findings are bounded. At most 25 findings are listed for one rule (for one rule and table in the database). The rest are counted in a note in the report.

Limitations

Read these before you rely on a result.

  • A CLEAN verdict is never proof. The rules look for known patterns and for shapes that malware tends to take. New or unusual malware can pass.
  • An attacker who knows the rules can evade pattern checks. The rules are public. Anyone who reads them can write code they do not match. Treat the scanner as a way to find known and common infections, not as a guarantee.
  • It cannot tell which administrator account is legitimate. It lists user and administrator counts (DB-USR-030) and flags unusually many users. A person has to look at the accounts.
  • Core, plugin and theme files are not verified against checksums. A modified core file is only found if its content matches a rule. Compare against a fresh download of the same version as a separate step.
  • Large files are read head and tail only. A file over 1.5 MB is scanned as its first 1.5 MB and its last 64 KB, and the report says how many files were cut. Code in the middle of a very large file is not seen.
  • A site with a custom layout is only partly covered. The path rules (uploads, must-use plugins, drop-ins) need a wp-content folder, and the configuration rules need wp-config.php directly in the folder you scan. With a renamed content directory, or a wp-config.php kept above the web root, those rules do not run. The report notes which ones did not run, and files are still read for signatures.
  • PHP in the uploads folder is reported, with a few recognised exceptions. FS-UPL-100 excuses a framework control stub of empty Redux_ classes, a silent index.php, and two kinds of file that plugins write there, each only after its content has been checked: the data files that one widely used security plugin keeps in a folder of its own under uploads (an exit guard first, then data with no PHP in it), and the compiled Twig cache of WPML under wp-content/uploads/cache/wpml/twig (a template class and nothing else). Any other plugin that writes PHP into uploads is reported. If you checked it, allowlist it by prefix, for example the location wp-content/uploads/example-plugin/* (see the allowlist guide).
  • Code after the settings include in wp-config.php is judged by tokens. The include must be one of the stock forms: require or include, with or without _once, of wp-settings.php, written plain or with ABSPATH, __DIR__ or dirname(__FILE__). Anything after the first such include is reported. A repeated include is excused only when it is the same statement, as in an if and else pair. It is FS-CFG-140 (FULLY-COMPROMISED, cannot be allowlisted) when the code after it includes another file, evaluates or decodes text, calls a function on an expression or by constant, calls a variable function, passes a callback as a string or variable (array_map, usort, set_error_handler and similar), reads request input (superglobals, filter_input, getenv, getallheaders, php://input, or any $_SERVER key other than a short list of host, proxy and path keys), writes files, runs a process, makes a network call, uses a backtick, or produces output or a redirect (echo, print, header, setcookie, text after a closing tag). Anything else is FS-CFG-142 (HIGH), which is normal on some host images and after manual edits, and can be allowlisted by exact location. A payload that does none of these passes as FS-CFG-142, and a settings include in any other form is not found, so the code after it is not checked (the report notes that case).
  • The content sniff of uploaded media has a known blind spot. A PHP tag hidden in a media file under uploads (FS-UPL-101) is found by reading the text that follows it. A short echo tag followed by bytes that are not valid UTF-8, such as a Latin-1 accented letter inside the code, is not detected by the content sniff, because accepting such bytes would flag random image data.
  • --fast skips whole trees. It leaves out vendor, assets, build, dist, languages, block-library, css, fonts, images, img, cache, .git and .svn folders, wp-admin and wp-includes at the web root, and media files under uploads. The report says so, and a full pass is needed before you call a site clean. Folders named node_modules or bower_components are always skipped, with or without --fast. Under the uploads folder no folder is skipped, because that is where planted code is most likely to be.
  • Compressed dumps must be decompressed first. The tool does not decompress anything. A dump that starts with the gzip, bzip2, zip, xz or zstandard signature is refused with exit code 66 and the message "compressed dumps are not supported: decompress it first". An empty dump is refused with "the dump is empty". Other files are read as text. If no WordPress tables are found in one, a scan with files says the dump could not be read as a WordPress database and that nothing in it was checked, and a scan of the dump alone exits with 66.
  • Only single-byte XOR is decoded. Encoded values are decoded through base64, a single-byte XOR over base64, zlib, raw deflate and gzip inflate, ROT13 and percent decoding, up to three layers, and a step counts only when its result looks like text. A multi-byte XOR key or a custom cipher is not decoded. At best the value shows up as an encoded value that could not be decoded (DB-OPT-016, LOW).
  • FS-HTA-151 excuses a known firewall loader by its file name. A auto_prepend_file setting that names the firewall plugin's loader is not reported when a loader of the expected shape sits at the site root next to the plugin's folder. The check is on the file name, not on the directory in the setting.
  • Triggers and routines are reported, but their logic is not analysed. WordPress core creates no database triggers, stored procedures, functions or events, so each one in the dump is reported (DB-TRG-050, HIGH) with its kind and name. The usual indicator and script checks also run over its text. What the code does is not worked out, so a person has to read it. Views are not reported, and their text goes through the same indicator and script checks and no other analysis.
  • The dump reader understands the shapes the standard tools write. Those are mysqldump (MySQL 5.7 and 8, MariaDB), phpMyAdmin, Adminer, wp db export and the common backup plugins: quoted, numeric, NULL and hex or bit values (hex values are decoded), the usual table definitions, keys and session settings, and the same statements inside versioned comments or a batch under a custom DELIMITER. Anything else that a restore would run is counted and named in the notes, and the result is then partial: an UPDATE, a DELETE, a RENAME, a table filled by a query, an ALTER TABLE that is not a stock key or column change, an INSERT ... ON DUPLICATE KEY UPDATE, a client command such as source, a switch to a character set in which a backslash can be part of a character (gbk, big5, gb18030, sjis, cp932), and a CREATE or ALTER of a kind it does not know. A view that takes the name of a core table is a finding. The tool does not evaluate what the server computes: a value written as a function, a variable, a subquery or an expression is read as its text and counted, and its result is not known.
  • It is not a live scanner. It does not see what a running site does, and it does not look at server logs, other sites on the host or scheduled jobs outside the copy you give it.

Contributing

Bug reports, false positives and new rules are welcome. A rule needs a registry entry, a test that triggers it and a test for the closest legitimate lookalike. Test fixtures must stay inert: never commit a working webshell or real stolen data. See CONTRIBUTING.md for the steps and the fixture rule.

To report a bypass or a false negative privately, see SECURITY.md.

Licence

MIT. See LICENSE. Copyright 2026 Code Vertex.

Built by Code Vertex: full-stack and AI engineering for agencies and businesses in the United States, the United Kingdom, Australia and Europe. More work: case studies.

About

Offline, read-only scanner for WordPress database dumps and site files. Flags backdoors, injected scripts, rogue options and spam, and says plainly what it could not check.

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages