PowerSEC — Full Changelog
=========================

= 1.4.230 =
* Fix (upgrade path, found by an independent review of 1.4.229): a malware scan left unfinished by the update to 1.4.229 could resume and be finalized under the NEW finding-label grammar even though its findings were recorded under the old one. A stored label such as "/{content}/uploads/a.php" — which under the old grammar meant a literal directory named {content} inside the WordPress root — would then be re-read as the relocated-content tag, i.e. a DIFFERENT file. A run now records the label grammar it writes in, a run recorded under a different grammar is RESTARTED with a fresh run id and fresh findings rather than resumed, and the finalized summary reports the grammar that run actually used instead of assuming the running build's. Reproduced and pinned by tests/test-finding-labels.php (UP1-UP7).
* Hardening: when the Central sync hashes a flagged file, a legacy ABSOLUTE stored path is now accepted only after realpath() resolution and containment against the roots the scanner walks. The previous check ("the string starts with ABSPATH") would also accept a traversal such as <ABSPATH>/../../etc/passwd, giving a poisoned scan summary a hash oracle over arbitrary readable files. Pinned by CX1-CX6.

= 1.4.229 =
WordPress.org review response (R powersec/amoomj/23Jul26/T13, 2026-09-10):
* Malware findings now carry an unambiguous, reserved-grammar identity. A file's finding label is emitted by one canonical helper (PowerSEC_Utils::finding_label) and resolved back by one canonical resolver (resolve_finding_label); on a site whose content or uploads directory is relocated outside the WordPress root (e.g. a Bedrock layout), files under those roots are tagged {content}/… or {uploads}/… so a file there can no longer be confused with a same-named file under wp-content, and quarantine/delete/read-flagged actions target the right file. Traversal, out-of-root symlinks and pre-1.4.229 ambiguous labels are refused with a clear "run a new scan" message, never resolved to a guessed file. Every authorization gate (flagged-only, core/credential/self denylist, AI-consent purpose) is unchanged; delete_file remains quarantine-only and now resolves against the quarantine directory (which may live under a relocated uploads dir) instead of the WordPress root.
* Removed "Tested up to" from the plugin header; it belongs only in readme.txt (its authoritative home).
* The AJAX-endpoint fallback in the endpoint-path helper is derived from the resolved admin URL instead of a hardcoded /wp-admin/admin-ajax.php literal.

= 1.4.228 =
WordPress.org review response (R powersec/amoomj/23Jul26/T9, 2026-09-02):
* WordPress core auto-updates: PowerSEC no longer manages them at all. The three opt-in filters on WordPress core's own automatic-update policy, the stored policy option, and the "core" branch of the set_auto_update remote action are removed; the plugin now adds NO hooks to WordPress's update system under any configuration, and the retired option is deleted on upgrade. Core auto-update state is still reported read-only, through WordPress's own wp_is_auto_update_enabled_for_type(). Per-plugin and per-theme auto-update toggles are unchanged — they write WordPress's own auto_update_plugins / auto_update_themes options, exactly as the Plugins screen does.
* Endpoint paths are resolved through WordPress, never assumed. The firewall's rate-limit bucketing, the WAF's path classifier, the Central-request check and the shipped WAF false-positive exceptions now take the admin, login, XML-RPC and REST paths from admin_url() / site_url() / rest_get_url_prefix() (one helper, PowerSEC_Utils::wp_endpoint_paths()), and the AJAX bucket uses WordPress's own DOING_AJAX signal instead of any path at all. An unusable value falls back to the endpoint WordPress serves, so a filtered-away URL can never become a match-everything token.
* Fix: the six built-in WAF false-positive exceptions (post editor, comment moderation, settings pages, plugin/theme editor, Customizer, wp-cron) are "^"-anchored regexes and were hardcoded to /wp-admin and the site root, so on a site that keeps WordPress in a subdirectory they matched nothing and were silently inert. Their base now comes from admin_url() / site_url().
* The uploads directory is always the one wp_upload_dir() reports. The three remaining "wp-content/uploads" fallbacks (backup roots, file-permission targets, the polyglot scan root) are removed; if WordPress cannot report the location, that root is omitted rather than guessed.
* Removed the legacy batch malware scanner (scan_batch() and its private enumerator, lock, status and threat handlers). It had had no caller since the resumable whole-tree scanner replaced it; PowerSEC_Malware_Scanner is retained for the Central rule-sync helpers and single-file signature scanning.

= 1.4.227 =
* Compatibility: the one-time migration that stops the file-integrity baseline from autoloading no longer calls the WordPress 6.4+ autoload helper; it uses the 5.8-era options API only (delete + re-add non-autoloaded, verified before the migration is marked done, with a retry so the baseline is never left missing). WordPress.org's Plugin Check flagged the 6.4-only call against the declared 5.8 floor.

= 1.4.226 =
* Performance: the file-integrity baseline (one entry per monitored file, several MB on a large site) is no longer autoloaded on every page load. A one-time upgrade step flips the stored option to autoload=no, and every writer keeps it that way.
* Performance: the Central-synced malware rules, false-positive suppression hashes and vPatch rules are no longer autoloaded either, and each set is now size-capped — a payload beyond its cap is rejected and the previously synced set is kept.
* Fix: a file-integrity pass whose cached target list expired mid-pass no longer resumes at the old offset into a rebuilt list (which silently skipped a run of files). The pass restarts from the beginning, the target list is kept alive while slices keep arriving, and the abandoned pass is reported to Central as partial coverage rather than complete.
* Fix: the "Disable REST API for non-authenticated users" hardening and Emergency Lockdown's REST restriction exempt PowerSEC's own namespace by request PATH only, anchored to a path segment. Previously the exemption matched anywhere in the URI, so any route could opt out by carrying "/wp-json/powersec/v1/" in its query string. Lockdown also gains the ?rest_route= form of the exemption for plain-permalink and subdirectory installs, so it can no longer lock Central out there.
* Fix: the firewall's signature matcher now distinguishes a pattern-engine error from "no match". An engine abort is counted, reported in the WAF statistics sent to Central, and logged once an hour in the audit log; the traffic decision is unchanged.
* Fix: the file scanner's WordPress-root containment check is anchored to a path segment, so a sibling directory sharing the install's name prefix is never walked.
* Maintenance: pull-mode parity with Central — run_vulnerability_scan, run_malware_scan and run_file_scan are no longer accepted over the pull channel (Central stopped issuing them there on 2026-08-21). Scan results from edge-blocked sites are no longer reported as complete when Central could not record them.
* Maintenance: the idempotency-record cleanup no longer runs an unbounded DELETE on every inbound remote action; it runs at most once an hour in bounded batches, and the daily sweep remains the primary reaper.
* Maintenance: the daily prune of the login-attempt, firewall-log and WAF-block-log tables repeats within a shared 20-second budget while a table still has backlog, instead of stopping at a fixed 20,000 rows per table per day.
* Maintenance: the audit-chain anchor sent on every Central sync reads the chained-row count from a maintained option instead of counting the audit table each time.
* Maintenance: removed the unreachable synchronous ajax_create_backup() handler (its registration was removed in 1.4.159; the async path is the only entry point).

= 1.4.225 =
* Performance: is_recovery_active() runs from the firewall, the WAF, the WAF decision engine and the geo gate — on EVERY front-end request. Moving the break-glass drop point under uploads in 1.4.224 made that path resolve wp_upload_dir() and prepare a directory on each request. It now reads the token option first and returns before touching the filesystem when nothing is outstanding, which is cheaper than even the pre-1.4.224 behaviour.
* Resolving the recovery path no longer creates a directory as a side effect; the guarded folder is prepared only when a token is actually issued.
* Corrected the fallback label on the Advanced screen, which still named the pre-1.4.224 location.

= 1.4.224 =
* WordPress.org automated-review response: the break-glass recovery token is now read from a guarded folder under wp_upload_dir() instead of the wp-content root, so it is resolved at runtime and protected from direct web access. The previous location is still read, so an owner following older documentation is never locked out. The plugin has never written this file — the owner places it by hand.
* X-Key-Id and X-Request-Id headers are bounded to the character set and length the issuer actually uses before being used as a key lookup or an idempotency row key. Neither header is part of the HMAC signing payload, so this cannot affect signature verification.

= 1.4.223 =
* WordPress.org review response: scans and the file scanner now raise memory through WordPress's own wp_raise_memory_limit() helper instead of ini_set(), so a host limit already above 256M is never lowered.
* Database restore no longer requests unlimited execution time; it takes a bounded budget and renews it as it works, so nothing runs unbounded and a large restore still cannot stop half-applied.
* Removed an unreachable wp-content path fallback, and documented the scanner's domain lists and the firewall's admin-ajax matcher as detection data rather than remote dependencies.

= 1.4.222 =
* Fix: the firewall's automatic IP blocking now exempts the site's own server address (loopback and
  the origin/CDN-egress IP), so it can never block the server from itself. A request whose client IP
  resolves to the server's own address is the site talking to itself — WP-Cron self-pings, loopback
  health checks — never an external attacker, and blocking it would break background work while
  stopping nothing. Surfaced when an adversarial firewall test run from the origin host tripped the
  auto-block threshold on the server's own IP and generated a "your server IP was blocked" alert.
  The exemption sits alongside the existing "never block PowerSEC Central" guard, on both the block
  and the enforcement paths; ordinary external IPs are unaffected.

= 1.4.221 =
* Security: the firewall/WAF now detects several evasive variants of attacks it already blocks,
  found during an adversarial remote-attack test of the full rule set. A `<script/...>` tag (whose
  slash terminator makes it execute in browsers while slipping past a stricter pattern) is now
  caught; octal-encoded IP addresses used for SSRF (e.g. 0177.0.0.1 for 127.0.0.1) are detected
  alongside the existing decimal and hex forms; and heavily obfuscated Log4Shell strings assembled
  from nested lookups (${${::-j}...}) — where no literal "jndi" appears — are now recognised. The
  patterns were tightened so ordinary content (numeric-leading domains like 007.com, template
  syntax like ${amount}, words such as "transcript"/"subscription") is never falsely blocked;
  verified by the false-positive regression battery (0 false positives, 0 undetected attacks).

