=== Bulwarden ===
Contributors: bulwarden
Tags: maintenance, security, backup, malware, monitoring
Requires at least: 6.0
Tested up to: 7.1
Requires PHP: 7.4
Stable tag: 1.28.2
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html

Site agent for health monitoring, malware scanning, encrypted backups and remote updates, driven by a Bulwarden hub you connect it to.

== Description ==

Bulwarden is the **site agent**, installed on each managed WordPress site. It
exposes a signed REST API (`jgm/v1`) that a **Bulwarden hub** calls to read
health and security data, run checks and trigger updates. The hub is a separate
application — it is not part of this plugin — and **this plugin talks to no hub
at all until you pair it with one**. On its own it sends nothing anywhere.

It is built for the person who is responsible for a site they did not build:
an agency looking after forty of them, or an owner who wants one place that
answers "is this thing all right?" without logging into it.

= What it reports =

* **Site health** — WordPress, PHP and plugin versions, pending updates, disk
  usage, and WordPress' own Site Health tests, collected in one snapshot.
* **A full plugin and theme inventory**, with each item's version, whether it
  is active, whether an update is waiting, and whether it came from
  WordPress.org at all — which is what lets a hub match it against a CVE feed.
* **Must-use plugins**, which WordPress runs on every single request with
  nothing activating them and no way to switch one off from the admin. Nothing
  else in wp-admin lists them properly, and dropping a file there is the tidiest
  way to install a backdoor.
* **Configuration-level security checks** with a posture score — file editing,
  directory listing, XML-RPC, debug output, and the rest of the settings that
  quietly decide how exposed a site is.
* **Unfinished updates.** An update that already replaced a site's files can
  still fail to finish, and neither state clears itself: a pending database
  schema upgrade that every administrator is sent to and the public site hides
  completely, or a leftover `.maintenance` lock from an updater that died
  mid-copy. Both are reported, and both can be finished from the hub by running
  exactly what WordPress itself would have run.
* **Email delivery.** WordPress keeps no record of the mail a site sends: when
  a contact form stops delivering or an order confirmation never arrives, the
  error is raised and thrown away. This counts what was attempted, what failed
  and why, and reports which transport is doing it — PHP's `mail()`, an SMTP
  server, or the plugin that configured one. No part of any message is stored:
  not the recipient, not the subject, not the body.

= What it finds =

* **A malware scan in three layers.** Every core and WordPress.org file is
  verified against its *published* checksums, so a modified file is found
  whether or not anybody has written a signature for what was put in it; then
  every file is read for the code patterns webshells and injected backdoors
  actually use. Findings carry the file, the line and a short excerpt.
* **Backdoors that arrive dressed as an ordinary plugin** — a forged
  administrator login, a privileged action gated on a key baked into the file,
  an account created from hard-coded credentials, users hidden from the users
  list. A plugin absent from WordPress.org has no published files to check it
  against, so without this a hostile one surfaced only as *unverifiable*.
* **A plugin hiding itself.** Malware that conceals a plugin does it by
  answering the question before WordPress can — and the hidden plugin is then
  also the one plugin whose files are never checked. The scan counts what is on
  disk itself and compares.
* **Server-configuration files.** `.htaccess`, `php.ini` and `.user.ini` are
  the one part of a WordPress install that changes what the *server* does
  rather than what WordPress does, and nothing in wp-admin shows them. A file
  set to run before every request, a rule that makes uploaded images execute as
  PHP, a redirect that only fires for search engines: all reported, with the
  line. Nothing is ever changed for you — a good share of those directives are
  written deliberately by a host or a security plugin, and the point is that
  you get to decide which.
* **Leftover files.** An interrupted backup, a finished restore and a migration
  plugin all leave things behind and none of them clean up: a
  `wp-config.php.bak` the web server hands out as plain text, a `database.sql`
  in the web root, an installer that overwrites the site with no login. Found,
  measured, and removable from the hub — and the *site* decides what may be
  deleted, so a stale list can only ever be refused.
* **Signatures that keep up.** The scan's pattern list can be updated by your
  provider without updating this plugin, so a new detection reaches the site in
  hours rather than waiting for a release to travel a whole fleet. A site that
  has never been paired scans with the list this plugin ships.
* **A scan that starts itself.** When a plugin WordPress.org does not publish
  appears in the plugins or must-use folder, the site starts a scan within
  seconds and tells your provider what set it off, instead of waiting for the
  next scheduled one. Fake plugins are how most WordPress break-ins arrive, and
  a scan is a cheap thing to be wrong about: nothing is blocked, quarantined or
  deleted — the site looks, and your provider decides.

= What it does =

* **Remote core, plugin and theme updates**, wrapped in maintenance mode so
  visitors never see a half-updated site — and taken off the host's PHP time
  limit, so a core update on shared hosting finishes instead of being killed
  part-way.
* **Zero-knowledge encrypted backups.** The archive is built *and encrypted on
  this server*, under a passphrase that is never transmitted anywhere;
  only the ciphertext is uploaded. Nobody — including whoever runs the hub —
  can read a backup without that passphrase.
* **Restores that fail safely.** The same thing in reverse, and it can put back
  the database alone if that is all that broke. Every table is rebuilt beside
  the live one and swapped in only once it is complete, so a restore stopped by
  a timeout or a server limit leaves the database exactly as it was rather than
  half-replaced — and picks itself back up where it stopped. What could not be
  done is counted and reported rather than discarded, so a restore that ran into
  trouble says so instead of reporting success.
* **Page tracing.** Your provider can ask this site to render one of its own
  public pages and report which file produced each line of the HTML — core, a
  plugin, the theme, a must-use plugin. It answers the question nothing in
  WordPress answers: *which* plugin adds the Google Fonts tag, or the tracking
  script, or the markup nobody can find a setting for. The page is never
  altered to do it.
* **WP-Cron on a schedule that actually runs.** A site with no visitors runs no
  cron, which is how scheduled posts, order emails and licence checks quietly
  stop.
* **A support card on the dashboard** naming whoever maintains the site, how to
  reach them, and a *Request help* button that files a ticket with them
  directly — attached to the right site, without the owner having to explain
  which one.
* **An activity log on the site itself**, append-only, recording everything that
  changed this site or sent part of it somewhere — and which side asked. Updates
  installed by your provider and anything that failed with them, backups,
  restores, scans, repairs, deleted leftovers, a file read off the server, a
  traced page, and every change to the credentials, the passphrase or the
  provider details. WordPress records what changed and never who asked, and the
  answer belongs on your own server rather than only in somebody else's console.

= Pairing =

On activation the plugin provisions an API key + secret. Copy the **connect
string** from the **Bulwarden** entry in the WordPress admin menu and paste
it into the hub's "Connect a site" form. Your provider's support details are
then pushed to the site automatically by the hub and appear on the dashboard.

= Security =

Hub → client requests are authenticated with the per-site API key and an
HMAC-SHA256 signature over the method, route, timestamp, a single-use nonce and
the body. The nonce is what makes a signature good exactly once: it is recorded
when the request arrives and refused if it comes back, so a captured request
cannot be replayed. Timestamps outside a 5-minute window are rejected outright,
which is also how long a nonce has to be remembered for. The secret never
travels on the wire — it lives only in this plugin and in the hub webapp.

== Privacy ==

