=== SMEPlan Security Shield — full changelog ===

WordPress.org truncates the Changelog section of readme.txt at 5,000
characters, so readme.txt keeps only the two most recent releases and the
complete history lives here instead. Same content, nothing removed.

= 0.7.31 =
Eight follow-up fixes from a second full-codebase review of 0.7.30. Several close gaps in 0.7.30's own fixes. All verified against the source and covered by the behavioural test suite.

**Migration / recovery**
* **Legacy quarantine adoption was still defeated on the delete-then-reinstall flow (H1).** 0.7.30 made the adoption run whenever the stored DB version was absent or predated signing, but activation stamped the DB version straight to the current one BEFORE that migration ever ran (the migration hook is missed in the activation request), so on a reinstall over the deliberately-kept quarantine folder the adoption never ran and pre-signing sessions stayed permanently un-rollbackable, original file already moved. Activation now runs the same guarded adoption first, then stamps.
* **A partially-completed adoption is retried instead of frozen as done (H2).** The migration stamped the DB version unconditionally even when adopting an individual session failed to write (disk full, permissions). Those sessions were then never retried. The stamp is now withheld when any session failed, so the next run retries only the still-unsigned ones (adoption is idempotent).

**Scanner reliability**
* **The scanner could restart forever without finishing a pass, while still reporting "no findings" (M4 + M3).** Two independent causes: (1) reading the size of a file that had vanished mid-scan — which happens routinely, e.g. when remediation auto-quarantines (moves) a flagged file during the same tick — threw an uncaught RuntimeException that aborted the entire scan tick; and (2) the scan checkpoint was written with a direct, non-atomic file write, so a crash or full disk part-way through left truncated JSON that the next tick discarded, resetting the scan to the start of its cycle. The vanished file is now skipped, and the checkpoint is written atomically (temp file + fsync + rename, via the same safe writer the rest of the plugin uses).

**Closing gaps in 0.7.30's own fixes**
* **Alert delivery (M5).** 0.7.30 stopped clearing the alert buffer before delivery, but treated any non-WP_Error webhook response as delivered — including an HTTP 4xx/5xx, which is returned as a normal response array, not an error. A receiver that rejected the alert therefore still cleared the buffer. Only a 2xx webhook status now counts as delivered.
* **Example system-cron command (M12).** 0.7.30 fixed the timestamp and percent-escaping, but the command still appended `?ts=...` to the REST URL — which on plain (non-pretty) permalinks already ends in `?rest_route=...`, producing two `?` and a request that reached /run with no signature at all. The correct separator is now chosen based on the URL.

**Other**
* **REST /run "busy" response (M14).** The 202 "scan lock already held" response returned `ok:false` but, unlike every other error branch on the route, carried no machine-readable `error` field. It now includes `error: "scan_busy"` (a soft, retryable code) alongside the existing `busy` flag.
* **Accessibility (L1).** The "Latest scan overview" table — the one data table missed by the earlier scope sweep — now marks its column headers with `scope="col"`.

Review notes: the second review's remaining findings were assessed and set aside as design work or bounded/low-impact rather than surgical fixes — chiefly the single cron secret serving three roles with no in-product rotation path, the settings sanitizer's implicit allowlist, network-wide maintenance-mode state on multisite, and several duplication/refactor items. The recurring "no tests" findings reflect the review running against the distribution archive, which excludes the test suite by design; the source ships behavioural and unit tests for the auth, quarantine, rollback and backup paths.

= 0.7.30 =
Fourteen fixes from a full-codebase security and correctness review of 0.7.29. Each was verified against the source and is covered by the behavioural test suite. Grouped by area.

**Security / recovery**
* **A new site's authenticated-scan secret was silently lost on multisite.** The per-site HMAC secret generated when a site is created was discarded by the settings sanitizer, which rebuilds the saved value from a field whitelist the secret was never on. On the "Add Site" path that sanitizer is active, so the new site was left with an empty secret permanently (init runs once, never retries): its authenticated scan runner could never authenticate, and its log/backup filename tokens fell back to a guessable form. The secret is now preserved through the sanitizer, verbatim (not through a text sanitizer that would strip characters a generated secret can legitimately contain). (rc-07)
* **Legacy quarantine was never adopted on multisite subsites — or after a reinstall.** The one-time signing migration only ran for a site that already carried a stored version number. No multisite subsite except the activation site does (activation stamps the version for one blog only), and a delete-then-reinstall clears it while deliberately keeping the on-disk quarantine folder. Unsigned sessions on those sites were then refused on rollback forever, with the original files already moved out — the exact black hole the adoption exists to prevent. The migration now runs whenever the stored version predates signing OR is absent; it returns immediately when there is no quarantine folder, so a genuinely fresh install costs nothing. (rc-06)
* **Blast-radius limit on unverifiable legacy sessions widened.** A quarantine session predating signing could still be restored over any bootstrap PHP file at the top of the WordPress root (index.php, wp-load.php, wp-cron.php, and the rest), anything under mu-plugins/, or a wp-content drop-in — all loaded unconditionally, exactly like the wp-config.php and core paths already blocked. All are now refused for such sessions; the correct recovery for an unverifiable session is always over SFTP. (rc-05)
* **Generated cron secret alphabet.** The secret is now generated from letters and digits only, matching the metadata-signing key. The previous alphabet could emit characters that break the documented system-cron command line. Existing secrets are untouched; a site that hit the old problem can clear the secret to have one regenerated. (rc-61)
* **Delete-plugin cleanup completed.** Uninstall had drifted out of sync and left behind several per-site option rows (findings index, alert buffer, pending-refresh, resolutions, single-use replay-guard nonces, transients), five scheduled hooks (network-activation continuation, initial-baseline builds, deferred component refresh, health-check retry), and — on multisite — the network-level rows in wp_sitemeta (last-run, offsets, activation/baseline checkpoints, and the TTL locks) that the per-site sweep never reached. All are now removed. The metadata-signing key is deliberately kept, so a mid-incident delete-then-reinstall can still roll back the quarantine folder that uninstall also preserves. (rc-01)

**Data integrity**
* **Automatic backup could refuse a clean component forever.** A benign `.js` file containing a short PHP open tag — shipped by common syntax-highlighter libraries — is classified "suspicious" and never quarantined, but the pre-backup scan counted it as malicious and blocked the backup with a false "found and quarantined N malicious files" message. The automatic post-update backup refresh then silently failed for that component. Only a genuinely malicious (auto-quarantined) file now blocks a backup. (rc-08)
* **Stale baseline read within a request.** The per-request baseline cache was not invalidated after a baseline was rewritten, so a rebuild-then-scan in the same request diffed against the old map and reported hundreds of just-baselined files as changed (and made the backup refuse the component). The cache entry is now dropped on write. (rc-28)
* **A failed deferred backup was treated as done.** The deferred baseline/backup refresh ignored a backup failure and cleared its own pending record, stranding a component with a fresh baseline but a stale backup zip that a later restore would silently revert to. A failed backup now keeps the pending record so the self-heal retries it. (rc-56)
* **Alerts could be lost on a failed send.** The alert routine deleted its durable buffer and started its 15-minute cooldown before sending, and ignored the send result — so a failed delivery (or a worker killed mid-send) dropped that tick's findings and suppressed alerts for the cooldown window. The buffer is now cleared only after a channel actually accepts the alert; otherwise the findings stay buffered for the next attempt. (rc-57)

**Correctness / UX**
* **Cron health check always reported failure.** It read a result key the health check never returns, so a click always showed "found problems" even while the panel directly below said everything was working. It now reads the key the health check actually returns. (rc-15)
* **The example system-cron command was unusable.** It baked in the timestamp from when the Wizard page was rendered, so every copied job failed authentication a few minutes later, and it left percent signs unescaped, so cron truncated the command. It now computes the timestamp at run time and escapes correctly. (rc-12)
* **Dead and dangerous Restore buttons.** The mu-plugins backup could never be restored because its target directory was not resolved; the drop-in / loose-file backups, had theirs been resolved, would have displaced the entire wp-content directory on restore. mu-plugins now restores correctly, and the unsafe buttons are replaced with guidance to recover those files over SFTP. (rc-29)
* **Oldest log line never shown.** The reverse (tail) log reader dropped the very first line of any log shorter than the requested count, so the oldest record never appeared. Fixed. (rc-73)
* **Scan I/O cap and its logged value.** Three flat-file scan phases (drop-ins, loose files, wp-content root) never advanced the I/O counter, so their I/O cap did nothing and their logged I/O was always zero. They now account for it the same bounded way the directory phase does. (rc-74)

Review notes: three headline findings were checked and set aside. A reported stored-XSS-to-RCE chain does not reproduce — the escaping used already neutralises the payload, confirmed by reproducing the escaper directly. A reported "missing guard" in the settings sanitizer is required for checkbox semantics, not a bug. A reported "permanently stuck lock" self-heals on the next tick. Three real-but-deeper items (binding directory-quarantine contents to the signature; a REST parameter that returns fewer results than intended; drop-in loose-file restore) are documented for a follow-up because they need a design decision and new tests rather than a surgical change.