= 1.4.220 =
* Fix: a bare `..\` in a request body is no longer treated as a directory-traversal attack.
  JSON escapes the next character with a backslash, so ordinary text containing ".." immediately
  before an escaped character — someone typing "ok.." and pressing enter, a tab, a quote, or any
  non-English word — is serialised as `..\` and matched the Windows path-traversal rule. On sites
  that receive JSON webhooks (chat bots, payment gateways, CRMs) this fired on normal traffic: one
  site's own chat-bot webhook accounted for 95% of every block and 80% of its security emails, and
  the bot was refused for as long as blocking was enabled. Both traversal rules now require real
  path structure — a repeated traversal segment, a known system target, a traversal onto a file
  with a meaningful extension, or an encoded form that is never legitimate — rather than a lone
  separator. Detection of genuine traversal is unchanged, including traversal sent inside a JSON
  body, which the previous rules could actually miss.

= 1.4.219 =
* New: PowerSEC now warns you when your site's file permissions stop WordPress from installing its
  own updates. If WordPress cannot overwrite its own files it abandons every core update, including
  SECURITY releases, and the site quietly stays on an old version while everything still looks fine.
  Each failed attempt also leaves one newer file behind, which then shows up as a "modified core
  file" — the confusing symptom you would actually notice, with nothing explaining the cause. It
  also warns when core files are writable by any account on the server, which would let another
  account or process modify WordPress itself. Both are things your hosting provider can correct.
  The check runs once a day, is bounded so it stays cheap on large sites, and uses no shell commands.

= 1.4.217 =
* Documentation only: the plugin description now lists vulnerability scanning under the PowerSEC
  Central features rather than the no-account local features, because matching your plugins, themes
  and WordPress version against the vulnerability database requires a Central connection. No code or
  behavior change.

= 1.4.216 =
* A vulnerability check that could not reach the vulnerability database now says so clearly, instead
  of showing a green "no vulnerabilities found". This matters most on a site that has not connected
  to PowerSEC Central: vulnerability checks match your components against a continuously updated
  database delivered through Central, so without a connection there is nothing to check against — the
  screen now invites you to connect rather than implying your site was checked and is clean. A check
  that was interrupted part-way is likewise reported as incomplete, and your recorded findings are
  never changed by a check that did not complete. Also hardens the vulnerability-matching service
  against oversized requests and corrects a few labels that mentioned a built-in database that no
  longer exists.

= 1.4.215 =
* Fixes scheduled vulnerability scans stopping a few hours after 1.4.212. The plugin reports two
  different things to the dashboard — which security database it HAS, and which one its last scan
  USED — and 1.4.212 accidentally reported the second in place of the first. Because the database is
  published several times a day the two rarely match, so the dashboard concluded the site had not
  installed the current database and stopped asking it to scan, while the site waited for a scan
  that would never be requested. Sites recover on their own after updating.

= 1.4.214 =
* Scan times sent to your PowerSEC dashboard are now converted to UTC. They were sent in the site's
  own timezone, so a site set to a timezone ahead of UTC showed a "last checked" time that had not
  happened yet — and could look permanently up to date when it was not. Times shown inside
  WordPress are unchanged; only what is sent to the dashboard was corrected.

= 1.4.213 =
* Fixes a vulnerability scan being reported as failed right after a security database update.
  1.4.212 made the plugin report the rules version a scan ACTUALLY used rather than the version it
  had last downloaded. Those two legitimately differ when a database update lands between a scan
  being requested and it running, and the plugin was treating that difference as an error — so a
  scan that was NEWER than the one requested was rejected. The check now applies only where it can
  still detect a genuinely stale local database.

= 1.4.212 =
* Vulnerability scanning now runs automatically once a day on EVERY plan. Previously the plugin had
  no schedule of its own for it, so a site was only checked when someone pressed the button or
  Central asked — in practice sites drifted for weeks between scans.
* The plugin now reports which rules version a scan ACTUALLY used, instead of the version it last
  downloaded. Those are different things, and conflating them meant a site always looked
  "already scanned with the latest rules", so a re-scan was never requested.
* A scan that cannot reach the vulnerability data no longer reports itself as a completed, clean
  scan. It records that it was deferred and leaves the previous findings untouched — a check that
  could not run is not a clean bill of health.
* A refused or failed data request no longer cascades into downloading the entire vulnerability
  feed, which used to multiply one failure into many requests.
* Removed a small hardcoded list of example vulnerabilities (including a test entry that could
  report a fake critical finding). Real vulnerability data is now the only source.
* Fixed incident response steps that promised a file scan but ran the component-version scan, which
  cannot detect a malicious file.

= 1.4.211 =
* Backup engine: the database-export fetch width is now byte-true and pessimistic (schema
  average, observed average, and the largest row seen all bound it), so a table mixing tiny
  and multi-megabyte rows can no longer trigger an out-of-memory crash loop mid-export.
  The export state persists on every width change, so a resume never replays a wider fetch.
* Backup engine: tables with a single integer primary key now export with keyed-range paging
  (deterministic order, no OFFSET re-walks); other tables keep the legacy paging. A resume
  from a pre-upgrade checkpoint finishes its table on the old scheme — never a mid-table switch.
* Backups no longer archive the plugin's own transient remote-request log table: restoring a
  stale idempotency log is wrong, and on busy sites it dwarfed the real site data.
* Remote-request log: response bodies of read-only actions (backups list, runtime status,
  scan state) are no longer stored — a duplicate read re-executes instead; oversized stored
  bodies are capped. This bounds the log table permanently.
* The backups list action supports optional limit/minimal-field requests from Central; without
  them the response is unchanged. Also fixed: a list with an in-progress backup mid-list could
  serialize as a JSON object instead of an array.
* Orphaned per-backup records (no local file, no live cloud copy, past a 14-day grace) are
  now tidied by maintenance; deleting a backup whose files are already gone now removes its
  records instead of failing. A live cloud copy is never touched.
* Best-effort memory raise at export start (where the host allows it); removed a dead legacy
  export function.

= 1.4.210 =
* Alert hygiene: a deterministic host condition (disk full, backup already running, a sync
  already in progress) now audits as a warning instead of pushing a HIGH security alert to
  Central on every occurrence — Central raises one standing, self-resolving notice instead.
* Failure messages name the action and carry the handler's own reason ("Remote action
  'create_backup' failed: Insufficient local storage...") instead of "Remote action failed."
* Attack alert: stable title ("Attack in progress", count moved into the message) and an
  escalating throttle (30 min -> 2 h -> 6 h while the same attack persists; resets after a
  quiet period) — a sustained attack no longer emails the owner every 30 minutes.
* Cloud-only differential guard: a differential REQUEST that self-downgraded to a full keeps
  its local stage (it is the base manifest future differentials need); user-chosen cloud-only
  fulls drop their local stage exactly as before.
* SQL hardening: the last interpolated LIMIT is bound through a %d placeholder.
* readme: Tested up to 7.1.

This file holds the complete version history. To keep readme.txt small (per
WordPress.org guidance), readme.txt lists only the most recent releases; every
prior release is recorded here.

= 1.4.209 =
* Security-review hardening wave:
* Quarantined files are now stored under neutral, non-executable names — the original file name and extension are no longer part of the stored name, so no server configuration can run a quarantined file. Both quarantine paths use one shared naming scheme, and files quarantined by earlier versions are renamed on upgrade without data loss.
* The quarantine folder's protective files are now verified after writing; if they cannot be confirmed, that is recorded — quarantining itself always proceeds, since refusing would leave a flagged file in place.
* Fixed the firewall's upload size check wrongly blocking every multi-file upload when "Block large file uploads" was enabled.
* Connecting the site to PowerSEC Central now completes only for the administrator who started the connection.
* Removed a leftover admin endpoint handler that was no longer registered and could not be invoked.
* WordPress core auto-update behavior: PowerSEC now adds no hooks to the update system at all unless an administrator has explicitly chosen a core update policy; "unmanaged" removes the stored policy entirely.
* The internal wp-config safety copies (taken before a database prefix change) are now excluded from the plugin's own backups and only the newest three are kept.

= 1.4.208 =
* Alert email filtering now works as the settings screen says it does:
* The "Minimum severity" setting under Settings > Notifications > Email previously could not raise the floor. Two separate code paths sent alert mail: the audit logger emailed every critical and high event without consulting the setting, while the SIEM dispatcher read it but deliberately handled only severities below critical/high to avoid duplicate mail. A site set to "Critical" therefore kept receiving every high-severity email. The audit logger now honours the setting, so the control finally means what it says.
* New: "Do not email these events" — a comma-separated list of event types to keep out of your inbox. A site under a sustained brute-force attack produces a legitimate, unbounded stream of login_lockout and ip_blocked alerts; previously the only remedy was switching email off entirely, which also silenced malware and file-integrity alerts. Muting is email-only: the event is still written to the audit log, still pushed to your dashboard, and still sent to any SIEM channel you have configured.
* Severity ranking now lives in one place shared by every notification channel, so email and the SIEM channels can no longer disagree about what "high" means. An unrecognised severity floor falls back to the shipped default rather than silencing a channel.
* Defaults are unchanged: minimum severity ships as "high" and the mute list ships empty, so a site that never opens the setting behaves exactly as it did before.

= 1.4.207 =
* Backup accuracy and cleanup pass:
* A backup whose off-site copy has rotated out of your restore-point window is now shown as such, instead of continuing to display as stored off-site. The plugin previously recorded "uploaded" once and never took it back, so backups kept a cloud badge long after their off-site copy had been replaced by newer ones.
* Because of that, "Remove local copy" could be offered for a backup whose off-site copy was already gone — deleting the only remaining copy. It is now offered only when an off-site copy really exists.
* A backup that is still being written no longer appears in the list as a finished, restorable backup, no longer counts toward the backup totals, and no longer sets the "last backup" time.
* Temporary files created when you download a backup are now removed after the download and swept up if abandoned, instead of accumulating on disk.
* The Backup page now warns when this server is running low on free disk space, before a backup fails for want of room.
* After deleting a backup, the restore-point count no longer shows a fixed "/7" that was wrong on every other plan.
* Clearer wording: the cloud storage figure is labelled as cloud, "Last full" is labelled "Last backup" (it was the newest backup of any type), and status labels no longer claim a health verdict the plugin cannot verify on its own.
* The Backup page no longer waits on PowerSEC Central before rendering, so a slow or blocked connection cannot leave the page blank.
* Pause/resume for cloud uploads is now reachable before the first upload completes.

= 1.4.206 =
* Reliability and hardening pass:
* Backups and restores now hold their working lock for the entire operation, so a restore's own safety checkpoint can no longer release that lock early and let another backup start mid-restore.
* Leftover temporary backup files from an interrupted backup or download are now cleaned up automatically once they are safely past any resumable window, instead of lingering on disk indefinitely.
* If one of PowerSEC's internal database tables is ever missing (after a partial restore or manual database edit), it is now rebuilt automatically — this covers the tables behind file-change history, session tracking, and duplicate-action protection.
* The audit-log tamper check now verifies the entire log, including the most recent entries, rather than only the oldest portion on very large logs.
* A background action that was interrupted before finishing can no longer permanently block later attempts of the same action; it becomes retryable after a safe interval.
* Diagnostic output no longer includes unrelated server error details.
* The Central connection is read consistently everywhere, and a heavy status sync will no longer run twice at once when triggers overlap.

= 1.4.205 =
* Fixed a case where enabling "Disable REST API for non-authenticated users" could stop PowerSEC Central from managing the site. The setting is meant to exempt PowerSEC's own secure management endpoints, but the exemption only recognised the pretty-permalink address form (/wp-json/powersec/v1/...). Sites using plain permalinks, or WordPress installed in a sub-directory, reach those same endpoints through the ?rest_route=/powersec/v1/... form instead, which the exemption missed — so the hardening returned a 401 to PowerSEC's own signed calls while the site kept reporting in and appeared healthy. The exemption now recognises PowerSEC's namespace in both address forms. It is scoped to PowerSEC's own endpoints, which enforce their own authentication, so every other route stays blocked exactly as before.

= 1.4.204 =
* The firewall's optional "Block low-confidence rules" setting now takes effect. When it is turned on, a lower-confidence attack-pattern match in Block mode is now enforced instead of only being recorded — previously the setting changed nothing. It stays off by default, and the few patterns that are prone to false positives against ordinary content remain log-only even when it is on, so turning it on cannot start blocking legitimate visitors.
* The firewall panel now states plainly what Block mode enforces: high-confidence detections are enforced, and the always-on firewall covers SQL injection and cross-site scripting on its own.

= 1.4.203 =
* Auto-blocking now blocks a repeat attacker. A self-protection guard — meant to stop an administrator locking out their own IP from the blocking screen — was also firing on the automatic path and refusing to block the very address that had tripped the rules, so a persistent attacker was never actually blocked. The guard now applies only to a signed-in administrator.
* The "known malicious bots" filter now includes sqlmap and other common scanning tools, which previously passed straight through.
* HoneyPot trap guidance is clearer: a trap only fires for a URL that reaches WordPress, so extension-less paths work best and traps do not fire while permalinks are set to Plain. The default trap list leads with paths that work on the common setup.

= 1.4.202 =
* Settings that PowerSEC Central manages are now marked on the Firewall screen. Central sends an authoritative value for a fixed group of settings on every sync, so changing one of those on the site appeared to save and was then quietly replaced, while the controls beside it kept their change. The label is built from what Central actually sends, so it cannot fall out of step.
* A request refused for exceeding a rate limit now answers 429 with a Retry-After header, instead of 403. A rate limit is not a permission problem, and a well-behaved client can now tell the difference and knows when to try again. Requests blocked by a firewall rule still answer 403.
* The bot-protection tile counted modules that could not run: with the master switch off, none of them act, but their individual settings stay stored as on and the tile reported them as enabled.

= 1.4.201 =
* "Remove WordPress version" removed the generator tag but left the exact release in every stylesheet and script URL, where anyone could read it from the page source — so the setting did not achieve what it is switched on for. The version in those URLs is now replaced by a value unique to your site. Browsers still know when to fetch updated files, and the release number is no longer published.

= 1.4.200 =
* Some hosts have no working mail transport at all, which silently breaks WordPress password resets, new-user notifications and two-factor codes — an administrator who logs out cannot get back in. When a site is connected to PowerSEC Central and its own attempt to send fails, Central now delivers the message instead. It only ever runs after a real failure, so nothing changes on a host where mail already works.
* Central will only send to addresses it already knows belong to that site: the account owner, and the administrators the site reported over its signed connection. It cannot be asked to send anywhere else, and the number of messages is capped, so a stolen site key cannot turn the service into a mail relay. Administrator addresses are shared only while they are needed for delivery — when two-factor is enabled, or when the site cannot send its own mail.

= 1.4.199 =
* Corrects 1.4.198: the XML-RPC multicall block was scheduled onto a WordPress hook that had already begun running, so it never executed and multicall stayed live. It is now invoked directly at the point the protection is configured.

= 1.4.198 =
* XML-RPC system.multicall was still live and executing even with XML-RPC disabled. WordPress applies the xmlrpc_methods filter and then hands the result to IXR_Server, whose constructor re-registers every system.* method — silently undoing the removal. pingback.ping was genuinely stripped, which is why the protection looked correct. multicall batches many calls into one request, which is what makes it a login brute-force amplifier; it is now refused before the server is built.
* The Save Settings button at the top of the Advanced screen submitted the form without triggering its own save handler, so changes appeared to revert. Both Save buttons now save.
* Pressing "Verify now" on the uploads check abandoned rendering of the whole Advanced page on builds that do not ship the active probe — which is every release from the WordPress.org directory. Admin notices also rendered outside the area WordPress styles, so successful actions looked like nothing had happened.
* Changing the database prefix left WordPress addressing the old table names for the rest of the request. The screen kept showing the previous prefix until a hard refresh, the file watcher's baseline update was lost, and the plugin then reported "CRITICAL: wp-config.php was modified!" about its own authorised edit. The audit entry recording the change was lost the same way.

= 1.4.197 =
* Corrects 1.4.196 on servers PowerSEC cannot identify. SERVER_SOFTWARE is empty on some nginx-to-PHP-FPM stacks, and 1.4.196 required a positively-recognised server before it would report "not verified" — so a site whose uploads genuinely refuse to run PHP was still reported unprotected. "Not protected" now requires positive evidence (a server known to ignore the rules, or a stored verdict that a server rule is needed); anything else with the protection files in place reports "not verified".

= 1.4.196 =
* Uploads PHP-execution protection is now reported as three states instead of two. The active verification probe is excluded from the WordPress.org package, so on a published install "unverified" is permanent — and reporting that as "not protected" told sites whose protection demonstrably works that they were unprotected, beside a Fix button that could not change the answer. Present-and-plausible now reports "not verified" (neutral); a .htaccess on a server that ignores it still reports unprotected.
* Two-factor verification codes now reach the site administrator. PowerSEC Central only delivers a code to an address it can tie to the site, and it builds that list partly from the administrator's email — which the plugin never actually sent. On sites whose WordPress administrator is not the PowerSEC account owner the code had nowhere to go once local mail was unavailable. The address is transmitted only while two-factor is switched on.

= 1.4.195 =
* Improved: when a backup cannot start because the web server cannot write files, the Backup screen now
* Core integrity no longer reports optional files (wp-config-sample.php, readme.html, license.txt) as missing when they have been deliberately removed. WordPress never loads them, so their absence cannot indicate tampering; they are now listed separately as information and are still checksummed when present.
  shows the same full instructions as the dashboard warning — which user your web server runs as, which
  user owns the folder, and the exact command to give your host — instead of only the one-line summary.
  Both screens use the same text, so they can never tell you different things about the same problem.

= 1.4.194 =
* Fixed: the "Web Server Cannot Write Files" warning had a "How to fix" button that simply opened the
  Backup screen, where the same problem appeared again with no instructions. The fix now opens in place
  and tells you what to do: which user your web server runs as, which user owns the folder, the exact
  command to give your host, and what will work once it is done. It also says plainly that no plugin
  can fix this for you, because a plugin cannot create a folder it has no permission to create.

= 1.4.193 =
* Improved: "Connect automatically" is now the main button at the top of the Central Connection
  screen. It replaces the two buttons that were there before, which both led to the longer manual
  route. Entering the four values by hand is still available, one click away.
* Fixed: after approving a website in PowerSEC Central you were returned to the Central dashboard
  instead of back to your site, so the connection never completed. The approval page mistook a
  signed-in owner for a signed-out visitor.

= 1.4.192 =
* Added: connect this site to PowerSEC Central in one click. Choose "Connect automatically", sign in to
  your PowerSEC account (or create one), approve this website, and the connection keys are delivered to
  the plugin for you. Previously you had to copy four separate values by hand.
* Entering the four values manually still works exactly as before — it is now tucked under
  "Enter keys manually instead" for sites that prefer it or cannot reach Central from a browser.
* Connecting remains entirely optional. Nothing is saved unless Central confirms every value is correct
  and the first secure sync succeeds; if anything fails, your previous settings are left untouched.

= 1.4.191 =
* Improved: when a backup cannot start, PowerSEC now explains why and what to do. On hosting where the
  web server has no write access, "Backup directory is missing" was accurate but unhelpful — the folder
  could not be created because WordPress cannot create its uploads folder at all. The message now says
  that, quotes WordPress's own explanation, and tells you what to ask your host for.
* Improved: backup failures appear in the page instead of a browser pop-up, so the guidance is readable
  and the progress indicator clears immediately.
* Added: the dashboard now warns that the web server cannot write files BEFORE you try to back up.
  Backups, quarantine and media uploads all depend on it.

= 1.4.190 =
* Improved: the PowerSEC dashboard now leads with connecting your site to PowerSEC Central, which is
  free and links you to your own dashboard for up to 10 sites. Previously the most prominent option
  was an upgrade to a paid plan, which is the wrong first step for a site that has not connected yet.
  Paid plans remain one click away.
* Fixed: a site already connected on the free tier was told to "add PowerSEC Central to this site",
  which it had already done. That card now describes what a paid plan adds.
* Fixed: the Central shortcuts were shown before connecting, where they linked to a dashboard the
  site did not have yet. They now appear once the site is connected.
* Connecting remains entirely optional, and every protection that runs on your own server keeps
  working whether you connect or not.

= 1.4.189 =
* Security: the file-removal containment check could widen from the quarantine folder to the site root
  on a host where the uploads directory cannot be resolved. PHP's realpath('') returns the current
  working directory rather than false, which inverted a deliberate fail-closed signal into a fail-open
  one. Containment now resolves through a single helper that rejects an unusable directory outright.
* Security: the wp-config.php copy taken before a database-prefix change now uses an unguessable
  filename and is not written at all unless its directory guards are in place. The previous
  timestamp-only name was reachable on servers where .htaccess and web.config have no effect.
* Fixed: file restores reported success even when individual files could not be written. Both restore
  engines now verify every file — bytes written are checked against the archive's recorded size, and
  per-file results are read rather than only the archive-level result.
* Fixed: when a differential restore could not apply its deletion list, files removed since the base
  backup silently reappeared. This is now reported.
* Fixed: a download bundle that failed to assemble could be handed over incomplete.
* Fixed: a backup could omit files on hosts without the ZipArchive extension.
* Fixed: deleting a local backup that only partly succeeded no longer discards the records for the
  files still on disk.
* Fixed: the site could stay in maintenance mode after a restore without saying so.

= 1.4.188 =
* Fixed: a critical error ("There has been a critical error on this website") on the Dashboard, Backup
  and Central Connection screens on hosts where WordPress cannot write files directly. WordPress falls
  back to its FTP transport on such hosts; with no FTP credentials configured that transport is created
  but never connected, and PowerSEC used it anyway. It now verifies the filesystem is usable and falls
  back to a direct write, so the screens render and backups continue to work.
* Fixed: the backup folder's protective .htaccess is no longer written into a folder whose creation
  failed, which removed a PHP warning on hosts with a read-only uploads directory.

= 1.4.187 =
* Privacy: a data-export request now returns login-attempt records entered with the account's EMAIL as
  well as its username. WordPress lets people sign in with either, so failed attempts often record the
  email; those records were already erased on request but were not shown in an export.
* Fixed: two admin screens inserted values into the page without escaping them - a backup file name and
  the database-tools job list. Neither is attacker-supplied today; both are now escaped.
* Changed: an internal bulk cleanup query now uses placeholders for its list of row IDs, matching
  WordPress's recommended form.
* Documentation: the historical 1.4.14x note saying database optimization is paid-only is marked as
  superseded - those tools have run on every plan since 1.4.158.

= 1.4.186 =
* Fixed: on WordPress 5.8 through 6.6 the opt-in "delete all data" uninstall removed only the first file
  of each folder it was supposed to clear, then stopped. WordPress only added a return value to its
  wp_delete_file() function in 6.7, and PowerSEC was reading that missing value as a failure. On those
  versions the firewall folder and the wp-config.php backup copies made before an optional database
  prefix change - which contain database credentials - were left behind after an uninstall that reported
  removing them. Deletion is now confirmed by checking the file is actually gone, which behaves the same
  on every supported WordPress version.
* Fixed: the same cause made deleting a file from the dashboard, or from PowerSEC Central, report
  "Failed to delete file" on those WordPress versions after the file had in fact been deleted, and made
  backup-cleanup counts read low.

= 1.4.185 =
* Fixed: the uninstall cleanup's one-off scheduled-task clear was a guaranteed no-op for the two
  families most likely to be queued. WordPress matches cron events by their arguments, and the
  central-sync and async-backup events are always scheduled WITH arguments, so the argument-less
  clear never touched them. Events are now removed with wp_unschedule_hook(), which clears every
  event for a hook regardless of arguments.
* Fixed: on a site with an external object cache, deleting a transient clears only the cache — a
  legacy transient row written before the cache was enabled stayed in the database forever, and the
  cleanup then removed its expiry row, leaving it permanently un-ageable. Enumerated transient rows
  are now deleted explicitly as well.
* Fixed: uninstall data deletion now also removes the wp-config.php backup copies made before the
  optional database-prefix change (they contain database credentials and authentication salts), and
  ends with an object-cache flush so dynamic cache entries the cleanup cannot enumerate are evicted
  where the host allows a flush.
* Fixed: a personal-data erasure request now anonymizes audit-log login records too. These rows are
  written before authentication completes, so they carry no user id and the previous user-id-keyed
  anonymization never matched them — the attempted username, IP and browser details survived while
  the erasure response said identifying details were removed. Rows naming the account are now
  rewritten; the non-identifying record (time, outcome severity) is kept.
* Fixed: an administrator's permanent IP block placed from the Central dashboard is now attributed
  to the administrator, not the automatic system source, so the retention sweep no longer removes
  it after a year. Applies to blocks placed from this version on.
* Fixed: the daily traffic-counter prune and two backup lookups now escape LIKE wildcards, and the
  prune deletes through the cache-aware API.
* Fixed: the uninstall transient cover-list gained nine fixed names that previously survived in an
  external object cache until expiry, and dropped one entry that was never a scheduled task.
* Documentation: the files list now names the wp-config backup folder; multisite notes state that
  data deletion removes per-user data network-wide.

= 1.4.184 =
* Fixed: the opt-in uninstall cleanup deleted option rows with direct SQL, which cannot invalidate an
  object cache. On a site running a persistent cache (Redis, Memcached) the deleted values — including
  Central connection keys — stayed readable after uninstall. Values are now removed through WordPress's
  cache-aware API, so the row and the cached copy go together.
* Fixed: uninstall now removes PowerSEC per-user data, including any active session and magic-login
  tokens, which previously survived a cleanup described as removing stored keys.
* Fixed: uninstall now clears one-off scheduled tasks as well as recurring ones.
* Fixed: transient cleanup no longer treats underscores in its own option prefix as wildcards, so it
  cannot match another plugin's similarly-named transients.
* Privacy: an erasure request now also deletes that account's PowerSEC per-user data. Session and
  magic-login tokens are deleted but never included in an export, because exporting one would hand back
  a working credential.
* Documentation: records that the uploads PHP-execution guards are kept when the plugin is deleted.

= 1.4.183 =
* Documentation: the opt-in uninstall description no longer makes an absolute cleanup claim. One
  option is intentionally exempt — a secret-free pending Central-revocation marker, kept when a Central
  release cannot be confirmed so a reinstall can surface that Central may still trust the old site
  identity. The readme, this changelog and the submission document now state that exception, and the
  documentation-truth gate rejects an absolute cleanup claim that does not disclose it.
* No change to the 1.4.182 cleanup behaviour: the full prefix sweep is unchanged.

= 1.4.182 =
* Uninstall: the opt-in cleanup now removes the PowerSEC option families that previously survived —
  installation identity and private-key state, recovery state, WAF exceptions, and dynamic families such
  as per-backup metadata. Fifty-seven option names had outlived a cleanup the readme described as
  removing settings and connection details. A secret-free pending Central-revocation marker is
  intentionally preserved when Central authority may remain unresolved.
* Uninstall: a partial Central credential set no longer reads as "not connected". It required both an
  API key and a site id, so a site holding one without the other skipped revocation handling entirely,
  which is exactly the state a failed connect or interrupted disconnect leaves behind.
* Documentation: corrected three claims in this changelog that contradicted the shipped readme or the
  code — an absolute statement about Central contact, an absolute description of the opt-in purge,
  and a claim that third-party requests omit the site URL, which held for the User-Agent but not for
  request bodies.

= 1.4.181 =
* Privacy: the personal-data erasure response no longer claims firewall and IP-blocking records are
  removed automatically on the retention schedule. A permanent block an administrator added by hand
  stays until an administrator removes it; only logged events age out.
* Documentation: the User-Agent disclosure is scoped to the request header instead of claiming no
  request carries a site URL or WordPress version. Uninstall cleanup now states that local backup and
  quarantine folders remain, and that deleting from wp-admin cannot notify Central. Bundled Chart.js and @kurkle/color now
  link to their non-minified upstream sources.

= 1.4.180 =
* Documentation: deleting the plugin keeps that site's PowerSEC tables, settings and connection details by
  default; the opt-in cleanup happens only when powersec_delete_data_on_uninstall was set beforehand. The
  1.4.179 readme said deletion removed the data, which was never true for a normal installation.

= 1.4.179 =
* Multisite: network activation is refused with an explanation; activate PowerSEC per site.
* Privacy: the readme now states the three blocked-IP retention rules separately instead of one blanket claim.
* Licences: the bundled Chart.js and @kurkle/color licences are now the exact upstream texts, unmodified.

= 1.4.178 =
* Privacy: the User-Agent on outbound requests to third-party and storage services now identifies the plugin by version only, and no longer carries your site URL or WordPress version. Request bodies still send what each service needs, disclosed per service under "External services" in readme.txt.
* Privacy: added WordPress privacy-policy content and personal-data export/erase support for records tied to a WordPress account. Audit entries are anonymised rather than deleted so the tamper-evident log stays intact.
* AI: automatic AI review of flagged files now requires an explicit opt-in by an administrator of this site, is off by default, and is disclosed in full under "External services".
* Central: connecting a site now requires all four credentials and is confirmed by a real authenticated sync before it reports success.
* Central: disconnecting no longer clears local credentials unless the server confirms it; a separate, clearly-marked local-only option is available.
* Central: a signing or authentication failure is no longer mistaken for a revoked key, so a transient problem cannot wipe a working connection.
* Licences: complete MIT notices for Chart.js and @kurkle/color now ship with the plugin.
* Packaging: shipped files are no longer marked executable, and the release archive is byte-for-byte reproducible.

= 1.4.177 =
* The connection screen now points to the right place in PowerSEC Central. It said the connection key
  comes from Settings → Install tokens; install tokens are not accepted here, and pasting one would never
  connect the site. Your keys come from Sites → Add site, which issues all three at once.

= 1.4.176 =
* Deactivating the plugin no longer disconnects the site from PowerSEC Central. Deactivation used to tell Central to revoke the site's keys, and that could not be undone — so deactivating for a few minutes to troubleshoot a conflict, or a routine update that deactivates plugins, permanently severed the connection and the site could not be reconnected. Deactivation is now silent and fully reversible; reactivating resumes syncing on its own. Disconnecting deliberately still works from the Disconnect button.
* The connection screen now accepts the telemetry and remote-control keys alongside the API key. PowerSEC Central issues three keys, but only the API key could be entered here, which left sites unable to accept settings changes, remote actions, cloud backup or restore from Central. Leave a field blank to keep the key already stored.
* Fixed magic-link sign-ins being recorded twice in the security log and creating a duplicate session record.

= 1.4.175 =
* Security: completes the Database Health escaping fix. Recommendation titles, descriptions and the table name in the action button are now escaped as well — a table whose name contained markup could still run script in an administrator's browser.
* Security: a magic-link login is now spent before the sign-in completes, so the link cannot be reused if something interrupts the process, and two simultaneous uses of the same link can no longer both succeed.
* Security: magic-link sign-ins now respect the same lockout that guards password sign-ins. An address locked out for repeated failures can no longer switch to the email link to get in.
* Magic-link emails are now limited to one per account every two minutes, so a mailbox cannot be flooded with link requests.
* Corrected messages shown while plan sync is degraded. They said protection had dropped to Free level; in fact the firewall, scanning, local backups and login security are unaffected — only Central-delivered services pause.
* Corrected the file-scanner description, which said "your entire site, every folder" without mentioning that PowerSEC's own files and wp-config.php are always skipped and that very large scans stop at a safety ceiling.
* Nonces passed to JavaScript now use the JavaScript escaping function, as WordPress.org requested.
* Corrected the Tor entry under External services, and added an Upgrade Notice for this release.

= 1.4.174 =
* Magic-link logins now appear in PowerSEC's own security log. They signed the user in without firing WordPress's standard login action, and the plugin's audit log, successful-login record, session tracking and failed-attempt reset all depend on it — so a magic-link login was invisible in the very log this plugin provides, and to other security plugins too.
* Security: table names shown on the Database Health screen are now escaped before display. They were taken from the database and written straight into the page, so a table name containing markup could have run script in an administrator's browser.
* Corrected the two-factor description. It said a code is required on every login, which is not true when magic links are enabled — those are already verified by email and do not ask for a second code.
* Corrected the Tor entry under External services: the Tor Project does publish a privacy policy, and it is now linked.
* Removed an unused internal function left behind by an earlier change.

= 1.4.173 =
* WAF blocking mode is now available immediately, on every site. It previously required seven days of observation and, on quieter sites, a hundred logged matches before it could be selected — shown as a padlock and a "Locked" label on a disabled control. Because the WAF runs entirely on your own server, that made a local security feature time- and usage-limited, and on a site with little attack traffic the requirement could never be met at all, so blocking stayed unreachable indefinitely. The observation period is now a recommendation on the screen rather than a restriction, and the `powersec_waf_force_block_mode` option and `powersec_waf_skip_observation_window` filter that existed to escape it have been removed as no longer needed.
* Security: PowerSEC no longer believes a request header that claims a different visitor address unless the connection genuinely arrived from a proxy. Previously a header such as `True-Client-IP` was trusted purely because it was present, so anyone could pick the address used for login-lockout counting, IP blocking and the IP allowlist — defeating brute-force protection and letting a blocked visitor walk straight past it. Forwarded headers are now read only when the connecting address is a private network address, a Cloudflare edge, or a proxy you list under Settings → Advanced → Trusted proxies. Where several addresses are chained, the one nearest your server is used rather than the one the visitor supplied. Sites behind Cloudflare or a load balancer continue to see real visitor addresses exactly as before.
* Security: messages received from PowerSEC Central are now escaped before being displayed in the admin screens, so a compromised or hostile endpoint cannot inject markup into an administrator's browser.
* Connecting to PowerSEC Central is now clearly optional throughout the interface. The setup checklist can be dismissed at any time, its Central steps are marked optional and no longer prevent it from completing, and the connect screen no longer describes scanning — which runs on your own site on every plan — as something a connection unlocks.
* Removed the automatic connect flow, which could no longer succeed. Connecting is done by pasting the site key from PowerSEC Central, which is now the primary option on the screen instead of being hidden behind an "Advanced" panel.
* Added Settings → Advanced → Trusted proxies for sites behind a proxy or CDN that PowerSEC does not recognise automatically.
* Corrected the external-services documentation: the alerting and SIEM section now lists exactly which fields each destination receives, adds the missing terms links, and states that the "Send test" button contacts a destination immediately. Also corrected when plugin checksums are fetched from WordPress.org, and described the component inventory sent to PowerSEC Central in full.
* Corrected the backup description: incremental backups run locally on every plan and are no longer listed as something the paid service adds.

= 1.4.172 =
* Fixed: local backup and restore no longer require a PowerSEC account. Previously the whole backup screen was replaced with "Please connect to PowerSEC Central to use backup features", even though the backup engine itself has never been gated. Creating a backup, the local backups list, restore (database, files-only, full), download, exclusions and both history tables now always appear. Only the AI advisor, the plan summary and Central-dispatched schedules need a connection.
* Diagnostic logging is now written only when WP_DEBUG is enabled, instead of always.
* Corrected three inaccurate statements in the readme: database-content scan findings do include a short redacted excerpt of the matched value; the optional database-prefix change edits wp-config.php in place; and the plugin does write empty index.php directory guards.
* Disclosed that when two-factor login is enabled on a connected site, the one-time code and recipient address are sent to Central for delivery. Sites that are not connected use WordPress's own mail and send nothing.

= 1.4.171 =
* Repaired a malformed code annotation introduced in 1.4.170. Comment only — no functional change.

= 1.4.170 =
* Verified this release against WordPress.org's own plugin-review tool running on a real WordPress install, rather than an approximation of it. Compared with the last pre-review build, the tool now reports 28 fewer categories of finding and nothing new. The few that remain are long-standing false positives in the malware scanner's streaming file reads and the quarantine move, which have to work the way they do.
* Corrected code annotations that had been placed on the wrong line, which had inadvertently re-exposed database findings that were previously explained.

= 1.4.169 =
* Cleared every remaining finding from WordPress's own plugin-review checks. Output escaping across the session, audit-log and backup screens; two redirects switched to the safe variant; a missing translator comment; and the security-token checks on the Central connection screen now use the form that stops the request on failure rather than merely reporting it. Trusted database table names are annotated so the automated review can tell them apart from user input.

= 1.4.168 =
* Security: changing the database table prefix no longer leaves a readable copy of wp-config.php in your site's root folder. That copy had no .php extension, so a web server would serve it as plain text — exposing your database credentials and security keys — and nothing removed it. Backups now go to a protected folder inside uploads that cannot be reached over the web.
* Fixed: blocked bot requests now correctly return a 403 status. The status code was being passed in the wrong argument, so those requests did not return 403 at all.
* Escaped two remaining translated strings that were printed directly: the settings tab labels and the PHP-version notice.
* Removed an unreachable "Restore disabled by plan" label from the backup screen. Local restore works on every plan.
* Removed a redundant security-token check that could never succeed, and corrected its comment.
* Corrected the readme: two API hostnames were written as links that a browser cannot open, and one sentence still described a check that is no longer part of this build.

= 1.4.167 =
* Removed the legacy admin-ajax settings endpoints, including an unauthenticated (`nopriv`) registration. They had been disabled by default and unused since PowerSEC Central moved to the signed REST route, and they are now gone entirely — along with the option, the authentication helper and the admin notice that existed only to serve them. About 200 lines of dormant code removed. No site loses any functionality.

= 1.4.166 =
* Removed a padlock icon and a "Pro required" label that appeared on every row of the malware-scan results when the optional PowerSEC Central AI service was not connected. The scan results are produced entirely by this plugin and are complete without that service, so nothing there is withheld.
* Moved the last inline stylesheet — the scan-progress animation, which was injected from JavaScript — into the plugin's enqueued stylesheet.
* WAF blocking mode: the seven-day observation window was made overridable by the site owner. (Superseded in 1.4.173, which removed the requirement and the override entirely.)
* Corrected two entries in the bundled changelog that still described auto-update and sync-frequency settings as plan-restricted. They are not.

= 1.4.165 =
* The plan-comparison table no longer describes local features as unavailable. Scheduled malware scanning, vulnerability scanning and every notification channel run on this site on every plan, and the table now says so — the other columns describe only what the optional PowerSEC Central service adds.
* Removed the padlock and "Pro feature" heading from the informational cards shown on the firewall, login-security and scanner screens. Those features are not restricted; the cards describe an optional service.
* Replaced "Start Pro Trial" buttons with "See PowerSEC Central plans". Nothing in this plugin is time-limited or trial-based.
* Escaped all remaining translated strings that were printed directly, including on the lockout, anomaly-block, bot-block and magic-link screens.
* The "Verify now" uploads check is now shown only in builds that include it.

= 1.4.164 =
* The sync-frequency setting no longer presents itself as a locked feature. How often this site checks in with PowerSEC Central is a property of that hosted service, not of your site — every security feature here runs continuously on every plan and is unaffected by it. The control now simply offers the intervals your account's service level processes, with a plain explanation.
* The uploads PHP-execution check has been removed from the WordPress.org build. It verified that PHP files cannot run in your uploads folder by briefly writing a test file there; WordPress.org does not permit writing code files to uploads, so it is no longer included. Where the check has not run, the status is reported honestly as "not verified" rather than assumed safe.
* Request URLs recorded in the audit log are now sanitized before being stored.
* Documented, in the source, why passwordless email-link sign-in is safe without a password: the link is single-use, expires, is stored only as a hash, and is compared in constant time.

= 1.4.163 =
* Fixed: quarantined files are no longer included in backups. Quarantine moved into the uploads directory in 1.4.162, and uploads is part of every files/full backup — so quarantined items would have been copied into your backup archives. They are now excluded.
* Fixed: the uploads PHP-execution check no longer reports "protected" unless it has actually verified it. Previously, when no verification had run, it fell back to checking whether an .htaccess file exists — which Nginx never reads, so an unprotected site could appear protected. An unverified site is now shown as unverified.
* Added a "Verify now" button to Settings → Advanced so you can run the uploads execution check on demand. The background daily check remains off by default.
* Hardened the path check used when restoring a file from quarantine.

= 1.4.162 =
* All local functionality is now free on every plan. Local backups and restores of every type (database, files, full), incremental backups, and the WordPress auto-update controls no longer check a plan or a licence — anything the plugin runs on your own server works the same on every plan. Paid plans cover only the services PowerSEC runs on its own infrastructure (off-site cloud backup storage, the fleet dashboard, Central-delivered alerting, AI analysis and incident response).
* Removed plan pills, upgrade prompts and "disabled" labels from local features they never applied to, and corrected the shipped documentation that described them as paid.
* Security: the site can no longer fall back to a public identifier as an HMAC signing secret. Three request-verification paths that did so now refuse the request instead.
* Privacy: the country lookup no longer contacts an external geolocation service unless geo-blocking is actually turned on, matching what the readme states. Documented every outbound request and every file and directory the plugin writes.
* Quarantined malware files now move to `uploads/powersec-quarantine/` instead of a directory inside the plugin, and existing quarantined files are migrated automatically. Cloud upload staging is likewise pinned under uploads rather than the system temp directory.
* Paths are now resolved through WordPress instead of assumed: relocated `wp-content`, uploads, plugins and themes directories, a `wp-config.php` kept one directory above the site root, and installs where the plugin folder is renamed are all handled correctly. Previously a relocated uploads directory could be skipped by backups and left out of file scans.
* The uploads PHP-execution check no longer runs on a daily schedule by default; it runs when you ask for it, and can be re-enabled.
* The blocked-visitor page is now rendered by WordPress itself, so the plugin ships no raw style or script markup at all.
* Fixed: the WAF request classifier could never reach its admin-ajax category, and the real-time file watcher did not watch a `wp-config.php` located above the site root.

= 1.4.161 =
* Internal hardening (dormant, no change to existing site behaviour): the plugin now derives a private per-installation identity key on this site and can prove to PowerSEC Central that syncs come from the SAME installation — so a copied or restored-from-backup clone that shares the connection can be distinguished from the original. The installation private key is deliberately excluded from PowerSEC backups. This new signal is inactive until explicitly enabled by PowerSEC Central; live sites behave exactly as before.

= 1.4.160 =
* Internal hardening (no change to existing site behaviour): the connection-directive consumer lifecycle is now fully implemented and end-to-end verified (durable state store, DB lock/compare-and-set, crash-safe recovery, 2/5/8-way concurrency safety, conflict/DEFER/stale-callback handling, generation fencing, restore/clone safety, and secret hygiene), and the plugin now advertises its verified rollback-safe capability to PowerSEC Central. This new connection path is dormant — it stays inactive until explicitly enabled — so live sites behave exactly as before.

= 1.4.159 =
* Security fix: hardened the optional legacy Central admin-ajax bridge so it always refuses a request when its dedicated signing secret is not yet provisioned (it now terminates instead of falling through). This bridge is off by default and unused by current PowerSEC Central connections, so live sites were not exposed; the fix removes the latent risk.
* Removed leftover "PRO" labels and dead upgrade prompts from the notification, database, and advanced-hardening screens for features that are already free on every plan, so the UI matches the plan.
* Fixed the client-side search/pagination on the firewall block-log table, tidied plan-neutral upgrade copy (the free local firewall is no longer described as a paid feature), and removed three unused internal admin endpoints.

= 1.4.158 =
* WordPress.org review round 1: every admin inline script/style now loads through WordPress's enqueue system (wp_add_inline_script / wp_add_inline_style); the plugin menu no longer forces a top admin-menu position; bundled Chart.js upgraded to 4.5.1.
* Now free on every plan: database optimization & cleanup, all notification/SIEM channels (Slack, Discord, webhook, Splunk, Datadog, syslog, PagerDuty), and the audit-log retention control. These features run locally on your own server, so they are no longer plan-gated; paid plans cover the optional PowerSEC Central cloud services (cloud backups, fleet dashboard, AI analysis, incident response).
* Security polish: stricter output escaping on several admin/login strings, IP-address validation on fallback code paths, and the legacy Central AJAX bridge now refuses requests when its dedicated signing secret is not provisioned (never falls back to the public API key).
* Docs: corrected the Tor Project links, documented the syslog/CEF alert channel and Amazon S3-compatible restore-download hosts in the External services disclosure.

= 1.4.157 =
* Security hardening: the plugin can now sign its calls home to PowerSEC with a versioned request signature that binds this site’s server-issued connection epoch, closing a class of copied-credential replay. The new signing is off by default and enabled per site after verification; existing sites are unaffected and no re-connection is required.

= 1.4.156 =
* Security hardening: the Central connection keys are no longer written into the database backup file, so a copied or externally-restored backup can’t be used to connect a different install as this site. PowerSEC’s own restore is unaffected — it keeps this site’s live connection.

= 1.4.155 =
* WordPress.org submission metadata: the plugin header now sets only a Plugin URI (the Author URI duplicated it, which the directory build check rejects). No functional changes.

= 1.4.154 =
* Connection fail-safety: a transient Cloudflare/WAF/CDN block or a maintenance page (an HTTP 401/403/404 that does not actually come from PowerSEC Central) no longer clears this site's connection. Credentials are now cleared only on a genuine, Central-confirmed disconnect — so a temporary edge error can't silently drop a paid site to Free; the site keeps working and simply retries on the next sync.

= 1.4.153 =
* Accuracy pass on the Backup page copy. The Free-plan upgrade banner now lists only shipped capabilities (removed "Unlimited retention", "Backup before updates", and "Geo-redundant cloud storage", which described features that are capped, not yet available, or on a different tier).
* The cloud-status badge from PowerSEC Central now actually reaches the backup list — 1.4.152 stored it but the list never read it, so a stale hardcoded label could keep rendering. The fallback label when no Central badge exists yet is now the neutral "Not off-site yet" instead of "Quota full" (a daily upload pace is not a full quota).
* An incremental backup waiting for its base backup to reach the cloud now shows the explanation from Central under its row (and a "Waiting for base backup" badge) instead of a bare LOCAL ONLY with no reason.

= 1.4.152 =
* Fixed the backup list showing each backup's raw UTC timestamp as if it were local time — on a non-UTC site every row's date read hours off (e.g. 3.5 hours early on a +3:30 site) with no label. Backup dates and times now display in the site's own timezone; sites using a manual numeric offset see an explicit UTC label instead.
* Fixed a database-only or files-only backup being badged "FULL" in the backup list; the scope badge now reads DATABASE or FILES ONLY for those backups.
* The cloud-status badge provided by PowerSEC Central (for example "Upload paused" or "At limit") is now stored and displayed — previously a stale hardcoded "Quota full" label could keep rendering even while the explanation text underneath was already correct.
* The completed-backup notification to PowerSEC Central now includes the backup's creation time, so Central can tell when a backup was actually made apart from when it was registered.

= 1.4.151 =
* Hotfix for 1.4.150: the connection-freshness check still compared sync time against the site-local clock, so on non-UTC sites the plugin could wrongly show "Central sync is degraded"/"Disconnected" and fail safe to Free entitlements even while sync was healthy. All elapsed-time checks now consistently use UTC. If your site showed that banner after 1.4.150, this restores the correct connected state immediately.

= 1.4.150 =
* Fixed the "time ago" labels on the Backup page (last backup, last scan, last sync) so they show the correct elapsed time regardless of your site's timezone. Previously these could read a few hours off on sites not set to UTC. Display-only; no change to backups, schedules, or data.

= 1.4.149 =
* Public-listing housekeeping + code-quality cleanups (no functional changes). Fixed double-encoded UTF-8 (mojibake em-dashes/arrows) in readme.txt and changelog.txt so the WordPress.org listing renders correctly. Added a `== Screenshots ==` section and refreshed `== Upgrade Notice ==`. Added `sanitize_callback`s to the Central-connection `register_setting()` calls, and added narrow justified `phpcs:ignore` annotations for the two-nonce admin AJAX verification idiom (Plugin Check's VerifyNonce sniff can't parse the `wp_verify_nonce(A) || wp_verify_nonce(B)` form even though the nonce IS sanitized and verified). WordPress.org submission-prep pass.

= 1.4.148 =
* Security hardening batch (2026-07-16 audit + external pen-test, WS-3). No exploit was live in production; these close medium/low-severity gaps.
* M-10 — XML-RPC unauthenticated abuse methods blocked by default. `add_filter('xmlrpc_enabled','__return_false')` only disables login-required methods; `pingback.ping` (SSRF/DDoS amplifier) and `system.multicall` (login brute-force batching) do NOT check that filter and stayed live even when XML-RPC was "disabled". New `PowerSEC_Advanced_Protection::strip_dangerous_xmlrpc_methods()` filter (via `xmlrpc_methods`) removes system.multicall + pingback.* while leaving legitimate methods intact. Gated by new default-ON config `xmlrpc_block_multicall`. Regression: `tests/test-xmlrpc-harden.php` (8).
* M-9 — remote control fails closed when the dedicated remote-control key is absent. `PowerSEC_Remote_Actions::authorize_request()` no longer falls back to the site api_key as the HMAC signing secret (the api_key is sent in cleartext on every request, so the fallback made signatures forgeable). An unprovisioned site now returns 401; reconnecting re-provisions the key.
* M-8 — the pull-fallback channel independently enforces a read/scan/create_backup-only allowlist. `PowerSEC_Remote_Actions::execute_pulled()` refuses any destructive/Central-side-effectful action over pull, mirroring Central's PULL_ELIGIBLE_ACTIONS (defense-in-depth). Regression: `tests/test-pull-allowlist.php` (25).
* L-12 — cloud restore downloads are host-restricted to the backup storage backbone (rejects a tampered URL pointing at an internal host — SSRF defense-in-depth). L-14 — magic-login token compared with hash_equals(). L-13 — login security-check answer compared with hash_equals(). L-15 — the backup worker-tick token is sent in the request body, not the URL query (keeps it out of access logs). L-16 — XML-RPC replay protection now claims the nonce with an atomic add_option() (closes a concurrent-request race). INFO-1 — the disconnect handler returns a real 403 on a failed security check.

= 1.4.147 =
* Path-containment consistency (defense-in-depth; non-exploitable, no behavior change). The remote `quarantine_file` and `read_flagged_file` handlers (`class-powersec-remote-actions.php`) previously used a raw `strpos($resolved, $root_prefix) !== 0` for the "is this file inside the WordPress install?" check. Both already applied a trailing-separator prefix + `realpath()` + `..` reject, so the sibling-prefix escape (e.g. `/var/www/html-evil` vs `/var/www/html`) was already prevented — but they now route through the canonical `PowerSEC_Utils::is_path_within($resolved, realpath(ABSPATH))` (trailing-separator, fail-closed) like every other file-action handler, per the "NEVER a raw strpos" invariant. `tests/test-path-containment.php` (15) is the single guard. Also relabeled a plugin admin CSS comment ("Soft Enterprise" → "admin design system"). Found by the 2026-07-16 full re-audit.

= 1.4.146 =
* Tamper-evident audit-log hash chain — new security feature (Option B; DB schema 1.3.0). Each wp_powersec_audit_log row now stores prev_hash + entry_hash, where entry_hash = sha256(prev_hash + canonical(row)) and prev_hash = the previous row's entry_hash — so the whole log is a cryptographically linked chain. Chaining runs OFF the write path (PowerSEC_Audit_Logger::chain_pending_rows(): a bounded, MySQL-advisory-locked, strictly id-ordered pass fired after each insert / on the Audit Log page / in the daily cron; pre-existing rows are backfilled over successive passes). verify_chain() walks the chain and reports the first break — an EDITED row fails its recomputed content hash; a DELETED or SPLICED row breaks the prev-link. The Audit Log "Retention & integrity" panel now shows a live Verified / Chain-break status (with a "Verify now" button) instead of the old rolling fingerprint. Retention pruning stays chain-safe: it only removes the oldest rows and never moves the head. OFF-SITE ANCHOR: the chain HEAD only ({head_id, head_hash, chained_count, verify_ok} — never log contents) is sent to PowerSEC Central on sync; Central enforces monotonic head_id + stable head_hash-per-id + the local verify result, and raises ONE honest medium alert on a genuine change — so a site-level attacker who edits a row and recomputes the local chain forward is STILL caught (the head hash for a given id changes, or head_id regresses). Kill switch (Central): AUDIT_CHAIN_ANCHOR_ENABLED. Known limitation (documented, v2 work): a full forward-recompute performed WHILE new events keep flowing advances head_id with a legitimately-new hash, so the head anchor alone can't prove the prefix intact — periodic body-checkpoint co-signing is the planned strengthening. Truth-separation: the chain signal NEVER feeds securityScore/threatLevel/siteState. Regression: tests/test-audit-chain.php (8 — clean verify, edit/delete/splice detection, recompute-forward-changes-head, prune-safe) + Central audit-chain-anchor.test.ts (11).

= 1.4.145 =
* Firewall view — undefined-variable PHP warnings + broken row-search (T-L2; display/log-hygiene only, no engine/detection/firewall change). `admin/views/firewall.php:658` placed a `data-search="$type $val $rule $hits"` attribute on the WAF-suggestions table's `<thead>` header `<tr>`, but those variables are only defined inside the `foreach` that starts at :667 — so every render with pending WAF suggestions logged four `Undefined variable` warnings (found via the live Apache error log on webamooz) AND the ps-data-table row search never matched (no body row carried a `data-search`). Moved the attribute onto the body `<tr>` where the vars are defined and where the search needs it. No behavior change beyond correct row-search + silent logs.
* Audit-log integrity copy — honest wording (T-D-CHAIN; copy only). The audit log is NOT a persisted per-row hash chain (the table has no hash/prev_hash columns; `PowerSEC_Audit_Logger::get_chain_hash()` is a SHA-256 fingerprint over the 100 most recent rows, recomputed on demand — the code comment says "Not a true per-row hash chain — just a tamper-indicator for the UI"). The Retention & Integrity panel lead-in said "Logs are hash-chained — any tampering breaks the chain", overstating a rolling digest. Retoned to "Logs carry a rolling integrity fingerprint — recompute it to check nothing changed", matching the accurate sub-copy already shown beneath it. No mechanism change.

= 1.4.144 =
* Right-to-left admin fix — relative-time wording (M-5; display/copy only, no engine/detection/scope/plan change). Admin "time ago" labels were built as `human_time_diff(...) . ' ago'` in ~11 views; `human_time_diff()` localizes only the UNIT to the site locale (e.g. "3 ساعت" under fa_IR) while the appended " ago" stays English, producing a mixed "3 ساعت ago" that visually reorders under an RTL admin ("ago ساعت 3"). All those call sites now route through one shared helper pair — `PowerSEC_Utils::human_diff($from,$to)` (magnitude only, e.g. "3 hours") and `PowerSEC_Utils::time_ago($ts,$now)` ("3 hours ago") — which build the string from the plugin's OWN `_n( …, 'powersec' )` catalog so the unit and the direction word localize TOGETHER (consistent English while the plugin ships no translation; fully Persian only if a `powersec` translation is ever added), never a hybrid. Being pure ASCII, the labels also no longer need bidi isolation (the existing `<bdi>` wraps stay as harmless no-ops). The numeric cascade matches the previous helper exactly, so displayed numbers do not shift. Note: separately, a third-party Persian-localization plugin on a site can still convert the plugin's Latin digits to Persian numerals — that is external to PowerSEC and unchanged here. Views updated: dashboard, central-connection, backup, file-scanner, login-security, ip-blocking, session-control, audit-log, advanced + the admin dashboard widget.

= 1.4.143 =
* Database Health scoring honesty: overhead percentages are capped at 100% (small InnoDB tables could report an impossible ">100%"), and a perfect 100/100 is no longer shown while medium/high cleanup recommendations remain (score clamps to 79 / "needs attention"). Central Connection tier table now reflects real per-tier capabilities instead of overstated "unlimited/real-time/white-label" claims and drops notification channels that don't exist. Hardening badge shows "Hardening partial · N of M rules" when only some rules are enabled. Accessibility: notification test buttons, hardening/notification toggles, and scanner row actions gained accessible names. Free plan copy correctly describes local database, files-only, and full backups.

= 1.4.142 =
* Uploads PHP-execution protection is now VERIFIED, not assumed: an active loopback probe writes a throwaway `.php` in the uploads folder, fetches it over the site's own URL, and reports "PROTECTED — verified" only when execution is genuinely blocked (Cloudflare-challenge-aware → inconclusive; always cleaned up). On servers that ignore `.htaccess` (e.g. Nginx) the status is honestly "Not protected" and the exact server rule to add is surfaced, instead of a misleading green badge.

= 1.4.141 =
* Right-to-left admin fix (follow-up): dates and counts in Persian/Arabic (RTL) admins stay in order via a bidi-isolation fix (`unicode-bidi: isolate` on numeric cells + `<bdi>` wraps on the dashboard), on top of the 1.4.140 layout change. Database tools now match the plan: table optimization/cleanup are paid-only (fail-closed server-side); Free keeps read-only database insights. [SUPERSEDED in 1.4.158 — database optimization and cleanup now run on every plan, with no plan gate; see the 1.4.158 entry above.]

= 1.4.140 =
* Right-to-left admin fix: PowerSEC screens render left-to-right so labels and numbers no longer appear scrambled in RTL admins. Removed a duplicate "AI Score" that could disagree with the Security Score. A scan older than a week is labeled "Run a fresh scan" instead of "Recently scanned". Pro sites are reminded when automatic scanning is off. Backup/file-count labels clarified (local backups vs. cloud restore points; file-integrity baseline vs. malware-scanned files).

= 1.4.139 =
* Pull mode for remote actions (PR26 Phase 2; reliability — no change to which actions exist, their plan gating, rate limits, or idempotency). When a site's edge (Cloudflare / a host WAF) blocks Central's direct pushes to the plugin's REST endpoint, Central now signals `remoteWork.pullMode` over the authenticated /api/sync response and the plugin fetches queued jobs OUTBOUND instead: a WP-Cron single event (`powersec_pull_actions_poll`, min 60s cadence, overlap-locked) POSTs the signed `/api/sync/pending-actions` (≤3 jobs), runs each job through the SAME production dispatch as a pushed action (`PowerSEC_Remote_Actions::execute_pulled()` -> `handle()`: allowed-action map, per-action rate limits, X-Request-Id idempotency guard, audit logging — `emit_json()` captures the response instead of wp_send_json+die), and posts each result back via the signed `/api/sync/action-result`. One bad job never kills the loop (`\Throwable`-guarded per job). Self-expires when sync stops re-confirming the signal (2x effective sync interval, fallback 2h) and turns off when Central signals pullMode=false. State in `powersec_pull_mode` (autoload=false, record kept on disable). The plugin advertises `supports_pull_actions` in the sync payload; Central flips a site to pull mode only after seeing it.

= 1.4.138 =
* Malware-scan exclusion hardening (security; no detection-rule/scope/firewall/backup change). The scanner's own transient staging dir (`<plugin>/_central`) is now excluded by ANCHORED realpath containment via `PowerSEC_Utils::is_path_within($rp, $central_real)` instead of three bare `"_central"` / `"/_central/"` / `"\\_central\\"` substring tokens pushed into `$exclude_dirs`. Under `should_exclude_file()`'s unanchored `strpos()`, those tokens ALSO matched any path merely CONTAINING `_central` (e.g. `wp-content/uploads/shell_central.php`, `evil_central.php`, `x_central/y.php`) → skipped by BOTH the malware signature scan AND the file-integrity monitor, a trivial filename-controlled evasion. New private helper `PowerSEC_File_Scanner::powersec_central_dir_real()` resolves the real staging dir once (via `POWERSEC_PLUGIN_PATH`, fallback `dirname(__DIR__,2)`) and both enumerators (`enumerate_wordpress_tree()` + legacy `scan_directory()`) drop the bare tokens and skip only files truly inside it (fail-safe: no exclusion when the dir is absent). Regression: `tests/test-path-containment.php` (+4 A1 cases). Aligns with `[[scanner-no-auto-exclusions]]` — trust by content hash / anchored path, never by name substring. (patch-plan item A1)
* Catches PHP webshells disguised as uploads (A3; detection reach — NOT after F1, no timeout regression). The malware scan selected targets purely by file extension, so a webshell renamed to `avatar.jpg` / `logo.ico` / `note.txt` in `wp-content/uploads` was never opened. It now HEAD-SNIFFS non-PHP files in the writable uploads tree (`head_sniff_has_php_tag`: a bounded ~8 KB head + ~4 KB tail native read, never executed) and adds any file that carries a `<?php`/`<?=` tag to the normal signature-scan targets. Restricted to the uploads tree + masquerade extensions, budget-bounded (2000 files/scan) with a persisted rotating cursor so every candidate is eventually covered across scans; a budget cut is reported as coverage-truncation (never presented as fully clean). Detection-gate only — a sniff hit just means the file gets scanned normally; it sets no truth by itself. Kill switch: `powersec_scan_polyglot_sniff=false` reverts to extension-only. Regression: `tests/test-polyglot-sniff.php` (8). (patch-plan item A3)
* Owner scan-exclusion matching is now segment-anchored (A2; closes an accidental-blind-spot footgun). A site owner's configured `scan_exclude_dirs`/`scan_exclude_files` entries were matched as raw substrings, so `plugins` silently excluded every path containing "plugins" (e.g. `my-plugins-helper.php`) and a bare `.php` could exclude the whole tree from scanning. `should_exclude_file()` now matches directory tokens as WHOLE PATH SEGMENTS and file tokens by BASENAME or a segment-anchored path suffix, and ignores invalid tokens (empty / `..` traversal / absolute / null-byte). Over-broad (top-level-folder) and invalid entries are surfaced as advise-only notices (`powersec_scan_exclusion_advisories` → Central `coverage.exclusionAdvisories`) — the owner's exclusions are still honored verbatim; nothing is auto-removed. Kill switch: `powersec_scan_exclusion_segment_anchor=false` reverts to legacy substring. Regression: `tests/test-exclusion-anchoring.php` (15). (patch-plan item A2)
* Per-file scan deadline eliminates a runaway-file / infinite-retry hazard (B3; co-designed with B2). B2 bounds each signature's time; B3 bounds a FILE's cumulative cost — the ~200-pattern loop plus the tokenizer/monitor/URLHaus post-passes ran with no intra-file wall-clock guard, so one pathological file could overrun a scheduled tick. A throttled intra-file deadline now cuts a file that crosses the tick budget: the remaining per-file work is skipped, the file is flagged partial coverage (`files_deadline_cut`, folded into `partial_coverage` → Central `coverage.filesDeadlineCut`), and the scan ADVANCES PAST it — the file is never re-scanned from the start, so there is no infinite-retry loop (any finding found before the cut still counts). Inert on on-demand full scans (no tick budget). Kill switch: config int `malware_scan_intra_file_deadline_check`. Regression: `tests/test-regex-deadline.php` (12). (patch-plan item B3)
* Malware-signature matching is now ReDoS-abort-safe (B2; detection integrity). Every fleet-fed signature match runs through `PowerSEC_File_Scanner::preg_match_guarded()`, which inspects `preg_last_error()` and returns a TRI-STATE (1 match · 0 clean · -1 engine error) — so a PCRE engine abort (backtrack/recursion/JIT-stack limit) is NEVER silently treated as "no match" (= clean). On an abort the scanner circuit-breaks that pattern for the rest of the pass and records the uncertainty as partial coverage (`regex_engine_limit_hits`, folded into `partial_coverage` and passed to Central) rather than reporting the file clean. The engine is also bounded per scan request via `pcre.backtrack_limit`/`pcre.recursion_limit` (restored at shutdown; honest no-op if `ini_set` is disabled) so one ReDoS-prone pattern aborts fast instead of hanging a tick. Applied to the main signature loop, the URLHaus second pass, and the large-file window scan (already flagged partial). Regression: `tests/test-pcre-guard.php` (10, incl. a real catastrophic-backtracking abort → -1 in <0.5s). (patch-plan item B2)
* Host-independent continuation for the file-change (FIM) scan (F2b; same reliability as the malware F2a, characterized independently first). The resumable file-integrity scan (`scan_files_chunked`, cursor `powersec_file_scan_cursor`, lock `powersec_file_scan_lock`) now self-continues on any host: after a mid-pass slice (`cursor > 0`) it arms — AFTER releasing its lock (so the next slice never loses the lock race) — a throttled non-blocking admin-ajax loopback (`wp_ajax[_nopriv]_powersec_file_scan_tick`, run-scoped `powersec_file_scan_worker_token` via `hash_equals` in the POST body; no nonce) + a single-event WP-Cron fallback (`powersec_run_async_file_scan`); a completed pass (`cursor 0`) retires the token, and a stale token can NEVER kick off a fresh off-schedule scan (the tick advances only an in-progress pass). All FIM continuation names are DISTINCT from the malware scan's (no lock/token/action/event collision). The scan_files_chunked engine + its cursor/lock invariants are unchanged (still covered by test-fim-scope). Kill switch: option `powersec_scan_loopback_enabled` (cron-only fallback). Regression: `tests/test-fim-continuation.php` (13). (patch-plan item F2b)
* Admin "Continue Scan" now uses the authoritative full-tree engine (F3; closes a real uploads blind spot). The no-JS admin malware "Continue Scan" fallback previously ran the legacy `PowerSEC_Malware_Scanner::scan_batch()`, which enumerated only 4 narrow roots (`wp-admin`/`wp-includes`/`plugins`/`themes`) and NEVER `wp-content/uploads` / `wflogs` / `cache` — the exact place webshells land — and used a `powersec_malware_scan_lock` option name that collided with the primary chunked engine's lock. It now drives `PowerSEC_File_Scanner::scan_malware_chunked()` (the same authoritative engine as the dashboard / Central scan), which walks the COMPLETE WordPress tree via `enumerate_wordpress_tree('malware')`, shares ONE lock + resumable state, and writes the single canonical summary — so the admin button covers the full tree and there is no divergent second truth path. `PowerSEC_Malware_Scanner` is retained ONLY for its static helpers (`sync_rules_from_central` / `sync_suppressed_file_hashes_from_central` / `SUPPRESSED_HASHES_OPTION`); `scan_batch()` now has no caller, so the lock-name collision is gone. Uploads/wflogs coverage of the engine the button now uses is locked by `tests/test-scanner-scope.php`. (patch-plan item F3)
* Host-independent scan continuation + browser-independent completion (F2a; pairs with F1). So a v2 scan finishes on every host config (not only when WP-Cron fires or the dashboard tab stays open), the plugin mirrors the backup engine's continuation: after each still-running slice it arms (throttled once/3s) a NON-BLOCKING admin-ajax loopback (`wp_ajax[_nopriv]_powersec_malware_scan_tick`, authed by a run-scoped one-time `worker_token` via `hash_equals` in the POST BODY — never the query string; no nonce on this server-to-self path; `sslverify` preferred true, degrading to cron on an unverifiable local cert) PLUS a single-event WP-Cron fallback (`powersec_run_async_malware_scan`). The read-only `get_malware_scan_state` poll stays read-only (the UI's `continue_malware_scan` is the mutating driver — no inline driver on the poll). At terminal completion the plugin fires a non-blocking signed `POST /api/sync/malware-complete` (`PowerSEC_Central_Connector::push_malware_completed_to_central`, mirroring the backup-complete push) carrying the canonical malware summary, so a scan that finishes via loopback/cron after the tab closed commits its truth immediately; Central applies it idempotently (keyed on `scan_run_id`, `buildMalwareCompleteCommit`) storing only the plugin-computed malwareStatus/score/threat (never derived) — the nudged periodic sync remains the full backstop incl. alerts. Kill switch: option `powersec_scan_loopback_enabled=false` (falls back to cron-only) / Central `MALWARE_SCAN_PROTOCOL_V2`. Regression: `tests/test-malware-scan-kickpoll.php` (+5 token-auth/idempotency cases, 20 total) + Central `malware-complete.util.test.ts` (6). (patch-plan item F2a)
* Protocol-v2 resumable malware scan — kick + poll (F1; no change to detection/scope/truth/plan). The synchronous `run_malware_scan` remote action (a single blocking full-tree `scan_for_malware()`) could exceed PHP `max_execution_time` on large sites (webamooz ~21k files) and pin the worker lane. New non-blocking flow over the existing resumable engine: `start_malware_scan` (force-starts a fresh run + one bounded slice), `continue_malware_scan` (advances ONE slice of a run identified by `scanRunId`), `get_malware_scan_state` (read-only poll; the only one in Central's `COALESCABLE_READONLY_ACTIONS`). Durable run identity `{scan_run_id, generation, terminal_committed_at}` added to `powersec_malware_scan_state`; a `continue` whose `expected_run_id` != the live run returns `stale_run` WITHOUT advancing (never advances a newer run for a stale caller), and a matched-but-completed run returns `done` idempotently. Truth (malwareStatus/score) is written ONLY at terminal completion — a partial slice never marks a site clean. Plugin broadcasts `scanner_protocol_version:2` / `supports_chunked_remote_scan` / `supports_scan_continuation` in sync; Central confirms capability by ATTEMPT and transparently falls back to the legacy blocking `run_malware_scan` for a v1 plugin (N-1 compatible, no schema change). Site opt-out: option `powersec_scan_v2_enabled=false`; Central kill switch `MALWARE_SCAN_PROTOCOL_V2=false`. New actions rate-limited (start 4/h, continue 200/h, get_state 120/min). Regression: `tests/test-malware-scan-kickpoll.php` (15) + Central `malware-scan-v2.service.test.ts` (13). The legacy `run_malware_scan` handler is retained unchanged for backward compatibility. (patch-plan item F1)
* Honest enumeration-truncation signal (coverage transparency; detection/scope/truth fields unchanged). `enumerate_wordpress_tree()` now flags when a host-safety ceiling caps the target list — `max_files` / `max_dirs` (break on ceiling) or `max_depth` (a directory reached at the max depth, so its children are not descended) — via `$this->last_enum_truncated` + de-duplicated reasons, exposed by the public getter `get_last_enumeration_truncation()`. The flag is threaded into both malware-scan summaries (`powersec_last_malware_scan_summary`: manual + scheduled/chunked finalize, captured into scan state so it survives ticks) and passed through Central sync in `get_malware_status_summary()` as `enumeration_truncated` + `enumeration_truncated_reasons` (present only when known). The three ceilings are now tunable via the `powersec_scan_enum_ceilings` filter (guarded `function_exists('apply_filters')` so the scanner never fatals offline; defaults 300000/500000/40, each floored at 1). Central surfaces it as `coverage.enumerationTruncated` (null when a pre-1.4.138 plugin didn't report it — never fabricated `false`, so a truncated scan is never presented as full coverage) behind the `COVERAGE_TRUNCATION_DISPLAY` kill switch (default on); the dashboard shows an honest "run another scan" note. Regression: `tests/test-enumeration-truncation.php` (12 cases). (patch-plan item A5)

= 1.4.137 =
* Event-driven backup registration (Option B) — closes a cloud-ingest lag on large/rarely-viewed sites. On a successful local backup the plugin now pushes a signed, NON-BLOCKING backup-complete notification to Central (`POST /api/sync/backup-complete`) so Central registers the backup + enqueues its cloud ingest IMMEDIATELY, instead of only when a customer opens the Backups page (the view-triggered registration path, capped at the newest 2 per view). New method `PowerSEC_Backup::push_backup_completed_to_central($backup_id,$type,$result)` fires from the backup-completed branch, after the lock is released. It is fire-and-forget (added a `$blocking` passthrough to the connector's `signed_request()`/`api_request()` — default true, unchanged for every other caller) so it never adds latency to a backup pass, even when completion runs inside the inline-pass driver of a Central runtime poll; the request is still delivered + HMAC-verified server-side. On the paired Central side, a periodic reconcile sweep backstops any dropped push and heals older un-registered local backups. Best-effort only: a failure logs an `info` event and is harmless. No change to backup contents, restore, retention, scanning, firewall, or plan limits.

= 1.4.136 =
* Path-containment hardening (security; no detection/scanning/firewall/backup change). Added the shared helper `PowerSEC_Utils::is_path_within($path, $base)` (B3 trailing-separator idiom: `$path === $base || strpos($path, rtrim($base,'/\\').'/'|'\\') === 0`, fail-closed on false/empty). Replaced 6 raw `strpos($resolved,$base)` / `strpos($real_path,$wp_root)` prefix checks with it: remote `delete_file` quarantine base (`class-powersec-remote-actions.php`) + admin malware delete/quarantine/restore-dir/bulk-delete/bulk-quarantine (`class-powersec-admin.php`), closing the sibling-prefix bypass (`/var/www/html-evil` vs `/var/www/html`). All were already capability-gated (`manage_options`+nonce) or Central-HMAC-gated, so low exploitability — this is defense-in-depth aligning every handler with the already-correct read_flagged_file B3 check. Regression test: `tests/test-path-containment.php` (11 cases). Existing inline B3 sites unchanged.

= 1.4.135 =
* Foreign admin-notice suppression on PowerSEC screens (UX only — no detection/scanning/firewall/backup change). Other plugins'/themes' admin_notices + all_admin_notices callbacks are removed on PowerSEC's own admin pages so the dashboard/scanner aren't buried under activate/upgrade/renew/update nags. STRICTLY SCOPED: `PowerSEC_Admin::suppress_foreign_admin_notices()` runs on `in_admin_header` (priority 1000) and only when `is_powersec_admin_screen()` (screen id / `$_GET['page']` prefixed `powersec-`). It PRESERVES PowerSEC's own notices (callback class/function name contains "powersec"; unattributable closures treated as foreign) and is filterable off via `powersec_hide_foreign_admin_notices` (default true). WordPress core critical notices are also hidden on PowerSEC pages only (visible everywhere else). Never global.

= 1.4.134 =
* Malware-scan intro copy accuracy (wording only — no detection/scanning/firewall/backup change). The "Run a malware scan" card said "Scans every PHP file (including hidden ones)…", which over-stated coverage since the scan honors the owner's scan_exclude_dirs/scan_exclude_files and enforces per-tick/per-scan enumeration ceilings. Reworded to "Pattern-scans your PHP files (including hidden ones)…" to match the v1.4.133 honesty retone (pattern/heuristic detection, not a guaranteed every-file sweep).

= 1.4.133 =
* Malware-scan results clarity (wording/labels only — no detection, scanning, firewall, or backup change). The scan-results summary now states that a flagged file is one that MATCHED a malware pattern and needs review, not a confirmed infection, and adds an at-a-glance severity breakdown (critical / high / medium / low) alongside the count. The count copy is now honest about scope — "N file(s) matched malware patterns and need review, out of M PHP file(s) pattern-scanned" — and the no-findings result no longer over-claims ("Your site is clean" → "No malware patterns matched in the M PHP file(s) scanned"). The Central-rules status card was relabeled so it can't be mistaken for site health: title "Central Rules Debug" → "Central Rules Sync", pill "Matched" → "Up to date", and the green footer "Healthy — using current Central bundle." → "Rules bundle up to date — this reflects your rules feed version, not your site's malware status."

= 1.4.132 =
* PR23.4a — plugin bootstrap-proof send + calm Test Connection UX. The plugin now receives the shared verify setup token from Central over the already-authenticated /api/sync response (a `verifyBootstrap` block delivered only to the authenticated site; the raw token is sent only in the live response and is redacted in the idempotency-cache copy), caches it in a short-lived transient (`powersec_verify_bootstrap`, 2h, refreshed every sync, never displayed or logged), and presents it as `bootstrapToken` on /api/sites/verify — so the PR23.4 connection-verification gate (optional | log | require) can be enforced without breaking Test Connection. Test Connection now maps the enforcement responses to calm, non-technical messages (VERIFY_BOOTSTRAP_REQUIRED → "requires a fresh setup token…", VERIFY_BOOTSTRAP_INVALID → "this setup token has expired…", VERIFY_BOOTSTRAP_MISCONFIGURED → "temporarily unavailable…") and never wipes stored connection keys on a failed verify. Delivery is inert until enforcement is enabled in Central (no VERIFY_BOOTSTRAP_TOKEN configured = no token sent). No change to scanning, firewall, or backup behavior.

= 1.4.131 =
* PR23.2b — signed rotated-key delivery + secret-log hygiene. When Central rotates a site's telemetry/remote signing keys, the plugin now receives them over the already-authenticated /api/sync response as a `keyRotation` block that is HMAC-signed with the key the plugin ALREADY holds (the old key vouches for the new one), verified before adoption (site-id match + delivered keyId must be newer + signature valid), and stored atomically via store_central_keys() — so it no longer needs /verify to return raw secrets (prepares PR23.3). The sync body now echoes the current telemetry+remote key IDs so Central can detect a stale key on either scope. The WP_DEBUG dumps of the /verify response (which contained cleartext apiKey/telemetryKey/remoteKey) are now redacted. Forward-compat: Test Connection sends a bootstrapToken to /verify if one is provisioned locally (none today; /verify's bootstrap gate remains optional until PR23.4). No change to scanning, firewall, or backup behavior.

= 1.4.130 =
* Security hardening (post-PR22 re-audit). Backup: the Central-download bypass is now restricted to the read-only file-stream action, so every state-changing backup action (create/delete/restore/exclusions) again requires its CSRF nonce; a Central-signed download must carry a valid file token (an empty token no longer passes). Malware: the "read flagged file" research action is now flagged-only (must be in the latest scan's suspicious list, mirroring quarantine) — a bounded, credential-blocked, path-contained research read, not a file browser. Auto-updates: changing the native WordPress auto-update policy now also checks the automation entitlement on the site (defense in depth; read stays all-plans). Robustness: active_plugins is array-cast before in_array/count; uninstall-with-data now purges powersec_central_* connection secrets. Telemetry: the malware summary forwards running-scan staleness so Central can show a stuck scan honestly. No change to detection, scanning, backup, or restore behavior.

= 1.4.129 =
* Quarantining a flagged malware file from the dashboard now immediately recalculates your site's security score and updates Central, so the change is reflected right away instead of waiting for the next scheduled scan. Detection, scanning, and quarantine behavior are unchanged.

= 1.4.128 =
* Reliability hardening for restricted/shared hosting (defense-in-depth follow-up to 1.4.127). The dashboard-sync path now catches any low-level PHP error instead of only exceptions, so an unexpected error while building sync data degrades to a logged, failed sync rather than a blank admin screen. Added safe guards around optional PHP capabilities that some hosts remove — zlib compression (used only for backup file manifests and malware de-obfuscation; it now cleanly skips when absent), DNS lookups in the firewall's bot verification, and syslog sockets in optional SIEM forwarding — and hardened a disk-space percentage calculation against a divide-by-zero on unusual filesystems. Internal correctness only; no change to backups, restores, scanning results, or any security decision.

= 1.4.127 =
* Compatibility fix: on hosts that disable PHP's disk_free_space()/disk_total_space() functions (common on some shared hosting), viewing the PowerSEC dashboard could hit a fatal "undefined function" error and stop the site from syncing to PowerSEC Central. These two calls in the sync data are now guarded, so the dashboard loads and sync works on those hosts. No change to backups, scanning, or any security behavior.

= 1.4.126 =
* Backup page fix: when a backup row's "More actions" menu is open, the "Full restore" and "Download" buttons no longer stretch into tall blocks — they stay normal, compact buttons. CSS layout only; no change to any backup, restore, or delete behavior.

= 1.4.125 =
* Slimmer, calmer Backup page. The default view now leads with your emergency local backup tools (Full restore + Download stay one click away), and advanced sections (plan entitlements, storage details, exclusions, full backup history, restore history) tuck into collapsible "Advanced" panels so the page reads as a local agent rather than a second dashboard. Per-backup extra actions (Database only / Files only restore, Remove local copy, and Delete) are grouped under a "More actions" menu, with Delete kept separated and clearly marked as destructive. Layout and copy only — no change to how backups, restores, deletes, or retention run.

= 1.4.124 =
* Backup page now points to your PowerSEC Central dashboard. Added an "Open in Central" button and a "Last sync" indicator, and clarified that cloud restore-point status is managed in Central (the plugin manages your on-server backups and emergency local tools). UI/copy only — no change to how backups or restores run.

= 1.4.123 =
* Backup deletes are now more forgiving. The per-site delete limit was raised from 5/day to 30/hour so routine cleanup of old backups is no longer blocked, and the "too many requests" notice is friendlier. Rate-limit configuration and copy only — no change to how backups or restores run.

= 1.4.122 =
* Clearer, safer backup delete controls. The permanent-delete warning now clearly states it removes the LOCAL and CLOUD copy and frees a restore-point slot, and the plugin's disk-cleanup control is now labeled "Remove local copy" with "Cloud copy stays available". Copy/label only — no change to how backups or restores run, no change to storage, and your emergency local tools are unchanged.

= 1.4.121 =
* Malware scan results now include scanner-reviewed file counters: how many files the scan pattern-inspected, how many core/plugin files were checksum-verified or flagged as modified, and the total files reviewed. Counting only — no change to what the scanner detects, how files are scanned, scan scope, or your security score.

= 1.4.120 =
PR19 - backup engine reliability (in-place fix; no parallel backup engine, no new queue/lock):
* Root cause fixed: the full-backup file manifest was stored as a wp_option (powersec_backup_meta_<hash>_fmf, ~4.8MB each) and accumulated one per full backup; the resumable DB export then loaded a 500-row chunk of the options table (SELECT * ... LIMIT 500) which OOM'd under a 256MB FPM memory_limit (CLI is unlimited, so wp-cli scans never reproduced it). On webamooz 19 manifests had bloated wp_options to ~92MB.
* Manifest storage moved OUT of wp_options onto disk: uploads/powersec-backups/manifests/<hash>.json.gz, gzip JSONL streamed one entry at a time (never a multi-MB string in memory), written atomically (.part -> rename), chmod 0644. The differential reader (backup_files_incremental) prefers the file and falls back to a legacy _fmf option for pre-1.4.120 sites. Restore and Central/cloud verification never read the manifest.
* One-time flag-guarded migration (PowerSEC_Installer::maybe_upgrade -> migrate_fmf_options_to_files_pr19): converts legacy _fmf options for still-present backups to on-disk manifests and deletes orphaned/unreadable ones (restore-safe: restore never uses _fmf; a differential self-downgrades to a full when its base manifest is missing). Processed one option at a time so the migration itself cannot OOM. Skipped while a backup is active; retried on a later load. Never touches durable _meta_/_paths_/_checksums_/_cloud_ records or local backup archives.
* DB export byte-bounded (export_table_rows_resumable): the fetch width is estimated from information_schema.avg_row_length and adapts at runtime toward a ~4MB fetch budget (resumable via state['chunk_size']); INSERT statements flush by BYTES (~1MB) instead of a fixed 100-row batch; a single row wider than the flush threshold is written as its own INSERT. Streaming fwrite, offset/checkpoint resumability, and table-by-table state are unchanged. No mysqldump / shell.
* wp_options dump now EXCLUDES PowerSEC's own runtime/transient backup state (lock, worker token, queue guard, progress, runtime/async status, cancel, per-backup db_state/scan_state, legacy _fmf) and orders by option_id for stable LIMIT/OFFSET paging - so a restored copy cannot reimport a stale running lock. Durable restore/cloud records are intentionally kept.
* .part hardening: on resume, an unwritable/wrong-owner partial dump (e.g. left root-owned by a prior root-run worker) is detected via an is_writable pre-check and discarded for a clean restart (the existing self-heal remains a backstop); a fresh .part is created with predictable 0644 perms. Never chown/shell.
* Stale-state recovery (recover_stale_backup_state): a backup stuck queued/running with no live lock, no worker token, and no recent forward progress is honestly marked failed and its runtime/temporary state cleared via the existing helpers, so a fresh backup starts clean. No-op on a healthy/active backup; never clears durable records or local archives.
* Tests: 4 new backup unit tests (69 assertions) - byte-bounded export, manifest-on-disk, option cleanup/migration, .part + status/recovery. Existing scanner/webshell/WAF regression suites remain green. No scanner/webshell-rule, AI Advisory, or billing/package/eligibility changes.

= 1.4.119 =
PR18C - promotion of the PR18B webshell-family rules from LOG-ONLY to ACTIVE deterministic detection:
* The 8 webshell-family rules (R1..R8, class-powersec-file-scanner.php::detect_webshell_monitor) now feed the deterministic truth path. On a match, a matched promoted rule appends to the per-file $matches -> $suspicious_files, so the file becomes an active webshell/malware finding counted by the EXISTING file-based model (derive_webshell_status): one infected file counts once even when multiple rules (e.g. R1+R2) match it; file severity = the highest matched promoted severity. Severity map: MS_DYNAMIC_FUNC_ASSEMBLED_EXEC/MS_REVERSE_SHELL_SHAPE/MS_HEADER_FED_EVAL = critical; MS_VARFUNC_TAINTED_ARG/MS_TAINTED_CALLBACK/MS_DECRYPT_EVAL_CHAIN = high; MS_EXTRACT_SUPERGLOBAL/MS_DYNAMIC_SUPERGLOBAL = medium (variable-injection, suspicious but not webshell-counted).
* Promotion enabled only after PR18B/B.1/B.2 fleet log-only validation: phpMyAdmin R2 FP eliminated (PR18B.1 callee-suspicion gate), fleet telemetry showed 0 FP candidates (only webamooz's 3 canary findings), clean corpus green (1084 WP-core + 70 plugin, 0 self-flag).
* DETECTION ONLY - never auto-quarantine, auto-delete, auto-remediate, auto-suppress, or hide a finding; AI Advisory logic unchanged (promoted findings remain eligible for AI second opinion downstream). Rule telemetry still flows (rule_action='detect', monitor_only=false, promoted_in='PR18C', promotion_version='1.4.119', bounded/redacted matched_context + hash) to the dedicated option + Central webshell_monitor field.
* Kill switch (rollback WITHOUT a redeploy): set option powersec_webshell_rules_promoted = false to revert these specific checks to monitor-only/log-only; the analyzer + all other detection are unaffected.
* Tests: promotion/truth-mutation/file-dedup/kill-switch/no-remediation (tests/test-webshell-monitor-isolation.php, 18), must-detect 32, must-not-flag 47 (0 FP incl. 4 phpMyAdmin/controller fixtures), clean corpus 0. Existing regressions green (WAF FP, malware-heuristics 121, urlhaus 38, backtick, fim-scope 16, js-skimmer, scanner-scope 23).

= 1.4.118 =
PR18B.1 - MS_VARFUNC_TAINTED_ARG callee-suspicion gate (still LOG-ONLY):
* Narrows the R2 log-only rule so an attacker-controlled ARGUMENT alone is not enough to record a match. R2 now fires only when the CALLEE is itself suspicious: (C1) an assembled dangerous function name, (C2) a callee taken directly from user input (incl. the nano $_GET[0](..) shape), or (C4) a bare-variable callee in a file that also exhibits obfuscated dangerous-name assembly (str_replace(str_split())/chr-run) - and NEVER when the callee is a benign object/controller ($var assigned from new / $x->method() / Class::method()). This suppresses the phpMyAdmin PrivilegesController-style false positive surfaced by the 1.4.117 webamooz canary (a controller object invoked with $_GET request params) while keeping the mj.php (str_replace(str_split()) + $A($_REQUEST[..])), mj2.php (chr-assembled $hook($_POST[..])), and nano (direct + one-hop) true positives. Still log-only / monitor-only: no change to malware status, counts, security score, threat level; no promotion (PR18C). New regression fixtures: phpMyAdmin/controller-dispatch must-not-flag (4) added to tests/test-webshell-fp-battery.php.

= 1.4.117 =
Webshell detection expansion (PR18B) - LOG-ONLY / monitor-only telemetry:
* New static webshell-family detection rules R1..R8 in class-powersec-file-scanner.php::detect_webshell_monitor(): MS_DYNAMIC_FUNC_ASSEMBLED_EXEC (dangerous function name assembled via chr/hex/strrev/base64/pack/str_replace(str_split) and invoked as a variable function), MS_VARFUNC_TAINTED_ARG (variable/superglobal-callee function called with attacker-controlled arg, incl. one-hop taint + the nano $_GET[0]($_POST[1]) shape), MS_REVERSE_SHELL_SHAPE (socket + shell/process primitive + shell descriptor), MS_HEADER_FED_EVAL (HTTP header value reaching eval/assert/create_function), MS_TAINTED_CALLBACK (attacker-controlled callback in the callback arg position), MS_EXTRACT_SUPERGLOBAL (extract() of a raw superglobal), MS_DECRYPT_EVAL_CHAIN (eval/assert of a decoded/decrypted payload), MS_DYNAMIC_SUPERGLOBAL (dynamically-assembled superglobal name).
* Closes the detection gap behind the wflogs mj.php/mj2.php webshells (chr()/str_replace(str_split()) name assembly + variable-function calls) that evaded the prior quoted-string-concat-only heuristic.
* LOG-ONLY by design: matches are written to a dedicated option (powersec_last_webshell_monitor_scan) + one info audit event (webshell_monitor_detection), and synced to Central as a separate `webshell_monitor` field (rule_action=log, monitor_only=true). They NEVER enter suspicious_files / webshell_count / malwareStatus / securityScore / threatLevel / incidents / quarantine. Truth isolation is enforced structurally (separate channel) and covered by tests/test-webshell-monitor-isolation.php. Kill switch: option powersec_webshell_monitor_scan.
* Static analysis only (tokenizer lexing + bounded regex) - never executes, evals, includes, or runs decoded content; decoded literals are size-capped (512 bytes / 4096 decompressed). Matched context is bounded (<=240 chars) + redacted (secrets/keys/long blobs stripped) + hashed; no full file content or secrets are stored or transmitted.
* FP methodology: NOT an absolute 0-FP claim. Gate = 0 known FP in the test corpus + clean WordPress core corpus (1084 files) + clean PowerSEC plugin corpus (0 self-flags) + log-first fleet telemetry before any promotion. New batteries: tests/test-webshell-detection-log-first.php (32 must-detect) + tests/test-webshell-fp-battery.php (41 must-not-flag / 0 FP).
* Promotion of any rule to an active/alerting severity is a SEPARATE future release (PR18C), gated on telemetry. No existing signature, severity, or malware verdict was changed in this release.

= 1.4.116 =
Scanner scope unification (PR18A) - complete-tree coverage by default:
* Both the malware scan and file-change monitoring now enumerate the ENTIRE WordPress tree through a single guarded recursive enumerator (enumerate_wordpress_tree($mode) in class-powersec-file-scanner.php), replacing the former narrow roots (wp-admin/wp-includes/wp-content/plugins/wp-content/themes + PR4 drop zones) that never enumerated wp-content/wflogs and other subdirectories. Root cause of the wflogs webshell blind spot (see memory wflogs-webshell-detection-gap): a file dropped outside the listed roots was invisible to file-change detection.
* Default directory exclusions changed: the built-in /wp-content/cache/, /wp-content/uploads/, /wp-content/backup/ exclusions were removed so nothing is skipped out of the box. A one-time, flag-guarded migration (PowerSEC_Installer::migrate_scan_exclusions_pr18a) rewrites the STORED exclusion list to the new default ONLY when it exactly matches the old default (order/slash/whitespace-insensitive); a customized list is left untouched.
* Host-safety guards on the enumerator: directory AND file symlinks are never followed, every yielded path is realpath()-contained to an allowed root (ABSPATH + a relocated WP_CONTENT_DIR/uploads), traversal depth and dir/file counts are bounded, and the caller slices work by cursor + time budget. wp-config.php is never pattern-scanned. The malware scan now ALSO honors owner-configured exclusions (which it previously ignored).
* FIM_SCOPE_VERSION bumped 2 -> 3: existing sites are re-baselined SILENTLY in the background (the newly-visible files are folded into the baseline with no change records), so widening scope never produces a "new file" alert storm. The target-list transient is keyed by scope version so a stale narrow list can't be served after upgrade.
* New offline regression batteries: tests/test-scanner-scope.php (23 checks: wflogs/uploads/cache now scanned, mode filtering, symlink containment, owner exclusions) and tests/test-scanner-exclusion-migration.php (7 checks: old default migrated, customized list preserved, runs once).

= 1.4.115 =
Backtick-operator false-positive fix (PR12.4 - surfaced once PR12.3 removed the URLHaus noise on webamooz):
* The php_backtick_exec patterns (`class-powersec-file-scanner.php` direct + one-hop forms) match a backtick CHARACTER near a superglobal. They false-positived on (a) SQL identifier quoting inside string literals (`` `column` ``, phpMyAdmin Server/Privileges.php + UserPassword.php, woocommerce Action Scheduler ListTable) and (b) docblock backticks (WP-core async-upload.php/admin.php/admin-ajax.php's "the dynamic portion of the hook name, `$action`," idiom) - 7 false "webshell" findings on webamooz, inflating webshell_count to 8 and tanking the score to 0.
* Fix: PowerSEC_File_Scanner::php_backtick_exec_ranges() uses token_get_all to compute the byte ranges of GENUINE php `…` shell-exec operator pairs (a backtick inside a string literal or a comment is NOT a `…` operator token, so it yields no range). backtick_finding_is_real() keeps a backtick finding only when a match overlaps a real exec range; otherwise it is a string/comment backtick artifact and is suppressed. Applied in the deep-scan match loop; backtick patterns are also excluded from streamed large-file window scanning (the tokenizer needs the whole file). Real `cmd $_GET[x]` / one-hop `$var` (where $var=$_GET) execution is still detected. +7 battery cases (38 total). No change to any other signature.

= 1.4.114 =
URLHaus benign-host allowlist - completes PR12.3 (discovered in live webamooz verification):
* The URLHaus feed carries host-wide IOCs for malicious URLs that are HOSTED ON benign infrastructure (e.g. a google.com / googleapis.com / CDN path). The 1.4.113 context classifier correctly saw those hosts loaded ACTIVELY (reCAPTCHA <script src=google.com>, a googleapis fetch) by legitimate plugins and flagged them - a feed-quality false positive. PowerSEC_File_Scanner::is_benign_infra_host() now suppresses URLHaus matches on well-known infrastructure hosts (Google/gstatic/googleapis/analytics/gtm/recaptcha, the major CDNs jsdelivr/unpkg/bootstrapcdn/cloudflare, gravatar, w3.org/schema.org, the social SDKs, wordpress.org) - matching CDN/infra domains on any subdomain and platform domains (google.com/youtube/facebook/...) on apex+www only, so user-content subdomains (sites.google.com, drive.google.com) stay fully scannable and a real IOC is never over-suppressed. General + host-based (no per-plugin allowlist). +8 battery cases (31 total).

= 1.4.113 =
Malware scan accuracy - context-aware handling of known-malicious-URL (threat-intel / URLHaus) indicators (PR12.3):
* Root cause fixed: a URLHaus IOC rule compiles a known-malicious URL/host into a regex matched against raw file content, with a fixed HIGH severity and no context. So ANY file that merely CONTAINED a flagged URL (a string, a link, a translation, a blocklist, a doc, a comment) was reported HIGH malware - a wall of look-alike findings on legitimate plugin/theme files (and on a security plugin's own translation/blocklist files) that inflated the threat level. The single real webshell was buried in the noise.
* GENERAL, context-aware classification (no per-plugin allowlist): new PowerSEC_File_Scanner::classify_urlhaus_match() treats a known-malicious URL as malware ONLY in an ACTIVE load/call/injection context - loaded by an <iframe>/<script src>, fetched by server code (curl/file_get_contents/fopen/fsockopen/wp_remote_*), navigated/fetched by JavaScript (location/fetch/window.open/import), or wrapped in an obfuscation/eval chain. A passive reference is suppressed. If the file is ALREADY confirmed malware by the deterministic signatures, a passive IOC reference is surfaced at LOW severity as corroboration only.
* Applies to both the deep file scanner and the batch malware scanner; URLHaus patterns are also excluded from streamed large-file window scanning (which can't run whole-file context analysis). The deterministic webshell/backdoor/exec/obfuscation signatures are UNCHANGED - confirmed malware (e.g. concat-built system() webshells) is detected exactly as before.
* Titles normalized: findings show a human label ("Known malicious URL loaded by an external script/iframe", "... fetched by site code", "... referenced (needs review)") with the host, never the internal rule id. Bounded, anchored, ReDoS-safe matching (pathological ~80KB input classifies in ~0.003s). Because passive references no longer count as suspicious files, they no longer tank the security score or raise a false Critical.

= 1.4.112 =
Database / content security scan - hidden iframe / external-script with UNQUOTED display:none (PR12.2):
* The DB-content scan now also catches hidden iframe and external-<script> injections that use an UNQUOTED CSS display:none (e.g. style=display:none) to dodge naive quoted-style detection. Tightly gated to a suspicious iframe / hidden-external-script context, so legitimate hidden UI (collapsed FAQ answers, hidden menus, vendor embeds) and <pre>/<code>/comment samples are never flagged. DB-content findings remain a separate "needs review" signal - never a confirmed malware file, never changing malware/webshell counts.

= 1.4.111 =
Database / content security scan (PR7) - a NEW, strictly SEPARATE detection family from the filesystem malware scanner:
* New plugin class PowerSEC_DB_Content_Scanner (includes/security/class-powersec-db-content-scanner.php). Inspects a BOUNDED set of high-risk WordPress DB locations: selected + autoloaded wp_options (siteurl/home/active_plugins/template/stylesheet/cron/rewrite_rules/sidebars_widgets; LIMIT 800), published wp_posts post_content (keyset/ID-cursored, hard cap 5000), and wp_users administrators (persistence).
* Detections (conservative, high-precision): db_content_php_payload (critical), db_content_obfuscated_js / db_content_iframe(hidden) / db_content_redirect (high), db_content_seo_spam / db_content_script_injection(raw-IP src) (medium), db_option_tamper (siteurl/home host changed vs baseline, high), db_admin_persistence (new admin vs baseline, needs-review).
* FP discipline: vendor allow-list (GA/GTM/YouTube/Stripe/PayPal/Cloudflare/CDNs...) suppresses legit embeds; <pre>/<code>/<xmp> + HTML-comment regions are treated as inert (security-blog code samples not flagged); SEO-spam only fires on hidden blocks linking to EXTERNAL non-vendor non-self hosts (cookie-consent/accordion menus suppressed); html_entity_decode for entity-encoded injections; bare base64_decode/atob without an executing wrapper is NOT flagged.
* No first-run storm: a baseline (siteurl/home host + admin IDs + powersec_db_content_scan_version) gates MEDIUM findings to baseline_added on pre-existing content; only high-confidence-dangerous fires on the first run; new/changed content (post_modified >= baseline) flagged after; a version bump silently re-baselines.
* Bounded + resumable: chunked phases (options -> users -> posts-cursored -> done), per-tick wall-clock budget, 128KB per-value cap, 200 findings cap, shared atomic DB lock (PowerSEC_File_Scanner::acquire_db_lock/release_db_lock now public static). STATIC ONLY - preg_match/string-ops only; never executes/evals/includes/renders/fetches/follows-redirects/runs-JS/unserialize.
* SEPARATION (hard invariant): writes ONLY its own state (powersec_db_content_scan_state / powersec_last_db_content_scan_summary) + ONE info audit per pass; NEVER touches malware_count/webshell_count/has_webshell/suspicious_files/security score. All plans (local deterministic scan).
* Sync: data.db_content (<=25 bounded redacted findings: metadata + short snippet + snippet_hash; NEVER full content/secrets/passwords/hashes/tokens) -> Central Site.dbContentStatus (NEW field, separate from malwareStatus). On-demand remote action run_db_content_scan (rate-limited) -> Scan.type=database_content + Site.dbContentStatus (never malwareStatus/securityScore/threatLevel).
* Tests: tests/test-db-content-scan.php = 49 PASS (must-detect / must-NOT-flag vendors+samples+consent / baseline gating / redaction / ReDoS). Adversarially reviewed (7 lenses: separation/static-only/FP/bounds/baseline/privacy/.org) - 0 ship-blockers; FP fixes applied. Deferred: wp_postmeta scanning + a dedicated Central UI panel.

= 1.4.110 =
Promote a create_function backdoor shape to the built-in scanner + Central CSV signature hygiene (PR6):
* New built-in pattern (DEEP $malware_patterns + BATCH $builtin_signatures, both engines, ALL plans/offline):
  /create_function\s*\([^)]{0,300}?(?:base64_decode|gzinflate|gzuncompress|str_rot13|gzdecode|strrev|convert_uudecode|hex2bin)\s*\(/i
  => 'create_function with decoded body (obfuscated backdoor)'. The pre-existing
  /(?:assert|create_function)\(\s*base64_decode/ only fired when the decoder was the FIRST token after the paren;
  the real lambda-backdoor create_function('', base64_decode('<evil body>')) hides the payload in the BODY (2nd arg).
  Lazy-bounded [^)]{0,300}? keeps it ReDoS-safe; the 'backdoor' label escalates it to webshell/critical. STATIC ONLY.
* Central signature CSV (backend/data/rules/malware-signatures.csv) tightened: disabled 7 rows that duplicated an
  offline built-in (assert/eval+base64/gzinflate+base64/backtick+input/create_function/preg /e/system+input), 1
  FP-prone bare function-name-assignment rule, 1 over-broad github.com host IOC, and 3 webshell-family rows that
  duplicated the built-in AND collided on IDs MS0312/MS0313 with URLHaus IOC rows (re-ID'd MS0500/MS0501/MS0502).
  Fixed a second pre-existing duplicate-ID (MS0009 -> the user-input row re-ID'd MS0503). Deleted 2 test-stub rows.
  Net 314 rows, 301 served, no duplicate IDs; a file is no longer double-listed by a built-in + an identical CSV rule.
* Tests: malware battery now 121 PASS (added create_function detect/not-flag + multi-level concat + obfuscation-chain
  + comment-suppression + ReDoS cases). No detection regression; no plan-boundary change.

= 1.4.109 =
Broadened file-change / integrity-monitoring scope (PR4) - signature-independent visibility:
* FIM now also monitors PHP-like files (.php/.php3-7/.phtml/.pht/.phar/.inc; .phps excluded) in the high-risk
  drop zones: ABSPATH root + wp-content root (top-level), and uploads + mu-plugins (recursive, symlink-safe via
  get_fim_php_like_paths), on top of the existing wp-admin/wp-includes/plugins/themes. A new/modified PHP-like
  file there is surfaced as a file-change finding even when no malware signature matches.
* Real content hashing: PHP-like files now use a streaming SHA-256 (content_hash) that catches a same-size
  content swap the legacy size+mtime pseudo-hash missed; non-PHP files keep the legacy md5/pseudo (no mass
  re-baseline). hash_file streams - the whole file is never loaded into memory. STATIC ONLY.
* No first-run storm: a scope-version migration (powersec_file_change_scope_version 1->2) SILENTLY re-baselines
  the expanded scope on existing sites (no change records), promoting the version only at a full-pass completion
  with one informational event. Fresh installs baseline the expanded scope immediately.
* Malware/FIM separation: a file-change finding is NEVER a malware verdict - it never increments
  malware_count/webshell_count; the malware scanner stays the sole authority. New PHP-like in a high-risk zone =
  medium, else low (never critical). The sync de-dups any path the malware scanner already flagged (malware wins).
* Central sync: bounded recent file-change findings (path, real sha256 or null, change_type, scope, severity,
  source=file_change, detected_at - no file contents, capped) stored in Site.fileChanges.
* The chunked FIM scan is now serialized with an atomic lock (the per-chunk baseline merge must not race a
  concurrent run) and its pass-completion test is skip-accurate. Adversarially reviewed (5 lenses) + re-verify;
  2 ship-blockers (unlocked migration race, stuck-migration completion test) + 4 lower issues fixed pre-release.

= 1.4.108 =
Large-file streaming / partial malware scanning (PR3) - closes the >5MB skip blind spot:
* PHP-like files larger than 5MB were previously skipped entirely (evasion: hide a payload in a big file).
  They are now inspected with a memory-safe streaming window scan (PowerSEC_File_Scanner::scan_large_file_windows
  + stream_scan_region): the file is read in bounded 512KB chunks via fopen/fread/fseek - NEVER loaded whole into
  memory - with 16KB carried between chunks so a signature split across a chunk boundary still matches.
* Files <= 8MB (LARGE_FILE_BUDGET) are streamed in full (coverage 'full'); larger files get head(3MB)+middle(2MB)+
  tail(3MB) windows (coverage 'partial'). Per-file 4s wall-clock cap checked each chunk, clamped to the scheduled
  tick's remaining budget so one big file can't monopolize a cron tick. STATIC ONLY - bytes are matched, never executed.
* Window scan applies the shape-gated signatures (which require request-input/obfuscation, FP-safe without
  comment-suppression); bare webshell-NAME string signatures are excluded to avoid flagging a benign comment.
* Honest coverage: new counters large_files_seen / large_files_partially_scanned / large_skipped (now only
  truly-unreadable) / partial_coverage_files_sample flow through the slice return, the full-scan + scheduled
  summaries, and the connector sync. A partially-scanned large file is NEVER reported as fully clean; partial
  coverage is a coverage signal, not a malware alert. A 32-bit >2GB filesize() overflow is routed to streaming.
* Adversarially reviewed (5 lenses) + re-verify; 2 HIGH + 1 MED + 2 LOW issues fixed before release (truncated-scan
  false-full, comment-FP via bare names, case-insensitive literal parity, 32-bit overflow, tick-budget coupling).

= 1.4.107 =
Scheduled chunked DEEP malware scan (PR2) - autonomous signature detection on WP-Cron:
* The WP-Cron path now runs the SAME authoritative deterministic malware scanner as the on-demand scan
  (built-in patterns, PR1 .phtml/.pht/.phar/.inc enumeration, tokenizer/concat/variable-function heuristics,
  known-good + suppressed-hash + checksum checks, comment-only FP suppression, Central live signatures when
  already synced) - in bounded, resumable slices. Static analysis only; no code is ever executed.
* New PowerSEC_File_Scanner::scan_malware_chunked() tick driver + maybe_run_scheduled_malware_scan() cron entry
  (hourly + daily). One bounded slice per fire (default 400 files / 12s), cursored + resumable across ticks,
  state machine in option powersec_malware_scan_state (idle->running->completed/failed), atomic per-tick lock
  with stale recovery, defers to a manual/Central full scan (no overlap), load-high abort.
* Clean-scan semantics: the canonical malware summary (which can mark a site "clean") is written ONLY after a
  full scope cycle completes - a partial/failed tick never clears malware status. A monotonic guard prevents a
  stale scheduled result from overwriting a fresher manual/Central scan.
* Sync: the periodic sync now also reports scan_status / progress and a bounded, content-hashed list of flagged
  files (path, severity, type, sha256 - never file contents) so Central can name affected files. Plan boundary
  unchanged: local scheduled scanning is available on every plan.
* scan_for_malware() refactored to accept an optional slice ($chunk) so the full scan and the chunked scan
  share 100% of the detection logic (no parallel scanner). Large (>5MB) files are accounted as skipped (partial
  coverage reported) pending streaming in a later release.

= 1.4.106 =
Malware scanner hotfix (PR1) - extended enumeration, new OFFLINE detections, BATCH false-positive fix:
* PHP-like extension coverage: get_all_php_files() (DEEP authoritative scan) + recursive_scan() (legacy/BATCH) now enumerate .php/.php3/.php4/.php5/.php7/.phtml/.pht/.phar/.inc via the new PowerSEC_File_Scanner::is_php_like_file() (kept in sync across both enumerators). Closes the blind spot where a backdoor renamed to .phtml/.pht/.phar/.inc was invisible. .phps (source view, not executed) stays out.
* New high-confidence OFFLINE built-ins (all plans, no Central feed needed) added to scan_for_malware()'s $malware_patterns: backtick shell-exec operator fed by a superglobal; preg_replace() with the /e (eval) modifier; and a dangerous function name assigned as a LITERAL string then invoked as a variable function with user input (sibling of the existing concat-name tokenizer heuristic). Comment-only matches are suppressed (php_comment_ranges/pattern_matches_only_in_comments), so core PHPDoc backtick examples do not false-positive. All bounded-quantifier (ReDoS-safe).
* BATCH false-positive fix: shell_exec/system/passthru built-in signatures changed from BARE to USER-INPUT-GATED (only fire when a superglobal flows into the call), so legitimate system('convert ...') / shell_exec('which gm') no longer flag. handle_threat() now logs at the highest matched signature severity instead of a hardcoded 'critical'. (Central-side, same release: the bare MS0009 php_system_call rule was disabled; its php_system_call_user_input sibling covers the real RCE.)
* SECURITY INVARIANT preserved: detection is 100% static (tokenizer + preg_match); nothing is executed/eval'd/deobfuscated-by-running. Regression battery test-malware-heuristics.php extended to 48 cases (must-detect + must-NOT-flag + ReDoS budget); WAF FP battery still 0 false positives.

= 1.4.105 =
Outdated software now affects the security score + is surfaced (closing a real gap):
* calculate_security_score()/_breakdown() now include a "Software Updates" factor (up to -20): a pending WordPress core security/minor update for the CURRENT branch weighs -12, a major-only upgrade -6, plus per-plugin/-theme deductions. Read from the cached update_plugins/themes/core transients DIRECTLY (not wp_get_update_data(), which zeroes counts without an admin capability) so it works in cron/sync. ALWAYS below the malware/webshell override, so a compromise still dominates. classify_core_update() distinguishes same-branch security/minor updates (urgent) from a newer-branch major upgrade (less urgent).
* (Central, same release) The site "Issues to fix" list now includes "WordPress Core Update Available", and PowerSecAI’s incident summary now warns about an outdated core (it was previously never given the WP version/update status). A "Software Updates" row was added to the score breakdown.

= 1.4.104 =
Managed WordPress NATIVE auto-updates (Central visibility + Pro policy control):
* New remote actions get_auto_update_state (read) and set_auto_update (write) surface and control WordPress's NATIVE per-plugin/theme auto-update flags (the auto_update_plugins / auto_update_themes site options) and a conservative core auto-update policy (core_minor / core_disabled, applied via the allow_minor_auto_core_updates / allow_major_auto_core_updates / auto_update_core filters). PowerSEC NEVER runs its own updater — it drives WordPress's built-in engine. The state includes a `premiumLikely` flag for components WordPress.org doesn't track (vendor/license-updated), so the UI never promises an update WP can't perform.
* Reading and changing WordPress's own auto-update settings both work on every plan (this runs entirely on your own server; see 1.4.162). New PowerSEC_Core core-update policy filters default to UNMANAGED, so PowerSEC never overrides the host's wp-config unless an admin explicitly opts in. Per-site toggles on the site page; fleet apply across selected sites for Pro/Agency. Removed the never-implemented schedule_auto_update / cancel_auto_update stubs (dead code) and the misleading "Auto-update / Rules" label (the card only ever showed the security-rules bundle, now "Security Rules").

= 1.4.103 =
Vulnerability reporting accuracy — stale-finding reconciliation + UNIVERSAL core-misclassification guard:
* Central now CLOSES vulnerability findings the scan no longer reports (patched/removed/false-positive-corrected) instead of letting them linger open forever. The plugin opts in by telling Central its synced list is the COMPLETE current enumeration: new `vulnerabilities_complete` flag in sync_site_data(), set true ONLY after a scan has run (new `powersec_last_vuln_scan_at` marker — distinguishes "never scanned" from "scanned clean / 0 findings") AND when the open list wasn't truncated by the 50-item cap. PowerSEC_Central_Connector::vulnerabilities_complete(). This is what activates Central's reconciliation block; without it Central stays additive and never wrongly closes a finding.
* Hardened looks_like_non_core_wordpress_row() (the core-misclassification guard, mirrored 1:1 in Central's RuleRepositoryService.looksLikeNonCoreWordpressRow): in addition to the title heuristic, ANY row tagged as WordPress CORE that "affects" every version (the <999.0.0 all-versions sentinel, an empty range, or '*') with no real fixed version is now suppressed — a genuine core CVE always carries a bounded range + a fix, so this never hides a real core finding. UNIVERSAL by design: it catches any mis-seeded plugin/theme advisory for ANY plugin and ANY CVE (e.g. the CISA-KEV Social Warfare / Duplicator / File Manager RCEs that were showing as "actively exploited core"), with NO per-CVE whitelist. Matcher PARITY re-verified (43-case shared fixture, PHP == TS == expected).

= 1.4.102 =
Vulnerability matcher: respect affected-range LOWER bounds + Central feed-quality fixes (false-positive batch):
* central_entry_match() (and Central's parity-matched matcher) now evaluate affected_versions when it carries a real LOWER bound (a hyphen range X-Y or an interval [X,…) instead of using fixed_in precedence — which assumed "everything below the fix is vulnerable" and so false-flagged installs BELOW the range (e.g. WPForms 1.6.3 flagged by an advisory affecting 1.8.4-1.8.5.3 / fixed 1.8.5.4). Open-ended/sentinel affected (<X, <=X, *-X, <999) still uses fixed_in precedence. New affected_has_lower_bound() helper; mirror of RuleRepositoryService.affectedHasLowerBound. Matcher PARITY re-verified (38-case shared fixture, PHP == TS == expected). Live-feed scope: ~3,198 of 35,668 entries are bounded ranges with a fixed_in (the FP-prone class).
* (Central-side, same release) Serve-time corrections restore two mis-built feed entries (a slug mis-attributed to 'classic-widgets' → 'widget-options'; a Loco Translate range collapsed to the all-versions sentinel → '<=2.8.2'). The ingestion pipeline is hardened so new entries aren't mis-built: the NVD normalizer extracts "up to/and-including X", "X and earlier", "≤ X", and the authoritative CPE version bounds BEFORE the <999.0.0 sentinel; the GitHub advisory name parser keeps multi-part product names (en-dash/ampersand) instead of collapsing onto a trailing common slug; and the cached-fallback re-use no longer launders rows into the top-priority Wordfence source. Backend regression tests added (vuln-ingestion-fp).

= 1.4.101 =
Vulnerability scan: Central-side matching is now the PRIMARY path (streamed feed + built-in DB remain fallbacks):
* PowerSEC_Vulnerability_Scanner::scan() now POSTs the installed component list to /api/rules/check-components; Central matches against its in-memory CVE DB and returns only the findings. The plugin transfers a few KB and does NO DB download or local matching — the lightest path for any host (measured scan memory footprint ~0MB, ~7s). FALLBACK chain: if Central matching is disabled (option powersec_vuln_central_matching='0' or const POWERSEC_VULN_CENTRAL_MATCHING=false), unreachable, 403 (Free/entitlement), or malformed -> the memory-bounded STREAMED feed (v1.4.99) -> the built-in 4-entry local_db. A scan never fully fails. Plan boundary UNCHANGED: /check-components enforces the same requireProSiteEntitlement as the feed, so Free sites fall through to the built-in DB exactly as before.
* MATCHER PARITY enforced before flipping the default: Central's TS matcher (RuleRepositoryService.entryMatchesComponent + isVersionAffected + version_compare) was reconciled to mirror the plugin's PHP matcher EXACTLY — fixed_in precedence, the full affected-version grammar (comparator / interval / prefix-wildcard / hyphen-range / pipe-union / exact), a PHP-faithful version_compare (dev/alpha/beta/RC/pl pre-release ordering), and the core-misclassification guard (looks_like_non_core_wordpress_row). A shared 32-case fixture (tests/fixtures/vuln-matcher-parity.json) is asserted by BOTH tests/test-vuln-matcher-parity.php AND backend/tests/unit/vuln-matcher-parity.test.ts; both green (PHP == TS == expected). This caught and fixed two real divergences during live verification: Central previously couldn't parse prefix-wildcard "*-X" affected ranges (false negative), and Central over-flagged plugin advisories that upstream feeds misclassify as WordPress core (false positive).

= 1.4.100 =
Vulnerability scan host-safety — completes the v1.4.99 streaming fix:
* Fixed signed-request auth for GET requests that carry a query string. Central verifies the HMAC over the query-LESS path (req.originalUrl.split('?')[0]), but the plugin signed the path WITH the query, so the new paginated feed requests (?slim=1&page=N) returned 401 "Invalid signature" and the scan silently degraded to the 4-entry built-in DB. PowerSEC_Central_Connector::api_request() now signs the query-less path (strtok($path,'?')) while still SENDING the full URL — a latent bug affecting ANY signed GET with query params, now fixed (query-less calls are unchanged). LIVE-VERIFIED on webamooz: auth 200, source=central, the 35,666-entry feed streamed in ~17s with a ~6MB scan memory footprint (was ~317MB / OOM), 5 vulnerable plugins + 1 theme found.

= 1.4.99 =
Vulnerability scan made host-safe (memory-bounded) — fixes the "Network error":
* ROOT CAUSE: the scan synchronously fetched the FULL Central CVE feed (~35k entries / ~25MB JSON), json_decoded it, stored it in one autoloaded wp_options row (~27MB), then linearly scanned every installed component against the whole in-memory array — peaking well above a 256MB FPM worker and PHP-fataling ("Allowed memory size exhausted"), which the admin UI surfaced as a generic "Network error". (Confirmed live on webamooz: fetch was fine — HTTP 200 in ~3s — the failure was memory, not network.)
* FIX (works on ANY host): PowerSEC_Vulnerability_Scanner::scan() now STREAMS the feed in SLIM, PAGINATED chunks (GET /api/rules/vulnerability-db?slim=1&page=N&page_size=3000) and matches each page against the installed components, holding at most ONE page in memory — so peak memory is bounded regardless of feed size. The full DB is no longer downloaded or stored; the legacy ~27MB autoloaded powersec_central_vuln_rules option is dropped on upgrade. sync_rules_from_central() is now a lightweight metadata-only record (version/release/hash/count). The matcher (central_entry_match + version_matches_affected_ranges + the built-in local DB fallback) is unchanged, so detection is identical; on a Central fetch failure the scan degrades to the built-in local DB rather than failing.
* The admin handler raises memory headroom best-effort (wp_raise_memory_limit), wraps the scan in try/catch, and returns a CLEAR, actionable error instead of a fatal; the admin JS now surfaces the real reason (timeout / server error / the handler's message) instead of a blanket "Network error".
* (Central, same release) GET /api/rules/vulnerability-db gains slim + pagination (page/page_size, total/has_more); no params = the original full payload (backward-compatible).

= 1.4.98 =
Scanner false-positive elimination — multi-version self-baseline + rule precision (Fixes 1–3):
* Bundled self-baseline (includes/data/powersec-self-baseline.php) is now CUMULATIVE across ALL shipped versions. scripts/gen-self-baseline.sh emits the de-duplicated UNION of the SHA-256 content hashes of every PowerSEC PHP file across every version (seeded once from the full git history — 945 historical hashes — then carried forward on each bump). The malware scanners already trust known-good files BY CONTENT HASH at any path (class-powersec-file-scanner.php:1521 / class-powersec-malware-scanner.php:132), so an older PowerSEC copy left in the plugins tree (e.g. a powersec.bak.* deploy backup) is now recognized as our own code instead of self-matching its own webshell signature DEFINITIONS (c99/r57/FilesMan/WSO/...) and being reported CRITICAL. This is content trust, NOT a path/filename exclusion — a tampered own-file (different bytes) has no entry and is still scanned, so there is no blind spot.
* Central malware rule MS0012 (suspicious_error_suppression_chain) no longer lists popen/proc_open. Error-suppressing those two is standard in legitimate process/mail libraries (Symfony Process's @proc_open, PHPMailer's @popen), so the rule produced false MEDIUM "evasion" findings on trusted vendor code. @eval/@assert/@system/@exec/@shell_exec/@passthru/@create_function are still flagged, and malicious user-input proc_open/popen is still caught by php_system_call_user_input. Served live from Central; no plugin code change required.
* (Central-side, same release) The plugin-baseline API now returns the slug-wide content-hash union so the ONLINE known-good set matches the offline bundled one, and the dashboard's incident-summary webshell count now uses the real per-site count instead of a 0/1 cap (so the banner and the score-breakdown agree).

= 1.4.97 =
Malware-file remediation from Central — new quarantine_file remote action (PR-D):
* New `quarantine_file` remote action (class-powersec-remote-actions.php) lets the PowerSEC dashboard neutralize a flagged file by MOVING it out of its executable path into wp-content/powersec-quarantine (web-denied via .htaccess/web.config + index.php, chmod 0600), so a webshell like a dropped m.php can no longer run — then it can be deleted via the existing quarantine-scoped delete_file. Reversible: a powersec_quarantine_manifest option records original path + sha256 + timestamp.
* Hardened, defense in depth: path must resolve INSIDE the install (no '..'; trailing-separator containment, the B3 read_flagged_file pattern); core (wp-admin/wp-includes), wp-config/credentials, and PowerSEC's own files are blocked; and a FLAGGED-ONLY gate requires the target to be in the latest scan's suspicious_files_list — so a stolen Central key can only quarantine what the scanner flagged, never arbitrary files. Rate-limited 20/day, idempotent, and audit-logged (file_quarantined).
* On success the stored scan summary is refreshed (file dropped from suspicious_files_list; counts/webshell flag recomputed) so the dashboard count updates immediately; the next full scan recomputes canonically. Central surfaces the real flagged-file list on the site page (get_malware_findings) with a per-file Quarantine button — closing the prior dead-end where Central showed "N suspicious files" with no way to act.

= 1.4.96 =
Security-score webshell count now matches the canonical malware summary (PR-A):
* check_malware_threats() (class-powersec-config.php) — which feeds calculate_security_score_breakdown() (the "Improve your score" tips + the score-breakdown card sent to Central as security_score_breakdown) — no longer RE-DERIVES webshells by naive substring-matching of pattern descriptions (it matched "exec" inside the label "php_backtick_exec", counting verified-clean core files as webshells: the bogus "9 webshells / Score overridden to 0" on webamooz that contradicted the real malwareStatus webshell_count=0). It now reads the SAME canonical source as the connector's get_malware_status_summary() — the scanner's stored powersec_last_malware_scan_summary (has_webshell / webshell_count / suspicious_files), with an audit-event canonical-count fallback. So the score breakdown can never again disagree with the dashboard's webshell count. No detection change (the scanner's own curated-label webshell_count, incl. the v1.4.95 evasive-webshell heuristic findings, is authoritative); static-only.

= 1.4.95 =
Malware scanner: detect evasive webshells + kill core/vendor false positives (PR1):
* DETECTION (false negative fix): added static, tokenizer-based heuristics that catch command-execution webshells which evade literal-token signatures by assembling a dangerous function name from string fragments and invoking it as a variable function — e.g. the live webamooz sample `$f='sys'.'te'.'m'; $f($_GET['x']);`. detect_dynamic_exec_obfuscation() (class-powersec-file-scanner.php) joins concatenated string LITERALS via token_get_all (lex only — never eval/execute/deobfuscate, preserving the scanner security invariant) and flags a concat-assembled dangerous name (system/exec/shell_exec/passthru/proc_open/popen/eval/assert/create_function/call_user_func[_array]/pcntl_exec) — CRITICAL if also invoked as $var(...), else HIGH. Wired into BOTH scanners (scan_for_malware + PowerSEC_Malware_Scanner::scan_file). Also added nested-decode-chain + dangerous call_user_func obfuscation signatures.
* FALSE POSITIVES (the 8 CRITICAL php_backtick_exec on verified-clean WP core + vendor MEDIUMs): added comment-only suppression — a signature whose every match falls inside a PHP comment/docblock (e.g. a Markdown `$_POST['x']` example in core PHPDoc matching php_backtick_exec) is suppressed as a false positive (php_comment_ranges()/pattern_matches_only_in_comments(), tokenizer-based; a real indicator in code or a string literal is never suppressed). Tightened the Central suspicious_error_suppression_chain signature to exclude the legitimate @include()/@require() idiom (Central malware-signatures.csv MS0012 + rules-ops default). Made the WordPress.org core-checksum allowlist resilient (persist last-good manifest) so an api.wordpress.org outage can no longer leave verified core unscanned and falsely flagged.
* Regression battery: tests/test-malware-heuristics.php (14 cases) — m.php + concat-shell_exec/passthru detected CRITICAL; system($_GET) + real backtick exec still detected; core docblock backtick + vendor @include/@require + legit concat callbacks produce NO finding. No plan gates changed; detection remains static-only.

= 1.4.93 =
Magic-link login is FREE (plan-boundary fix):
* The magic-link (passwordless email sign-in) toggle no longer shows a "PRO" pill (admin/views/login-security.php). It was UI-positioned as Pro but never gated — no entitlement key, no save-handler gate, and class-powersec-magic-login.php enforcement ignores plan — so it always worked on every plan. Product decision (2026-06-26): keep it FREE, matching 2FA + CAPTCHA (all free local login protections). The login-security upgrade card (includes/helpers/upgrade-card.php) now sells genuinely-Pro Central-side value (login alerts, failed-login incidents+runbooks, fleet login monitoring) instead of the local magic-link feature.

= 1.4.92 =
Launch-audit follow-ups — B3 path containment + N5 WordPress.org phpcs compliance:
* read_flagged_file (includes/class-powersec-remote-actions.php): the in-install containment check now compares the realpath against the install root WITH a trailing separator, so a sibling directory that merely shares the root's name prefix can't pass. (The read remains owner-only + credential/key/DB-blocklisted + 2MB-capped, and is NOT reachable via support access grants.)
* WordPress.org compliance (N5): removed the BLANKET file-level `// phpcs:disable` of security/escaping sniffs (EscapeOutput, ValidatedSanitizedInput, NonceVerification, SafeRedirect, PreparedSQL, PluginCheck.Security.DirectDB) from 46 files, per the project rule against blanket security suppressions. Replaced with 52 real wp_unslash() fixes on superglobal reads + 179 narrow, individually-justified inline phpcs:ignore annotations. No runtime behavior changed; Plugin Check reports 0 errors / 0 warnings on the changed files, and the WAF/firewall FP batteries stay green. Also re-synced source↔dist (20 shipped files had drifted, including line-ending normalization).