Bulwarden is a client agent: on its own it sends data nowhere. Pairing this
site with a hub (yours, or your maintenance provider's) is an explicit,
opt-in action — you choose when to copy the connect string from the Bulwarden
screen in the WordPress admin menu and hand it to that hub. Nothing is
transmitted before that.

Once paired, data only moves in these cases:

* **Health, inventory, and security checks.** The paired hub reads these from
  this site's signed REST API when it polls or when a check is requested —
  WordPress/plugin/theme versions and update status, a full plugin & theme
  inventory, disk usage, and the configuration-level security posture score.
* **Backups**, only when triggered (the Settings screen's "Back up now"
  button, or a request from the paired hub): the full site is archived and
  encrypted on this server with a passphrase that is never transmitted
  anywhere, then the ciphertext is uploaded to the hub. Only someone who has
  the passphrase can decrypt a backup — the hub operator cannot read its
  contents.
* **Malware scan results**, only when a scan is triggered (admin- or
  hub-initiated): findings (type, severity, file path, line, and a short
  excerpt) are sent to the hub that requested the scan.
* **Finishing an interrupted update**, when the paired hub asks. Nothing is
  sent in that exchange; the site runs WordPress' own database upgrade, or
  deletes an expired `.maintenance` lock file, and reports the outcome.
* **Server-configuration files**, when the paired hub asks: the contents of
  this site's `.htaccess`, `php.ini` and `.user.ini` files, so the hub can show
  what is in them and when they last changed. These are configuration, not
  content — no post, page, media file or user data is read.
* **Email counters**, as part of the health snapshot: how many messages this
  site attempted and how many failed over the last seven days, the error text
  the mail server returned, and which transport is sending. **No part of any
  message is recorded** — not the recipient, not the subject, not the body.
* **A traced page**, only when the hub asks for one: the HTML of a single
  public page of this site, exactly as an anonymous visitor would receive it,
  plus the file paths and line numbers that produced it. No file contents are
  read and no logged-in view is ever rendered.

Independently of any hub pairing, this plugin verifies WordPress core and
wordpress.org-hosted plugin files by comparing local file hashes against the
public checksum APIs at `api.wordpress.org` and `downloads.wordpress.org`.
Those requests carry only what identifies the release to look up — the
WordPress version and locale, and each plugin's slug and version. No file
contents, hashes, URLs or user data are sent: the reference checksums come
back and the comparison happens here, on this server.

Bulwarden does not call home on activation, does not collect analytics, and
stores data on no server this site hasn't been explicitly connected to. The
hub itself is a separate service outside this plugin's control; consult your
provider for how it handles the data described above.

== External services ==

This plugin connects to the services below. Nothing here runs on activation.

= Your Bulwarden hub =

Bulwarden is the site-side agent for a **hub** — the management application an
agency or site owner runs to watch and maintain a fleet of WordPress sites. The
hub is a separate product and is not part of this plugin.

The default hosted hub is operated by Bulwarden at https://bulwarden.net/ —
terms of use: https://bulwarden.net/terms/ , privacy policy:
https://bulwarden.net/privacy/ . A hub can also be self-hosted, or run by the
maintenance provider you buy from, in which case this plugin talks to that
provider's server instead and their terms and privacy policy apply.

**No connection to any hub exists until you make one.** You pair a site by
copying the connect string from the Bulwarden screen in the WordPress admin
menu and pasting it into the hub; before that the plugin contacts no hub at
all, and it never discovers or chooses one on its own. Once paired, data moves only in these cases:

* **When the hub asks for status.** It reads this site's signed REST API and
  receives WordPress/plugin/theme versions and update status, the full plugin
  and theme inventory, disk usage, the results of WordPress' own Site Health
  tests, and the configuration-level security posture score.
* **When a backup runs** (you press "Back up now", or the hub requests one).
  The site is archived and encrypted here, with a passphrase that is never
  transmitted, and only the ciphertext is uploaded. The hub operator cannot
  read a backup's contents without that passphrase.
* **When a malware scan runs** (started by you or by the hub). The findings —
  type, severity, file path, line number and a short excerpt — are sent to the
  hub that asked for the scan.
* **When the hub pushes your provider's support details** — their name, logo,
  support address and accent colour — on connect and whenever they change
  them. Nothing is sent from the site in that exchange.

Every one of those requests is authenticated with this site's own API key and
an HMAC-SHA256 signature; the shared secret never travels over the wire.

= api.wordpress.org and downloads.wordpress.org =

The malware scan verifies WordPress core and wordpress.org-hosted plugins
against their published checksums, using WordPress.org's own public APIs at
`https://api.wordpress.org/core/checksums/1.0/` and
`https://downloads.wordpress.org/plugin-checksums/`. This happens whenever a
scan runs, whether or not the site is paired with a hub.

These requests identify only the release being looked up: the WordPress version
and locale, and each installed plugin's slug and version. No file contents,
hashes, site URL or user data are sent — the reference checksums are returned
and compared locally.

These are WordPress.org services, covered by the WordPress.org privacy policy:
https://wordpress.org/about/privacy/

== Installation ==

Bulwarden is one half of a pair: this plugin runs on the site, and a **hub**
runs somewhere else and manages it. Installing the plugin on its own does
nothing until you pair it with a hub.

1. Install and activate the plugin as you would any other &mdash; upload it under
   Plugins &rarr; Add New &rarr; Upload Plugin, or install it from the directory.
2. Go to **Bulwarden** in the WordPress admin menu. The plugin generated an
   API key and secret for this site on activation; the screen shows a single
   **connect string** that carries the site name, URL and both credentials.
3. Copy that string and paste it into your hub's "Connect a site" form. That is
   the whole pairing step &mdash; there is nothing to type on this side, and no
   hub address to configure here.
4. If you want encrypted backups, set a **backup passphrase** on the same
   screen. It never leaves this server, and it is required to restore. Keep it
   somewhere you will still have it after the disaster you are backing up
   against &mdash; nobody, including your hub operator, can recover a backup
   without it.

= Do I need a hub? =

Yes, for anything beyond installing it. The hub is separate software: use the
hosted one, run your own, or use the one your maintenance provider runs. Until
the site is paired, this plugin contacts nothing and does nothing.

= Upgrading =

Updates install like any other plugin. Your credentials, passphrase and settings
survive an update; nothing needs re-pairing.

== Frequently Asked Questions ==

= Does this plugin send my site's data anywhere on its own? =

No. Before you pair it with a hub it contacts nothing at all, and it never
discovers or chooses a hub by itself. After pairing, data moves only for the
things listed under Privacy below &mdash; health checks, backups, malware scan
findings &mdash; and only to the hub you paired with.

= Can the hub operator read my backups? =

No, provided you set a passphrase. The archive is encrypted **on this server**
with a key derived from your passphrase, and only the ciphertext is uploaded.
The passphrase is never transmitted, so a hub holds bytes it cannot decrypt.
The same property is why nobody can recover a lost passphrase for you.

= What happens if I deactivate or delete the plugin? =

The site stops answering its hub, which the hub will notice and report as the
agent going unreachable. Deleting the plugin removes its settings, its audit-log
table and its working directories (see `uninstall.php`); backups already stored
on a hub are unaffected.

= Is the plugin available in German? =

Yes. Everything the plugin puts on screen &mdash; the settings page, the support
card on the dashboard, every status message and every scan finding &mdash; is
translated, and WordPress picks it up from your site's own language setting with
nothing to configure. Both German locales are covered: *Deutsch* and *Deutsch
(Sie)*. The wording is formal throughout, because these screens are read by the
owner of the site rather than by the agency that maintains it.

= Does it work on multisite? =

The plugin runs per site. A network-wide database upgrade is reported but not
performed &mdash; core's own network upgrade screen walks every site in the
network, and that is where it belongs.

= Why does the malware scan flag a file I know is fine? =

The file-integrity check compares every core and WordPress.org-hosted file
against the checksums WordPress.org published for that exact release. A caching
or optimisation plugin that rewrites a stylesheet produces the same mismatch a
backdoor does, which is why "this file no longer matches what was published" is
reported as a fact rather than a verdict, and why non-executable files are
warnings rather than critical findings. Your hub can show you the actual diff.

= What does it do to my site's performance? =

Nothing on page loads: there is no front-end code path. Backups and malware
scans are real work and run in the background across WordPress cron ticks, to a
time budget, so they do not block visitors. Health checks are answered on demand
when the hub asks.

= Which PHP and WordPress versions are supported? =

PHP 7.4 or newer and WordPress 6.0 or newer. Backups additionally need the
`sodium` extension (encryption) and either `zlib` or the `zip` extension
(archiving); the Settings screen tells you if either is missing.

== Screenshots ==

1. The Bulwarden screen at a glance: whether the site is connected, whether
   backups are encrypted, when the last backup and scan ran, and how current
   the scanner's signature list is.
2. Connecting the site: the connect string you paste into your hub, and the
   API credentials behind it.
3. Backups: a run in progress showing the phase it has reached, its log and a
   cancel button, above the encryption passphrase and the folders left out of
   the archive.
4. The malware scan: what the last one found, grouped by kind, and how much of
   the site could be checked against the files WordPress.org published.
5. The activity log: an append-only record of credential changes, backups,
   restores, scans and repairs, including the ones the hub triggered.
6. The dashboard support card &mdash; who maintains this site, how to reach
   them, and the *Request help* button that files a ticket with them directly.

== Upgrade Notice ==

= 1.23.0 =
The malware scan gains eighteen checks written against real backdoors, and eight
existing checks stop reporting honest code. If your provider's hub had refused an
update as infected, it will go through after this.

= 1.22.2 =
Nothing on your site changes. This release exists to correct what the plugin's
own listing says about it, which had fallen behind several releases.

= 1.22.1 =
Fixes backups of sites larger than 4 GB, which failed while verifying the
finished archive. If you have such a site, run a backup after updating.

= 1.22.0 =
A restore that cannot finish no longer leaves your site half-restored, and a
restore that ran into trouble now says so instead of reporting success. Nothing
you had set up changes; no action needed.

= 1.21.0 =
The activity log now records the updates your provider installs on this site,
and three other things it was missing. Nothing else changes; no action needed.

= 1.20.0 =
A plugin that appears on your site without WordPress.org publishing it now
starts a malware scan by itself, within seconds, instead of waiting for the next
scheduled one. Nothing is blocked or deleted; no action needed.

= 1.19.0 =
Adds German, server-configuration checks, email delivery monitoring and a
cancel button for a stuck scan, and stops a slow update on shared hosting being
killed part-way. Nothing you had set up changes; no action needed.

= 1.18.0 =
The malware scan now recognises backdoors that arrive dressed as an ordinary
plugin, not just obfuscated webshells. Nothing you had set up changes; no action
needed.

= 1.17.1 =
Fixes false "a WordPress core file is missing" and "no longer matches the
official release" findings on the readme and licence files a non-English
WordPress installs under a translated name.

= 1.16.0 =
The support card on your dashboard can now show your provider's telephone
number, if they have set one in their hub. Nothing you had set up changes; no
action needed.

= 1.15.0 =
The malware scan now counts the plugins on disk itself instead of taking
WordPress' word for what is installed, so a plugin that has hidden itself from
the plugin list is reported &mdash; and checked against WordPress.org like every
other one. Must-use plugins are now listed too: they run on every request and
the admin gives you no way to switch one off, so the scan reports what is there.
Nothing you had set up changes; no action needed.

= 1.14.0 =
Adds a "Request help" button to the dashboard support card, and lets your hub
find and clear the files old backups, restores and migrations left behind.
Nothing you had set up changes; no action needed.

= 1.13.7 =
Identical to 1.13.5 and 1.13.6 in behaviour. Nothing on your site changes.

= 1.13.6 =
Identical to 1.13.5 in behaviour. It exists so the plugin directory's listing
and its screenshots describe the same version; nothing on your site changes.

= 1.13.5 =
Corrections to the Bulwarden screen rebuilt in 1.13.4. Nothing changes in how
the plugin works; no action needed.

= 1.13.4 =
The Bulwarden screen has been rebuilt around what you actually came to it for.
Nothing you had set up changes; no action needed.

= 1.13.3 =
Bulwarden moves out of the Settings submenu into its own entry in the WordPress
admin menu. Nothing else changes; no action needed.

= 1.13.2 =
Fixes a false "a WordPress core file no longer matches the official release"
finding on `wp-includes/version.php` &mdash; raised on any site whose display
language differs from the WordPress package it was installed from.

= 1.13.1 =
Fixes a false "a plugin file no longer matches the version published on
WordPress.org" finding on plugins WordPress.org publishes more than one copy of.

= 1.13.0 =
Your hub can now see and finish an update that changed this site's files but
never completed &mdash; a pending database upgrade or a leftover maintenance
lock. Restores can also put back only the database, leaving files untouched.

= 1.11.0 =
Must be installed alongside the matching hub update: the two sign requests to
each other and the signing form changed on both sides at once.

= 1.9.0 =
Stored data moved to a new prefix and there is no automatic migration &mdash;
re-pair the site with your hub after updating, and re-enter the backup
passphrase. Existing backups still need the old passphrase to decrypt.

== Changelog ==

= 1.28.2 =
* Fix: **the progress of a file restore is now reported correctly when it
  finishes.** The step that writes a backup's files back ended by reporting
  that it had written none of them &mdash; a completed restore showed
  "0 of 93,680 files, 0%" beside a count of the files it had just written.
  The two figures were also counted differently: the total included the
  backup's database dump and its description alongside the files, so it could
  never be reached however many files were restored. It now counts the
  backup's files, and every one of them is reported either as written or as
  deliberately left alone &mdash; this site's own wp-config.php, Bulwarden
  itself, and WordPress' drop-ins are never overwritten, and your hub now says
  so instead of leaving the difference unexplained.

= 1.28.1 =
* Fix: **a restore no longer spends most of its time waiting to be asked to
  carry on.** A restore runs in rounds, and at the end of each one the plugin
  asked WordPress' scheduler to come straight back for the next — except that
  the request was made from inside the scheduler, which refuses it. So the work
  only continued when a visitor happened to arrive, or after a three-minute
  safety timer, and on a quiet site — which is most sites being restored — a
  restore took hours of waiting for minutes of work. On sites where WordPress'
  own scheduler is switched off in favour of a real cron job, which is the
  usual advice for a site that matters, nothing continued the restore at all
  between those cron runs. Each round now asks for the next one directly.

= 1.28.0 =
* New: **a banner on a site that has the plugin but is not connected to
  anyone.** Until a site is paired with a maintenance hub the plugin does
  nothing at all — no backups, no scans, nothing watched — and from the
  dashboard that is indistinguishable from a plugin quietly working. Anyone who
  can install plugins now sees a notice saying so, with the way to connect the
  site one click away, and it can be put off for a month at a time. The support
  widget on the dashboard no longer says Bulwarden maintains a site that nobody
  has agreed to maintain.
* New: **a way to ask Bulwarden for help when there is no maintenance provider
  at all.** The Request help button on the dashboard writes to whoever looks
  after the site, which is no use to somebody who installed this and has nobody.
  The plugin's own settings screen now carries a short form that reaches
  Bulwarden directly, and says exactly what it sends: the site's address, its
  WordPress and PHP versions and the reply address typed into it. Nothing else,
  and no keys.
* New: **the hub is told whether a backup encryption passphrase has been set** —
  the fact that there is one, never the passphrase itself, which stays on this
  site and is never sent anywhere. Without one every backup stops before it
  starts, and an agency previously found that out from a failed run at three in
  the morning rather than from the setup nobody finished.
* New: **five more malware detections**, from a study of 400,000 production
  webservers (Kasturi et al., *Mistrust Plugins You Must*): code that redefines
  a built-in PHP function instead of loading the real one, a function name
  assembled from fragments and then called, a file written to disk, made
  executable and run, code that deletes the file it ran from, and the branding
  "nulled" plugin marketplaces stamp into the paid plugins they give away with
  something added.

= 1.27.3 =
* Fixed: **a restore of a large backup no longer leaves the site stuck showing
  "Briefly unavailable for scheduled maintenance".** Downloading and decrypting
  a backup take as long as they take, and a round of work that had spent its
  whole time doing that went on to start restoring the database anyway — which
  begins by switching the site into maintenance mode. It then managed a single
  instruction before stopping for the round, so the maintenance mode bought
  nothing; and because WordPress checks for maintenance mode before it loads
  any plugin, a round that did not live long enough to switch it back off took
  the site's own timer down with it — the timer being the only thing that could
  start the next round. The site sat unavailable, and the restore sat still,
  until WordPress itself gave up on the maintenance flag ten minutes later. A
  round that is out of time now stops cleanly between phases, and maintenance
  mode is switched off the moment the database work pauses rather than at the
  very end of the round.

= 1.27.2 =
* Fixed: **restoring a backup onto a different site now rewrites its addresses
  to *this* site's.** With "rewrite addresses" ticked, the restore replaced the
  backup's web address with the address it read from the site — but it read it
  after the backup's database was already in place, so it was reading the
  backup's own address back. A backup moved to a new domain either had nothing
  rewritten at all, leaving the restored site pointing at the domain it was
  copied from, or had every address in the database rewritten from `http://` to
  `https://` on that old domain. The restore reported success in both cases.
  This site's addresses are now recorded before the restore starts and used as
  the target; if they could not be recorded, the addresses are left alone and
  the restore says so instead of guessing.
* Fixed: a backup now records the site's own stored web address rather than the
  address of the internal request that happened to start it, so the restore
  searches for the address that is really in the backup.

= 1.27.1 =
* Fixed: **a long restore of a backup that predates this plugin now finishes.**
  Restoring the database replaces the list of active plugins with the backup's,
  and a backup taken before this plugin was installed does not list it. On a
  site big enough to need several rounds of work, the round after the swap
  loaded WordPress without the plugin, so nothing was left to carry on and the
  restore sat half-done until the hub gave up. The plugin now puts itself back
  the moment the table is swapped in, in the same round.
* Changed: **a restore never writes WordPress' drop-in files**
  (`object-cache.php`, `db.php`, `advanced-cache.php` and the rest of that
  family in `wp-content`). They are loaded before any plugin and are tied to
  the server they were written on — a cache server address, database hosts,
  absolute paths — so a backup's copy restored onto another server took the
  whole site down before anything could repair it. The copy already on the site
  is kept, and the restore log says which files were left alone.

= 1.27.0 =
* Fixed: **a large restore no longer stops part-way through the database.**
  Restoring the database replaces the WordPress options table, and the
  restore's own progress record and its "carry on" timer were rows in that
  table — so on a site big enough to need several rounds of work, the round
  that swapped the table in was the last one that ever ran. The site sat
  half-restored, the dashboard showed the last progress it had heard of, and
  if the backup came from another site, the hub could no longer reach this one
  at all. The restore now puts its own state and this site's own credentials
  back the moment the table is swapped in, and carries on.
* Fixed: each round of a long restore now starts the next one immediately
  instead of waiting for a timer, which roughly halves the wall-clock time of a
  restore that spans many rounds — and no longer depends on a visitor arriving
  to trigger it.
* Improved: **restoring a large database is many times faster.** Backups now
  write rows in batches rather than one statement per row, and the restore
  replays them inside transactions, so a database that used to trickle in at
  about a thousand rows a second now goes back at the speed of the disk.
  Backups made by earlier versions restore exactly as before.
* Improved: a restore interrupted by the hosting provider (a killed process, a
  dropped database connection) now resumes exactly where its last saved
  progress left off. Previously the rows written since the last save were
  replayed on top of themselves, which on a bad day was enough errors to
  abandon the restore.
* Improved: a restore that resumes after a pause keeps the database session
  settings the backup asked for (character set, foreign-key checks, the rule
  that keeps a row with id 0 from being renumbered).
* Improved: two rounds of one restore can no longer run at the same time.
* Improved: the hub's restore page now says when it could not ask the site for
  progress, and why, rather than showing the last answer as if it were live.
* Developer note: `BULW_RESTORE_TICK_BUDGET` (seconds) shortens a restore's
  per-round budget so that a multi-round restore can be reproduced on a small
  test site.

= 1.26.0 =
* New: **the scan now reports program files stored on your site.** A malware scan
  reads code — PHP, JavaScript, page templates — so an installer or a program
  sitting in your media library was invisible to it. That is the commonest thing
  a compromised WordPress site is actually used for: not defacing the site, but
  quietly hosting a file that is mailed out to other people, until the domain is
  flagged by Google Safe Browsing or the host suspends the account. These are now
  listed on the security tab.
* New: a program stored under a name that says otherwise — a Windows or Linux
  program saved as `.jpg`, `.pdf` or `.mp4` in the uploads folder — is reported as
  a critical finding. There is no innocent reason for one, and it is exactly how
  such a file is kept out of sight.
* New: a program file found inside `wp-admin` or `wp-includes` is reported as
  critical. WordPress itself ships none, in any language.
* New: every one of these findings carries a SHA-256 checksum of the file, shown
  next to it in your provider's dashboard. That is the part your provider can act
  on: it identifies the file itself, so it can be checked against a malware
  database and recognised if the same file turns up on another site.
* Note: an ordinary program file elsewhere on the site is reported for
  information only. It never marks the site as infected and never triggers an
  alert — a business that distributes its own software has every right to store
  one, and the scan says so rather than crying wolf.

= 1.25.1 =
* Fix: **a restore no longer hands this site another site's identity.** WordPress
  keeps this plugin's settings in the database, so restoring a backup made on a
  *different* site replaced this site's connection credentials with that site's —
  and from that moment your provider's hub could not reach this site at all: no
  health checks, no cron, no scans, no backups. Putting it right meant logging in
  and pairing the site again by hand. The credentials, the hub address and the
  backup passphrase are now carried across a restore and put back the instant the
  database is in place. Everything else in the backup's settings is restored as
  before.
* Fix: the backup passphrase was replaced the same way, so the next backup was
  encrypted with a passphrase recorded for a different site — something nobody
  would discover until the day they needed it.
* Fix: a backup no longer carries its own "restore in progress" marker, which
  could drop one run's state onto a site part-way through another.

= 1.25.0 =
* New: **a backup can now be restored onto a site whose database tables are named
  differently.** WordPress puts a short prefix in front of every one of its table
  names, and it is rarely the same on two installs &mdash; so restoring a backup onto
  a staging copy, or onto a site being rebuilt after a break-in, used to be refused
  outright. Your provider can now ask for the tables to be renamed as they are
  restored, which leaves the site reading its own names and needs no change to
  wp-config.php at all. Everyone's role and personal settings are moved across with
  them, so nobody loses their login.
* Change: **the message shown when the names do not match no longer recommends
  something harmful.** It used to suggest editing wp-config.php to match the backup.
  On a site that is up, that stops WordPress finding its own database, takes the
  site down, and stops this plugin running &mdash; which removes the only thing able to
  perform the restore. The message now explains the two safe ways forward instead.

= 1.24.1 =
* Fix: **a restore that stopped explained itself in punctuation codes.** The messages
  this site sends your provider when a restore cannot go ahead &mdash; the reason it
  stopped, and the database errors behind it &mdash; were encoded for a web page here
  before being sent, and then encoded a second time by the page that displayed them.
  Every apostrophe and quotation mark in the one line meant to explain the failure
  arrived as a run of digits and semicolons, including the table names in a database
  error, which left the explanation unreadable exactly when it was needed. The
  messages are now sent as written.

= 1.24.0 =
* New: **a restore can now be watched while it runs.** Putting a site back can
  take an hour on a large one, and until now your provider's hub learned nothing
  at all until it had finished &mdash; a spinner, and then an answer. This site now
  reports every step of a restore as it goes: checking, downloading, decrypting,
  the database, the files, the addresses, and the finish, each with how far
  through it is and how long it has taken, alongside the run log as it is
  written.
* Change: a step that will not run says so from the start, and says why &mdash; a
  database-only restore is not quietly "about to" restore your files.

= 1.23.0 =
* New: **the malware scan knows eighteen more kinds of backdoor.** They were
  written against real ones: the plugins compromised on wordpress.org in June
  2024 and April 2026, the two backdoored plugins found there in 2026, and the
  fake plugins that plant a hidden administrator, rewrite `wp-cron.php`, take a
  role from the request, or unpack code fetched from a server nobody published.
  Each check pairs the dangerous action with the thing that makes it
  illegitimate, so an honest plugin doing the same work is not reported.
* Fix: **eight existing checks reported honest code as malware.** A code comment
  mentioning a shell command, a full-width video frame, a compressed setting, an
  embedded font, a temporary-login plugin creating its account from variables,
  and a plugin's own test files were each being flagged. Measured against the
  250 most popular plugins and WordPress core, those checks now report nothing
  there. Your provider's hub refuses to install an update it considers infected,
  so this also unblocks updates it was wrongly refusing.

= 1.22.2 =
* Change: **nothing on your site changes in this release.** It exists because the
  plugin's description here had fallen behind what the plugin actually does, and
  the directory shows that text from the released version rather than from the
  latest one &mdash; so correcting it takes a release.
* Change: three features that shipped in 1.20.0, 1.21.0 and 1.22.0 were described
  only in this changelog and not in the description anybody reads first: the
  activity log on your own site, the scan the site starts for itself when a plugin
  nobody published appears, and what a restore now does when it cannot finish.
* Change: the security section described how requests between this site and your
  provider are signed, and had been describing the form used before 1.11.0. The
  signature has covered a single-use value per request since then, which is what
  makes a captured request useless rather than merely short-lived.
* Change: the screenshots in the directory listing were taken nine releases ago,
  before the support card had a *Request help* button or a telephone number. They
  are re-taken from this version.

= 1.22.1 =
* Fix: **backups of sites larger than 4 GB could not be completed.** Past that size the
  archive was written in a form no unpacker can read, so the check that runs on every
  finished archive rejected it and the backup was reported as failed with "the finished zip
  could not be re-opened (code 21)". Smaller sites were never affected. On a site whose
  hosting is missing PHP's `zip` extension that check cannot run, so an affected backup may
  have been stored instead of failing &mdash; those cannot be restored, and should be
  replaced with a fresh backup after updating.

= 1.22.0 =
* Improve: **a restore that cannot finish no longer leaves your site half-restored.** Each
  table is now rebuilt alongside the live one and swapped in only once it is complete, so a
  restore interrupted by a timeout, a server limit or an error leaves your database exactly
  as it was instead of partly replaced. Long restores also pick themselves back up where
  they stopped rather than starting over, and your site is put into maintenance mode only
  while the database is actually being written.
* Improve: **a restore now says when something went wrong.** Failed database statements are
  counted and reported instead of being discarded, and a restore that could not create or
  replace your tables stops immediately and explains why rather than finishing and
  reporting success. Your provider's dashboard shows the difference.
* Improve: **checks before a restore starts, so it fails before it can do damage.** Free
  disk space, the database permissions the restore needs, whether the downloaded backup
  matches what was stored, and whether the backup's tables match this site's are all
  verified first. An incomplete download is now reported as an incomplete download rather
  than as a wrong passphrase.
* Improve: **restores can optionally remove files the backup does not contain**, for
  recovering from a hack rather than from a mistake. Off unless your provider asks for it,
  and never applied to your uploads.
* Improve: **after a restore, the site is put back in working order**: caches are cleared
  (so you see the restored site and not the old one), plugins and themes the restored
  database names but that are no longer installed are deactivated rather than breaking the
  site, and a pending WordPress database upgrade is completed.
* Improve: backups and restores can no longer run at the same time. A backup started while
  a restore was in progress could previously store a copy of a half-restored site.
* Improve: web addresses are rewritten more completely on a restore &mdash; the WordPress
  address and the site's folder path as well as the home address &mdash; and the rewrite can
  no longer skip rows on large tables or be stopped by unusual stored data.
* Improve: database triggers and stored routines are now included in backups and restored
  with them.
* Fix: the number of files a restore wrote is shown again on your provider's dashboard.
* Fix: files whose names contain two dots in a row (`photo..2019.jpg`) are no longer
  skipped by a restore.

= 1.21.0 =
* Improve: **the Activity log now records the things it was missing.** Updates installed on
  this site by your provider are written down &mdash; what was updated, and anything that
  failed &mdash; and so are a file being read off this server, a change to the provider
  details shown on your dashboard, and a change to the address this site reports to.
  Between them those were the actions with the biggest effect on the site and the least
  trace of who asked for them. Nothing about how the plugin works changes; the log simply
  answers more of the questions people bring to it.

= 1.20.0 =
* Add: **a plugin that appears out of nowhere is now scanned immediately.** When a plugin
  turns up on your site that WordPress.org does not publish &mdash; a premium plugin you
  installed by hand, or something nobody installed at all &mdash; the site starts a malware
  scan itself, within seconds, and tells your provider what set it off. Fake plugins are
  how most WordPress break-ins arrive, and until now the first look at one waited for the
  next scheduled scan. Nothing is blocked, quarantined or deleted: the site looks, and your
  provider decides. Anything appearing in the *must-use plugins* folder counts too, since a
  file put there runs on every request with nothing to activate it and no way to switch it
  off.

= 1.19.0 =
* Add: **German**. Every screen this plugin adds &mdash; the settings page, the dashboard
  support card, every status message, every scan finding and every error &mdash; is
  translated, in both *Deutsch* and *Deutsch (Sie)*. Nothing to configure: WordPress uses
  your site's own language setting. The wording is formal throughout, because these
  screens are read by the person whose site it is rather than by the agency maintaining
  it.
* Add: a page trace now lists the page's resource hints &mdash; the
  `<link rel="preconnect">` and `<link rel="dns-prefetch">` tags &mdash; and says which
  plugin asked for each one. That tag is the reason a page reaches out to a third-party
  server before it has drawn anything, it is what a webfont privacy check flags, and it
  was the one thing on a traced page the trace could not name: nothing enqueues it, and
  WordPress prints it itself, so tracing the bytes only ever answered "WordPress". Hints
  added through WordPress' own filter now carry the callback that added them; hints
  printed straight into a theme template are found in the page and labelled with the file
  that printed them.
* Add: **server-configuration checks**. The `.htaccess`, `php.ini` and `.user.ini` files
  inside your WordPress folder are now read and reported to your provider, with a history
  of every time one of them changed. These are the one part of a WordPress install that
  changes what the *server* does rather than what WordPress does &mdash; nothing in the
  WordPress admin shows them, and a single line in one can outlive every other trace of a
  break-in. Flagged: a file set to run before every request, a rule that makes uploaded
  images execute as PHP, a redirect that only fires for search engines or for visitors
  arriving from one, and a `php.ini` sitting in the media library, which has no legitimate
  reason to exist.
* Note: nothing is ever changed on your site. A good share of these directives are written
  deliberately by a host or a security plugin, so the point is that you get to see them and
  decide &mdash; not that they are removed for you. A folder correctly set to refuse PHP is
  reported too, as good news rather than as a problem.
* Add: the scan's signature list can now be updated by your provider without updating this
  plugin. A new detection reaches your site within hours instead of waiting for a plugin
  release to travel a whole fleet &mdash; and a signature that turns out to fire on a
  legitimate plugin can be switched off just as quickly. A site that has never been paired,
  or whose provider is unreachable, keeps scanning with the list this plugin ships.
* Add: **email delivery is now monitored**. WordPress keeps no record of the mail a site
  sends: when a contact form stops delivering, or an order confirmation never arrives, the
  error is raised and thrown away and the site goes on looking perfectly healthy. This now
  counts what the site tries to send and what fails, keeps the reason the mail server gave,
  and reports which transport is doing it &mdash; PHP's `mail()`, an SMTP server, or the
  plugin that configured one. Your provider sees it alongside everything else.
* Note: no part of any message is recorded. Not the recipient, not the subject, not the
  body &mdash; only how many were attempted, how many failed, and why. And "this site has
  sent no email" is reported as exactly that rather than as a fault: a site with no forms
  and no shop legitimately sends nothing for months.
* Add: a malware scan can now be **cancelled** from your provider's hub. A full scan reads
  every file on the site across several scheduled runs, so on a large or slow one it can
  sit half-finished for a long time &mdash; and until now nothing could stop it, including
  the next scan, which was refused because a run was already going. Cancelling stops the
  walk on the site and lets a new scan start straight away.
* Add: the backup passphrase field now shows how strong the passphrase is as you type.
  Nothing is enforced &mdash; a backup passphrase is not a login, and nobody, including
  your provider, can recover a backup without it, so being locked out by a rule you did
  not expect would be far worse than a weak one. The meter simply says what helps:
  **length beats punctuation**, and four unrelated words reach the top of the scale where
  a short password with symbols in it does not.
* Fix: a remote update could be killed part-way through by the host's PHP time limit. A
  core update or a batch of plugin updates is downloaded, unpacked and installed inside a
  single request, and thirty seconds &mdash; the default on a great many shared hosts
  &mdash; is not long enough. The update is now taken off that clock and detached from the
  connection that asked for it, so it always runs to completion even if the hub stops
  waiting; the hub, for its part, now re-checks the site rather than reporting a slow
  update as a failed one.

= 1.18.0 =
* Add: the malware scan now recognises the backdoors that arrive dressed as an ordinary plugin, not just the classic obfuscated webshells. A plugin absent from WordPress.org has no published files to check it against, so until now one like this surfaced only as *unverifiable* &mdash; never as malware &mdash; however plainly hostile its code. The scan now flags, as critical, a plugin that forges a login as an administrator it picked for the caller, that gates a privileged action on a secret key baked into the file, that creates an administrator from hard-coded credentials, that hides accounts from the users list, or that runs request data through a dynamic function call.
* Note: each of these is matched by the thing that makes it illegitimate, not by an API a healthy plugin also uses &mdash; a login plugin that sets a session after checking a password is untouched, and WordPress' own installer, which creates the first administrator on every site, is not mistaken for a planted one. The false positives it must not raise are pinned by a test alongside the detections themselves.

= 1.17.1 =
* Fix: an untouched WordPress could be reported as having a missing or modified core file. A non-English WordPress installs the readme and licence in its own language too &mdash; `liesmich.html` on a German site, `licenza.html` on an Italian one, eight more names across the languages &mdash; and only the English `readme.html` and `license.txt` were treated as the documentation they are. So deleting them, which most hardening guides tell you to do and a good many hosts already have, was reported as a missing core file, and a copy edited to hide the version number or rewritten in transit was reported as a critical modification. Neither could be cleared: the file's own language package stopped shipping it years ago.
* Change: the release's documentation is now recognised by what it is &mdash; a readme or licence sitting in the site root &mdash; rather than by a list of the English names, so the same is true of every language and of any name a future one uses. Files inside `wp-admin` and `wp-includes` are unaffected: nothing legitimately rewrites one of those, whatever its extension, and a modified one is still a critical finding. The skipped files are still pattern-scanned for malware exactly as before.

= 1.17.0 =
* Add: **page tracing**. Your provider can now ask this site, from their hub, to render one of its own public pages and report which file produced each line of the HTML that came back &mdash; core, a plugin, the theme, a must-use plugin, a drop-in. The question it exists to answer is the one nothing in WordPress answers: *which* plugin adds the Google Fonts tag, or the tracking script, or the markup nobody can find a setting for. Styles and scripts are reported separately with the callback that enqueued each one, because those tags are printed by WordPress itself and naming WordPress helps nobody.
* Note: the trace never alters the page. Nothing is injected into the output, no marker and no comment; the attribution travels beside the HTML as byte offsets, so what is measured is exactly the page a visitor would have received. Two things it reports honestly rather than papering over: a page served straight from a page cache never reaches this plugin at all, and a plugin that captures whole sections of the page into its own output buffer collapses everything inside it to one line. Both are said out loud rather than guessed around.
* Note: a trace runs only for a request carrying a single-use token this site minted seconds earlier for one specific page of its own, and the token is spent before the page renders. Only paths on this site can be traced, no file contents are ever read or sent, and every trace is written to the activity log.
* Add: `POST /jgm/v1/trace` for the above. Additive: a hub too old to know about it never calls it, and a hub that calls it on an older release of this plugin is told the route does not exist rather than handed an empty answer.

= 1.16.0 =
* Add: the dashboard support card can show a telephone number for whoever maintains this site, alongside the email address. It is set by your provider in their hub and pushed here with the rest of the card, and the line is shown only when they have set one &mdash; a support card advertising a blank telephone number is worse than one without. On a phone it is a `tel:` link, because that is where a good share of WordPress dashboards are actually read.
* Note: additive in both directions. A hub too old to send a number leaves the card exactly as it is today, and a number that arrives on a site running an older release of this plugin is simply ignored.

= 1.15.0 =
* Add: the malware scan now counts the plugins in `wp-content/plugins` itself and compares that against the list WordPress reports. Malware that hides a plugin does it by answering the question before WordPress can &mdash; the Plugins screen, this plugin's inventory and, worst of all, its file checks then all agree the plugin is not installed, so not one of its files is ever hashed against the published original. A plugin on disk that the list leaves out, or an active plugin file that declares no plugin at all so nothing can list it, is now reported as a critical finding.
* Change: a plugin found this way is checked against WordPress.org along with the ones WordPress named, rather than being skipped for not appearing on a list it had removed itself from.
* Note: a discrepancy is only reported when WordPress itself, asked again with its cached answer thrown away, disagrees with the answer it first gave. A plugin this plugin reads a header for and WordPress does not is treated as this plugin being wrong, and is not reported.
* Add: must-use plugins are now reported. WordPress runs every `.php` file in `wp-content/mu-plugins` on every single request &mdash; nothing has to activate it, and the admin offers no way to switch one off &mdash; so dropping a file there is the tidiest way to install a backdoor. The scan now lists what is in there, which nothing in this plugin has ever done, and flags two cases outright: a file WordPress runs but leaves off its own must-use list (WordPress hides `index.php` from that list purely for being under 30 bytes, and `&lt;?php include"x";` is eighteen), and a file named to stay out of a directory listing, which WordPress runs anyway.
* Add: the scan also reports when the Plugins screen is not showing an administrator the whole truth &mdash; a plugin filtered out of the list, or a whole tab of it hidden. Both are ordinary WordPress features that white-label plugins and managed hosts use on purpose, so both are reported as information rather than as a problem, naming whatever is being concealed so you can decide which it is.

= 1.14.0 =
* Add: a **Request help** button on the dashboard support card, next to Contact support. It asks for confirmation, then tells everyone on your provider's team &mdash; through the hub, already attached to this site &mdash; that this site needs attention, with an optional note. The mailto: link reaches one person's inbox and arrives without saying which site it is about; this reaches the whole team and does. Hidden on a site that is not connected to a hub.
* Add: a **leftover-file check**. The hub can now ask this site for the artifacts an interrupted backup, a finished restore or a migration plugin left behind &mdash; a `wp-config.php.bak` the web server hands out as plain text, a `database.sql` in the web root, a Duplicator `installer.php`, another backup plugin's local archive folder, an unfinished working folder holding an unencrypted copy of the site &mdash; and delete the ones you pick. Only paths this site itself reported are ever deletable, and WordPress' own files never are.
* Add: `POST /jgm/v1/residue` for the above, and `POST /api/support/request` in the reverse direction for the help button. Both are additive: an older hub never calls either.
* Add: your provider can now switch off either button on the support card, or the card itself, from their hub. Turning off **Request help** closes the route as well as hiding the button, so nothing can be submitted through it. A hub too old to have an opinion leaves all three showing, exactly as before &mdash; the card is only ever removed because somebody chose to remove it. When it is, the Bulwarden screen says so, so the setting does not read as a bug.

= 1.13.7 =
* No change to the plugin. Like 1.13.6 it exists for the release itself: publishing to the plugin directory failed on the very first attempt, in the step that tells the directory which files a version has dropped &mdash; there was no previous version for this one to have dropped any, and the step could not cope with having nothing to do.

= 1.13.6 =
* No change to the plugin itself &mdash; it is 1.13.5 under a new number. The screenshots on the plugin directory name the version they were taken from, so publishing them alongside a matching release is what keeps the listing and the download describing the same thing.

= 1.13.5 =
* Fix: opening a running backup's log squeezed it into the half of the panel beside the Cancel button, leaving the button stranded in the middle of a column of log lines. The log now gets the full width of the panel.
* Fix: the progress bar for the encryption and upload phases sat empty at 0% for a few seconds after each page load, before the first update reached it &mdash; directly under a line already saying how much had been transferred. It now appears only once there is progress to draw.
* Change: the encryption passphrase field no longer stretches the whole width of the panel.

= 1.13.4 =
* Change: the Bulwarden screen has been rebuilt around the question it never answered &mdash; is this site actually protected right now. It opens with the connection, the encryption passphrase, the last backup and the last scan, each with its own state, and a site that has not been set up yet is walked through the three steps that finish it instead of being shown a disabled button and told to look elsewhere.
* Add: a running backup now shows which phase it is in &mdash; database, files, encryption, upload &mdash; and its run log, which the site has been recording all along and never displayed. A failed or cancelled run stays on screen until something replaces it.
* Add: the last scan is summarised with what it actually found, grouped by kind, and says plainly how much of the site could be checked against published originals and how much publishes none.
* Add: a panel for what this server can do &mdash; PHP, the encryption extension, the archive backend, and whether WP-Cron is running &mdash; because a backup that never starts on one site is otherwise invisible from the admin.
* Add: your provider's support details, as pushed by the hub, are now visible on the screen rather than only on the dashboard.
* Add: the connect string and API key can be copied with a button, and the secret is masked until you ask to see it.
* Fix: the progress bar for the encryption and upload phases never appeared, so a long upload looked identical to a stalled one.
* Fix: the button that removes a backup exclusion did nothing when the click landed on its icon.

= 1.13.3 =
* Change: Bulwarden now has its own entry in the WordPress admin menu instead of sitting under Settings. It is a screen you come back to &mdash; to pair the site, set the backup passphrase, start a backup or read the activity log &mdash; not something configured once and forgotten.
* Add: a **Settings** link on the plugin's row on the Plugins screen, so the way to it is where you look right after activating.

= 1.13.2 =
* Fix: an untouched WordPress could be reported as having a modified core file. WordPress publishes a separate package per language, and they differ in exactly one file it checks: `wp-includes/version.php`. The package a site installed is not the language it is displayed in &mdash; switching the admin language installs translation files and rewrites no core file at all &mdash; so a site whose two disagree (an English install later switched to German, or a German one switched to "Deutsch (Sie)") had that one file compared against a package it never installed. It was reported as a critical modification on every scan, and nothing an operator could do would clear it. The package on disk now decides which checksums are fetched, and if WordPress.org publishes none for it, that one file is left unchecked rather than judged against another package's.
* Fix: core findings now tell the hub which package they were measured against, so the diff view can say why WordPress.org's copy of `version.php` differs from a localized site's rather than presenting it as an unexplained mismatch.

= 1.13.1 =
* Fix: some untouched plugins were reported as modified. WordPress.org publishes more than one accepted copy of a file whenever a release's repository copy and its downloadable zip drift apart &mdash; both are genuine, and a site can legitimately have installed either. Every published copy is now accepted, so a file matching any of them is clean. Affected files were reported as critical findings on plugins nothing had touched.
* Fix: findings on those files now tell the hub every hash the file was measured against, so it can show you the diff instead of reporting that WordPress.org has no copy of the file.

= 1.13.0 =
* New: your hub can now see &mdash; and finish &mdash; an update that changed this site's files but never completed. The two that strand a site are a pending WordPress database upgrade (the "Database Update Required" screen every administrator is sent to, which the public site hides completely) and a leftover `.maintenance` lock from an update that died half-way. Both are reported to the hub, and both can be finished from it: the repair runs exactly what WordPress itself would have run, and nothing else.
* New: a plugin waiting on its own database migration (WooCommerce, most commonly) is reported too. It is reported only &mdash; that migration belongs to its author and runs from their own screen.
* New: a restore can now put back **only the database**, leaving every file on disk untouched. A bad content edit or a plugin that mangled its own tables is a database problem, and restoring the files alongside it would also undo every plugin, theme and core update made since the backup &mdash; including the security ones. Restores made by an older hub are unaffected and still restore everything.
* Add: the health report now includes this plugin's own version, so your hub can tell which sites are new enough for a feature before offering it rather than after failing.

= 1.12.0 =
* New: when a file no longer matches the version published on WordPress.org, your hub can now show you the actual diff — the published file beside the one on your site, with the changed lines marked. A checksum mismatch on its own can't tell a caching plugin rewriting an asset apart from a backdoor appended to a core file; this is what tells them apart. Findings now carry the release they were compared against and the hash they should have had, so the hub can prove the copy it fetched really is the published one.
* New: `POST /jgm/v1/file` returns the current contents of a file, and **only** of a file the site's own last scan flagged. It is not a general file read: anything not named by a finding is refused, as are wp-config.php, symlinks, paths outside the site, and files over 1 MB.

= 1.11.1 =
* Fix: the malware scan called a site infected over files that cannot run. A stylesheet, image or font inside a plugin or theme that no longer matches its published release is now reported as a warning rather than as a critical finding — the usual author of a rewritten `style.min.css` is a caching or optimisation plugin, not an attacker, and the file cannot execute either way. It is still checked, still reported, and still scanned for signatures; code files (PHP, JavaScript, HTML, SVG) and WordPress core are unaffected.
* Change: findings now say what was actually observed — "no longer matches the version published on WordPress.org" — instead of implying a verdict. A checksum mismatch means a file changed after it was installed, which is a fact about the file, not a diagnosis of the site.

= 1.11.0 =
* Fix: sites intermittently reported themselves as unreachable to their hub, clearing again on the next check. Every signed request now carries a single-use nonce, so two legitimate requests that arrive in the same second are no longer mistaken for one being replayed and refused.
* **This release must be installed alongside the matching hub update** — the two sign requests to each other, and the signing form changed on both sides at once.

= 1.10.4 =
* Fix: the malware scan flagged a plugin's readme file as "does not match the released version". wordpress.org generates a plugin's checksums once, at release, but authors keep editing the tagged readme afterwards (a "Tested up to" bump, a changelog typo), so this fired on most healthy sites. Readme, changelog and licence files inside a plugin or theme are now reported as informational instead of as a modified plugin — they are still checked, and code files are unaffected.
* Fix: when file-integrity findings from several plugins were listed together, the section was titled after whichever plugin happened to sort first, so it named one plugin above a list that included others. Findings are now titled by what they are, with the affected plugin shown on each file.

= 1.10.3 =
* Fix: a missing or broken component at boot (e.g. an interrupted plugin update that leaves the site with a mix of old and new files) no longer takes down every page on the site. Each boot component now initializes in isolation, the same way a single failing Site Health test already can't break the rest of the health report.

= 1.10.2 =
* Fix: the uploads-folder hidden-PHP check still had false positives after 1.10.1 on images whose embedded color profile or gamma curve happened to be readable-looking binary (a narrow, slowly-drifting run of bytes that lands in printable ASCII without being text — and, being copied verbatim into every thumbnail WordPress generates, showed up identically across a whole set of derivative image sizes). It now also requires the readable run to contain actual letters, which genuine injected PHP always has (a function name, a keyword, at least a superglobal like `$_GET`) and this kind of structured binary noise essentially never does.

= 1.10.1 =
* Fix: the malware scan's uploads-folder check for hidden PHP could flag a legitimate image as a false positive — JPEG/PNG data is effectively random binary, and a large enough image has a real chance of containing a bare `<?=` by pure coincidence. It now also requires readable text to follow the tag, which genuine injected PHP always has but random image data essentially never does.

= 1.10.0 =
* Add: the malware scan's file-integrity check now also covers your **active theme**, the same byte-for-byte verification core files and wordpress.org-hosted plugins already get. A custom or premium theme with no published release to compare against is reported as unverifiable rather than treated as a finding, exactly like a premium plugin.
* Fix: the malware scan's file-integrity check could wrongly report `wp-includes/version.php` as modified on a completely untouched WordPress install, on hosts where PHP's own cached view of that file can briefly disagree with what's actually on disk (e.g. after a core auto-update completes). The check now reads the version straight off disk instead of trusting that cache, and any other checksum mismatch is double-checked before being reported, in case it was caught mid-write.

= 1.9.0 =
* Remove: **white-labelling is gone.** The plugin always appears as "Bulwarden" with its real author in the plugins list, and can no longer be renamed, re-attributed or hidden from users who don't administer the site. Your provider's name, logo and support address still appear on the dashboard support card — that part is unchanged.
* Change: all of this plugin's stored data moved to a `bulw_` prefix, as the Plugin Directory guidelines require. **There is no automatic migration**: a site updated from an earlier release finds no stored settings, and has to be re-paired with its hub (Settings → Bulwarden → Regenerate credentials, then paste the new connect string into the hub). Note the backup passphrase has to be re-entered too, and existing backups still need the *old* passphrase to decrypt — keep it before you change it.
* Change: the settings screen's styles and scripts are now loaded as proper enqueued files instead of being written inline into the page, and two unnecessary loads of WordPress core files were removed.
* Add: readme now documents every external service the plugin contacts, and what is sent to each.

= 1.8.0 =
* Add: when your provider restores a backup to this site, it can now choose whether to also rewrite every URL/domain found in the database to this site's own — on by default (unchanged), with the option to leave the restored content exactly as backed up, which matters when a backup is restored onto a different domain than the one it came from.

= 1.6.0 =
* Add: every backup keeps a detailed **run log** — each phase with timings, per-table row counts, anything skipped or pruned and why, and the exact operation that failed. It shows live on the Settings screen while a backup runs, travels to your provider with every finished backup, and is attached to failure reports, so a failed backup can be diagnosed remotely without server access.
* Add: **chunked, resumable uploads**. Large backups are now sent in small, individually retried pieces that resume mid-file after a network error, instead of one huge request that a proxy body-size limit or dropped connection could doom outright. Falls back automatically when the hub doesn't support it yet. The old hard one-hour upload limit is gone — a genuinely stalled transfer still aborts within about 2 minutes, but a slow, healthy one is never cut off again.
* Add: **end-to-end integrity checks**. The finished archive is verified on disk before it is encrypted (exact size, plus a full read-back of the archive index), and a SHA-256 of the encrypted blob travels with the upload so the hub can prove it stored exactly the bytes this site produced.
* Add: a **disk-space preflight** refuses to start a backup that clearly cannot fit (based on the previous backup's size), failing in seconds with a clear message instead of after an hour of work — or filling the disk under the live site.
* Add: a new streaming archive writer builds the backup in a single pass with no rewrites — dramatically less disk I/O on large sites (a 400 MB test tree dropped from ~24 GB of rewrite traffic to zero) — and already-compressed media (images, video, archives) is stored without recompression, cutting CPU time on media-heavy sites. Requires only zlib; ZipArchive remains as a fallback.
* Fix: several ways a database dump could silently come out incomplete are now hard failures instead — a dropped database connection mid-dump no longer masquerades as "table finished", and a dump missing the options table is refused outright. Dumps are also taken as a single point-in-time snapshot, binary data is written in a corruption-proof form, database views are handled as views (previously their rows were duplicated as table data), and large rows can no longer exhaust memory.
* Fix: an unreadable directory anywhere in the site (a root-owned folder, a mounted volume's lost+found) no longer aborts the entire backup — it is skipped, recorded, and reported.
* Fix: sites whose plugins or themes are installed as symlinks now get an explicit record that those directories were not archived, instead of silently missing data.
* Fix: a backup run killed by the host no longer leaves its temporary working directory — including an unencrypted copy of the site — behind forever; leftovers are cleaned up at the start of the next run.
* Fix: a restored backup no longer carries the "backup in progress" state of the run that produced it, which could block new backups on the restored site for up to an hour.

= 1.4.1 =
* Fix: two `phpcs:ignore` comments in the 1.4.0 working-directory cleanup referenced sniff codes that don't exist, so they didn't actually suppress anything — the WordPress Plugin Check tool correctly flagged direct `rmdir()`/`scandir()` use. No functional change.

= 1.4.0 =
* Add: a "Cancel backup" button on the Settings screen, shown while a backup is running. It works even if the backup process is wedged, not just when it's responsive: it cleans up the working directory and notifies your provider immediately, rather than waiting for the process to notice.
* Fix: a symlinked directory anywhere in the site (pointing at an ancestor, another site sharing storage, or a loop between two directories) could send the archiving step into unbounded recursion with no error — indistinguishable from a hang. Symlinked directories are no longer followed.
* Fix: a stray Unix socket or FIFO under the site (e.g. a cache/queue backend's socket file) could make the archiver hang indefinitely trying to read it. Only regular files are ever archived now.
* Fix: an upload that stalls completely (a proxy or load balancer silently dropping an idle transfer) now fails within about 2 minutes with a clear error, instead of hanging for up to an hour.
* Fix: every backup's temporary working directory is now fully removed when it finishes — previously an empty folder was left behind after every single run, successful or not.

= 1.3.0 =
* Add: live backup status — the Settings screen now shows which step a running backup is on (dumping the database, archiving, encrypting, uploading), its size, and upload speed, refreshed automatically while it runs. Your provider can now also query this status directly, so a backup that crashes without reporting back no longer leaves its record stuck at "running" forever.
* Add: only one backup can run at a time; starting a second one while another is in progress is refused with a clear reason instead of the two colliding.

= 1.2.1 =
* Fix: a phpcs:ignore comment for the backup-exclusions form field no longer covered the line it was meant to (Settings.php), and the repository now ships a `.gitattributes` pinning source files to LF line endings — no functional change.

= 1.2.0 =
* Add: backup exclusions are now a plain editable list on the Settings screen (add/remove a directory row) instead of a freeform textarea, pre-filled with every exclusion currently in effect — including the defaults, which used to be invisible and hardcoded.
* Add: new default exclusions for the folders the most common alternative backup plugins (UpdraftPlus, All-in-One WP Migration, Duplicator, BackupBuddy, Total Upkeep, WP Clone) use for their own local archives, so they no longer bloat every backup by default.
* Add: a `.git` directory is now excluded wherever it occurs in the site tree (not just at the site root), and is pruned from the walk entirely rather than iterated and discarded — much faster on a site with a large repository history.
* Fix: sites that already had a custom backup-exclusions list keep the protection that used to be hardcoded (the cache/upgrade exclusion) via a one-time migration, so upgrading can't silently shrink what gets excluded.

= 1.1.1 =
* Fix: the plugin's own pre-branding default (Settings menu label, admin plugins-list name) is now `Bulwarden` instead of the generic `Site Maintenance` placeholder left over from before the rebrand — matches the hub's own agency branding default, which already shipped as `Bulwarden`. An agency's white-label branding still overrides it after a site is paired.

= 1.1.0 =
* Add: the malware scan's signature walk (Layer B) now skips any file already proven byte-identical to its published release by the checksum verifier (Layer A) — a pattern match inside code the vendor actually shipped isn't evidence of compromise on this site, so it no longer gets reported.
* Fix: the scan no longer flags its own installation folder. It isn't hosted on wordpress.org so it was never checksum-verified, and its own pattern documentation quotes the very strings the signature scanner looks for — which the scanner was matching against itself.

= 1.0.2 =
* Fix: text domain corrected to `bulwarden` (header and every translatable string). The 1.0.0 revert to `jg-maintenance` was itself a mistake: it matched this plugin's source-repo folder name rather than its actual installed slug, which has shipped as `bulwarden` since 0.3.2.
* Fix: the restore process's self-protection check (skip this plugin's own folder mid-restore, so it can't overwrite the code that's running it) was still checking `wp-content/plugins/jg-maintenance` and so silently failed to protect the real `wp-content/plugins/bulwarden` install.

= 1.0.1 =
* Fix: resolved the remaining WordPress Plugin Check findings — the header/translatable-string text domain regression from 1.0.0 (see below), a stale `Tested up to`, and several false-positive warnings (direct file I/O in the backup/restore subsystem, direct `$wpdb` calls, `set_time_limit()` in background jobs, and core-filter lookups) now carry documented justification so they no longer flag.

= 1.0.0 =
* Fix: text domain reverted to `jg-maintenance` everywhere (header and every translatable string). It must match the plugin's slug/folder name, not the `Bulwarden` brand — the earlier switch to `bulwarden` broke WordPress.org's text-domain convention despite matching the new name.
* Add: a Privacy section documenting exactly what data is sent to a paired hub, and when.
* Harden: encrypted backup uploads now honor a site's configured HTTP proxy and verify TLS certificates the way the WordPress HTTP API does, rather than relying on raw curl defaults.

= 0.3.3 =
* No plugin changes. Version bump only, to re-cut a release after a suite CI fix (release:maintenance/deploy:plugin were still looking up the pre-rebrand plugin slug).

= 0.3.2 =
* Fix: main plugin file renamed to bulwarden.php to match the Bulwarden rebrand (the CI build looks up the file by plugin slug).

= 0.3.1 =
* Rebrand: the plugin is now Bulwarden (user-facing strings, plugin header, and default branding).

= 0.3.0 =
* Rebrand: the plugin is now Bulwark, the site agent for Bulwarden (user-facing strings and plugin header).

= 0.2.3 =
* Backups: the archiver now tolerates files that are deleted or replaced mid-backup (e.g. another backup plugin rotating its own files) — the affected files are skipped and logged instead of aborting the whole backup.

= 0.2.2 =
* Backups: admin-configurable exclusion paths (Settings → JG Maintenance → Backup exclusions) so directories the plugin can't know about on its own — e.g. a legacy backup plugin's folder — can be left out of the archive instead of bloating every future backup.

= 0.1.0 =
* Initial scaffold: client agent with signed REST API, health + security collectors, remote updates, white-label settings. (Hub is a separate webapp.)