= 0.7.29 =
* **Fix:** WordPress 6.7+ logged "_load_textdomain_just_in_time was called incorrectly ... translation loading was triggered too early" on every admin request. `Smeplan_SS_Admin::init()` runs on `plugins_loaded` — before `init` — and called a WP_DEBUG-only coverage assertion directly. That assertion reads a map built from `__()` calls, so every admin page load translated this domain before WordPress was ready. The assertion now runs on `admin_init` instead; it is a development check and loses nothing by running slightly later. Present since 0.7.21.
* The 0.7.27 release fixed a second, genuine instance of the same class (the custom cron interval's display name) but not this one, because the search looked at `init()`'s own body rather than following what it calls. The regression test added here counts actual translation calls across the whole call graph, which is the only check that would have caught either.

= 0.7.28 =
* **Fix (log noise):** 0.7.25 added a WP_DEBUG-only warning that fired whenever an event code had no dedicated case in the Logs screen's Details renderer. The premise was wrong — 53 of the 69 event codes this plugin emits have no such case and are not meant to have one, because the generic renderer added in the same release is their intended rendering. So the check warned about the normal case, once per table row, on every render: on a site with WP_DEBUG on it produced eight or more warnings per request and buried the notices that actually mattered. Removed.

= 0.7.27 =
* **Fix:** WordPress 6.7+ filled the debug log with "Function _load_textdomain_just_in_time was called incorrectly ... Translation loading for the smeplan-security-shield domain was triggered too early" on every request. The cause was the custom cron interval's display name: that filter runs whenever anything calls `wp_get_schedules()` or `wp_schedule_event()`, including other plugins doing so on `plugins_loaded`, which is before `init`. Registering the filter later would have meant an early caller not seeing the interval at all, so the translation is deferred instead of the filter — before `init` the untranslated English is returned, which is what any "translation" at that point would have produced anyway, minus the notice. The login-lockout message got the same treatment, since it is reachable early in some auth flows and would otherwise repeat the notice on every blocked attempt.

= 0.7.26 =
One security fix and three i18n corrections, arising from a WordPress.org compliance audit of 0.7.25. Scope note: the audit itself listed eleven items to confirm; on re-checking each against the source, three were real (the i18n strings below), five were false positives, one was already handled elsewhere in the code, one was a documented design decision, and one is now moot. The fix below was NOT one of the eleven — it was found while tracing what the audit's `write-tainted-path-variable` finding was actually pointing at.

**Security — arbitrary file write inside the WordPress root (affects 0.7.24 and 0.7.25)**

Quarantine metadata was not authenticated. `Smeplan_SS_Fs::rollback_file()` decides where to restore a file from the `src` field of the session's `meta.json`, and nothing tied that file to this plugin having written it. The quarantine tree also sits at a completely predictable path — `uploads/smeplan-security-shield/quarantine/`, with no unguessable component (0.7.21 added an HMAC suffix to log *filenames* only).

So an attacker with a write primitive into `wp-content/uploads/` — the exact situation this plugin exists to detect — could create a directory there holding their payload plus a `meta.json` naming any destination inside the WordPress root. The session then appeared on the Remediation screen looking entirely ordinary, and one click on Rollback moved the payload into place. "Rollback all" required no per-session choice at all. The `hash_equals( $meta['src'], $relative )` check added in an earlier release gave no protection here: with `meta.json` attacker-written, both sides of that comparison were attacker-chosen. Reproduced end to end, including overwriting `wp-config.php`.

Path containment could never have closed this, because the destination genuinely is inside the WordPress root and restoring a core file is a legitimate thing for this plugin to do. The missing property was authenticity, so that is what is checked now:

* `meta.json` is signed with an HMAC whose key lives in `wp_options` — in the database, where an uploads-write primitive cannot read it. An unsigned or altered session is refused on rollback and logged at critical.
* The quarantined file is now compared against the `hash_before` recorded when it was quarantined. That field had been written since the beginning and never read; it was worthless before signing existed (a forger simply filled in the hash of their own payload) and is meaningful now, catching a file swapped inside an otherwise genuine session.
* Sessions that already existed on disk when this version is installed are adopted rather than refused — deleting or blocking them would destroy an admin's only rollback copy, the same failure fixed from the other direction in 0.7.25. They are marked as unverifiable and may no longer be restored to `wp-config.php`, into `wp-admin/` or `wp-includes/`, or over this plugin's own files.

Known residue, stated rather than buried: a forged session planted *before* the upgrade is adopted along with the genuine ones. This is not a new exposure — such a session was already fully exploitable on 0.7.24 and 0.7.25 — but the upgrade does not retroactively clean a site that was already compromised. The destination limits above are what backstops that case. If you are upgrading a site you believe was compromised, review the Remediation screen before clicking Rollback. From this version onward, anything unsigned is refused outright.

**Internationalisation**

* Three strings combined a count with a countable noun using `__()` and an "(s)" suffix rather than `_n()`: the core-mismatch panel's "showing the first N of M" line, its "restore all N mismatched file(s)" confirmation, and the Remediation screen's "restore all N file(s)" confirmation. Languages with more than two plural forms could not translate these correctly. All three now use `_n()`.

**Documentation**

* The FAQ now explains what deleting the plugin does and does not remove. Uninstall deliberately leaves `wp-content/uploads/smeplan-security-shield/` in place, because quarantined files are moved rather than copied and that folder therefore holds the only remaining copy of anything taken off the site — an uninstall performed mid-incident must not be what destroys it. The entry gives the reasoning, tells users how to remove the folder themselves, and warns that `quarantine/` can contain live malicious files that should be deleted rather than moved back.

= 0.7.25 =
Fixes for findings from an independent multi-lens code review of 0.7.24, each re-verified line by line against the source before being acted on. Scope note, stated deliberately: of the 26 findings checked, 25 held and 1 (a claimed quarantine-tree wipe via an empty session id) did not — it was blocked by a metadata check the original report had read past, and is fixed here as hardening rather than as the critical it was filed as. Four further findings from that review's adversarial rounds could NOT be re-verified, because the report did not retain enough detail to locate them; they are not addressed below and should be treated as still open. Please do not read this entry as "all findings confirmed".

**Critical**

* **Login lockout did not stop a correct password.** The rate-limit filter runs at priority 30 — after WordPress has already validated the password — and treated "we have a WP_User" as an authorized bypass. So the lockout only ever changed the error message for WRONG guesses, while the one request brute force actually cares about went through every time. The lockout decision no longer depends on the authentication result. WP-CLI stays exempt.
* **Cross-tenant scanning via REST on multisite.** A `/run` request with no `blog_id` was authenticated against the CURRENT site's `cron_secret` but then executed the network-wide round-robin — so a subsite admin holding only their own secret could trigger a scan, with auto-quarantine, on another tenant's site and read its results back. An untargeted request now scans only the site whose secret authenticated it.
* **Infinite recursion when changing the scan interval.** `sanitize_settings()` is registered as the `sanitize_option_*` callback and called `update_option()` on the same option from inside itself. Core sanitizes before writing, so every nested level re-read the stale value, re-entered, and never reached the write that would have ended the cycle: changing "Scan frequency" and saving produced a fatal, saved nothing, and rescheduled nothing. Rescheduling moved to `update_option_{$option}`, which fires after the row is committed.
* **"Trust this IP" emptied both IP lists.** `login_ip_allowlist` and `trusted_proxies` are stored as arrays, but the sanitize callback cast them with `(string)`, producing the literal "Array", which then failed validation. Any programmatic settings update — including the self-rescue button a locked-out admin clicks — wiped both lists instead of extending them. Losing `trusted_proxies` also collapses every visitor behind a CDN onto one IP, so one attacker can lock out everybody. The handler ignored the return value and reported success regardless. Both fixed.
* **Quarantine could self-destruct every cron cycle.** `quarantine_retention_days` accepted 0, which makes every session — including one seconds old — count as expired, and the purge runs at the end of EVERY scan tick, not just on the button. One mistyped field destroyed the only rollback copy and the only forensic sample within a single cycle, with no notice and no event. That field and thirteen others now have real floors and ceilings.
* **"Clear logs" silently emptied the Findings screen and disabled the Quarantine button — and an anonymous xmlrpc flood did the same.** Findings had no store of their own; they were derived from the last 500 log lines. So anything that appended enough events, rotated the log, or cleared it made a live finding vanish from the UI while the flagged file stayed on disk — and `path_has_open_finding()` then refused the manual Quarantine button for that same file, hiding the problem and disabling the fix in one move. Open findings now live in their own durable, bounded index that log housekeeping does not touch, and the multicall guard no longer writes one log line per blocked request.

**Reliability and availability**

* One ordinary `.js` bundle could take a healthy site offline. The three-character marker `<?=` — which this file's own rule says is too short to use, written after that exact token caused false positives before — was treated as conclusive proof of a backdoor; a single quarantined file over 512 KB then switched the whole site into maintenance mode for fifteen minutes. `<?=` is now reported as suspicious (logged, never auto-remediated), and the batch thresholds only apply once something has actually been quarantined this tick. Maintenance mode is also no longer enabled before the quarantine it covers, nor left on when that quarantine fails.
* The maintenance watchdog deleted WordPress core's own `.maintenance` file during core and plugin updates, because it checked only that the file existed. It now requires the plugin's own state to claim ownership.
* Quarantining wrote its metadata AFTER moving the file, so a crash in between left a session that no screen listed, that rollback refused, and that the purge deliberately spared "for an admin to find" — via the one function guaranteed not to show it. Metadata is written first now, the move is refused if it cannot be recorded, and pre-existing orphans are listed.
* Backups renamed the archive into place before hashing it, so a crash left a new zip described by the previous metadata. Restore reads that as tampering and logs it as critical, while the component still counts as backed up and is skipped by the bulk path forever. Hash and metadata now land before the archive is published.
* Scan checkpoints went through a helper that discarded `file_put_contents()`'s return value, so a full disk lost progress silently and sent the scan back to the start of its cycle. Failures are reported now.
* The two long admin operations held a 90-second lock around a 300-second download, and `Lock::release()` then failed its token comparison and returned false without logging. Those paths use a TTL sized for the work, and a mismatched release is logged.
* `/run`'s rate limit ran before the signature check and keyed on an IP that defaults to `REMOTE_ADDR`, so behind a CDN roughly eleven unsigned requests a minute stopped scheduled scanning for everyone. It now runs after verification.
* Resolving a finding was a read-modify-write with no compare-and-swap, so two concurrent resolutions could lose one — leaving a quarantined file listed as unresolved forever, with the button on that row pointing at a path that no longer exists. Now CAS with retry.
* Both bulk remediation screens reported "not attempted yet, click again to continue" as a failure. A partial first pass therefore read as a broken tool, and stopping there leaves a part-restored site that is worse than either end state. Skipped and failed are now distinct, and the "click again" hint survives a partial failure.

**Correctness and transparency**

* Restoring a core file accepted any path at `remediate` capability with no requirement that the scanner be reporting it — the missing half of the 0.7.15 pairing that gates manual quarantine. Content was never at risk (every byte written is checksum-verified), but availability was. Now gated on the file genuinely differing from the official checksum.
* Four irreversible actions — clear logs, purge quarantine, dismiss a config finding, change an author slug — completed without emitting any audit record, so afterwards nothing in the system could say who did it or when. All four now log.
* Three handlers discarded a failure and reported success anyway: `wp_update_user()` (the author-slug button claimed to have closed the exposure it exists to close), the settings update, and `build_component_baseline()` (a finding was cleared while no baseline had been written).
* Both human-facing surfaces of a finding were wrong at once. The Details column dumped raw JSON — including an 80-byte excerpt of the flagged file — for eighteen of the ~34 event codes, and the Suggested action column returned whichever signal happened to be inserted first rather than the most severe, so a backdoor could appear beside reassuring advice. Both fixed, and the coverage assertion that already existed for one map was generalised.
* `handle_cron_health()` was the only handler bypassing the shared redirect contract; when JSON encoding failed, the page reloaded with no feedback at all.
* The quarantine screen read and parsed every session's metadata on disk to render one 50-row page, and its 60-second cache was invalidated by the four actions performed most often on that very screen.
* `begin_tick()`/`flush_alerts()` — a pair whose omission has silently dropped alerts twice — is now an enforced scope rather than something each caller has to remember.

= 0.7.24 =
Compliance pass against the WordPress.org submission checks, verified by running PHP CodeSniffer with the official WordPress Coding Standards locally (not a simulation) before and after each change. Every Security, DB-prepare, i18n and IniSet sniff the plugin previously tripped is now clear; the remaining reports are formatting/docblock style, which WordPress.org does not reject for.
* **Fix (uninstall)**: the 3 custom capabilities granted to the administrator role at activation (`smeplan_ss_manage`/`review`/`remediate`) were never removed on uninstall, so they stayed in `wp_user_roles` forever after the plugin was deleted — once per site on multisite. They are now removed alongside the options and scheduled events.
* **Fix (admin notice)**: a PHP "Undefined array key" warning appeared on the Wizard screen with `WP_DEBUG` on whenever the health-check result in the URL was valid JSON without the expected keys (including a stale link from an older version). Both fields are now read defensively.
* **Fix (suppressed PHPCS annotation)**: the `phpcs:ignore` on the uninstall cleanup query named only one of the two sniffs that actually fire on that line, so the annotation looked complete while the tool kept reporting it. Verified against real PHPCS output; both sniffs are now named.
* **Security hardening (defence in depth, no known exploit)**: `$_GET` values on the notice/health/logs screens are now unslashed and sanitized at the point of input rather than only before display; the URL-encoded notice message is sanitized both before and after decoding; `$_SERVER` IP headers are unslashed before validation; and file-operation exception messages escape the interpolated path. None of these were reachable as an XSS — every one already escaped on output — but a security plugin should not be the one leaving them for a reviewer to re-derive.
* **Accessibility/maintainability**: the settings-field helpers (`field_number`/`field_text`/`field_password`/`field_checkbox`, plus a new shared `field_textarea`) now emit their markup through `printf()` with escaping applied inline, replacing long concatenated `echo` statements. The 3 hand-written textarea rows on the Policies screen were consolidated into that one helper. The scan-status colour cell now passes through `wp_kses()` with an explicit allowlist that preserves the status colour (`wp_kses_post()` would silently strip it).
* **Internationalization**: added the 35 missing `translators:` comments PHPCS requires for every placeholder-bearing string, and converted 25 count-plus-noun strings from a single fixed form to `_n()` so translations into languages with more than two plural forms are grammatical.
* **Formatting**: applied `phpcbf` (607 fixes: indentation, alignment, array spacing). Verified as cosmetic-only by comparing the PHP token stream of every file before and after — identical once whitespace, comments and trailing commas are normalized.
* **Packaging**: added the full GPL-2.0 licence text as a `LICENSE` file (the header and readme already declared GPL-2.0-or-later consistently), and the build script now excludes development-only files (`composer.json`, `phpunit.xml.dist`, `tests/`, `vendor/`, `.git*`) from the distributed archive.

= 0.7.23 =
Findings from an independent multi-lens code review (security, logic-bug, performance, test-coverage, maintainability, accessibility, and api-contract lenses, plus a red-team pass and a combination-finding round) — 40 findings reviewed, all confirmed accurate against the code.
* **Critical security fix**: `rollback_file()` trusted the `relative` path a rollback request named without checking it against the path the quarantine session actually recorded — a caller holding only the remediate capability could name a file NESTED inside a directory-quarantine session instead of the session's own top-level path, partially restoring the session and then permanently deleting the rest of it via the unconditional session-directory cleanup that assumed a full restore had just happened. Now requires an exact match against the session's own recorded path before touching anything.
* **Security fix**: `verify_run_request()`'s "cron secret not configured" case returned a distinct error code and HTTP status (403) from every other rejection (401) — visible to a fully unauthenticated caller, this let anyone scan for which sites hadn't set up their cron secret yet. Now falls through to the exact same generic signature-mismatch response as any other wrong secret.
* **Security/reliability fix**: manual quarantine/rollback (the Findings/Remediation admin actions) didn't hold the plugin's scan lock, so a background scan tick auto-quarantining a file could race with an admin manually acting on the same file at the same moment. Now serialized through the same lock every other restore path in the plugin already uses.
* **Reliability fix**: `Core_Repair::restore_files()`/`restore_one_file()` didn't use the scan lock (unlike the equivalent backup-restore path, fixed for this in 0.7.21) and could leak its downloaded WordPress.org package permanently if the request hit its own time budget mid-restore. Now lock-guarded, and cleans up the temp download via a shutdown handler even when the request is cut off mid-loop.
* **Reliability fix**: the gap between quarantining a component's old content and logging that a backup/core-file restore had started left no trace at all if the process died in that exact window — a killed request could leave a plugin/theme directory or a core file missing with nothing in Logs pointing at what happened. The log now runs immediately after quarantining, before any other risk of interruption.
* **Logic-bug fix**: `analyze_php()`'s eval-density scoring was not monotonic — a file with fewer `eval()` calls per KB could score higher than one with more, right at the boundary between two of its severity tiers. Each tier now stacks on top of the one below it instead of replacing it.
* **Logic-bug fix**: the pre-backup live file-classification check (used to verify a component is clean at the moment of backup) and the background scanner's own per-file check had quietly diverged — only the background scanner applied the baseline-mismatch safety layer, so a file modified since its last confirmed-good baseline but still "clean" on signatures alone could be zipped into a "trusted" backup. Both now share one classification core.
* **Performance fix**: "Rollback all" had no time budget and could exceed a shared host's execution-time limit partway through hundreds of queued sessions; the Remediation page's quarantine table now paginates instead of rendering every session (each with its own form + nonce) in one page load.
* **API contract fixes**: `GET /findings` now returns an object with an `ok` field (like the other 3 routes) instead of a bare array, and supports `?blog_id=` like every other route; the "busy" branch of `POST /run` now reports `ok:false` (nothing was actually run this request) instead of `ok:true`; the 400 error responses for `/health` and `/queue-status` now carry the same field set (as null) their success responses do, instead of dropping them; all 4 routes' REST schemas now declare `type`/`pattern`, not just `required`.
* **Maintainability**: the "which findings are open, mark one resolved" domain logic — used directly by REST and the remediation engine, not just the admin UI — moved out of the admin-UI class into its own `Smeplan_SS_Findings` class. The repeated "log finding + alert on it" pairing across 6 config-check call sites was consolidated into one helper after 3 of those call sites were found to have silently dropped the alert half for years. `begin_tick()`/`persist_finding()`/`flush_alerts()` (already the cause of 2 past silent-alert-loss incidents) and `Smeplan_SS_Summary::begin_batch()` now detect and log being called out of order instead of just quietly under-reporting.
* **Accessibility**: policy field descriptions (HSTS, `disallow_file_edit`, etc.) are now wired to their inputs via `aria-describedby`; every data table's column headers now declare `scope="col"`.
* **Test coverage**: added a PHPUnit suite (`composer test`, requires a MySQL test DB — see `tests/bootstrap.php`) covering the HMAC request-verification chain, quarantine/rollback (including a regression test for the critical fix above), backup tamper-detection, and the core-file checksum-verify-before-overwrite chain. A full split of the ~2600-line admin-UI class into smaller units, and test coverage for the remaining scanner/multisite/settings-validation modules, are still open — see the class's own "KNOWN MAINTAINABILITY DEBT" docblock.

= 0.7.22 =
Findings from an independent multi-lens code review (security, logic-bug, performance, test-coverage, maintainability, accessibility, and api-contract lenses, plus an adversarial cross-lens round) — 31 findings reviewed. One headline finding (a claim that `add_option()` isn't atomic) was checked carefully and found to be technically incorrect — see the note at the end; everything else confirmed accurate.
* **Fix (reliability)**: a rollback completing successfully could defer disabling maintenance mode forever on a site stuck crash-looping — `maybe_disable_maintenance_after_health_check()` checked only whether a scan-lock row existed, not whether it had actually expired, so a lock row orphaned by a tick that died before releasing it (fatal error, before its own cleanup could run) looked identical to a lock genuinely still in use. Now checks real validity, not just row presence.
* **Fix (logic)**: `scan_time_cap_s` was only ever clamped to the current safe maximum at the moment the settings form was saved — a value carried over from an older version (before today's cap existed, or when it was higher) stayed completely unvalidated through an auto-update if the admin never resaved Policies, risking a tick that outlives the scan lock and runs concurrently with a second one. Now re-validated every time settings are read, regardless of what's stored.
* **Fix (logic)**: `handle_core_restore()`'s "Restore all" flow — the one that most needs to raise an alarm — didn't feed a rediscovered core-file mismatch into the email/webhook alert pipeline the way the site's own manual "Check core files" button correctly does. Now wrapped the same way.
* **Fix (logic)**: 5 places that temporarily switch WordPress's "current site" context (multisite rate-limiting counters, permission checks, network activation, uninstall cleanup) didn't use a try/finally guard — an exception thrown by another plugin's hook running in between could leave the site context stuck on the wrong site for the rest of the request. All 5 now restore the context unconditionally.
* **Fix (api-contract)**: `/health` and `/queue-status` now echo back the `blog_id` they actually resolved to (matching `/run`'s existing `targeted` field), and their permission checks now return the same kind of specific, machine-readable error code `/run` already does instead of WordPress's generic fallback.
* **Fix (api-contract)**: `wp smeplan-ss scan run`'s "busy" case now exits with a distinct code (75) instead of exit 0, so a cron-wrapper script can tell "skipped, retry" apart from "ran successfully."
* **Correction**: an earlier draft of this review's findings claimed `add_option()` isn't atomic under concurrent access, which would have undermined this plugin's replay-protection and rate-limiting. On close inspection this is incorrect: WordPress core's `add_option()` performs a single `INSERT ... ON DUPLICATE KEY UPDATE` guarded by a UNIQUE KEY constraint at the database engine level, and correctly returns failure (0 affected rows, distinct from a real insert's 1) when two callers race for the same option name — it is a genuinely atomic "first caller wins" primitive, exactly as this plugin's existing code comments already state. No change was needed there.

= 0.7.21 =
Findings from an independent multi-lens code review (security, logic-bug, performance, maintainability, accessibility, api-contract, database-migration, and license-compliance lenses) — 41 findings reviewed, all confirmed accurate against the code.
* **Critical fix (content scanning blind spot)**: a file with 5MB+ of padding/comments before its actual payload (e.g. `eval($_POST['x'])` placed past the scanner's 5MB read cap) generated no signal at all — being a newly-planted file, the baseline-mismatch layer didn't catch it either, since a new file is never a "mismatch". The scanner now also streams the rest of an oversized file, in bounded chunks with no added memory cost, checking for the same short list of unambiguous execution primitives already used for the media/polyglot check.
* **Critical fix (multisite)**: the per-site "enable login rate-limiting" setting could be turned off by an admin on any ONE site to remove brute-force protection for EVERY account network-wide (WordPress's `wp_users` table is shared across all sites) — including a Super Admin's — since the per-username check (already correctly tracked network-wide) was skipped entirely whenever that one site's own toggle was off. The per-site setting can now only ever affect the per-IP check; the per-username check can no longer be disabled this way on multisite.
* **Critical fix (silent data-loss risk)**: "Restore all" (WordPress core file recovery) read its file list from a log entry deliberately capped at 50 entries for log-size reasons — on a real mass core-file compromise (more than 50 files, exactly the worst case this button exists for), it silently restored only the first 50 and reported success, leaving the rest (potentially still backdoored) untouched with no indication anything was left out. It now re-checks fresh and unbounded every time; the admin panel also now shows a "showing 50 of N" note when the display list itself is capped.
* **Critical fix (log exposure)**: event/transaction log filenames were fully deterministic and only protected by `.htaccess`/`web.config` (which nginx doesn't read) — since a quarantine reason records the exact detection signal that fired (e.g. `eval_density_very_high`), an attacker with an upload primitive could fetch the log directly and unauthenticated on an nginx host, see exactly which signal caught their file, and adjust the next attempt to evade it. Log filenames now carry the same per-site HMAC-derived unguessable suffix already used for backup zip filenames, protecting them regardless of server config.
* **Fix**: the manual "Check core files" button found and recorded a core-file mismatch but never triggered its email/webhook alert (only an automatic scheduled check did) — a modified core file found this way went unreported until the next routine check caught it independently, up to 24 hours later.
* **Fix (performance)**: the live pre-backup scan added in 0.7.20 had no time budget — unlike every other expensive loop in this codebase — risking a PHP timeout on a very large plugin/theme (thousands of files). Now bounded, and refuses the backup (rather than silently trusting a partial scan) if it can't finish in time.
* **Fix (reliability, multisite)**: the initial baseline build at activation only ever ran once, in whichever single site's context the scheduled event happened to fire in — every other site on the network silently never got an initial baseline. Now loops every site resumably, matching the same pattern already used for per-site capability/secret setup, with a self-heal if it's ever interrupted.
* **Fix (reliability)**: maintenance mode's "active" flag could stay stuck indefinitely on a zero-traffic site (the only thing that clears an expired flag runs on `init`, which needs an actual page view) — meaning a genuinely new incident afterward would silently skip re-arming it. The flag's own recorded expiry is now re-validated on every attempt to enable it; added `wp smeplan-ss maintenance status|clear` for a direct manual override.
* **Fix (reliability)**: restoring a component from backup didn't use the plugin's own scan lock and left no trace if the process was killed mid-extraction. Now synchronized with the scan lock and logs the moment extraction starts.
* **Fix (reliability)**: a finding's email/webhook alert lived only in memory until the very end of a scan tick — a crash between a file being quarantined and that final step meant the alert silently never went out, worst exactly during the largest incidents. Findings are now durably checkpointed every 10 findings during a tick instead of only at the very end.
* **Fix (database-migration)**: uninstalling on a network with more than 100 sites silently only cleaned up the first 100 (a missing `'number' => 0` on the site query) — orphaning options on the rest, and making "reinstall to rotate a leaked secret" a silent no-op for those sites.
* **Fix (api-contract)**: `/health` and `/queue-status`'s success responses were missing the `ok` field present on every branch of `/run`'s response.
* **Maintainability**: `resolve_or_null_blog_id()` (added in 0.7.20) renamed to `strict_resolve_blog_id_param()` — its similarity to `resolve_blog_id_param()` risked a future route picking the wrong one without noticing the different fallback behavior each implements. The 3 separately-maintained enumerations of "config" finding sub-types (a bare type list, a Target-column extractor, and an advice-text map) were consolidated to 2, with a debug-mode-only consistency check guarding against a future mismatch.

= 0.7.20 =
Findings from an independent multi-lens code review (security, logic-bug, performance, maintainability, accessibility, and API-contract lenses, plus a dedicated red-team pass) — 35 findings reviewed, all confirmed accurate against the code.
* **Security/critical fix (backup poisoning)**: every backup action (baseline confirmation, manual/bulk backup creation, and the automatic post-update refresh) now runs a live, synchronous re-scan of the exact files about to be trusted, right before zipping them — instead of relying only on whatever the background file-scanner's own queue had already found and logged. `schedule_component_refresh()` fires a backup only 5 seconds after a plugin/theme update; the scanner's own FIFO queue can take far longer to reach that directory, leaving a window where a file planted through some other vulnerability, timed to land during/right after the update, could get zipped into a "trusted" backup before the scanner ever saw it — surviving indefinitely even after later being quarantined from its live location, ready to be re-planted by anyone with just the lowest ("clean up what's found") capability clicking "Restore from backup."
* **High fix (logic, defense-dead)**: the relpath computed for a SINGLE-FILE plugin/theme (e.g. Hello Dolly) during scanning didn't match the relpath its own baseline was stored under (a string-prefix bug: "hello" is a literal prefix of "hello.php"), so `baseline_mismatch` — an entire independent, content-based defense layer — could never fire for any single-file plugin/theme. Matches a fix already applied to a different function for the identical class of bug in 0.7.9, which was never carried over here.
* **High fix (performance/reliability)**: the database scanner fetched up to 500+ rows per query with no per-row memory check and only saved its resume checkpoint after an entire batch completed — a single oversized row (e.g. a large serialized value from a page builder) could exhaust memory mid-batch, and since the checkpoint never advanced, the next tick retried the exact same batch and failed the same way, forever. Now checks memory/time budgets and saves its checkpoint every 25 rows, so only a genuinely oversized row is ever retried, not the whole batch; the admin-configurable batch size also gained an upper bound (previously unbounded).
* **High fix (reliability)**: in a crash-loop (a worker dying immediately after acquiring the scan lock, before doing any real work), the "last run" timestamp used to be stamped the moment the lock was acquired, before the actual scan ran — so every doomed attempt still looked like a successful recent run to the watchdog, permanently masking the crash-loop from Dashboard/REST/WP-CLI. Now stamped only after a tick actually completes.
* **High fix (real bug)**: an admin-configured IPv6 allowlist/trusted-proxy CIDR entry was accepted as valid but never actually matched anything — the matching function unconditionally returned false for any IPv6 input. Implemented real IPv6 CIDR matching.
* **Accessibility**: the "✓ Clean" status color (the single most common state, so the one most people actually read) was ~3.35:1 contrast on white, below WCAG AA's 4.5:1 minimum — 2 sibling colors in the same block were already fixed for the same issue, this one was missed.
* **Medium fix (security)**: the SSRF check for outbound alert webhooks now also validates any IPv4 address embedded in an IPv4-mapped/-compatible IPv6 literal (e.g. `::ffff:127.0.0.1`) — PHP's private/reserved-range filter for a raw IPv6 literal doesn't unwrap and re-check the embedded IPv4 on its own.
* **Medium fix (security)**: component backup filenames are no longer fully guessable (`plugin_<slug>.zip`) — a per-site HMAC-derived suffix is now appended, protecting them regardless of web server (the previous `.htaccess`/`web.config` guards don't apply on nginx).
* **Medium fix (security)**: maintenance mode can no longer be re-armed for a short cooldown immediately after it's disabled — closes a self-DoS path for an attacker who already has a file-write primitive into a hot path repeatedly re-triggering it.
* **Medium fix (state/sequencing)**: a rollback completing successfully no longer disables maintenance mode while a *different* concurrent scan tick/remediation batch is still actively moving files (checked via the existing scan lock) — previously it could reopen the exact window maintenance mode exists to close.
* **Medium fix (maintainability/reliability)**: a modified/missing WordPress core file — arguably the most severe possible finding — never fed into the email/webhook alert pipeline or REST `/findings`, visible only via a dedicated admin panel. Now reported through the same finding pipeline every other check uses.
* **Medium fix (reliability)**: the scheduled event that refreshes a component's baseline/backup after an update had no self-heal if it never fired (`DISABLE_WP_CRON`, a low-traffic site) — unlike the equivalent self-heal already added for network-activation checkpoints. Now durably tracked and automatically resumed if it stalls.
* **Medium fix (API contract)**: `/health` and `/queue-status` now reject an explicit, invalid `blog_id` the same way `/run` already does, instead of silently substituting the current site; the `/health` response also documents that `last_run` is deliberately network-wide (not scoped to the requested site), and always includes `maintenance.started_at` regardless of whether maintenance happens to be active.
* **Fix**: `handle_run()`'s `ok` field could read `true` on a multisite network with 0 sites, even though nothing was scanned.
* **Maintainability**: removed a dead, never-called `heartbeat()` method (and the docblocks describing it as active); the scan lock's TTL constant is now derived from one shared source instead of being redeclared in 2 places; a single-site network no longer wastes retries on a compare-and-swap update that can never actually change; ~40 duplicated lines between the two backup-writing functions, and the 4 near-identical phase loops in the database scanner, were consolidated into shared helpers.

= 0.7.19 =
Findings from an independent multi-lens code review (security, logic-bug, performance, test-coverage, maintainability, and API-contract lenses, plus a dedicated red-team pass).
* **Security/critical fix (multisite)**: `Smeplan_SS_Lock` (the scan-run TTL/heartbeat lock) still used `add_option()`/`get_option()`, which resolve against whichever site is "current" at call time — WP-Cron's real event only ever fires in the main site's context, while every other site's watchdog/REST self-trigger lands in that site's own context, so two concurrent triggers could acquire what looked like "the same" lock but actually lived in two different sites' option tables, both succeeding at once. Now uses network-wide site-option storage (a no-op change on non-multisite), the same fix already applied to the round-robin cursor and last-run timestamp in 0.7.16.
* **Critical fix (data loss)**: quarantining a file/directory moved it on disk, then wrote its `meta.json` as a separate, unchecked step. A process killed in the gap (OOM, deploy restart) left the item quarantined but invisible to the Remediation screen (which skipped any session missing `meta.json`) while still eligible for automatic purge after the retention period — silently and permanently deleting a file nobody ever saw listed as quarantined, no attacker required. `meta.json` is now written atomically with its failure logged; a session missing it is now surfaced as "incomplete" instead of hidden, and is never auto-purged.
* **Critical fix (DoS)**: confirming/backing up a `dropins`/`other` finding (very common — the "loose file directly in wp-content/" bucket) zipped the *entire* `wp-content/` directory recursively, including `uploads/` (often tens of GB, no time budget) and this plugin's own in-progress backup file. Now backs up only the loose top-level files these 2 components actually mean, matching how their baseline is already built — never recursive.
* **Critical fix (security)**: confirming a component's baseline or creating/refreshing its backup zipped the component's current on-disk content unconditionally, even if a *different* file in the same directory still had an unresolved finding flagged malicious (e.g. its own auto-quarantine had failed) — planting that file into the "last confirmed-good" backup, which `restore_component()` would later restore verbatim during cleanup, re-introducing the exact backdoor this plugin exists to remove. Backups are now refused outright while a component has any unresolved malicious finding.
* **Fix (high, logic)**: `path_has_open_finding()` (gates the Quarantine button) defaulted to reading only the last 300 unresolved findings while the Findings page/REST already showed up to 500 — a finding between position 300 and 500 was visibly listed but failed its own Quarantine button with a false error. Default now matches every other caller (500).
* **Fix (high, multisite)**: the round-robin "which site is next" cursor advanced via a plain read-then-write, not atomic — under the (rare, previously possible via 0.7.18-and-earlier's fragmented lock) case of 2 concurrent ticks, both could land on the same offset, process the same site, and race to write the next value, silently skipping a site's turn. Now a compare-and-swap update.
* **Fix (high, reliability)**: network-wide plugin activation's per-site init batch only persisted its resume point (as a scheduled cron event's own argument) *after* its batch loop finished — a process killed mid-loop left every remaining site permanently without capabilities/a `cron_secret`, with nothing scheduled to ever continue. The batch now checkpoints after every site to a site-option, and a new `admin_init` self-heal (mirroring the existing WP-Cron self-heal) detects and resumes an interrupted activation automatically.
* **Fix (high, maintainability)**: `Smeplan_SS_Backup::undo_partial_restore()` and `Smeplan_SS_Core_Repair::undo_partial_restore()` were two independently-maintained copies of the same "put the quarantined original back, or clean up a partial write" logic — the 0.7.16 fix had to be hand-applied to both. Now one shared implementation.
* **Fix (medium, API contract)**: `POST /run`'s response used `'targeted': null` both for "no `blog_id` was requested" and for "a `blog_id` was requested but doesn't resolve to a real site" — a caller aiming at a specific site had no way to tell its target was silently ignored (falling back to the round-robin site) from "nothing was requested". An invalid target is now rejected outright with a distinct `400 invalid_target_site` error.
* **Fix (medium, reliability)**: the maintenance-mode health-check (`wp_remote_get()` against the site's own front end) is itself subject to WordPress core's own unconditional `.maintenance` gate for the first 600 seconds after it's enabled — so the check that's supposed to clear maintenance mode as soon as the site is healthy again could essentially never succeed in its first ~10 minutes, needlessly extending real downtime and logging a false critical alert. Now waits out that window and retries automatically the moment it lifts.
* **Fix (medium, correctness)**: all 4 baseline-writing functions ignored the return value of their final `rename()` — a failed rename (cross-filesystem temp dir, permissions, disk full) silently left the *old* baseline in place while still reporting success, so a subsequent legitimate update to that component would be misreported as a `mismatch` finding indefinitely. Failures are now logged and reported as 0 files written.
* **Fix (low, security)**: `POST /run`'s single-use nonce was claimed as "used" before its HMAC signature was verified, contradicting this route's own documented order — a request with a guessed/observed `(ts, nonce)` pair but the wrong signature could still burn a legitimate, not-yet-used nonce. Signature verification now runs first.
* **Fix (maintainability)**: removed an implicit ordering dependency where every caller of `Smeplan_SS_Scanner::run_tick_single_site()` had to remember to call `Smeplan_SS_Remediation::begin_tick()` immediately before it — now self-contained. The 4 baseline-building functions' near-identical hash/NDJSON-write loops, and the small duplicated quarantine-session setup between `quarantine_file()`/`quarantine_directory()`, were also consolidated into shared helpers.

= 0.7.18 =
Findings from an independent multi-lens code review (logic-bug, performance, security/red-team, and accessibility lenses).
* **Security fix (high, multisite)**: the login/XML-RPC rate-limit lockout counted attempts per-site (`get_option()`/a per-site object-cache group), but WordPress user accounts (`wp_users`) are shared network-wide — an attacker locked out on one subsite after `login_user_max` failed attempts against a given username could simply repeat the same credentials on any other subsite on the network, multiplying the effective attempt budget by the number of sites instead of being capped as configured. The username-keyed counter and its lockout flag are now tracked against one canonical (the main) site on multisite, so a lockout is enforced network-wide; IP-keyed counters are unaffected (still per-site, as before).
* **Fix (high, logic)**: `scan_time_cap_s` (the per-tick time budget) had no upper bound in `sanitize_settings()`, so it could be set at or above the scan lock's 90-second TTL — a tick running longer than the lock's TTL could have the lock expire mid-run and get taken over by a second concurrent trigger (the watchdog, "Scan now", or a short scan interval), racing against the still-running first tick on the same site's checkpoint/quarantine state. Now capped at a safe margin below the lock TTL.
* **Fix (high, logic)**: `scan_db_batch` (DB rows scanned per tick) accepted `0` as a valid value — every DB-scan SQL query uses it as a `LIMIT`, so `0` made every phase return zero rows immediately, which the scanner reads as "phase finished", cycling through all 4 phases and marking the DB scan cycle complete without ever inspecting `wp_options`/`wp_posts`/`wp_postmeta`. Now floored at 1.
* **Fix (high, performance)**: `Smeplan_SS_Remediation::list_quarantine_sessions()` re-read and re-parsed every quarantine session's `meta.json` from disk on every Dashboard/Remediation page load, with no cache — unlike its sibling helpers, which already cache for this exact reason. Worst exactly when it matters most: after a mass-quarantine (a real infection or a false-positive scan), session counts can reach into the hundreds/thousands, and an admin actively triaging the incident reloads the Dashboard repeatedly. Now cached for 1 minute, invalidated immediately by the interactive quarantine/rollback/purge actions.
* **Accessibility**: the "Warning" and "Info" event-log colors (`#dba617`, `#888`) were under WCAG AA's 4.5:1 contrast minimum for text (~2.2:1 and ~3.54:1 respectively). Darkened to `#996800` and `#6b6b6b`, same hues, ~4.8:1 and ~5.3:1.
* **Accessibility**: the Findings menu badge's visible count and its screen-reader description were both read by assistive tech (the description was nested inside the visible number's own span rather than replacing it), announcing the count twice (e.g. "3 3 unresolved security findings"). The visible number now has `aria-hidden="true"` and the description is a sibling, matching WP core's own update-count badge markup.

= 0.7.17 =
Findings from an independent multi-lens code review (security/red-team, logic-bug, performance/maintainability, and accessibility/API-contract lenses).
* **Security fix (medium, SSRF)**: the 0.7.16 webhook SSRF fix (`webhook_url_is_safe()`) validates the DNS-resolved IP(s) of the configured URL before sending, but the actual `wp_remote_post()` call didn't disable HTTP redirects — a webhook endpoint that initially resolves to a safe, public IP could reply with a `3xx` redirect to `http://127.0.0.1/…` or a cloud metadata endpoint, and WP_Http would follow it automatically with no re-validation, fully bypassing the check with no DNS-rebinding race required. Now sends with `redirection => 0`; a JSON alert receiver has no legitimate reason to redirect.
* **Security fix (low, multisite)**: `GET /queue-status`'s `permission_callback` checked `SMEPLAN_SS_CAP_REVIEW` against whichever site's REST endpoint received the request, while its handler already honored a caller-supplied `?blog_id=` to read a *different* site's current scan phase — the same cross-tenant gap the `/run` HMAC check was fixed for in 0.7.9, just for a capability check instead of an HMAC secret. A user holding the capability only on their own site could read another site's scan phase (low sensitivity: just which of `file`/`db`/`config`/etc. the scanner is on) with no authorization on that site at all. Now checked against the actual target site. `GET /health` gained the same `?blog_id=` targeting (and the same target-site permission check) that `/run` and `/queue-status` already had — a stale comment on `/queue-status` claimed `/health` already supported this, but it never did.
* **Fix (medium, API contract)**: `POST /run`'s `result` field was a list containing one per-site result object for every untargeted (round-robin) call, but the single object directly (unwrapped) for every `?blog_id=`-targeted call — the same "field silently changes JSON shape depending on outcome" bug class already fixed for `maintenance`/`lock` in 0.7.14/0.7.16, just not caught for `result`. `Smeplan_SS_Multisite::run_targeted()` now always wraps its result in a list, matching its round-robin sibling.
* **Fix (medium, multisite)**: `wp smeplan-ss scan reset --blog-id=<id>` resolved its storage path via `wp_upload_dir()` and wrote its phase option via `update_option()` — both of which follow whichever site is *currently* active, not the `--blog-id` argument (only ever used as a filename/option-name suffix). Without also passing a matching `--url`, this silently reset a different site's storage under a name resembling the intended target, while the intended site's real scan progress stayed untouched. Now switches into the target site's context first, the same way `wp smeplan-ss scan run --blog-id=<id>` already does.
* **Fix (medium, multisite)**: network-wide activation's resumable per-site init batch (`smeplan_ss_init_sites_batch()`) resumed from a raw positional index into a freshly re-queried site-id list. If a site with an id before the saved offset was deleted between one batch and the scheduled continuation (which can land well past its nominal 10-second delay if WP-Cron is slow — the exact condition this plugin exists to tolerate), the list shifted down by one and resuming from the same index permanently skipped a site that was never actually initialized (no capabilities, no `cron_secret`), with nothing to ever retry it. Now resumes by "site id greater than the last one processed" instead of by position — the same fix already applied to the file scanner's own resume checkpoint in 0.6.4.
* **Fix (high, performance)**: `Smeplan_SS_Scanner_File::component_of()`/`is_light_dir()` rebuilt `WP_CONTENT_DIR`/`WP_PLUGIN_DIR`/theme-root paths and called `wp_upload_dir()` (re-running its filterable `upload_dir` hook) from scratch on *every single file* scanned — up to 500 times in one tick. These now read from a small cache rebuilt once per tick instead of once per file.
* **Fix (high, performance)**: the file scanner's 3 flat-list phases (drop-ins, loose top-level plugin/theme files, loose `wp-content/`-root files) called their `scandir()`-based list builders as plain function arguments to `run_capped_list()`, which PHP evaluates before that function's own "already done" early-exit can run — so all 3 `scandir()` sweeps kept running on every tick for the entire rest of a scan cycle, even once fully done. Same eager-evaluation bug class the 0.7.16 changelog already fixed for `load_state()`'s `seed_dirs()` default, just not carried over to these 3 sibling call sites. Now skipped outright once each phase's done-flag is set.
* **Fix (medium, performance)**: `smeplan_ss_ensure_storage()` (~21 `is_dir()`/`file_exists()` calls plus ~8 `wp_upload_dir()` calls) ran unconditionally on `plugins_loaded` — every front-end page view, admin page, REST call, and cron tick — even though the directories/deny-files it creates are only ever needed once and every actual write path (quarantine, backup, logger) already recreates its own directory defensively on demand if missing. Now gated behind a 1-hour transient, the same caching trade-off already used for `exposed_users()` and `findings_badge_count()`.
* **Accessibility**: 3 more sets of per-row action buttons had an identical accessible name across every row, the same bug class already fixed for Quarantine/Dismiss/Rollback in 0.7.16: the Wizard's "Fix" button (exposed-username table), the Findings page's "Confirm this is a legitimate update" button (missed when its Quarantine/Dismiss siblings on the same row were fixed), the core-mismatch panel's "Restore original" button, and the Remediation page's "Restore from backup" / "Create initial backup" buttons. All now embed the specific file/component in their accessible name.

= 0.7.16 =
Findings from an independent multi-lens code review (logic-bug, maintainability, test-coverage, performance, accessibility, API-contract, and security/red-team lenses).
* **Fix (critical)**: `Smeplan_SS_Backup::restore_component()` (and the identical bug in `Smeplan_SS_Core_Repair::restore_one_file()`, which mirrors it) only remembered what to roll back to if the destination already existed before the restore attempt. Restoring a component that had already been quarantined by an earlier action — a real, common path through "Restore from backup" — meant nothing was tracked to undo; if `ZipArchive::extractTo()` then failed partway (disk full, a corrupt entry), a half-extracted directory (or, for core-file restore, unverified content that already failed its post-write checksum) was left live on disk while the returned message falsely claimed "the original has been restored". Now cleans up the partial/unverified content directly when there was nothing to restore, and the message reflects which case actually happened.
* **Fix (critical)**: `Smeplan_SS_Fs::path_is_contained()`'s `$must_exist = false` mode (used by quarantine rollback) rejected any path containing the substring ".." anywhere at all, not just a real ".." traversal segment — a legitimately-named quarantined file/directory like `wp-config.php..bak` or `v1..2` could never be rolled back via the UI ("Invalid session path"), with no other way to recover it. Now only rejects an exact ".." path segment.
* **Fix (critical, API contract)**: `GET /health`'s `lock` field returned `null` for the (>99% of the time) "no lock held" state and an object when a scan was running — the exact same bool/object JSON-shape inconsistency the neighboring `maintenance` field was already fixed for in 0.7.14, just never carried over to this field. Now always the same `{held, ...}` shape.
* **Fix (high, security)**: the DB scanner's 2 autoload-based phases matched `autoload = 'yes'` / `'no'` literally — WordPress 6.6+'s newer autoload values (`on`/`off`/`auto-on`/`auto-off`/`auto`, written automatically by core for any `update_option()` call using the new API) were never selected by either phase, permanently invisible to this scanner on any WP 6.6+ site. Now matches the actual "autoloaded" set (`IN`) and its complement (`NOT IN`), covering every value including ones WP may add later.
* **Fix (high, multisite)**: the round-robin "which site is next" cursor and the "last tick ran at" timestamp were stored via `get_option()`/`update_option()` — read/written against whichever site happened to be "current" at the moment a tick ran. WP-Cron's real recurring event only ever fires in the main site's context, while every other site's tick is triggered by its own watchdog/frontend-sample self-POSTing to REST `/run`, landing back in that site's own context — so each triggering site was reading/writing its own fragment of state instead of one real shared cursor, breaking the "every site gets scanned in turn" guarantee on multisite. Now network-wide (`get_site_option()`/`update_site_option()`; a no-op change on non-multisite).
* **Fix (high, multisite)**: the adaptive file-scan throttle judged a tick "slow" (>24s, halving the cap toward its 100-file floor) or "fast" (<10s) using fixed literals unrelated to the admin-configurable, uncapped `scan_time_cap_s` setting — a host given a large time budget still had every tick over 24s throttled, fighting the admin's own configuration. Both thresholds now scale with the configured budget instead.
* **Fix (high, API contract)**: `POST /run`'s schema never declared `blog_id`, and `GET /findings`'s never declared `limit`, even though both routes read them from the request. Both now declared, matching every other route that already documents the parameters it reads.
* **Fix (high, performance)**: `Smeplan_SS_Scanner_File::load_state()`'s default state (built via a plain array literal) called `seed_dirs()` — a `scandir()`/`is_dir()` walk over every plugin and theme — as a normal PHP function argument, so it ran on **every** call regardless of whether a valid state file already existed (true on the vast majority of ticks). `Smeplan_SS_Fs::read_json_state()` now also accepts a callable default, invoked only when actually needed.
* **Fix (medium, security/SSRF)**: the alert webhook URL (Policies) was only run through `esc_url_raw()` at save time (well-formedness, not destination safety) before this plugin's own server made a request to it on every alert — it could be pointed at localhost, an internal/RFC1918 address, or a cloud metadata endpoint. Now resolves the host and refuses to send if it (or any resolved IP) is loopback/private/link-local/reserved. Best-effort (not immune to DNS rebinding — see the docblock on `Smeplan_SS_Remediation::webhook_url_is_safe()`), but closes the straightforward case.
* **Fix (medium, API contract)**: `POST /run` returned 3 differently-shaped responses depending on outcome, with no field guaranteed present in all 3; the untargeted (round-robin) success case reported `queued: true` even though the tick had already run fully and synchronously by that point, and discarded its own result data instead of returning it — nothing this route does is ever actually queued for later. Every response now consistently includes `ok`/`busy`/`targeted`/`result`, and `ok` is now `false` when the underlying result itself carries an error (e.g. `site_not_found`) instead of being unconditionally `true`.
* **Fix (medium, performance)**: a single Findings-page render read and parsed the event log from disk twice (`recent_unresolved_findings( 500 )` and, moments later, `render_core_mismatch_panel()`'s own `latest_core_mismatches( 300 )`). `Smeplan_SS_Logger::tail()` now keeps a request-scoped cache (invalidated immediately by any new log write), serving the second call from memory.
* **Fix (medium, performance)**: `Smeplan_SS_Detect::analyze_php()` tokenized every scanned file's content twice — once to strip comments, once to distinguish a real backtick shell-exec operator from a backtick character inside a string. Merged into a single `token_get_all()` pass returning both results.
* **Fix (medium, performance)**: `Smeplan_SS_Hardening::exposed_users()` re-ran its full paginated user scan (up to 50 queries) on every single Wizard page load. Now cached for 5 minutes, invalidated immediately on user create/update/delete.
* **Accessibility**: the Dashboard's 3 stat cards (Critical/Warning/Quarantine) had no heading or role/aria-label — a screen reader's "next heading" navigation skipped straight over them. Now have a heading plus a group role/label combining each caption with its number. The Quarantine/Dismiss/Rollback action buttons (Findings/Remediation) had an identical accessible name on every row ("Quarantine, button" repeated with no way to tell rows apart); they and their confirm dialogs now embed the specific file/target. The Events table's "Level" explanation lived only in a `title=""` attribute (not read by most screen readers, not keyboard-reachable); now also carried via visually-hidden text. The Findings menu badge's raw count had no accessible explanation of what it counts; now paired with hidden text spelling it out.
* **Accessibility**: 2 low-contrast text elements (the Dashboard version label, the Events table's raw event-name code) were opacity'd low enough to sit at or below the WCAG AA 4.5:1 minimum; both increased.
* **Maintainability**: `filter_auto_update_core/plugin/theme()` were 3 near-identical bodies differing only in which setting gates them and which directory the VCS check looks at; consolidated into one shared implementation.
* **Hardening (defense-in-depth)**: the rate-limit counter's raw-SQL fallback writes (used when no external object cache is configured) never invalidated WordPress's own object cache for that option, unlike every other compare-and-swap write in this codebase — not currently reachable (nothing reads this row via `get_option()`), but now consistent with the rest of the codebase against a future caller that does.

= 0.7.15 =
Findings from an independent multi-lens code review (security, logic-bug, and test-coverage lenses), focused specifically on the trust-boundary between the `smeplan_ss_remediate` and `smeplan_ss_manage` capabilities.
* **Security fix (critical)**: `handle_backup_create()` — which unconditionally overwrites a component's "last confirmed-good" backup with whatever is on disk right now, with no content verification — was gated at `SMEPLAN_SS_CAP_REMEDIATE` instead of `SMEPLAN_SS_CAP_MANAGE`, unlike its trust-equivalent sibling `handle_confirm_baseline()`. A user (or an attacker who had separately gained only `smeplan_ss_remediate`, e.g. via a site owner delegating that single capability to a lower-privileged role) could overwrite a legitimate backup with a snapshot of a since-compromised plugin/theme directory, then later use the still-correctly-gated `handle_backup_restore()` to reintroduce that content — bypassing the scanner's own quarantine of it. Now requires `SMEPLAN_SS_CAP_MANAGE`, matching `handle_confirm_baseline()`; the "Create backup" buttons on the Remediation screen are hidden accordingly for users who only hold `smeplan_ss_remediate`.
* **Security fix (critical)**: `handle_quarantine()` accepted any `$_POST['path']` under `ABSPATH` from a user holding only `SMEPLAN_SS_CAP_REMEDIATE`, with no check that the path corresponded to something the scanner had actually flagged — including `wp-config.php` or this plugin's own main file. Now requires the path to match a currently open (unresolved) finding first; see `Smeplan_SS_Admin::path_has_open_finding()`.
* **Security fix**: `finding_resolution_keys()`'s `component:` resolution key (written only by `handle_confirm_baseline()`, meaning "the admin reviewed this whole plugin/theme's current content") could still mask a `baseline_mismatch` finding that had independently scored as `malicious` from content-based signatures (not just "differs from the last baseline") — so confirming one legitimately-changed file could silently also resolve a genuinely malicious sibling file sharing the same component, hiding it from Dashboard/Findings/badge/REST while it stayed live on disk. The `component:` key is now only eligible for `suspicious`/`clean`-level findings.

= 0.7.14 =
Findings from an independent multi-lens code review (security, logic-bug, and test-coverage lenses).
* **Security fix (critical)**: a finding auto-resolved by quarantining one malicious file could silently mask OTHER, unrelated findings that were never reviewed. `Smeplan_SS_Admin::finding_resolution_key()`'s priority rule resolved a `baseline_mismatch` finding by `component:$component` rather than by its own file path whenever a component was known — and that SAME key is what `handle_confirm_baseline()` uses to mean "the admin reviewed the entire component." For the 3 components that group unrelated files from across the whole site under one name (`other` and `dropins` both cover the entire `wp-content/` tree, `mu-plugins` the whole `mu-plugins/` dir), auto-quarantining (or manually quarantining, via the Findings-page button) just ONE malicious file marked every OTHER outstanding finding in that bucket as resolved too — including a second, never-reviewed malicious file elsewhere on disk, which then disappeared from the Dashboard/Findings/badge/REST while remaining live on disk. Single-file resolution (auto-quarantine, the manual Quarantine button, "file no longer exists") now always resolves by path only; only an explicit `handle_confirm_baseline()` call resolves by component. See `Smeplan_SS_Admin::finding_resolution_keys()`.
* **Security fix**: `Smeplan_SS_Fs::rollback_file()` never checked `is_link()` on the quarantined item before restoring it, unlike `quarantine_file()`/`quarantine_directory()`, which both refuse to quarantine a symlink in the first place. A symlink planted directly inside a quarantine session's `files/` directory (or nested inside a whole-directory quarantine) would be restored as-is by `rename()`, planting a live symlink back inside `ABSPATH`. Now refused at the top level and recursively for a whole-directory restore.
* **Fix (API contract)**: `GET /health`'s `maintenance` field returned `false` (bool) for any site that had never toggled maintenance mode, but the `{active, until}` object shape for any site that had — silently changing JSON type depending on site history. Now always the object shape.
* **Fix (API contract)**: `GET /queue-status` ignored `?blog_id=`, unlike `/run` and `/health` — a caller checking on the progress of a scan it just triggered against a specific site got back the phase for the wrong site with no error. Now accepts `?blog_id=` consistently with the other routes.
* **Fix**: `GET /findings`'s `include_resolved` parameter had no sanitize callback, so `?include_resolved=false` was read as a non-empty (truthy) string and behaved the same as `?include_resolved=1`. Now uses `rest_sanitize_boolean`.
* **Fix (performance)**: `Smeplan_SS_Rest::handle_findings()` computed the findings list twice on the default (most common) request path — once itself, then discarded that in favor of `Smeplan_SS_Admin::recent_unresolved_findings()`. Now only does the work actually needed for the requested mode.
* **Accessibility**: Policies-screen text/number/password inputs and textareas now have a properly associated `<label for>`; checkboxes' generic "Enabled" text now includes the setting's own name for screen readers; the shared JS behind long-running actions (Scan now, Rollback, Reset scan) now also sets `aria-busy` and announces the loading state through an `aria-live` region, not just a visual button-text change.

= 0.7.13 =
* **Docs**: added the `== Screenshots ==` section (WordPress.org reads its numbered captions and matches them to `screenshot-N.png` in the SVN `assets/` directory — not part of this installable zip, since `assets/` sits alongside `trunk/` in the SVN layout, not inside the plugin folder itself). No functional/code change.

= 0.7.12 =
Findings from a third, independent code review.
* **Fix**: `Smeplan_SS_Fs::quarantine_file()`'s fallback path (when `rename()` fails and it falls back to `copy()` + `unlink()`) never checked whether `unlink()` actually succeeded — if it failed (e.g. permission denied), the function still reported success even though the original malicious file was still sitting at its original location, untouched. Now returns a `WP_Error` in that case, which surfaces as a critical `quarantine_failed` alert instead of a false "quarantined" result.
* **Fix**: a file auto-quarantined by the scanner never had its Findings entry marked resolved — only the 3 manual admin actions (confirm-baseline, dismiss-config, the Findings-page Quarantine button) did. The Dashboard's "Critical (unresolved)" count and the menu badge only ever grew, permanently over-counting files that had already been safely dealt with by auto-quarantine. `Smeplan_SS_Remediation::handle_file_finding()` now marks its own finding resolved right after a successful quarantine.
* **Fix (performance)**: `opcache_reset()` (a server-wide operation) was called on every file past the 20th quarantined in a scan tick, instead of once — with the default 300-file/tick cap, a large cleanup could call it up to ~280 times in a single request. Now fires exactly once per tick.
* **Fix (performance)**: bulk "Rollback all" ran a health-check (2 synchronous HTTP requests, 10s timeout each) after every single restore, for as long as maintenance mode stayed active — up to ~200 sessions x 2 requests if the site were slow to respond, in exactly this plugin's own worst-case scenario (recovering from a mass false-positive quarantine). Now checks health once after the whole batch.
* **Fix (performance)**: the "Create initial backup for all of these" bulk action hashed and zipped every listed plugin/theme synchronously, inline in the one admin request — the same class of bug already fixed for `on_upgrade_complete()` in 0.7.9, just not carried over to this button. The bulk path now defers each component to WP-Cron; a single component (from its own row) stays synchronous.
* **Fix**: 4 of `Smeplan_SS_Scanner_Config`'s 5 check functions (`check_auto_prepend_file()`, `check_salts()`, `check_permissions()`, `check_cron_array()`) logged their finding but never called `Smeplan_SS_Remediation::handle_config_finding()` — unlike `check_handler_file()` — so none of them ever fed into the email/webhook alert; an admin would only ever learn about a weak-salts/world-writable-permission/suspicious-cron/`auto_prepend_file` finding by manually visiting the Findings page. All 4 now trigger the same alert path.
* **Fix**: a "suspicious" file finding that ALSO has the `baseline_mismatch` signal resolves via a `component:` key (per `finding_resolution_key()`'s priority rule), but `handle_quarantine()` always marked `path:` resolved regardless — for that specific combination, the finding kept showing as unresolved even after being quarantined. `handle_quarantine()` now looks up the actual resolution key the same way the Findings filter does.
* **Fix (performance)**: the "Restore all" core-file button had no time budget, unlike `check_core_integrity()` (fixed in 0.7.10) — a mass core-file-compromise (this button's actual worst case, with the largest mismatch list) could exceed `max_execution_time` partway through writing files. Now stops after a time budget and reports the remainder as `skipped` rather than silently never attempting them.
* **Maintainability**: `check_core_integrity()` returned 2 different array shapes depending on whether it got time-capped (a flat list normally, `{incomplete, mismatches}` when capped) — introduced in 0.7.10, with no static typing to catch a future caller using the wrong one. Always returns the same `{mismatches, incomplete}` shape now; both callers updated.
* **Fix**: `Smeplan_SS_Rest::handle_findings()` (the REST `/findings` endpoint) was a 3rd, separate copy of "fetch finding_* events" that never applied the "already resolved" filter the admin UI uses — a file already Quarantined/Dismissed from the Findings page kept showing up via the API. Now matches the UI by default; `?include_resolved=1` opts back into the raw list.
* **Security fix (defense-in-depth)**: the Basic-Auth password field (Policies → Login/Rate-limit, used for the WP-Cron loopback) rendered the actual stored plaintext value into the page's HTML (`type="password"` only masks it in the browser UI, not in the page source) — readable via "View Source", or by same-origin JS from an XSS in a completely different plugin/theme on the same site. Now always renders blank with a placeholder; leaving it blank on save keeps the existing stored password unchanged.
* **Tests**: added `CoreRepairTest.php` (`restore_one_file()` — missing-from-package, no-checksum, checksum-mismatch, and the successful restore+quarantine-first path, using a real `ZipArchive`), plus additional cases in `FsTest.php` (`rollback_file()`'s directory-conflict-at-destination branch), `SchedulerTest.php` (`run_locked()`'s "lock already held" branch), and `DetectTest.php` (6 of `analyze_php()`'s `force_malicious` triggers: `preg_replace_e`, `auto_prepend_file`, dynamic-include-from-`$_GET`, variable-function-from-`$_POST`, network-fetch-then-`eval`, and PHP-in-uploads-with-an-exec-signal). Not yet covered, and explicitly deferred rather than silently skipped: `decoder_chain_high_entropy` (needs a carefully-generated high-entropy literal to trigger deterministically), the tokenizer-disabled fallback in `class-ss-detect.php`, first-run/corrupt-`.ndjson` handling in `class-ss-baseline.php`, `uninstall.php`'s multisite/`LIKE`-query path, and a full HTTP-level regression test for `verify_run_request()` (needs the real WP REST/multisite stack, not this hand-written double) — see `tests/README.md`.

= 0.7.11 =
* **Fix**: found in a live site's `debug.log` — WordPress core logged `Cron reschedule event error for hook: smeplan_ss_scan_tick, Error code: invalid_schedule, Error message: Event schedule does not exist.`. This happens when `wp_reschedule_event()` can't find the plugin's custom `smeplan_ss_minutely` interval at the exact moment WP-Cron tries to reschedule the recurring scan tick (most likely during a plugin file update/deploy window) — when that happens, WordPress core drops the recurring event entirely with no automatic recovery, and this plugin never noticed or re-created it afterward, silently relying on the watchdog/REST self-trigger fallback forever from that point on. `Smeplan_SS_Scheduler::watchdog()` (already running on every `admin_init`) now also checks `wp_next_scheduled()` and re-schedules the event if it's ever found missing, regardless of why it disappeared.

= 0.7.10 =
Findings from a second, independent code review of 0.7.9.
* **Fix**: `Smeplan_SS_Scanner_File::run_batch()`'s 3 preliminary phases (drop-ins, loose top-level files in `wp-content/plugins|themes/`, loose files directly in `wp-content/`) ran to completion unconditionally, with no `time_cap`/`file_cap` check at all — unlike the main directory queue in the same function. A site with many loose `.php` files dropped directly in `wp-content/plugins/` could blow past `max_execution_time` here with no way to resume, silently defeating the adaptive-cap mechanism. All 3 are now cap-checked and resumable via a shared `run_capped_list()` helper.
* **Fix**: `Smeplan_SS_Scanner_Config::check_handler_file()` never classified a bare (non-`<FilesMatch>`-scoped) `SetHandler ... php` directive in `.htaccess`/`.user.ini` as `malicious`, only `suspicious` — even though it applies PHP execution to the ENTIRE directory it sits in, at least as dangerous as the `<FilesMatch>`-scoped case just above it which already was classified `malicious`. Now escalated to `malicious` specifically when the file is inside `uploads/` (which should never execute PHP); left as `suspicious` outside `uploads/`, since a bare `SetHandler`-PHP directive elsewhere (site root, a theme/plugin's own `.htaccess`) is sometimes a legitimate hosting-level PHP-version pin.
* **Security fix**: `Smeplan_SS_Admin::handle_quarantine()` resolved the requested path with `realpath()` and then passed that FULLY-RESOLVED (symlink-dereferenced) path down to `Smeplan_SS_Fs::quarantine_file()` — which silently defeated that function's own "refuse to quarantine a symlink" check, since `is_link()` on an already-`realpath()`'d value is always `false`. If a flagged file's location were swapped for a symlink (e.g. pointing at `wp-config.php`) between being flagged and an admin clicking "Quarantine", the wrong file would be moved. The un-resolved path is now passed through instead; the existing containment check still safely uses `realpath()` internally without feeding that resolved value back downstream.
* **Fix (performance)**: `Smeplan_SS_Baseline::check_core_integrity()` looped `md5_file()` over ~1000-1600 WordPress core files with no time budget at all, once/day — on slow-I/O shared hosting this could run well past `max_execution_time`. The automatic scheduled check now passes a real time cap and, if cut short partway, skips updating its "last checked" timestamp so the next tick retries instead of waiting a full day on an incomplete result; the admin-triggered manual "Check core files" button on Findings deliberately stays uncapped, since an admin who explicitly clicked it needs a complete, trustworthy result, not a silently truncated one.
* **Fix (performance)**: `Smeplan_SS_Hardening::track_frontend_request()` wrote to a rate-limit counter (a DB row per write, on sites without an external object cache) on EVERY front-end request, just to feed the "requests/minute" estimate `is_site_busy()` uses to defer auto-updates — a check only ever read a few times a day. Now samples 1 in 10 requests instead, scaled back up on write.
* **Fix (performance)**: `Smeplan_SS_Hardening::exposed_users()` called `get_users()` with no `number`/`offset`, loading every single user row into memory in one query on every Wizard page load — a real cost on a membership/forum/LMS site with a large user table. Now paginated in batches of 1000, capped at 50,000 users scanned.
* **Fix**: the "Source: ..." label map in `Smeplan_SS_Admin::event_summary()` was missing an entry for `'cli'` — `Smeplan_SS_Scheduler::run_locked()` has taken `source='cli'` from WP-CLI since 0.7.9's lock-logic consolidation, but the map only ever had 4 of the 5 possible sources, so the Logs page showed the raw string `"Source: cli"` after a `wp smeplan-ss scan run`.
* **Removed**: `rest_webhook_patterns` setting — dead code, same reasoning as `maintenance_min_files`/`maintenance_min_bytes` in 0.7.9: never sanitized, never rendered on Policies, never read anywhere.
* **Docs**: clarified in the 0.7.9 changelog entry below that the `tests/` PHPUnit suite added in that release lives in the source repository only and is deliberately excluded from the WP.org-style distribution zip via `.distignore` (standard practice — the same as `vendor/`/`node_modules/` for any plugin) — a second review of the packaged zip specifically (not the repository) reasonably flagged this as unclear from the changelog wording alone.

= 0.7.9 =
* **Fix (critical)**: `Smeplan_SS_Remediation::maintenance_watchdog()` compared `time()` directly against `$upgrading`, the value `enable_maintenance()` writes into `.maintenance` — but `$upgrading` is deliberately the START time (per WordPress core's own convention for that file), not an expiry. `time() > $upgrading` becomes true almost immediately (as soon as the clock ticks forward by a second), so maintenance mode — meant to hold for 900 seconds while a malicious file touching a hot path or a large batch is being remediated — was being torn down within 1-2 seconds on the very next request instead. The watchdog now compares against `OPT_MAINT_STATE['until']` (the real configured expiry, already being written correctly alongside the file, just never read by the watchdog), and fails safe (expires immediately) if that state is ever missing.
* **Security fix (critical, multisite)**: `Smeplan_SS_Rest::verify_run_request()` validated the HMAC signature on `POST /run` against the CURRENT site's `cron_secret` (`Smeplan_SS_Settings::get()`) even when the request's `blog_id` param targeted a different site. Since `cron_secret` is generated per-site, an admin who only knew site A's secret could sign a request with it, add `blog_id=<site B>`, and have `handle_run()` execute a scan against site B and return part of its results — a site they were never authenticated against. The HMAC is now validated against the TARGET site's own `cron_secret` (via `get_blog_option()`), for both the trigger and the response data.
* **Fix**: `Smeplan_SS_Remediation::is_hot_path()` never recognized an actively-exploited single-file plugin (e.g. Hello Dolly) as a hot path — `component_of()` strips the `.php` extension when naming a loose top-level single-file plugin (`plugin_hello`), but `active_plugins` keeps it (`hello.php`), so the comparison never matched and maintenance mode could be skipped for exactly the case it's meant to cover.
* **Fix**: `Smeplan_SS_Backup::restore_component()` left the target directory in a half-extracted state if `ZipArchive::extractTo()` failed partway (disk full, a corrupt entry, a permissions error) — the original had already been quarantined, but the caller only got a `WP_Error` with no indication the original was gone. It now automatically rolls the quarantined original back into place on any extraction failure.
* **Removed**: `maintenance_min_files`/`maintenance_min_bytes` settings — dead code. Never read by `Smeplan_SS_Remediation` (which only ever checks `auto_clean_max_files`/`auto_clean_max_bytes`) and never rendered on the Policies screen, so there was no way for an admin to see or rely on them either way.
* **Fix (performance)**: `Smeplan_SS_Multisite::get_all_site_ids()` re-ran the full paginated `get_sites()` enumeration on every scan tick just to pick 1 site for the round-robin — on a network with thousands of sites, that's dozens of queries every 30-60 seconds for a single element. Cached for 5 minutes, invalidated immediately on site creation/deletion.
* **Fix (performance)**: `Smeplan_SS_Admin::findings_badge_count()` re-read and re-parsed up to 300 log lines from disk on every single wp-admin page load, for every admin, site-wide (`register_menu()` runs on every admin screen, not just this plugin's own). Cached for 1 minute; resolving a finding busts the cache immediately so the badge still updates right away.
* **Fix (performance)**: `Smeplan_SS_Baseline::on_upgrade_complete()` hashed and zipped every file of an updated plugin/theme synchronously, inline in the same admin request that performed the update — for a large component this could add several seconds to "Update now", and a bulk update of several large plugins at once risked exceeding `max_execution_time`. Deferred to a WP-Cron event a few seconds out instead.
* **Fix (performance/reliability)**: network-wide activation looped over every site unconditionally in the one activation request — on a very large network this could exceed `max_execution_time` partway through, leaving some sites initialized (capabilities + `cron_secret`) and others silently not, with no record of which. Now processes sites within a ~15s budget per request and schedules a follow-up event to finish the rest.
* **Maintainability**: consolidated 3 independently-maintained copies of the same "is this path safely inside this directory" containment check (`Smeplan_SS_Fs::rollback_file()`, `Smeplan_SS_Baseline::resolve_component_dir()`, `Smeplan_SS_Admin::handle_quarantine()`) into one shared `Smeplan_SS_Fs::path_is_contained()`.
* **Maintainability**: consolidated 4 independently-maintained copies of the same "read/decode a JSON state file, or fall back to a default shape" pattern (`Smeplan_SS_Scanner_File`, `Smeplan_SS_Scanner_Db`, `Smeplan_SS_Scanner`'s adaptive-cap state, `Smeplan_SS_Summary`) into `Smeplan_SS_Fs::read_json_state()`/`write_json_state()`.
* **Maintainability**: `Smeplan_SS_CLI_Command::run()` and `Smeplan_SS_Rest::handle_run()` each reimplemented lock acquire/run/release by hand — with a different TTL from everything else (120s vs. the 90s WP-Cron/the watchdog use) and, for the REST targeted-scan path, no lock at all (a `blog_id`-targeted REST scan could run concurrently with a WP-Cron tick or another targeted call touching the same site). Both now go through `Smeplan_SS_Scheduler::run_locked()`, which also logs `scan_tick_source` for every source consistently.
* **Maintainability**: the finding-count logic behind the Dashboard's stat cards and the "Findings" menu badge was copy-pasted in two places; the config-finding-type whitelist (`auto_prepend_file`/`weak_salts`/`file_permissions`/`cron_suspicious_hooks`) was duplicated in two more. Both now have one source of truth.
* **Maintainability**: `Smeplan_SS_Summary::begin_batch()`/`end_batch()`'s pairing was enforced only by a code comment, with a manual `end_batch()` call duplicated before each of the file scanner's return points. `Smeplan_SS_Scanner_File::run_batch()` now wraps its tick in `try`/`finally` so this can't be forgotten by a future return branch.
* **Tests**: added a PHPUnit unit-test suite (`tests/`, 0% coverage previously) covering the logic behind this release's critical/high fixes — the maintenance watchdog's TTL handling, `is_hot_path()`, the shared path-containment check, `Smeplan_SS_Lock::acquire()`'s compare-and-swap race handling, `Smeplan_SS_Detect::classify()`'s score thresholds, and the cross-tenant REST fix's `settings_for_site()`. See `tests/README.md` for exactly what is and isn't covered and how to run it — it's a hand-written WordPress-function double, not the official `wp-phpunit` suite. **Lives in the source repository only** — `tests/`, `composer.json`, and `phpunit.xml.dist` are dev tooling, deliberately excluded from the WP.org-style distribution zip via `.distignore` (the same way `tests/`/`vendor/` are excluded from any plugin's shipped package); they are not, and were never intended to be, inside the zip a site installs.

= 0.7.8 =
* **Fix**: `Smeplan_SS_Hardening::is_site_busy()` (the "hold auto-update while the site is busy" safeguard) incremented its own request counter every time it was CALLED — but it's only ever called from the 3 `auto_update_*` filters during WP core's own `wp_maybe_auto_update()` pass, so the counter only ever measured how many components needed updating in that pass, never real visitor traffic. With the default threshold of 60/min this essentially never triggered, so busy sites were never actually deferred. Now sampled from a `wp` hook on real front-end requests instead.
* **Fix**: the rate-limit counters (login/XML-RPC lockout, REST `/run`) detected "is this a new time window" and reset the hit counter via a plain `get_transient()`-then-`set_transient()` read-then-write — not atomic, so two requests arriving at the same instant right as a window expired could both see "no active window" and both reset, silently discarding whichever hit had already landed a moment earlier. This reappeared in the REST `/run` rate limiter added earlier in this same release, copied from the pre-existing login-lockout code. Window detection is now atomic (`wp_cache_add()`/a compare-and-swap `UPDATE`, the same technique already used for lock takeover), closing the gap that the previous atomic-counter fix (this release, and 0.7.2) didn't cover.
* **Fix**: `Smeplan_SS_Rest`'s `/run` route declared `permission_callback => '__return_true'` literally, with the real HMAC/nonce authentication living inside the request handler instead — functionally equivalent, but a pattern reviewers (human or automated) reasonably single out for scrutiny on sight. The HMAC/nonce/rate-limit verification now lives in its own `permission_callback` (`verify_run_request()`), so the route's actual access control is visible at the point where the route is registered.
* **Hardening**: `uninstall.php`'s cleanup query now goes through `$wpdb->prepare()`/`esc_like()` for its `LIKE` patterns instead of hand-escaped literals (all patterns are still hardcoded, not user input — no injection was possible either way), and also cleans up the `smeplan_ss_rlw_*` rate-limit window-tracking rows the fix above introduces.
* **Docs**: added an inline note on `Smeplan_SS_Remediation::enable_maintenance()` explaining why writing `.maintenance` to `ABSPATH` is the one deliberate exception to this plugin otherwise only ever writing inside `wp_upload_dir()` — WordPress core hardcodes that exact path and there's no filter to relocate it.
* **Fix (detection accuracy)**: `Smeplan_SS_Detect::analyze_php()` computed the eval()/assert() density (matches per KB) using the file's full on-disk size as the denominator, while the numerator was only ever counted within the first 5MB actually read (`Smeplan_SS_Scanner_File::MAX_DEEP_READ`) — for a file above that cap, this silently diluted the density below what it really was in the portion that got scanned, which could drop a file below the `eval_density_suspicious`/`eval_density_very_high` thresholds regardless of how concentrated the matches were. Now uses the length of the content actually analyzed instead of the on-disk file size.
* **Fix**: a targeted scan of one specific site (REST `/run` with `blog_id`, or `wp smeplan-ss scan run --blog-id=<id>`) went straight to `Smeplan_SS_Multisite::run_targeted()`, bypassing `Smeplan_SS_Scanner::run_tick()` entirely — findings were still logged and malicious files still auto-quarantined, but `Smeplan_SS_Remediation::flush_alerts()` (the "Alert email"/"Alert webhook" features) and `purge_expired_quarantine()` never ran for this path, silently going unsent/unrun for exactly the trigger method the Wizard recommends for multisite when WP-Cron isn't reliable. `run_targeted()` now wraps the same begin_tick()/purge/flush bracket `run_tick()` uses.
* **Fix**: `Smeplan_SS_Baseline::resolve_component_dir()`'s containment check compared `realpath()` output with `strpos($real, $real_base) === 0`, with no guaranteed trailing separator on `$real_base` — a sibling directory sharing the same string prefix (e.g. `wp-content/pluginsBackup` next to `wp-content/plugins`) would incorrectly pass as "inside" `$real_base`. Now requires the separator as part of the prefix, matching the safe pattern `Smeplan_SS_Fs::rollback_file()` already uses elsewhere in the codebase.
* **Fix (coverage gap)**: a loose file dropped directly inside `wp-content/` itself (not inside `mu-plugins/`, `plugins/`, `themes/`, or `uploads/`, and not one of the 7 fixed drop-in filenames) was never scanned at all — `seed_dirs()` only ever queues directories for the recursive walk, and the loose-file check added for `wp-content/plugins/` and `wp-content/themes/` didn't cover the `wp-content/` root itself. A webshell placed at e.g. `wp-content/x9f2.php` would have no baseline and never appear in Findings. Now scanned the same way drop-ins are.
* **Fix**: `Smeplan_SS_Rest::rate_limited()` (the `/run` endpoint's per-IP rate limit) used a plain `get_transient()` read followed by a separate `set_transient()` write — not atomic, so two requests arriving a few milliseconds apart could both read the same pre-increment count and both proceed, letting the caller exceed the configured limit under concurrent load. This is the same bug class already fixed for login rate-limiting in 0.7.2 and for lock takeover above, just not carried over to this endpoint. Now delegates to `Smeplan_SS_Hardening`'s atomic counter.
* **Removed**: dead code — `Smeplan_SS_Lock::file_lock_acquire()`/`file_lock_release()` (a `flock()` fallback) were never called anywhere in the plugin.
* **Removed**: the best-effort `chattr +i` (Linux immutable flag) lock on component backups, introduced in 0.6.0. It called `exec()`, which added review friction with no real security benefit — it silently did nothing on the majority of hosting environments anyway (unprivileged PHP process, `exec()` disabled, or non-Linux), and the SHA-256 checksum re-verified before every restore was already the layer that actually guarantees a tampered backup is never trusted. Backups are still protected by the tightest file permission WordPress can set on its own files (0400) plus that checksum check.
* **Fix (performance)**: the file scanner re-read and re-parsed a component's entire baseline `.ndjson` file from scratch for every single file scanned in that component (`Smeplan_SS_Baseline::read_component_baseline()`, called once per file via `diff_file()`) — O(n^2) for a component with n files, which could exhaust a scan tick's time budget on large plugins/themes and force the adaptive throttle down to its floor for far longer than necessary. Now cached per baseline path for the life of the request, so each component's baseline is read from disk once per scan tick instead of once per file.
* **Fix**: `Smeplan_SS_Lock::acquire()` took over an expired lock via a plain `get_option()` read followed by a separate `update_option()` write — not atomic, so two callers racing to take over the same expired lock at the same moment (e.g. the watchdog and the REST HMAC runner) could both succeed and run a scan tick concurrently. Now uses a compare-and-swap `UPDATE ... WHERE option_value = <value just read>`, so only one of the racing callers can win.
* **Fix**: core file restore silently skipped checksum verification entirely when a relpath wasn't present in the checksums returned by `get_core_checksums()` (writing the downloaded content with no verification beyond the package's own signature). Now refuses to restore that file (`status: failed`) instead of writing unverified content.

= 0.7.7 =
* **Fix**: `Tested up to` used a 3-part version number (`7.0.2`); WordPress.org's automated readme check only accepts major.minor (`7.0`).

= 0.7.6 =
* **Fix**: since 0.7.3 moved plugin storage (baseline/state/logs/quarantine/backups) into `wp_upload_dir()`, the file scanner's own walk of the uploads folder would find its own quarantined `.php` copies (files quarantined from elsewhere, kept for restore) sitting inside `uploads/smeplan-security-shield/quarantine/`, and re-flag them as "PHP file in uploads directory" — a self-inflicted false-positive loop. The scanner now skips its own storage directory entirely while walking uploads.

= 0.7.5 =
* **Fix**: the 0.7.4 fix for the long-literal scanner's PCRE "regular expression is too large" warning was incomplete — it removed the backreference but kept a `{64,4000}` upper bound on a multi-branch group, which PCRE still has to unroll into up to 4000 copies of the group's bytecode regardless of whether a backreference is involved. Both patterns now use an unbounded `*` repeat (a single loop, not unrolled), and the 64-4000 character length window is enforced in PHP after the match instead of inside the regex.

= 0.7.4 =
* **Fix**: the long-literal scanner (used to spot obfuscated/base64 payloads) used a regex with a bounded repeat around a backreference-based negative lookahead (`(?:(?!\1).){64,4000}`). A quantified subpattern whose body references a capture group defined outside itself can't be compiled as a simple loop, so PCRE unrolls all 4000 repetitions into individual instructions — big enough to exceed PCRE's compiled-pattern size limit, throwing a `preg_match_all(): Compilation failed: regular expression is too large` warning on every single file scanned. Replaced with two quote-specific patterns (one for `"`, one for `'`) that also now correctly skip over escaped quotes (`\"`, `\'`) inside the literal, which the previous pattern didn't handle at all.

= 0.7.3 =
* **Fix**: storage (baseline, state, logs, quarantine, backups) moved from a hidden folder inside `wp-content` to `wp-content/uploads/smeplan-security-shield/`, resolved at runtime via `wp_upload_dir()` instead of a hard-coded path built from `WP_CONTENT_DIR`. This also fixes cross-site data collisions on multisite installs that share one `wp-content` folder: the storage path now correctly follows `switch_to_blog()` per site, same as WordPress's own uploads directory.
* **Fix**: the CSP report-uri and alert webhook URL settings are now sanitized with `esc_url_raw()` instead of `sanitize_text_field()`; the trusted-proxies and login-lockout IP allowlists are now validated as real IP/CIDR entries (invalid lines are dropped) instead of only trimmed.
* **Fix**: `$_SERVER['REMOTE_ADDR']` is now passed through the same IP sanitizer as the Cloudflare/X-Forwarded-For headers before being used for rate-limiting and logging.
* **Fix**: `login_ip_window`, `login_user_window`, `login_block_seconds`, and `xmlrpc_ip_window` could be saved as 0 in Policies, which is used directly as a `set_transient()` expiration — WordPress treats a 0-second expiration as "no timeout", so a rate-limit window or a login lockout set to 0 would never clear on its own. These 4 settings are now floored at 30 seconds.
* Removed the bundled `languages/smeplan-security-shield-vi.po`/`.mo` files and the manual `load_plugin_textdomain()` call — standardizing on a single WordPress.org distribution instead of a separate self-hosted build; translations are now handled entirely through translate.wordpress.org once the plugin is listed there.

= 0.7.2 =
* **Fix**: the login/XML-RPC rate-limit counter was incremented with a read-then-write on a transient (get the count, add 1, write it back) instead of a single atomic operation. Two attempts arriving close together (routine under a real distributed brute-force) could both read the same value before either wrote back, silently losing a hit — under sustained concurrent load the count could then never reach the configured max, so the lockout failed to engage even though the real number of attempts had already passed it. Now uses the persistent object cache's own atomic increment (Redis/Memcached, when configured) or a single atomic `INSERT ... ON DUPLICATE KEY UPDATE` statement when the site only has the database — neither can lose a hit no matter how many attempts land at the same time.

= 0.7.1 =
* Changed the default "Max failures / username" from 20 to 5, matching the "Max failures / IP" default — both remain fully admin-configurable (and independent) in Policies. Only affects fresh installs; existing sites keep whatever value they already saved.

= 0.7.0 =
* **Fix**: an IP/account already under a login lockout logged a fresh "Temporarily locked" warning event on every single subsequent attempt for as long as an attacker kept retrying (wp_login_failed still fires even for an attempt rejected purely for being rate-limited, and that was re-triggering the log every time). The lockout itself was always working correctly — this only affected log noise. Now logs once per lockout, not once per retry, while still re-extending the actual block duration on each retry as before.

= 0.6.9 =
* **Fix**: on a host that blocks/fails loopback HTTP requests, the watchdog's non-blocking self-trigger could re-fire on every single `admin_init` (i.e. every admin page load) instead of waiting out its own cooldown — because it only re-checked "is the last scan overdue", which never clears if the triggered request never actually lands. Added an independent throttle so it retries at most once per cooldown window regardless of whether the previous attempt succeeded.

= 0.6.8 =
* **Fix**: a missing closing parenthesis in the 0.6.7 fix for the Policies page (the "Trusted proxies" and "Login lockout allowlist" fields) caused a PHP parse error on `class-ss-admin.php`, which made the plugin fail activation entirely ("Plugin could not be activated because it triggered a fatal error"). Fixed.

= 0.6.7 =
* **Fix**: on the Policies page, the help text under "Trusted proxies", "Login lockout allowlist" (Login/Rate-limit section) and "CSP policy" (Security headers section) was being placed just outside its `<tr>` table row instead of inside the cell. Browsers "foster-parent" invalid table content like this out of the table entirely — so all 3 descriptions rendered bunched together right under the section heading, ABOVE every actual field in that section, instead of next to the field they each describe. Fixed by nesting the description inside its own cell like every other field on the page.
* Added a short explanation under the "Latest scan overview" table whenever a "Other" row appears, since it wasn't obvious what those files were: almost always WordPress's own harmless `index.php` ("Silence is golden") stub file that ships by default directly inside `wp-content/`, `wp-content/plugins/`, or `wp-content/themes/`.
* Thanks to another independent code review for catching the misplaced help text.

= 0.6.6 =
* **Fix**: network-activating on Multisite only granted the plugin's capabilities to the administrator role of whichever site happened to be "current" during activation, not to every site in the network — administrators of other sites saw no Security Shield menu at all, even though the background scanner was already covering their site. Capabilities are now granted per-site on network activation, and to any site created afterward.
* **Fix**: for the same reason as above, the HMAC `cron_secret` used to authenticate the REST runner was also only generated for the "current" site during network activation — every other site on the network was left with an empty secret, silently breaking their REST/WP-CLI runner (and showing a broken example command on their Wizard page) even though scheduled WP-Cron scanning kept working normally. Now generated per-site alongside capabilities.
* **Fix**: the wp-config.php permission check flagged normal, default file permissions (0644) as a finding, because the bitmask tested for world-write OR world-read combined instead of world-write only — meaning nearly every site with unmodified default permissions saw a false "suspicious" finding. Now only flags true world-writable permissions.
* Corrected "Tested up to" to the WordPress version actually verified against this release.
* Minor cleanup: removed a dead no-op line and 2 unused, empty asset folders; added a `phpcs:ignore` note to the one hardcoded (non-dynamic) uninstall query.
* Thanks to another independent code review for catching these.

= 0.6.5 =
* Addressed WordPress.org plugin review feedback: the admin-only inline `<script>` block is now properly enqueued via `wp_add_inline_script()` instead of being printed directly; `load_plugin_textdomain()` moved from `plugins_loaded` to `init` (avoids WordPress 6.7+'s "translation loaded too early" notice). The version submitted to the WordPress.org directory omits the bundled `languages/*.po`/`*.mo` files (translations are managed through translate.wordpress.org once listed there) — a separate self-hosted build with the bundled Vietnamese translation remains available directly from the author for sites installing the plugin outside of WordPress.org.
* Fixed the Contributors line in this readme to list the actual WordPress.org account maintaining this listing.

= 0.6.4 =
* **Fix**: the file scanner's resume checkpoint could silently blind itself to the rest of a directory (including its subdirectories) if the file it had bookmarked as "resume from here" was moved out before the next tick — which is exactly what happens when that very file turns out to be malicious and gets auto-quarantined as part of scanning it. The next tick would then never find a match to stop skipping on, so every remaining entry in that directory (and any subdirectories, only ever queued from the non-skipping branch) got silently skipped with no error, right at the moment a nearby webshell's companion files were most worth catching. Now checks the bookmarked file actually still exists before deciding to skip anything; if it's gone, the directory is simply re-scanned from scratch that tick (a small amount of duplicate, still-safe, read-only work) instead of leaving a blind spot.
* **Fix**: the database-scan tick summary always reported "found 0 issue(s)" in Logs/Dashboard regardless of what was actually found — the counter was initialized but never incremented anywhere. Individual findings were still logged and shown correctly on the Findings page throughout, only the tick-level summary count was wrong. Now increments correctly.
* Thanks to another independent code review for catching both of these.

= 0.6.3 =
* Fix: the scan overview showed phantom "Plugin: index" / "Theme: index" rows. The 0.6.2 fix for loose top-level files correctly started scanning WordPress's own `index.php` "Silence is golden" anti-listing stub (present by default in `wp-content/plugins/` and `wp-content/themes/` on virtually every install), but mislabeled it as if it were a single-file plugin/theme. It's still scanned as before — this only fixes the label, now grouped under "Other" instead of a fake component that doesn't exist. Existing installs: the phantom rows clear themselves on the next fully-completed scan cycle, or immediately via "Clear this table and start over" on the Dashboard.

= 0.6.2 =
* **Security fix (critical)**: the login/XML-RPC rate-limiter could be bypassed entirely by sending any `Authorization` header, valid or not — the code only checked whether the header was present, not whether it actually authenticated anything, so a brute-force script could defeat lockout completely by adding one bogus header to every attempt. Now checks whether authentication genuinely succeeded (i.e. a prior method like Application Passwords actually produced a valid user) instead of trusting a client-supplied header's mere presence.
* **Security fix**: the path-traversal check on manual "Quarantine" (Findings page) never actually rejected anything — it compared a string that was, by construction, guaranteed to already start with the expected prefix, so a crafted path with `../` sequences was never caught by that check. Now resolves the real path with `realpath()` and verifies it stays inside the WordPress install before doing anything, the same pattern already used elsewhere in the plugin (e.g. component-directory resolution). Exploiting this required an already-authenticated account with the remediation capability (administrator by default) plus a valid nonce, but the check that appeared to guard against it did not.
* **Coverage fix**: a `.php` file sitting directly at the top level of `wp-content/plugins/` or `wp-content/themes/` — a common webshell location, and also how legitimate single-file plugins like Hello Dolly are packaged — was never scanned, never baselined, and never backed up, because the scanner only ever queued directories to walk and silently skipped anything that wasn't one. Now scanned, baselined, and (for genuine single-file plugins refreshed through WordPress's own updater or confirmed on Findings) included in baseline coverage; per-component zip backup remains directory-based and does not cover single-file plugins.
* Thanks to an independent code review for catching all three of these.

= 0.6.1 =
* The Remediation page now lists components with no backup yet (e.g. in-house plugins/themes that rarely get an "official" update, so neither automatic backup trigger from 0.6.0 has ever fired for them) and offers a manual "Create initial backup" button per component, plus a "Create initial backup for all of these" bulk option. The button disappears for a component as soon as it has a backup.

= 0.6.0 =
* New: "last confirmed good" backups per plugin/theme. A snapshot is refreshed automatically whenever a component is updated through WordPress's own updater, or whenever an admin confirms a manual change as legitimate from Findings — the previous snapshot is overwritten each time (1 backup per component, not a growing history). Deliberately does not cover WordPress core (already restorable straight from a signature-verified WordPress.org package — see 0.5.0), uploads (too large, out of scope), or the database (changes constantly, doesn't fit a "confirmed good" snapshot model).
* Three layers protect each backup: a best-effort Linux immutable-flag lock (chattr +i, only where the hosting environment allows it), a tight file permission (0400), and — the layer that actually matters even where the first two aren't available — the backup's SHA-256 checksum is re-verified immediately before every restore, and the restore is refused outright if it doesn't match what was recorded when the backup was created.
* Restoring a component from backup first quarantines whatever is currently in its place (moved, never deleted), so the restore itself can always be undone from Remediation. New "Component backups" panel on the Remediation page lists every backup with a "Restore from backup" button.
* Note on scope: these backups live on the same server/filesystem as the site — they protect against a same-privilege attacker (e.g. malware injected through a vulnerable plugin) or an honest mistake, not against someone with full hosting/server-level access, who could tamper with local backups exactly as with any other file. Off-site backup for that threat model is out of scope for a free security plugin — pair with a dedicated backup plugin for that.

= 0.5.4 =
* Config findings (weak salts, world-writable permissions, suspicious cron hooks, auto_prepend_file) now have a "Dismiss" button on the Findings page, using the same "resolved as of now" mechanism as Quarantine/Confirm baseline — useful when the underlying risk genuinely doesn't apply to your setup (e.g. a single-tenant server with no other sites that could exploit shared write access). If the same check logs the finding again on a later scan, it reappears as usual; it is not silenced permanently.

= 0.5.3 =
* Fix: the "Target" column on Findings showed blank for most config findings (auto_prepend_file, weak salts, world-writable file/directory permissions, suspicious cron hooks) — only the "handler_file" type ever populated it. The path/value/key names were already being recorded in the log, just not surfaced in that column. Findings for those types now show exactly which file, ini value, key, or hook they're about.

= 0.5.2 =
* Fix: clicking "Quarantine" (or "Confirm this is a legitimate update") for a finding whose file/plugin/theme had already been deleted by other means (e.g. removed manually before checking Findings) always failed with a "not found" error and got stuck in the list forever — the finding was never marked resolved, because that only happened on the success path. Now, when the target genuinely no longer exists on disk, the finding is cleared automatically instead, since there is nothing left to act on.

= 0.5.1 =
* Removed a small whitelist rule that slightly reduced the suspicion score for files produced by certain commercial PHP code-protection tools (their output looks exactly like obfuscated malware to any heuristic scanner, so the rule existed purely to reduce false positives on legitimately licensed encoded plugins). Removed because WordPress.org's automated plugin-submission scanner treats the mere presence of those tool names in source code as "obfuscation detected" and blocks submission outright. Files protected by such tools may now score slightly more "suspicious" than before — quarantine still only ever triggers at the "malicious" level, and quarantine is always reversible.

= 0.5.0 =
* New: the Findings page now shows a panel for any WordPress core file that fails the checksum check, with a "Restore original" button per file (and a bulk "Restore all"). Restoring downloads the matching official package from WordPress.org with cryptographic signature verification (the same mechanism WordPress's own updater uses), re-checks the file against the official checksum, quarantines the current file first (never deletes it), then writes the verified content atomically — so a restore can always be undone from the Remediation page. Requires the PHP ZipArchive extension.

= 0.4.0 =
* The plugin's source language is now English (previously Vietnamese) so it can be published for an international audience on WordPress.org/GitHub. Every UI string and code comment was translated. A Vietnamese translation ships in the box (`languages/smeplan-security-shield-vi.mo`), so sites running in Vietnamese keep seeing the exact same Vietnamese text as before — nothing changes for existing Vietnamese users.

= 0.3.6 =
* Following a code audit: added a signal (VERY low weight, purely supporting evidence, never enough on its own to reach the "suspicious" level) for chmod()/chown()/unlink()/rename()/rmdir() — file-operation functions that are extremely common in legitimate plugins (cache, uploads, backup), only worth noting when paired with another strong signal in the same file.

= 0.3.5 =
* Fix: clicking "Quarantine" or "Confirm this is a legitimate update" on Findings previously performed the action correctly but the finding row stayed visible (Findings only replayed the raw log, with no concept of "resolved"). Now, once handled, that row (and every OLDER row about the exact same file/plugin) automatically disappears from Findings, and the menu badge count and the 2 Dashboard stat cards update accordingly. If that same file/plugin has a NEW problem later (including a real hack), Security Shield still detects it and reports it again — this is not a permanent bypass.

= 0.3.4 =
* Findings: the "baseline_mismatch" advice text is now shorter and names the specific plugin/theme (e.g. "Plugin: SMEPlan SEO Foundation just had its data change...") instead of a long generic explanation.
* The "Findings" menu item now shows a red count badge (like WordPress's own update-count badge) next to its label whenever there's a critical/warning finding, so you can tell at a glance whether anything needs attention.

= 0.3.3 =
* Findings page: a "baseline_mismatch" finding (a file that differs from its baseline, usually from updating a plugin/theme manually via cPanel/FTP instead of through WordPress) now has a "Confirm this is a legitimate update" button — it only captures the plugin/theme's current hash as a NEW comparison point, not a permanent whitelist: if a file in that same plugin/theme changes again afterward (including a real hack), Security Shield still detects it and reports it as usual.

= 0.3.2 =
* Added a "Login lockout allowlist" (Policies) — unlike Trusted proxies (used to read the real IP behind a CDN), this list genuinely bypasses login/XML-RPC rate-limiting for the IPs added to it.
* Logs page: every "temporarily locked out after repeated failed logins" row now clearly shows the IP/account that was locked, with a "Trust this IP" button to add it straight to the list above + clear the lockout instantly, without having to type it into Policies by hand.

= 0.3.1 =
* Fixed nearly-invisible text on the 3 Dashboard stat cards (Critical/Warning/Quarantine sessions) and 2 code-sample blocks on the Wizard page when another plugin changes the whole admin page's text color to a light color (e.g. a dark theme like SMEPlan Admin Experience) — previously only a hardcoded background color was set without a matching text color, so the text "inherited" the light color from the outside theme onto a white background.

= 0.3.0 =
* Added a "Block username enumeration" feature (moved over from SMEPlan Admin Suite, since renamed to SMEPlan Admin Experience): hides the account list from the REST API and the sitemap, blocks the `?author=1` enumeration trick. Enabled by default, with its own toggle in Policies.
* The Wizard page now shows a warning if any account is leaking its login name through the author URL, with a 1-click fix button.

= 0.2.2 =
* Added a "Clear this table and start over" button for the scan overview table on the Dashboard.
* The events table (Dashboard + Logs) now translates technical event names and JSON payloads into plain sentences (e.g. "Processed 500 files, found 0 issues, took 1.7 seconds" instead of raw JSON), along with an explanation of what the "Info/Warning/Critical" levels mean.
* Renamed and added a clear explanation for the core-checksum-check button: "Check whether WordPress core files were modified".

= 0.2.1 =
* Fixed a false positive: the "light scan" area (cache/backup) previously matched the folder name "cache" anywhere in the path, wrongly catching a legitimate library that happened to share that folder name (e.g. `google-site-kit/third-party/psr/cache/`) and using `<?php` as a suspicion token there — but every valid PHP file starts with that tag, so it was a guaranteed 100% false positive. It now only matches direct child folders of `wp-content/`, and `<?php` was removed from the suspicion token list for .php files there.
* Every timestamp in the admin UI now displays in the site's configured timezone (Settings → General) instead of UTC.
* The Dashboard now shows the "next expected run" along with a note that WP-Cron only runs when someone loads a page on the site (it is not a real system timer).
* Added a "Clear log history" button on the Logs page.
* Added a "Scan frequency (seconds)" setting in Policies (30-3600s, default 60) — changing it immediately reschedules WP-Cron.
* Every field in Policies now has a short explanation underneath it, to help non-technical users understand what each option does.
* The Findings page now has a "Suggested action" column — translating each technical signal type into concrete advice on what to check.

= 0.2.0 =
* Added a "Latest scan overview" table on the Dashboard: total files scanned, duration, and the status of each plugin/theme/mu-plugins/uploads with a ✓ (clean) / ⚠ (warning) / ✗ (critical) mark, sorted with problem areas first. Also shows the last DB scan and config check times. No more digging through the raw log table for an overview.

= 0.1.8 =
* Added a "Rollback all" button on the Remediation page — restores every quarantine entry currently shown in a single click instead of one at a time.

= 0.1.7 =
* The core checksum comparison now pins the `en_US` locale instead of following the site's display locale — PHP files under `wp-admin/`/`wp-includes/` never differ by locale (only the `.mo` translation files in `wp-content/languages/` do, and that branch was already skipped), so pinning `en_US` removes the risk of a locale-related checksum lookup mistake — the suspected cause of a false alarm on `wp-includes/version.php`.

= 0.1.6 =
* Fixed `backtick_exec`: previously any pair of backticks on the same line was flagged, even when it was just markdown-style \`code\` formatting inside an ordinary error message (e.g. Hello Elementor). It now uses PHP's own tokenizer to recognize only the real shell-execution operator, leaving backticks inside string literals alone.
* The plugin version number is now shown directly in the admin menu and on the Dashboard, so you can always confirm exactly which build a site is running while debugging.

= 0.1.5 =
* Action buttons (Scan now, Reset scan, Rollback, Quarantine, Purge quarantine, Check core checksum, Health-check) now show a clear "Running..." state and disable themselves immediately on click, instead of staying silent during the ~5-25 second wait, which made users think nothing had happened or that they had to wait for the next scheduled run.

= 0.1.4 =
* Fixed a remaining edge case from the 0.1.3 bug: if a file had already been restored BEFORE the session-cleanup fix was installed (leaving only stale metadata behind, no real file left in quarantine), clicking Rollback used to report "not found" forever and the entry would never disappear from the list. This case is now recognized correctly (the file is already back in its original location), the leftover metadata is cleaned up, and it reports success instead of an error.

= 0.1.3 =
* **Fixed "clicking Rollback does nothing"**: after successfully restoring a file, the old code never deleted the quarantine session folder, so the Remediation page kept listing it as if it had never been rolled back even though the file really was restored — every click after the first failed silently because the file was no longer in quarantine. The session is now cleaned up properly right after a rollback completes.
* Every action on the admin pages (Scan now, Reset scan, manual Quarantine, Rollback, Purge quarantine, Check core checksum) now clearly shows a success/failure notice instead of a silent redirect.

= 0.1.2 =
* **Fixed a serious bug (the scanner flagging itself)**: the scanner used to always scan its own plugin folder — and the detection engine's own source code naturally contains literal strings like `'eval('`, `'shell_exec('`, `'auto_prepend_file'` (used as match patterns), causing its own core files to score as "malicious" and get auto-quarantined, which could trigger a cascade of fatal errors. As of this version, the plugin never scans its own directory.
* **Fixed mass false positives on real images**: removed the `<?=` token (only 3 characters) from the media polyglot-detection list — a token that short has a meaningful chance of matching randomly when scanning the compressed/binary content of large images (especially once a directory gets upgraded to full-scan mode), which had caused many legitimate Media Library images to be wrongly flagged "malicious" and quarantined.
* Reduced false positives on third-party plugins/themes: signals like `eval`/`assert`/`shell_exec`/`auto_prepend_file`/`dynamic_include` are now checked against a COMMENT-stripped version of the code (using PHP's own tokenizer, not a guessing regex) — mentioning these keywords in a docblock/comment (very common in security plugins and technical documentation) no longer triggers a false alarm. Real string literals (where a payload actually hides when exploited through `assert()`/`preg_replace()`/`ini_set()`) are left untouched so real attacks are still caught.
* Finding logs now also record the actual matched value/excerpt (not just the signal name), making it much faster to diagnose false positives later.

= 0.1.1 =
* **Fixed a serious bug**: `enable_maintenance()` set the `$upgrading` value incorrectly (into the future instead of the actual start time), causing WordPress core to lock the site (`.maintenance`) LONGER than intended and disabling the plugin's own ability to remove it (because core dies before the plugin is even loaded). If a site gets stuck on the "Briefly unavailable for scheduled maintenance" page, manually delete the `.maintenance` file in the WordPress root via FTP/File Manager.
* Reduced false positives: excluded `.js` from the PHP-scanning heuristic engine (eval density/entropy) since legitimate minified/bundled JS very easily matches those signals; `.js` files are now only flagged when they contain a literal `<?php`/`<?=` opening tag (a sign of a PHP backdoor hidden inside a .js file).

= 0.1.0 =
* Initial release: file/DB/config scanner, baseline/integrity, quarantine/rollback, basic hardening, multi-layer scan scheduling, multisite support.