= 1.4.91 =
IP Blocking CIDR unblock + clearer messages (completes the v1.4.90 CIDR work):
* unblock_ip() (includes/security/class-powersec-ip-blocker.php) now validates via PowerSEC_IP_Utils::is_valid_ip_or_cidr() — it had kept the old is_valid_ip check, which rejects CIDR, so v1.4.90 could BLOCK a range but never UNBLOCK it (the delete returned early). Block and unblock now use the same validation.
* ajax_block_ip()/ajax_unblock_ip() (admin/class-powersec-admin.php) return specific, actionable errors instead of a generic "Failed to block/unblock IP": invalid IP/CIDR format, "you can't block your own IP/range" (self-lockout), and "that address isn't in your block list" — per the user-facing-clarity directive.

= 1.4.90 =
QA-campaign fixes — IP Blocking CIDR enforcement, notification persistence, session/dashboard accuracy:
* IP Blocking (includes/security/class-powersec-ip-blocker.php + includes/class-powersec-ip-utils.php): CIDR ranges are now ENFORCED. block_ip() validates via the new PowerSEC_IP_Utils::is_valid_ip_or_cidr() (the old is_valid_ip rejected CIDR), and is_blocked() now range-matches active CIDR rows via PowerSEC_IP_Utils::is_ip_in_range() in addition to the exact-IP query — previously a /24 was stored but never matched a real request IP, so range blocks were silently inert. Added a self-lockout guard: block_ip() refuses to block the admin's own current IP or any CIDR containing it, making the "your IP is protected" notice truthful (admin/views/ip-blocking.php copy updated).
* Notifications & SIEM save (admin/class-powersec-admin.php ajax_save_notifications): now calls $config->save() after writing the keys — every field had been set with the default $save=false (in-memory only) and the handler returned success without persisting, so no Pro notification/SIEM channel setting actually saved. All notification and SIEM channels are available on every plan.
* Active Sessions (admin/views/session-control.php): "New location" detection is now IPv6-aware — it split both IPs on '.', so an IPv6 address compared as a whole string and flagged nearly every session; now compares the first octet (/8) for IPv4 and the first two hextets (~/32) for IPv6.
* Dashboard (admin/views/dashboard.php): the "Blocked threats" KPI mini-trend now derives from the blocked-threats value instead of plotting the weekly failed-login series. Removed a dead line in admin/views/settings.php that read an object property on an array ($ch->enabled_key), silencing a PHP 8 notice.

