Feature Overview
The update gate#
The gate hooks into WordPress at the moment a new package has been unpacked but before anything is installed. It then:
- Hashes every file of the new package and of the installed copy with SHA-256, and builds a package hash from the sorted list of paths and hashes. The hash is the same whether the package comes from the vendor or from quarantine.
- Classes files as added, removed or changed, with line diffs for text files up to the Diff size limit.
- Runs the rules engine on added and changed code only. PHP comments are stripped first so findings point at the right line.
- Scores the update and gives it a verdict: Clean, Review or Hold, based on the thresholds in Settings.
- On Hold, copies the package to quarantine, aborts the update with a clear error, and records a pending review. The plugin is left active and maintenance mode is turned off.
The gate works on single updates, bulk and AJAX updates from the Plugins screen, automatic background updates, WP-CLI updates, and “Replace current with uploaded” zip installs over an existing in-scope plugin. A package hash that is already pending or rejected is held again immediately without re-scanning, and a newer held package for the same plugin supersedes the older one.
Rules#
Each rule adds its weight once per package, however many places it matches, so one large library cannot inflate the score. Every match is still listed with its file, line and snippet.
| Rule | Weight | Detects |
|---|---|---|
remote-raw-ip | 40 | Calls to a bare IP address |
remote-new-host | 15 | Network calls to a host the installed version never used |
plugin-install | 40 | Code that installs plugins or writes into the plugins folders |
plugin-activate | 30 | Code that activates plugins or edits the active plugin list |
plugin-hide-list | 50 | Filters that hide plugins from the Plugins screen |
plugin-hide-actions | 30 | Filters that remove plugin action links |
admin-user-create | 50 | Creating administrators or super admins, direct capability writes |
caps-grant | 25 | add_cap, add_role, set_role, capability filters |
obf-eval | 50 | PHP eval() |
obf-js-eval | 25 | JavaScript eval() / new Function() |
obf-assert | 40 | assert() with dynamic input |
obf-create-function | 40 | create_function() |
obf-decode-chain | 40 | Nested decoders and function names built from string pieces |
obf-long-string | 20 | 1,000+ character encoded strings, long escape runs or chr() chains |
obf-preg-e | 50 | preg_replace() with the /e modifier |
exec-shell | 30 | system, exec, shell_exec, passthru, proc_open, popen |
cred-smtp | 40 | New references to SMTP settings or the PHPMailer password |
cred-2fa | 40 | New references to two-factor secret storage |
cred-auth-hook | 40 | Login and password hooks; reading passwords or 2FA codes from the request |
every-request-remote | 30 | Hooks that run on every request combined with a network call |
cloaking | 45 | Crawler user-agent checks combined with output changes or redirects |
crawler-check | 20 | Crawler user-agent checks without output changes |
php-in-unusual-dir | 30 | New PHP in assets/, images/, languages/ and similar folders |
php-in-non-php-file | 50 | PHP open tags inside image, media, font or archive files |
risky-file-type | 25 | .phtml, .phar, .php5, double extensions, .htaccess, .user.ini, web.config |
server-config-exec | 45 | AddHandler, SetHandler, auto_prepend_file and similar in server config files |
symlink | 30 | Symbolic links in the package |
large-unscanned | 25 | Code files over the 8 MB scan size limit |
With the default thresholds, one Hold-weight finding (50) or a few smaller ones together hold the update. Weights can be changed with the pug_rules filter.
Quarantine#
Held packages are stored as one zip per review, never as loose PHP files in a web-accessible folder. The folder is protected with index.php, .htaccess and web.config, and every read and delete is validated against the known quarantine folders. Package code is never executed while it is inspected.
Hidden plugin detector#
Plugin headers are read straight from disk, so filters and caches cannot hide a plugin from the detector. The results are compared with what the Plugins screen shows, from two vantage points:
- a scan, run daily by cron, on demand from the Alerts screen, and by
wp pug scan-hidden; - a passive check every time an administrator opens the Plugins screen, which catches malware that hides itself only there.
Every mismatch raises a Critical alert: plugins hidden from the list, active plugins that are not listed (including active files without a plugin header), plugins force-loaded by a filter on the active plugins option, a tampered plugin list, and hidden must-use plugins and drop-ins.
Integrity watch#
A SHA-256 baseline of every file is kept for each in-scope plugin. It is taken after a gated install, after any successful upgrader run, the first time the daily check sees a plugin without one, and when you click Accept changes on an alert. A held or failed update never refreshes a baseline, so it cannot hide tampering.
The daily check re-hashes every baselined plugin. Files that changed without an update raise a High alert when PHP, JavaScript or server-config files changed, and a Medium alert when only other files changed. .log files are ignored; add more patterns with the pug_integrity_ignore filter.
Fail mode#
If the gate itself fails (for example the package cannot be read or the quarantine folder is not writable), the If the gate fails setting decides what happens. Fail open (default) lets the update through, records the run as an error and raises a High alert. Fail closed stops the update, records it as blocked and raises a High alert.
Multisite#
On a network the plugin is network-activated, all its screens including License live in the Network Admin, and its data (reviews, alerts, baselines, settings) is network-wide. Every screen and action requires manage_options and manage_network_plugins.