= 1.4.89 =
Site favicon for the Central dashboard avatar (includes/class-powersec-central-connector.php):
* sync_site_data() now reports `site_icon_url` => get_site_icon_url(64) — the WP Site Icon (Settings → General → Site Icon) at 64px, '' when unset (function_exists-guarded). Central stores it as Site.faviconUrl (http(s) only, ≤2048) and renders it as the site avatar across the dashboard (Overview, Site detail, Backups fleet), with the existing letter-on-gradient avatar as the fallback when there's no icon / it fails to load. Re-synced every sync so a changed or removed icon self-corrects. No new outbound request — it's an additional field on the existing sync payload.

= 1.4.88 =
Backup restore verifiability — surface the EXISTING restore audit trail (admin/views/backup.php, includes/class-powersec-audit-logger.php, includes/backup/class-powersec-backup.php):
* Restore History panel: a new card on the Backup screen lists recent restore OUTCOMES (success/failure · type · backup id · rollback checkpoint · time). It REUSES the existing audit log via a new PowerSEC_Audit_Logger::get_restore_history() (event_type LIKE 'backup_restore%', excluding the 'started' marker) — restores were already recorded by log_restore_event(); this just makes them visible where you work instead of only under the Audit Log tab. No new storage. Read-only render, esc_html/esc_attr, prepared query.
* Success detail modal: the restore success path now opens a modal showing Type · Backup · Rollback point · Notes (from the data restore_backup() already returns) + "recorded in your Restore History and Audit Log", replacing the bare "Backup restored successfully!" alert.
* Descriptive audit messages: backup_restore_succeeded / _started / _preflight_failed messages now embed the restore type + backup id + rollback checkpoint (e.g. "Restored database from backup_… (rollback checkpoint backup_…)") so the Audit Log list and the new Restore History are self-describing, not a generic "Backup restore completed". Metadata unchanged.

= 1.4.87 =
Audit Log hardening + accuracy (admin/views/audit-log.php, includes/class-powersec-audit-logger.php, admin/class-powersec-admin.php):
* IPv6 overflow: the IP column widened 120->140px + the IP span got word-break/min-width + a title, so a long IPv6 wraps in-cell instead of spilling into the Time column (header + row grids both updated).
* CSV formula-injection (CWE-1236): export_csv() now passes every cell through csv_safe(), prefixing a leading =/+/-/@/tab/CR/LF with a single quote. The message column carries attacker-influenced data (a failed-login username), so an exported CSV opened in Excel/LibreOffice could otherwise execute an injected formula.
* Timezone: get_24h_stats(), get_hourly_timeline(), and the render + CSV range cutoffs now build the cutoff in WP-local time (current_time('timestamp') - DAY_IN_SECONDS, etc.) to match created_at (current_time('mysql')) — was a UTC gmdate(strtotime()) cutoff that skewed the 24h/range windows by the site's offset (e.g. +3:30 Iran fleet).
* Severity chips: new get_severity_breakdown($search,$event_type,$date_from) counts per-severity over the SAME active filter as the total, so the chips reconcile with 'All severities' and the post-click list (was a fixed-24h snapshot that ignored range/search/event_type). Added the low + warning tiers (sev_meta, chips, valid_severities) that were previously uncounted and rendered as 'Info'.
* CSV header 'Time (UTC)' -> 'Time (local)' (the value is WP-local, matching the detail panel). Export button notes the 5,000-row cap.
* Integrity panel honesty: removed the false 'Hash chain valid' / 'Tamper-evident' / unconditional 'Valid' claims (the verify button was a client-side setTimeout with no server check). The hero now says 'Logging active'; the panel shows the actual SHA-256 fingerprint (or 'No events recorded yet'); 'Verify chain' is now 'Recompute' and calls a real ajax_verify_audit_chain handler that recomputes the fingerprint server-side. (A full per-row hash chain remains a possible future Pro feature.)
* Upgrade links: audit-log.php (retention/siem), settings.php (siem), and database.php (auto_optimize/logging/sql_shield) now use $connector->get_upgrade_url() — respects a custom central_url and routes connected sites to the per-site /dashboard/sites/{id}/upgrade picker — instead of a hardcoded powersec.io/pro host.

= 1.4.86 =
* File Scanner (admin/class-powersec-admin.php ajax_scan_malware): fixes the 1.4.85 malware-rules-panel attempt, which set the TOP-LEVEL rules_version — but the malware "Central Rules Debug" panel reads central_rules_meta.version (file-scanner.js renderRulesVersionDebug(data.central_rules_meta,...)), so it still rendered "Scan used: -". Now central_rules_meta.version falls back to 'builtin' when there is no Central bundle (Free), so the panel shows "builtin" like the vulnerability scan. Removed the unused top-level rules_version/source the 1.4.85 patch added (dead code on this path).

= 1.4.85 =
* File Scanner accuracy (admin/views/file-scanner.php + admin/js/powersec-ds-table.js): the "Recent file changes" list is capped at the 50 most recent rows, but the toolbar showed "50 of 50 changes" (count of LOADED rows) which read as "only 50 changes exist" even when thousands were tracked (e.g. 2,865 after a Plugin Check install). Now surfaces the real total — added a capped-list notice using the already-computed $total_changes ("Showing the N most recent changes. This site has T tracked changes (U unreviewed) — use Mark all reviewed to clear the backlog"), and the shared data-table meta appends "· T total" via a new data-grand-total attribute. No behavior change to detection; this is display accuracy only.
* Malware scan rules panel (admin/class-powersec-admin.php ajax_scan_malware): now sets rules_version ("builtin" on Free, else the loaded Central malware bundle version) and source ("local"/"central") on the scan response, so the "Central Rules Debug" panel shows the active bundle like the vulnerability scan instead of "-/Unknown".

= 1.4.84 =
* Reporting cadence to PowerSEC Central (how often this site sends data to that hosted service) follows the connected account's service level. This affects only the frequency of requests to PowerSEC's servers — every security feature on this site runs continuously on every plan. (Presentation reworked in 1.4.164 so it is not shown as a locked feature.)
* Central Settings page: the PUT now sends only the settings the user actually changed (frontend/app/dashboard/sites/[id]/settings/page.tsx diff). Fixes the v1.4.83 side effect where a Free site's real central_sync_interval='5min' (now visible to Central after the rest_route fix) was round-tripped and rejected the ENTIRE save with a "Pro Protection is required for custom Central sync cadence" error — silently blocking the free CAPTCHA/2FA toggles the user actually changed. The autoUpdateScope select is now also gated for non-entitled users (like its toggle) to avoid the same whole-save-rejection class.

= 1.4.83 =
* Central Connection page (admin/views/central-connection.php): the three "Open PowerSEC Central" links now deep-link to this site's Central page (/dashboard/sites/<site_id>) when connected, or /dashboard otherwise, via a new $central_open_url — they previously pointed at the bare host (scheme://host) stripped from $dashboard_url.
* Central sync interval is now wired to a real recurring cron event (class-powersec-core.php): central_sync_interval drives a dedicated powersec_central_sync_recurring event scheduled at the matching cadence. Added prefixed sub-hourly schedules (powersec_5min/15min/30min) to add_cron_schedules (WordPress ships none by default, so the setting previously could not schedule), a sync_interval_schedule_map(), ensure_scheduled_sync($force) (schedules only while connected; clears when disconnected), and a throttled admin_init self-heal (maybe_ensure_scheduled_sync). The save handler (admin/class-powersec-admin.php) calls ensure_scheduled_sync(true) so a changed interval reschedules immediately. Removed the old hard-wired hourly sync hook in the connector (class-powersec-central-connector.php) that ignored the interval; the daily sync remains as a low-frequency safety net. Deactivation clears the new event.
* Login Security (admin/views/login-security.php + includes/helpers/upgrade-card.php): two-factor authentication is now correctly presented as available on all plans — removed its "PRO" badge (the toggle and enforcement were never plan-gated) and updated the login-security upgrade card to highlight Central-side value (login alerts, incident runbooks, fleet-wide login monitoring) instead of 2FA/CAPTCHA. Magic-link sign-in, like 2FA and CAPTCHA, is local login hardening and is free on every plan.

= 1.4.82 =
* File-integrity watcher (class-powersec-file-watcher.php): now keys change detection on a CONTENT sha256 (mtime kept only as a cheap pre-check) instead of bare filemtime. A re-save / deploy / `touch` / WordPress rewrite-rule flush changes mtime but not content and no longer raises a false "CRITICAL: <file> was modified!" alert. Legacy pre-hash baselines are backfilled silently (no migration false-alert, no missed real change).
* File-integrity watcher: the plugin's own authorized writes now re-baseline the watcher via the new static PowerSEC_File_Watcher::rebaseline() so they never self-alert — the database prefix change (rewrites wp-config.php) and a backup restore (rewrites site files).
* File-integrity watcher: .htaccess modifications are now reported at "high" severity (configuration file, routinely regenerated by WordPress) instead of "critical"; wp-config.php and the PHP bootstrap files remain "critical".

= 1.4.81 =
* Remote actions: record_idempotency_finish() now emits a status-accurate message — a finished action with status='failed' logs "Remote action failed." instead of the hardcoded "Remote action completed." that surfaced in Central as the self-contradictory "Remote Action Failed" + "Remote action completed." alert.
* Remote actions: benign request rejections no longer push HIGH "Remote Action Blocked/Failed" alerts to Central — rate-limit throttling and the rate-limit idempotency-finish, plus unsupported-action_type rejections, are now logged locally at 'info' (duplicate/in-flight rejections were already 'info'). This removes a large alert-flood source (these are normal operation, not security events). Genuine action failures still alert at HIGH.

= 1.4.80 =
* Dashboard: the "PowerSEC Central Shortcuts" card linked all three buttons to the bare host (the marketing homepage) instead of the Central app. They now deep-link correctly: "View security dashboard" -> this site's Central page (/dashboard/sites/<id>), "Manage all sites in PowerSEC Central" -> the fleet sites list (/dashboard/sites), and "Open Central dashboard" -> the dashboard overview (/dashboard). The per-site link falls back to the overview when the site isn't connected to Central yet.

= 1.4.79 =
* Spam Prevention: comments caught by the built-in heuristic scorer are now routed into the WordPress spam queue (comment_approved='spam', via pre_comment_approved) instead of being hard-rejected with wp_die(403). Because the comment is now STORED, it is counted in the dashboard stats ("Total spam", "Spam today", "This week"), surfaced under "Top spam IPs", and picked up by the hourly IP auto-block sweep — none of which a never-inserted (wp_die'd) comment could feed. Visitors no longer hit a 403 error page; the comment simply never appears publicly.
* Spam Prevention: removed gambling keywords (casino/poker/lottery) from the high-weight (+20) block pattern — on betting/gambling client sites those are legitimate words and were causing false-positive blocks. The pharma terms (viagra/cialis) remain. The full keyword/weight map is now filterable via the `powersec_spam_keyword_patterns` filter so a site can re-add or tune patterns.
* Spam Prevention: the "Spam today" / "This week" / "Legit comments (30d)" counts now compare against comment_date_gmt (UTC) instead of the local comment_date, removing a timezone skew. The hourly auto-block sweep now runs its spam-IP query once instead of once per qualifying transient. The spam threshold is clamped to 1–100 when saved (a 0 would have auto-blocked on the first spam).

= 1.4.78 =
* Database: the header "Optimize now" button now runs optimization from EVERY Database tab — previously it carried no submit field and the target form only existed on two tabs, so on most tabs it did nothing.
* Database: the header "Export SQL" button is renamed "Database backup" and now starts a database backup directly (deep-links into the Backup screen's create-database-backup flow) instead of just opening the Backup screen with no action.
* Database: the "Overhead" status card no longer shows "Optimization recommended" for small amounts of normal InnoDB tablespace free space. The nudge now uses the same significance test as the database-health advisor (it ignores small or low-ratio InnoDB free space), so the badge stays consistent with the health score and clears once overhead is genuinely low (InnoDB Data_free is reused automatically and is not reclaimable by OPTIMIZE TABLE).

= 1.4.77 =
* WordPress.org: the plugin's public display name is now "PowerSEC" (directory slug "powersec", matching the text domain) for the wordpress.org listing. No functional changes.

= 1.4.76 =
* Dashboard: the "Upgrade to Pro" card now appears at the TOP of the dashboard for free sites (previously below the fold) and uses accurate plan copy. New "PowerSEC Security" dashboard widget on the main WordPress dashboard shows your security score, last scan, last backup, threats blocked (30 days), and plan, with a one-click link into each area; it is hideable per-user via Screen Options. PowerSecAI plan-upgrade recommendations now deep-link to the exact recommended plan tier.

= 1.4.75 =
* Admin UI: third-party plugin and WordPress admin notices shown on PowerSEC pages are now always readable. Previously a notice from another plugin could render as white text on its own white background (invisible) because it inherited the dashboard header light text colour. PowerSEC now forces the standard dark-on-white WordPress styling for those notices on every PowerSEC screen and relocates them out of the dark header, without hiding any other plugin notices.

= 1.4.74 =
* Audit log: the retention panel now shows the real 90-day window on every plan. Previously the Free view displayed a "capped at 7 days" badge that never matched actual behaviour — audit events were always kept for 90 days. Pro still adds configurable retention (up to 365 days) plus Slack/Discord streaming and Splunk/Datadog/syslog export.

= 1.4.73 =
* Free sites can now create and restore all LOCAL backup types (database, files, full) even when disconnected from Central — the offline fallback now matches the plan (cloud backups stay Pro-only).
* Blocked IPs now record the visitor's country via a fast Cloudflare/cache lookup (no extra latency on the request path).
* Permanent IP blocks created automatically (honeypot/firewall/scanner) are now auto-pruned after 1 year (configurable); blocks you place by hand are never auto-removed.
* New Firewall-page notice when virtual-patch rules are synced but the Behavioral WAF isn't in Block mode, so it's clear they aren't enforcing yet.
* Removed an unused internal IP-blacklist setting.

= 1.4.72 =
* Firewall/WAF hardening: tightened the remote-file-inclusion rule so it only blocks remote URLs whose path points at an executable script — legitimate links to remote images, pages, stylesheets, and documents are no longer mistakenly blocked, while real attacks (including obfuscated ones) are still caught.
* WAF exceptions you add now also relieve false positives at the always-on firewall layer, not just the behavioral WAF.
* Serialized data that page builders and form/commerce plugins legitimately submit to admin endpoints is no longer at risk of being blocked, while still being monitored.
* Reliability: the security tables (and the blocked-IP uniqueness safeguard) now self-heal if a database upgrade was ever skipped, so IP blocking and logging can't silently stop working. Temporary IP blocks now expire correctly regardless of the server's timezone.
* Live virtual-patch rules now update independently of the malware/vulnerability feed, and are cleared promptly when a site moves off a paid plan. The firewall stats panel reports a real "false positives (7d)" count and an accurate "last updated" time.

= 1.4.71 =
* Scanner: added a client-side JavaScript/CSS skimmer-loader check that inspects theme, upload, and must-use-plugin scripts for code-from-encoded-data loader patterns commonly used by injected payment skimmers. It runs in a low-noise, report-only mode (informational audit-log entries only) and honours your false-positive list. It can be turned off via the `powersec_js_skimmer_scan` option.

= 1.4.70 =
* Scanner: vulnerability scan results are now persisted and reported to the dashboard (previously findings were computed but never stored, so the fleet vulnerability list could stay empty).
* Scanner: the deep (remote-triggered) malware scan now honours the admin "false positive" suppression list, so a file you marked safe is no longer re-flagged on the next scan.
* Scanner: files larger than 2 MB are no longer skipped entirely — their leading content is now inspected, closing a blind spot, while memory use stays bounded.
* Reporting: corrected the suspicious-file count reported to the dashboard so non-webshell malware findings are reflected accurately.

= 1.4.69 =
* Backups: repaired the weekly-full + daily-differential backup engine so differential restore points correctly track their base full backup. Chain metadata (backup kind and base reference) is now preserved end-to-end, so a differential is no longer stored or reported as a standalone full. Cloud backup only; no change to local backups.

= 1.4.68 =
* Backups: hardened the database restore so it requests extended PHP execution time (set_time_limit) before replaying a large SQL dump statement-by-statement — large-database restores now complete reliably on more host configurations (where the host permits it; no-op behind a hard FPM request timeout). Memory use stays bounded by the existing line-by-line read. Backup CREATION is unchanged, and this is the PHP-native restore path every install uses. No other changes.

= 1.4.67 =
* Compliance: cleared all findings from the latest WordPress.org Plugin Check (2.0.0) — removed an unused legacy database-backup method (PowerSEC_Database_Security::create_backup(), which had no callers; the real backup engine is class-powersec-backup.php) and added narrow, documented PluginCheck.WriteFile annotations for the backup-restore and malware-quarantine file writes. Those writes target wp-content/uploads (restore) and wp-content/powersec-quarantine (quarantine) — never the plugin's own folder — so the sniff was a false positive; the annotations make Plugin Check pass without changing any behavior. No functional changes.

= 1.4.66 =
* Compliance/housekeeping: WordPress.org submission polish — shortened the plugin header description, removed the unnecessary `Network` header (PowerSEC now activates per-site on Multisite), split the long changelog into changelog.txt to keep readme.txt lean, added the bundled Chart.js source/release link, and emphasized the built-in local backup engine in the description. No functional changes.

= 1.4.65 =
* Backup: the Free plan can create and restore all LOCAL backup types — database, files-only, and full (database + files) — with local restore points, no account required. Off-site cloud storage, scheduled/automatic backups, and additional restore points remain part of the paid plans. Backup creation and restore are gated consistently across the plugin and PowerSEC Central.

= 1.4.63 =
* Compliance: removed the only native shell invocation from the shipped build — backup host-profiling now reads logical CPU count from the `/proc/cpuinfo` kernel pseudo-file (read-only, native fallback) instead of shelling out to `nproc`. No functional change to backups; the value only tunes throughput estimates.
* Compliance: WordPress.org Plugin Check now reports zero errors and zero warnings — simplified a guarded random call to `wp_rand()` (the `mt_rand()` fallback was unreachable on the plugin's WP 5.8+ floor) and corrected `phpcs:ignore` placement on three trusted-prefix WAF-statistics queries.

= 1.4.62 =
* Maintenance: aligned the internal version constant with the plugin header and readme stable tag.

= 1.4.61 =
* Fix: the Free-tier commercial-status fallback no longer enables file/full restore. Free is database-only per plan; the local default could otherwise allow file/full restore preflight on a site with no valid Central snapshot (fresh or disconnected). Pro tiers are unaffected (Central grants those on sync).

= 1.4.60 =
* Align legacy Pro commercial-status fallback with Central's canonical Pro + Backup Plus mapping.

= 1.4.59 =
* Hardening (WAF): added bounded, category-scoped normalization views for deep URL encoding, HTML entities, legacy `%uXXXX` escapes, zero-width/fullwidth Unicode tricks, and SQL comment-splitting evasions while preserving existing confidence/action gates.
* Hardening (WAF): multipart/form-data inspection now extracts only safe text metadata (field names, text fields, filenames, part content types) and skips file bytes to reduce false positives and regex cost.
* Tests: expanded the standalone WAF regression battery with encoded-evasion, multipart, false-positive, and ReDoS guardrail cases.

= 1.4.58 =
* Fix: the WAF Exception Lists modal now shows the real exception value (and its reason) instead of "Array", and the Remove button works again — custom/accepted exceptions are stored as structured entries and the admin view was rendering the whole entry. Enforcement was always correct; this fixes admin visibility and removal.
* Hardening: the always-on core firewall never blocks a logged-in administrator (lockout safety), but it now LOGS a SQLi/XSS signature found in a privileged-admin request (monitor-only, medium severity — no block, no alarm spam) so a compromised or XSS-driven admin session leaves an audit trail at this layer too.

= 1.4.57 =
* Fix: a WAF exception recommendation is now validated when it is proposed (same rules as a manual add), so an un-acceptable value (e.g. a url_paths entry that isn't a valid regex, or an unknown rule id) is rejected up front instead of being queued and then failing every time an admin tries to accept it.
* Fix: accepting a recommendation that fails to apply no longer silently removes it from the pending queue — it stays so the admin can see it didn't apply and dismiss it explicitly.

= 1.4.56 =
* Security/consent: the Central "recommend WAF exception" support action no longer applies an exception directly — it now queues a PENDING recommendation that a site admin reviews and accepts in Firewall → WAF Exceptions (Accept/Dismiss). Support can propose under read-only access without changing enforcement.
* New: WAF status panel on the site settings page is now backed by a real endpoint (observed/matched/pending metrics) instead of a missing route.
* Hardening: the behavioral WAF now inspects privileged-admin requests too — it never blocks an admin (no lockout) but logs critical-severity matches (RCE/SSRF/XXE/Log4Shell/object-injection/etc.), so a compromised admin session is no longer invisible.
* Fix: corrected the remaining WP_Error-as-success handling on the Central exception path.

= 1.4.55 =
* Security/consent: support remote actions now enforce the access-grant scope — state-changing actions (e.g. WAF mode change) are rejected under a read-only grant (Central backend).
* Security: WAF block mode can no longer be set prematurely via the Central settings/sync path — the block-promotion gate (and a strict learning/monitor/block validation) now applies on every write path, not just the admin screen.
* Fix: WAF exception + "mark false positive" workflow now reports real errors and actually persists — corrected error handling, the IP-allowlist field key, and the URL-exception regex delimiters (entries previously failed silently while showing success).
* Hardening: the default administrator WAF exception no longer blanket-allows critical-severity signatures (RCE/SSRF/XXE/Log4Shell/object-injection/wp-config/stacked-SQL) on every path — those are now logged for visibility (never blocked, so no lockout), so a compromised admin session isn't invisible.
* Hardening: the WAF now structurally parses JSON request bodies, catching injection payloads hidden by JSON string escaping; copy corrected from "ML-driven" to the accurate signature/behavior-based description.

= 1.4.54 =
* Improvement (WAF): re-promoted several detections from monitor to BLOCK after tightening each to pass a new committed false-positive regression battery — SSTI (now requires a real sink token, ignores arithmetic/calls like {{ count * 2 }}), Remote File Inclusion (now keyed on file-include parameter context, not URL extension — catches extensionless/.txt and ignores OAuth callbacks/feeds), command-exec (requires a shell metachar in the call), stacked SQL (requires injection context, not a bare verb), and NoSQL operators (added the JSON-body form). PHP-object/serialized data stays in monitor (cannot be distinguished from legitimate serialized form/option data without a gadget allowlist).

= 1.4.53 =
* Fix (false positives): an adversarial review found several v1.4.51/v1.4.52 signatures were over-eager and could block legitimate traffic. The core firewall now honours each rule's confidence/action (it no longer hard-blocks on monitor/low-confidence matches), and the following were returned to monitor (log) mode pending tighter patterns: stacked-SQL, PHP-object/serialized data, remote-include URLs, SSTI ({{ }}/<%= %>), command-exec keywords, and NoSQL operators — so ordinary comments, serialized form/option data, OAuth callback URLs, and template/JS syntax are no longer blocked.
* Fix: request-header (Referer/User-Agent/X-Forwarded-For) scanning was removed — a visitor's previous-page URL legitimately contains paths/links that tripped path/RFI/SSRF rules and 403'd normal navigation.
* Fix: Log4Shell detection narrowed to the actual exploit token (${jndi:...}) so legitimate ${env:...}/${date:...} templating is no longer flagged.
* High-precision rules remain blocking: SSRF (cloud metadata / dangerous schemes / obfuscated IPs), php://filter and /proc LFI, wp-config traversal, error-based SQLi, XXE, prototype pollution, and the existing SQLi tautology/time-based + XSS protections.

= 1.4.52 =
* Improvement (WAF): promoted the modern-attack signatures from monitor to blocking after tightening them for zero false positives — Remote File Inclusion (now requires a remote URL to an executable script), XXE (ENTITY / DOCTYPE-SYSTEM, no longer matches standard XHTML doctypes), SSTI (requires an exploit token, ignores ordinary {{ name }} / ${price} bindings), NoSQL operator injection, and prototype pollution now BLOCK.
* Improvement (WAF): tightened UNION-based SQL-injection detection to require post-SELECT injection syntax, so ordinary text mentioning "UNION SELECT" is no longer blocked.
* Improvement (Central): the wp-config.php traversal virtual-patch rule is now enforced (block) instead of monitor-only.

= 1.4.51 =
* New (WAF): added detection for modern attack classes — Local/Remote File Inclusion (php://filter chains, /proc/self, wp-config traversal), SSRF (cloud metadata, dangerous URL schemes, obfuscated IPs), XXE, SSTI, PHP object injection/deserialization, Log4Shell/JNDI, NoSQL operator injection, prototype pollution, and stacked/error-based SQL injection. New rules ship in monitor (log) mode and are promoted to blocking after validation.
* New (WAF): the behavioral WAF now also inspects JSON/XML/GraphQL request bodies and key request headers (User-Agent, Referer, X-Forwarded-For), and scans parameter names/keys (catching e.g. NoSQL and prototype-pollution payloads carried in the key).
* Improvement (firewall): the core SQL-injection/XSS scanners now unslash request data before matching (closing a magic-quotes evasion of quote-based payloads) and recurse nested array parameters.
* Improvement (false positives): tightened the information_schema and system()/exec() signatures so ordinary prose and JavaScript no longer trigger a block, while real injections still match.
* Improvement (resilience): fake-bot verification now caches DNS results, times out, and fails OPEN (a DNS failure no longer blocks a legitimate search-engine crawler); added an optional, off-by-default aggregate flood guard for distributed floods that slip past per-IP rate limits.
* Improvement (visibility): the global IP-reputation blocklist synced from PowerSEC Central is now actually enforced (gated by "Block known bad IPs"); monitor/learning-mode WAF sites now surface a throttled "attack in progress" signal to Central instead of being blind to spikes.
* Improvement (database): bounded/batched cleanup of the remote-request idempotency table; added composite audit-log indexes (applied automatically on upgrade); the WAF block-log table is now removed on uninstall.

= 1.4.50 =
* New: WAF block-log retention — the behavioral-WAF block log is now pruned daily (bounded, batched; default 30 days, filterable via `waf_block_log_retention_days`) so it can't grow unbounded under sustained attack traffic.
* Improvement: attack-spike notifications now also count behavioral-WAF blocks (SQLi/traversal/LFI), not just core-firewall blocks, so a flood mitigated purely by the WAF still raises an alert; the alert's "top sources" merges both layers.
* Improvement: audit-log cleanup can now drain a full attack-storm day in a single daily run (batch ceiling raised; window unchanged).
* Fix: removed a duplicate security email on Pro/Backup-Only sites — the canonical, throttled alert email (sent on every plan) is now the single email; SIEM streaming channels are unaffected.

= 1.4.44 =
* Fix (Central): "Bulk update" now updates ALL selected plugins, not just the first one. The first upgrade was clearing WordPress's update cache mid-batch, so every plugin after it looked "already up to date." The update cache is now kept until the batch finishes, then refreshed.

= 1.4.43 =
* New (Central): "Bulk update" now updates all selected plugins in a single action. Each plugin is updated independently — if one can't be upgraded, it's reported with its reason and the rest still complete (no more all-or-nothing failures). Reuses the same update logic as single-plugin updates.

= 1.4.42 =
* Fix (Central Connection): sites already on Central Pro no longer see a misleading "Upgrade to Central Pro" button on the Central Connection screen. When you're on a Pro tier, the screen now confirms your plan and offers "Manage plan" instead. Any Pro-tier package (including Pro + Backup Plus) is now correctly recognized as Pro on this screen.

= 1.4.41 =
* Improvement (Two-Factor login): two-factor is now a proper two-step flow. After you sign in with your username, password and CAPTCHA, you're taken to a dedicated page that asks for ONLY the emailed verification code — you no longer have to re-enter your username, password and CAPTCHA a second time. Verification codes are also now sent from noreply@powersec.io.
* Security (Two-Factor): the verification code now has a limited number of attempts before you must restart, codes are timing-safe compared, and the login carries between steps via a single-use, short-lived token (never your password).

= 1.4.40 =
* Fix (Two-Factor email): login verification codes are now delivered through PowerSEC Central's email service by default, so 2FA works even on sites whose own WordPress email (wp_mail/SMTP) isn't configured — previously the code could silently never arrive. Falls back to the site's local email if Central is unavailable. The login screen now only says "a code has been sent" when it actually was; if delivery fails you get a clear message instead of being locked out.

= 1.4.39 =
* Fix (Backup Advisor): the advisor no longer says "cloud/off-site backup is not enabled" on plans that include cloud backup (e.g. Pro + Backup Plus). When AI guidance is temporarily unavailable it now correctly reflects that cloud backup is enabled instead of showing a misleading "NO CLOUD BACKUP" warning that contradicted the active cloud backups on the same page.

= 1.4.38 =
* Fix (Malware scanner): the scanner no longer flags PowerSEC's own files as "webshells". The plugin's detection files necessarily contain the malware signature patterns, so on a version where the server-side known-good list was missing or incomplete the scanner could match its own code and report false webshells (and crash the security score). PowerSEC now ships a built-in, content-hash list of its own files so it always recognizes them as safe — by exact content, so any genuine tampering of a plugin file is still detected.

= 1.4.37 =
* Fix (Login Security): the statistics panel now reports accurate failed/successful login counts. It reads from the tamper-evident audit log instead of the lockout table (which is intentionally cleared on each successful login), so it no longer showed "0 failed / 100% success" while failures were actually occurring.
* Fix (Vulnerability scan): the results headline now counts individual findings (one per CVE) to match the list below — it previously showed a component count that disagreed with the number of rows in the table.
* Fix (Plan label): the Firewall and Audit Log pages now show your exact plan name (e.g. "Pro + Backup Plus") like the rest of the dashboard, instead of a generic "PowerSEC Pro" / "Pro Plan".
* Improvement (Audit noise): routine database structure changes (which happen during normal plugin/theme/core updates) are now recorded at "medium" severity instead of "critical", so they no longer flood the incident feed while still being kept in the audit trail.

= 1.4.32 =
* Fix: the general Settings page no longer fires a duplicate background save (the page's own save handler now stops a second, redundant save from also running). Settings save once, cleanly.

= 1.4.31 =
* Fix: the general Settings page could silently fail to save (e.g. "Enable File Scanner" reverting after a refresh) because the save request used a security token read from an unrelated page element that wasn't always present. The save now uses a self-contained token and also persists multi-line fields. Settings save reliably.

= 1.4.30 =
* Security (vPatch-lite): the firewall can now apply targeted virtual-patch mitigation rules for actively-exploited WordPress attack classes, delivered from PowerSEC (Pro). New rules arrive in monitor mode so they surface attempts without affecting legitimate traffic until reviewed. Every rule is regex-validated before use.

= 1.4.29 =
* Security: when an administrator confirms a flagged file is a false positive, that exact file is now skipped by the scanner on all your sites (matched by content fingerprint). The detection pattern keeps working everywhere else, so coverage is never reduced.

= 1.4.28 =
* Admin/security: added a safe, read-only "fetch flagged file for review" capability so an administrator can inspect a flagged file's full contents to confirm or dismiss a detection. The file is streamed on demand and never stored; reads are limited to files inside the WordPress install (no traversal), capped at 2 MB, and credential/key/database files are blocked.

= 1.4.27 =
* Compatibility: verified and documented support for WordPress 7.0 and PHP 8.4 / 8.5 (the plugin's minimum stays PHP 7.4).
* Security (transparency): documented the malware scanner's core safety invariant — it analyses files by static pattern matching and never executes, evals, or "unpacks by running" the code it scans, so a scanned file can never trigger code execution inside the scanner.

= 1.4.26 =
* Restore safety: a restore can no longer start while a backup is running (and vice versa) — preventing a half-written backup from being published while the site is being restored under it.
* Restore safety: a cloud-downloaded base backup that predates checksum support is now structurally validated (ZIP consistency) before it is used as the foundation of an incremental restore.
* Backups: the incomplete-attempts panel now shows WHY the last attempt failed, and recovery now works on hosts where a scheduled worker event can never fire (WP-Cron disabled with no system cron).
* Transparency: the backup options and plan pages now accurately describe how backups are protected (TLS in transit, encrypted at rest, SHA-256 verification); the External Services disclosure now covers the cloud storage provider (Wasabi) directly.
* Cleanup: removed an unused legacy command handler.

= 1.4.25 =
* Backups: multi-pass backups now continue reliably and quickly on every hosting setup — including hosts where WP-Cron is disabled and self-requests (loopback) are blocked. The plugin self-loopback is more reliable on HTTPS, and if neither cron nor loopback can advance a backup it is driven forward from the dashboard's own status checks, so a large backup no longer crawls or stalls.

= 1.4.24 =
* Backups: a backup no longer fails with "Database export failed" when a previous attempt left an in-progress dump file the current worker can't write to (e.g. its owner changed between runs) — it now discards the unusable partial and restarts the database export cleanly.

= 1.4.23 =
* Restore: corrected and end-to-end-verified the cloud-restore download — fixes a destination check that previously blocked cloud-only / pruned restores, makes sure an incremental chain's base files reach the site, finalizes the download with an atomic move that works on every host, and cleanly recovers a leftover partial. Verified with a checksum-matched cloud download.

= 1.4.22 =
* Restore: very large cloud backups now download resumably — the transfer runs in chunks and, if it can't finish in one pass on a slow host, picks up exactly where it left off on the next attempt instead of starting over. Every downloaded file is still integrity-checked before it is applied, and a corrupted or partial download is re-fetched automatically.

= 1.4.21 =
* Restore: large backups now download from the cloud with the full time budget on slow hosts (no premature PHP timeout), and after a database restore the site is sanity-checked (core settings present) so an incomplete backup is flagged instead of silently leaving a broken site.
* Plan page: the plan panel now also shows the incremental-backup state, and lays out responsively on small screens.
* Backups (server-side): a background integrity check periodically confirms your cloud backups' files are still present and flags any that have gone missing.

= 1.4.20 =
* Restore: each base backup downloaded from the cloud for an incremental restore is now integrity-checked (checksum) before it is applied — a corrupted or partial download can no longer produce a silently broken restore.
* Backups: cloud uploads now self-heal — a backup left stuck "uploading" by a worker restart, or one that failed transiently, is recovered and retried automatically in the background (previously you had to delete/resume to nudge it).

= 1.4.19 =
* Backups & restore — reliability hardening:
* Incremental restore now works even when the base full backup was removed locally to save space: the base (and the whole chain) is fetched from the cloud first. A genuinely missing base now fails with a clear message instead of silently restoring an incomplete site.
* Cloud uploads survive many more interruptions, and a backup can no longer get stuck "uploading" forever — a genuinely failed upload is marked failed and retried automatically.
* When a backup is too large for your plan (or another limit is hit), the backup page now explains exactly why it stayed local-only, instead of a vague failure.
* Fixed a stale-recovery edge that could keep a finished/failed backup showing as "running"; large file archives get more time to finalize on slow hosts; restore messages are clearer about whether a cloud copy is available.

= 1.4.18 =
* Backups: cloud uploads now show live progress — percent, MB uploaded / total, and an ETA — on both the plugin Backup page and the PowerSEC Central dashboard, instead of a generic "Uploading…" with no detail. Also fixes a dashboard layout issue where the upload status badge overlapped the action buttons, and improves the small-screen layout.

= 1.4.17 =
* Backups: added a Pause / Resume control for cloud uploads. And when you free up space (e.g. delete an old backup), a backup that previously couldn't reach the cloud because the plan limit was full now uploads automatically — no need to re-run it.

= 1.4.16 =
* Backups: fixed a rare repeated-attempt loop on very large databases under heavy load — a transient database read error could fail a backup and a leftover scheduled worker could then start a fresh attempt each cron tick. The database export now retries transient read errors, persists progress markers less aggressively (lower DB load), and a finished job no longer re-spawns from a stale scheduled event.

= 1.4.15 =
* Backups: the database export now also splits a single very large table (e.g. a security/activity log with hundreds of thousands of rows) across worker passes, so even one huge table can't stall a backup on a host with a strict request time limit. This completes the resumable backup engine — databases and file sets of any size now back up reliably on constrained/shared hosts.

= 1.4.14 =
* Backups: hardened the resumable large-backup engine — raised the per-backup pass limit so very large backups aren't abandoned part-way, made worker passes strictly serial (no two passes touching the in-progress dump/archive at once), and a backup now fails cleanly if the database export can't complete instead of continuing with a missing database.

= 1.4.13 =
* Backups: large multi-pass backups now resume back-to-back via a secure internal worker trigger instead of waiting on the host's cron schedule, so a big full backup finishes in minutes rather than being paced by 5-minute cron ticks. It still falls back to cron if the internal trigger can't run on a given host.

= 1.4.12 =
* Backups: the database export is now also resumable across worker passes (table by table), so very large databases (millions of log rows) no longer have to finish inside a single request. Combined with the chunked files archive, large full backups now reliably complete on constrained/shared hosts without timing out. Also fixes a case where a files-archive checkpoint was mistaken for a failure and the archive was rebuilt from scratch.

= 1.4.11 =
* Backups: large full backups now complete on constrained/shared hosts without changing host limits. The files archive is built in bounded data chunks per pass (each step finishes well inside the host's PHP request time limit) and the worker chains the next pass immediately via a loopback — instead of registering gigabytes and compressing them in one request that the host kills. This fixes multi-GB full backups that stalled at the file-archive stage.

= 1.4.10 =
* Backups: deleting a backup now removes BOTH the local copy and the cloud copy, freeing a cloud restore-point slot (previously "Delete" only removed the local file, so the cloud restore point — and the plan limit — were unaffected). Use "Free disk (keep cloud)" to remove only the local copy.

= 1.4.9 =
* Backups: cancelling a running backup now actually stops it promptly. The cancel signal is read live from the database, so an in-progress database export or file archive halts at the next chunk instead of running to completion (previously the long-running backup kept a stale cached copy of the flag and finished anyway).
* Backups: the file count is shown only during the file-archiving phase, not during the database export.

= 1.4.8 =
* Backups: the in-progress backup bar now shows REAL progress — percent done, MB written vs total, speed (MB/s) and a live ETA — instead of an animated estimate.
* Backups: the dashboard now shows a live cloud-upload percentage and ETA while a backup uploads to the cloud.
* Backups: cancelling a backup is now recorded in the audit log together with the user who cancelled it (an administrator on the site, or via PowerSEC Central).

= 1.4.7 =
* Backups: you can now choose "Cloud only" when creating a backup, so no copy is kept on your server (saves disk). Cloud-only and pruned backups stay fully restorable — PowerSEC downloads them from the cloud on restore.
* Backups: added a "Free disk (keep cloud)" action to remove a local copy while keeping the cloud copy.
* Backups: large backups are no longer blocked by a small fixed per-backup size cap — a single backup may use up to your available cloud storage; you're warned as you approach the limit and notified if a backup can't be uploaded.
* Agency pools: shared backup storage is now allocated fairly per site, so one site can't consume another site's reserved space.

= 1.4.6 =
* Database Health: the advisor now flags oversized/high-row tables (e.g. a runaway 404 or activity log), not just transients/overhead, and the health score reflects them.
* Database Health: added a safe, user-initiated "Empty log table" cleanup for known append-only log/analytics tables (with an impact preview and confirmation) — never core/content tables.
* Database: softened the default-prefix wording (a hardening recommendation, not a SQL-injection vulnerability).
* Consistency: the plan badge now shows the actual package (e.g. "Backup Only") on every screen instead of "Free" on some.
* Central Connection: fixed the worker "last heartbeat" age (was comparing a UTC timestamp to local time, showing hours instead of seconds).
* Backups: attempts stuck "in progress" far longer than any real backup are now shown as failed/stalled so they can be cleared.

= 1.4.5 =
* Fixed false "modified core file" reports (e.g. wp-includes/version.php) on sites that run the en_US core with a language pack: core-checksum verification now uses the actual core build locale ($wp_local_package) instead of the UI locale.

= 1.4.4 =
* Fixed "modified plugin/core file" false positives: WordPress.org checksums list multiple acceptable hashes per file (an array), and the verifier now matches against any of them instead of only a single value.

= 1.4.3 =
* Further false-positive reduction in the malware scanner: removed bare backtick detection and the PHP 7.0-removed `preg_replace` /e modifier check (these false-matched legitimate libraries such as TGM Plugin Activation and virastar); `create_function` is now flagged only when built from user input.

= 1.4.2 =
* Malware scanner calibrated to cut false positives: dangerous functions (system/exec/shell_exec/backtick/move_uploaded_file/php://input) are now only flagged when driven by user input, instead of on mere presence — legitimate themes/frameworks are no longer mislabelled as webshells.
* "Webshell" is now reserved for high-confidence indicators (obfuscated eval, exec-from-input, known shell signatures); other matches stay "suspicious" without forcing a critical score.
* Vulnerability scan: a Free site now clearly shows "built-in ruleset (Free plan)" instead of a misleading "network issue" message (live Central feeds are Pro Protection).

= 1.4.1 =
* Scanner: PowerSEC's own files are now verified against Central known-good signatures (SHA-256) rather than skipped — no path exclusions, no blind spots; fixes self-detection false positives.
* Core integrity now verifies WordPress core only; bundled default themes update independently of core and are covered by the malware and file-change scans.
* File-change baseline auto-refreshes for the plugin's own files after an update, so legitimate updates are no longer reported as "modified".

= 1.4.0 =
* First public release on WordPress.org.
* Local security: login hardening, request firewall, IP allow/block lists, file-change monitoring, malware scan workflows, audit log.
* Optional Central connection for multi-site management, cloud backup, and incident response.
