Forge12 Security — full changelog

readme.txt carries only the most recent releases: the plugin directory truncates
the Changelog section at 5000 characters, and a silently cut-off list is worse
than a short one. Everything ever released is here.

== Changelog ==

= 3.7.16 =
A server set to write files over FTP without the FTP extension no longer takes the site down.

* Where WordPress was configured to write files over FTP but PHP has no FTP support, every request ended with a fatal error as soon as the plugin wrote a protection file. The plugin now recognises a file system that could not be set up and leaves the write out.

= 3.7.15 =
Forge12 Security is now distributed through WordPress.org, and installations that received their updates from forge12.com switch over by themselves.

* An installation that received updates from forge12.com replaces itself with the WordPress.org copy on the next visit to the dashboard. Settings, records and findings are kept, and automatic updates stay switched on if they were.
* If a server does not allow the switch, for example because plugins cannot be installed from the dashboard, a notice explains how to do it by hand and offers to try again.
* Deleting one of two installed copies of the plugin no longer removes the settings and records the other copy still uses.
* The WordPress.org package includes the German translation.

= 3.7.14 =
Settings that went back to their old value after saving now stay, and a finding is emailed once instead of on every check.

* The scan steps "Unpublished Files" and "Persistence", and "Seconds per Scan Request", were confirmed as saved but showed their previous value on the next load. They are now saved.
* Saving on one screen could undo settings changed on another screen during the same visit, for example a hardening switch after "Save all Settings". Each screen now saves only what was changed on it.
* The audit log switch reported success without changing anything. It now switches audit logging on and off.
* "Not listed on WordPress.org, so it cannot be checked there" is information for the log and is no longer sent by email.
* An outdated or abandoned plugin or theme, and a known vulnerability, are emailed when they are first found, not again on every check. The log still records them each time. A new version, a new vulnerability or a change of state is emailed again.
* Plugins listed on WordPress.org were missing from the vulnerability overview, because their last-update date did not fit the database field. They are now listed.
* The setup wizard showed brute-force protection as a switch that could be turned off. The protection is always on, and the wizard now shows it that way.
* "Custom Login URL" on the Hardening screen looked like a switch but only shows whether a login address is set. It no longer reacts to clicks.
* The plugin's own menu, the test email and the footer of alert emails now use the plugin's name, Forge12 Security, instead of the old short form.

= 3.7.13 =
An expired license from before 26 September 2026 keeps its Pro features, and an unreachable license server no longer switches anything off.

* A license whose term began before 26 September 2026 keeps the Pro features of the installed version after it expires. Updates and support end with the term.
* A license whose term begins on or after 26 September 2026 pauses the Pro features when it expires. All settings and data are kept, and a new license resumes them at once.
* If the license server cannot be reached, the last known license status stays in force. A license that expires during such an outage keeps its Pro features for another 14 days, in case it has already been renewed.
* If the license server has not been reachable for more than seven days, a notice on the plugin's screens says so. Nothing is switched off.
* A suspended or revoked license switches the Pro features off, whatever its model.
* A license renewed on the same key keeps, once its new term has expired, the Pro features up to the Forge12 Security Pro version its earlier term covered.
* A license is valid until the end of its last day, German time, as the license server counts it. Dates are judged by the license server's clock, so a wrong clock on the web server neither ends a license early nor keeps it running.
* The license page says which of these applies.

= 3.7.12 =
Each firewall rule counts once, and images are no longer refused as SQL injection.

* Firewall rules that arrived through the rule feed ran next to the built-in rule of the same name, so every match counted twice. Rules meant as a hint, which should only block together with a second one, blocked on their own. Each rule now counts once, the copies are removed on update, and a rule you switched off stays off.
* The rule "SQLi: Inline comment evasion" read the Accept header that browsers send with every image, video and audio request as an SQL comment. Media served through WordPress, and requests for missing images, were refused with 403. The rule no longer matches it.
* A rule set built before a correction no longer brings back the pattern that was corrected.

= 3.7.11 =
The block page prints its stylesheet through WordPress.

* The page a blocked visitor sees no longer writes its own style tag. Its stylesheet is a registered WordPress style with the CSS attached inline, so it goes out the way every other stylesheet does, and filters on stylesheet output apply to it. The page looks the same.

= 3.7.10 =
Alerts are collected for every channel that is set up, not only for email.

* With the email address field empty, no alert was collected at all, so other channels such as the Slack and Telegram alerts of Forge12 Security Pro never received anything. Alerts are now collected as soon as any channel is set up.
* An empty address field still sends no email. There is no fallback to the site's admin address.

= 3.7.9 =
Moving old quarantine folders out of wp-content is now safe if a step fails along the way.

* The access rules of an old quarantine or backup folder stay in place until every file in it has been moved.
* A file is only removed from the old folder once the scanner records point at its new, encrypted copy. If that update fails, the file stays where it was and the move is tried again.
* If an old file cannot be deleted, the move is not reported as finished, and it is tried again.

= 3.7.8 =
Quarantine and backup folders that very old versions kept directly in wp-content are moved into the encrypted store under uploads.

* Versions before 3.4.6 kept quarantined files and backups in wp-content. They are now moved into the encrypted store in the uploads folder, the scanner records follow them, and the old folders are removed. Nothing of the plugin is left in wp-content.

= 3.7.7 =
Four features that write into WordPress's own folders are now part of Forge12 Security Pro, and request values are cleaned the moment they are read.

* Extended Protection (the firewall stage before WordPress), repairing modified files, restoring from quarantine, and the two rules in the site root's .htaccess moved to Forge12 Security Pro. They write code to disk, or into the WordPress, plugin and theme folders or the site root, which the WordPress plugin directory does not allow this plugin to do.
* Findings, file comparison, encrypted quarantine and deleting stay. Replace a modified file by reinstalling or updating it from the dashboard. Quarantined files are kept.
* Without Pro, a pre-WordPress firewall stage or site-root rules set up earlier are removed cleanly. The firewall inside WordPress keeps running.
* Removing the pre-WordPress stage no longer risks a few minutes of errors on servers that cache .user.ini: its file is deleted only once that cache has expired.
* Quarantined files and backups no longer begin with PHP code.
* Request addresses, User-Agents, cookies and form values are cleaned as soon as they are read, keeping every character the firewall has to see. Login and two-factor redirect targets are checked against this site as soon as they are read.
* The file-writing diagnosis no longer creates test files in the WordPress, plugin and theme folders.
* Without the PHP zip extension, the findings export no longer writes flagged files to a temporary folder.
= 3.7.5 =
Suspicious files can now be quarantined or deleted straight from the scanner, and several security-sensitive actions check permissions more strictly.

* Suspicious files now have Quarantine and Delete buttons in the scanner. Quarantine removes the original and keeps an encrypted copy that can be restored later; deleting a file for good asks for a separate confirmation.
* Restoring a quarantined file failed, because the place its copy was kept was not recorded. It is recorded now.
* Quarantined files and the backups taken before a repair are stored encrypted, so they can no longer be run or read on the server. Existing ones are converted automatically.
* While a user still has to set up two-factor authentication, no unrelated REST routes are opened for them. Turning 2FA off, resetting it or creating new backup codes now asks for a current code first.
* Actions that change files respect the WordPress setting that disallows file modifications, and on a multisite network only a network administrator can use them.
* An action that failed to change a file no longer reports success.
* Forwarding headers such as X-Forwarded-For are only believed when the request comes from a trusted proxy.
* Uninstalling removes this plugin's data from every site of a multisite network, and nothing else.

= 3.7.4 =
A long scan no longer looks stuck, no longer stands still for hours when the server ends one of its steps, and can be stopped.

* The scan log stopped updating at its two hundredth line. The scan went on working, but the screen showed nothing new for the rest of the run, which reads as a scan that has hung. New lines now keep arriving however long the scan runs.
* When the server ended a scan step before it could pause — the usual time limit on shared hosting — nothing arranged for the scan to carry on. It waited until somebody opened wp-admin or the next scheduled scan came round, which could be hours later. Every step now arranges its own continuation before it starts.
* A step the server ends is now written to the log, and the following steps are made shorter. A scan that is ended three times in a row without getting any further is stopped with an explanation, instead of staying "running" for ever with the start button disabled.
* The malware check kept a time limit of its own and looked at it only every 500 files, so a single step could run well past what the server allows. It now keeps to the scan's limit and checks it after every file.
* Picking a malware check up again after a pause no longer re-reads every file it had already passed, and excluded directories are not walked at all.
* A malware check that needed more than an hour in total started again from its first file every hour and could never finish. Its position now only expires after an hour without progress.
* A new scan no longer continues where an earlier, unfinished scan's malware check had stopped. It checks every file.
* A running scan can now be stopped with "Stop Scan", and a new one started straight away.
* The step summary reported "0 files checked" for the malware check, and the closing line gave the length of the last step instead of the scan's working time.

= 3.7.3 =
The custom login address works on installations that are not at the root of a domain, and every file check now covers a wp-content kept outside the WordPress folder.

* The custom login address had no effect on a WordPress installed in a subdirectory, or on one that keeps WordPress in a folder of its own. The setting saved, the screen reported that the login page had moved, and wp-login.php went on answering as before. It now holds on every installation layout WordPress supports.
* With a custom login address set, wp-admin is now actually refused for visitors who are not signed in. It never was: the check sat behind a condition that is already true by the time it runs, so /wp-admin still redirected to the login page and the redirect named the address that had just been hidden. admin-ajax.php and admin-post.php stay open, so contact forms and consent banners on your pages are unaffected.
* A page of your own whose address begins with "wp-admin" — /wp-administration-tips, for instance — is no longer answered 404 while a custom login address is set.
* WordPress lets you keep wp-content outside the WordPress folder, and some hosts do. On such an installation the excluded paths never matched anything, the findings export refused every file it was asked for, the file inventory recorded nothing, and the check that reports a PHP file executing out of your uploads directory treated that directory as somewhere else entirely. All of them cover it now, and the paths on screen and in the database read the same as everywhere else. Nothing changes on an installation that keeps wp-content where WordPress puts it.
* The firewall no longer brings the site down on hosts that switch off ini_set(). A single request was enough to produce a fatal error there.
* Plugin, mu-plugin and theme directories are read from where WordPress says they are, instead of being assembled from wp-content and a folder name.
* A development file left over from testing is no longer part of the download.

= 3.7.2 =
The Excluded Paths page says what each of its entries is.

* Two of the directories nothing ever judges are called f12-security — the plugin and its working directory — and the page listed both by path alone, which reads as the same entry twice. Each one is now named: what it is, then where it is, then why.
* That list also left places out. It now includes Forge12 Security Pro, the quarantine and backup directories from before 3.4.6 and another scanner's signature store, each shown only when it is actually there. A page that under-declares is the thing being fixed, so it should not stop halfway.
* The plugin's own directory is read from where it really is, instead of assembled from wp-content/plugins and a folder name. On an installation where the plugin is mounted or linked somewhere else, the page printed a path that did not exist while claiming the scanner honoured it.
* Ticking a candidate no longer stores its name. The name is resolved when the row is drawn, so the list reads in your language rather than in the language of whoever ticked it — a stored translation cannot be translated afterwards.
* A candidate ticked or a path added now takes effect on screen straight away instead of springing back until the server answers, which read as a control that had not worked.
* If the page cannot fetch the list, it says so. It used to show an empty list, which reads as "nothing is excluded" — a statement about your installation rather than about a failed request.
= 3.7.1 =
A page for the excluded paths — and the exclusions now apply to every file check, not only the malware scanner.

* A path written into Settings > Scanner > Excluded Paths was read by the malware scanner and by nothing else. The rule that reports an executable file where only data belongs never saw that list, so a directory a plugin fills with generated PHP — a compiled template cache, for instance — kept producing findings and alert mail after it had been excluded.
* Every file check now asks the same list: the location rule, the unknown-file walk, the comparison against published plugin and theme packages, and the core file comparison. Files under an excluded path are still walked and still inventoried, but nothing judges them. The scan log says how many were skipped and where, so an exclusion that reaches further than intended is visible rather than silent.
* There is now a page for the list, under Security > Excluded Paths. It records, at the end of each scan, which directories the findings fell into and how many scans in a row each has appeared in — "11 scans in a row, 47 files" is what separates a generated directory from an intrusion, and it cannot be seen from a single finding. Each of those places is one button.
* Places that usually hold generated code are offered as ticks — WPML's compiled templates, WP Staging's clones, disk page caches — each with a line on what you stop seeing if you tick it. Offered, never applied: nothing excludes a directory on its own.
* An entry can be switched off instead of deleted, which is how you find out whether an exclusion is what hides a finding without having to retype the path afterwards. Findings themselves carry the two shortcuts as well: exclude this file, or exclude its folder.
* The recording can be switched off. It holds a directory, a count and two dates — nothing about a visitor.
= 3.7.0 =
A page for the REST API routes, so restricting the API stops being a guess.

* "Restrict REST API" refuses every anonymous request, and a good deal of perfectly ordinary behaviour is anonymous: a cookie banner recording consent, a contact form submitting, a shop's checkout. Until now the only way to make an exception was a filter written into a mu-plugin — an answer for a developer, and none at all for the operator whose consent log has gone quiet. There is now a page for it under Security, called REST Routes.
* It lists what has actually been asked for, how often, and how often it was refused, with a decision per route: follow the setting, always open, always closed. Always closed applies whether or not the restriction is switched on, because it is your instruction and not a modifier on a different switch.
* Recording runs whether or not the restriction is on, and that is the point of it. Switch the recording on, leave it for a day of ordinary traffic, decide, and only then restrict. Finding out what the restriction breaks by breaking it is not a plan.
* Ten namespaces people commonly have to keep open are offered as ticks — Borlabs Cookie, Complianz, Contact Form 7, WPForms, WooCommerce's Store API, Elementor and others — each with a line on what stops working if it stays closed. Offered, never applied.
* A field underneath takes routes that have not been asked for yet, for an integration you are about to install. A route typed there is opened; it never reopens one you closed on the page.
* What is recorded is the route with any identifier in it replaced by a placeholder — /wp/v2/posts/# rather than /wp/v2/posts/17 — together with counters and the dates it was first and last asked for. No address, no user, no query string, no payload. The recording can be switched off, and the data protection page declares the table like every other.
* The filter forge12_security/hardening/allow_anonymous_rest keeps working unchanged. It remains the way to open a route from a mu-plugin, before this plugin's own settings can be read.
= 3.6.7 =
The audit trail can be given a retention period.

* It had none. Entries were kept for as long as the site existed, and the privacy page had to say so — in a plugin sold on data minimisation. There is now a setting beside the audit log switch, at 365 days by default. Zero keeps everything, for operators whose own rules require that.
* Entries go from the oldest end and never from the middle. The record is a hash chain, and a gap in the middle is indistinguishable from somebody covering their tracks; taking whole entries off the front leaves the rest intact. What is removed is recorded — how many, and up to when — so the chain that remains still verifies, and tampering is still caught.
* Needs Forge12 Security Pro 3.6.8 or later, which does the deleting. With an older Pro the setting has no effect, nothing is deleted, and the privacy page says exactly that rather than announcing a deletion period that nobody is applying.
= 3.6.6 =
The privacy page names every table, not most of them.

* The page sets out what this installation stores, and it was leaving tables out. Its own firewall rules table, on every site. And with the Pro add-on installed, four more: the audit trail, which records who changed what together with a user ID, a login name and a hashed address; the database restore points, which hold copies of user records and posts; the file restore copies; and the country and range blocks. All five are now described — what they hold, why, and when it goes away again. Two of them hold personal data and reach the policy draft accordingly.
* The page is honest about the two that are never cleared out. Neither the audit trail nor the file copies have any automatic deletion: the trail is a chain and removing an entry would break it, the copies are discarded when you reset the file baseline. Both now say so where you can read it.
* Alerts you send to Slack, and events delivered to a webhook address you subscribed, are declared as disclosures to third parties. Telegram was already listed and these two carry the same content, including the address an attack came from.
* Anything else can be declared too. Two filters, forge12_security/privacy/stores and forge12_security/privacy/transfers, take further entries, each with the finished sentence for the policy draft in German and English. They can only add: nothing reached through them can remove what the plugin says about itself, because a page that fails to name a store is exactly the fault they exist to prevent.
= 3.6.5 =
The database scan shares what it knows with the Pro add-on.

* On its own, this plugin behaves exactly as it did. The list of options its database scan does not read as code — its own settings, WordPress's internals, and the handful that plugins fill with markup on purpose — is now readable by the Pro add-on, along with the two pattern sets that go with it. The Pro add-on walks the same tables with patterns of its own and had none of this, which is why it reported the malware signature list as malware; that is fixed in Forge12 Security Pro 3.6.6 by using these.
* Nothing was added to the list and nothing was taken off it. It moved into one place so there is one list rather than two that drift apart.
= 3.6.4 =
The diagnostics report says which plugins are really running.

* Fixed: The diagnostics answer claimed this site was running the Pro add-on, on every installation, whether or not it was. The value was written into the code when the field was added and never read from anything. It is the first line anybody looks at on a support ticket, and it was wrong on every free site.

= 3.6.3 =
Two-factor authentication can be required of a role, from a screen.

* New: Which roles must use two-factor authentication is now set on the Two-Factor page, next to the grace period they are given to arrange it. The plugin has acted on that setting since 3.1.0 and this readme has advertised it just as long — but nothing here could write it. Switching the requirement on took a hand-written API call, which in practice meant it stayed off.
* The roles offered are the ones this site actually has, including any a plugin or a theme adds, so a shop manager or a customer role can be covered like an editor. A role that was required and has since been deleted stays listed and stays set until you clear it, rather than being dropped without anyone deciding to lift it.
* Nobody is locked out by the requirement: the sign-in still succeeds, and an account that has no second factor yet is taken to the setup screen until it has one. A grace period of zero days means that happens at the next sign-in; up to 90 days can be granted.
* The grace period is now held to the 0 to 90 days this readme promises. Any number at all could be stored before.
* The screen says so when two-factor authentication is switched off under Settings altogether. The roles are stored either way, but nothing is required of anybody until it is on.

= 3.6.2 =
Two-factor authentication works now, and everyone can use it.

* You are asked for your authentication code on a screen of its own, once your password has been accepted. Until now the field for it sat in the login page but was permanently hidden, so an account with two-factor authentication switched on could not finish signing in at all.
* Any signed-in account can now set up two-factor authentication for itself. The screen is under Users > Two-Factor Auth for administrators and under Profile > Two-Factor Auth for everybody else. Only administrators could enrol before — which also meant that requiring two-factor authentication for a role below administrator left those accounts unable to reach the admin area at all, with no way to satisfy the requirement.
* Your recovery codes are shown while you set two-factor authentication up. That is the only moment they can be shown, because they are stored hashed; previously they were generated and never displayed, so nobody had a way back into a locked-out account.
* The screen no longer says "not enabled" for an account that has two-factor authentication switched on. Because it did, the only button it offered was "Set Up 2FA" — and starting setup again replaces the secret and switches protection off, so a single click could silently put the site back to a password alone. Re-enrolling is now a separate, clearly worded "Set up a new device".
* "Regenerate Recovery Codes" works. It was calling an address that does not exist and reported a failure every time.
* Being asked for your code no longer counts as a failed login. An account with two-factor authentication was spending one of its five allowed attempts on every successful sign-in, so five ordinary logins in a row could lock the address out.
* A login lockout no longer restarts every time somebody tries during it. "Please try again in 15 minutes" now means fifteen minutes, not fifteen minutes after the last attempt.
* Addresses on the firewall's trusted list are no longer locked out of the login. Every other part of the plugin already honoured that list; this was the one place it was ignored, and it is the place it is reached for.
* The login rate limit counts requests to the login page rather than failed attempts, and a two-step sign-in needs three of them. The default is now 15 per minute instead of 5. Installations that never changed the value are moved to the new one; a number you chose yourself is left alone.
* The brute-force "count window" on the Firewall screen is saved now. The control has been there for a long time and every value entered was silently discarded.
* A wrong six-digit code is answered far more cheaply. Recovery codes are stored as password hashes and were checked against every submission, including ones that could not be a recovery code.
* Recovery codes are accepted whichever case you type them in.
* The rate-limit table no longer grows by one row per request. Existing counters are cleared once during the update.

= 3.6.1 =
Groundwork so a management tool can catch up on what it missed.

* Every logged security event now carries its own record id to whatever is listening. That sounds like plumbing, and it is — but it is what lets the Pro add-on hand the same event over a second time without your management tool filing it twice. A tool coming back from an outage and asking for what it missed would otherwise receive duplicates of everything that did get through, which is worse than the gap it was closing.
* Uninstalling now also removes the webhook subscriptions and their queue. They are written by the Pro add-on, but a site that removes Pro by deleting its folder never runs Pro's own uninstall — and the signing secrets would have stayed in the database for as long as the site did.

= 3.6.0 =
Blocking a search engine no longer removes your pages from search.

* Fixed: A blocked address was refused with "403 Forbidden" whoever it belonged to. Google removes a page from its index after any 4xx status except 429 — so a search engine that ended up on the block list was not merely turned away, it was taken out of search results, and it stayed out. The way there is ordinary rather than exotic: a broken image produces a "not found" on every page view, the crawler reads twenty pages, trips the 404 limit and lands on the list. **A crawler that has proved what it is now gets "429 Too Many Requests" instead, which carries no such consequence.** Everyone else still gets 403.
* Fixed: The same applies to a request the firewall refuses on one of its rules. A search engine matching a signature is either a mistake on our side or a compromised crawler, and losing your listing is the wrong price for either.
* Fixed: A block that has lasted longer than two days no longer applies to a proved search engine at all. Beyond that point even a 429 makes Google drop the URLs, so the refusal has stopped protecting the site and started shrinking it. The block continues to apply to everybody else, and the log says when this happens, because an address blocked on purpose deserves to say so out loud.
* Proof, not a claim: this only applies to a crawler verified against the address ranges its operator publishes, or its reverse DNS. Writing "Googlebot" into a header changes nothing.
* Fixed: Rate limiting, IP blocking and bot detection recognised an administrator only while they were on a wp-admin page. This plugin's screens talk to WordPress over its REST interface, which does not count as one — so your own clicks in the Firewall settings were being filtered like a stranger's traffic. It had been invisible until 3.5.6 made rate-limit throttling actually refuse a request, after which working quickly through your own settings could be answered "429 Too Many Requests" by the plugin you were configuring. **If you saw that since updating to 3.5.6, this is why, and it is fixed.** An administrator whose address was on the block list was worse off still: the settings page opened and then loaded nothing, including the control that lifts the block.
* Fixed: With debug logging switched on, every request the firewall refused added three lines of "Translation loading was triggered too early" to your log. Nothing was broken by it and no text was left untranslated — but a site under a run of attempts could put tens of thousands of those lines into debug.log, which is the file you want readable when something actually goes wrong. The plugin now settles the question once at startup instead of asking on every blocked request.

= 3.5.9 =
The Firewall screen now tells you when a proxy of yours is missing from the list.

* New: Since 3.5.7 a forwarding header from an address that is not on your Trusted Proxies list is ignored, and the connection decides instead. That is the right answer, and it is a silent one — a site behind a CDN nobody entered has all of its visitors counted as one address, and the first anyone usually hears of it is the rate limit treating them as a single client. **The Firewall screen now names the addresses whose headers are being ignored**, right beside the field you would enter them in, so it can be corrected before it is noticed by your visitors. If they are not proxies of yours, one button says so and the notice goes away.
* New: The same panel shows how the request you are reading it with was counted — the address it reached the site from, what its forwarding header claims, and which of the two was used. Checking a proxy setting is guesswork without seeing both.

= 3.5.8 =
File Guard now also sees files that never load WordPress.

* Fixed: File Guard reported an unexpected PHP file the moment it ran — but only if that file loaded WordPress. Most dropped backdoors do, because they want the database. One that brings its own code and needs nothing from WordPress ran without the guard ever getting a turn. **With Extended Protection switched on, the check now happens before WordPress starts:** a PHP file executing from your uploads directory, or one in wp-includes called directly, is caught either way and appears in the security log.
* Worth knowing where this stops. The check that runs before WordPress cannot ask the database, so it covers those two places and not the rest — a file dropped into a plugin folder is still only seen if it loads WordPress. And it needs Extended Protection, which needs a server that allows it. The settings screen and this readme say so rather than implying the cover is complete.

= 3.5.7 =
A visitor can no longer choose which address the plugin counts them as.

* Fixed: The plugin worked out who a visitor was by reading the forwarding headers a request carries — and it read them before the address the request actually came from. Anyone could set one of those headers and be counted as any address they liked. That made every protection keyed on address side-steppable at once: the rate limit, the login lockout, the block list and the country block. It needed no proxy, no misconfiguration and no special access, just one header. **The connection now decides, and a header is only believed when the request came from something entitled to speak for a visitor.**
* New: Trusted Proxies, on the Firewall screen. Cloudflare's published ranges are included from the start, so a site behind Cloudflare keeps seeing its real visitors without anyone doing anything. If your site sits behind a different CDN or a load balancer, add it there — otherwise all of its visitors are counted as one address and the rate limit will treat them as a single client. Sites with no proxy at all need no attention.
* Fixed: Where a forwarding header lists several addresses, the plugin took the first — the one written by whoever sent the request. It now reads the list from the other end, skipping the proxies it knows, which is the only order in which the entries mean anything.

= 3.5.6 =
The firewall stops reading an attack in whatever spelling the attacker chose.

* Fixed: Rules were matched only against the request exactly as it arrived, never against what it means once decoded. "UNION SELECT" sent in a header was blocked; the same thing in a web address, where a space is written %20, was not — and the same held for directory traversal written %2e%2e%2f. Anyone who knew this could put the payload through in a spelling the rules did not recognise. The firewall now checks both forms, decoding repeatedly so that double encoding does not help either. Rules that deliberately look for the encoding itself keep working, because the request as sent is still checked as well.
* Fixed: Data sent by POST was rebuilt into a form it never arrived in — spaces came back as "+", so rules requiring whitespace stopped matching. An attack sent that way could pass a rule that catches the identical string in a web address. The body is now read in the shape the site actually receives it.
* Measured before shipping: the complete rule set, including the signatures delivered by the update feed, against ordinary WordPress traffic containing encoded characters — search terms with umlauts, media file names with spaces, redirect targets, REST requests. No legitimate request became a match through this change.

= 3.5.5 =
The database scan stops reporting the plugin's own settings as malware.

* Fixed: The database scan read this plugin's own options as if they were content someone had stored on your site, and reported them at high severity. The one that hurt is the option holding the malware signatures themselves: rule names like "Backdoor - eval(base64_decode)" and "RCE: assert() as code exec" were read as code, so the scanner accused its own rule book — twelve findings on every installation that receives signature updates, with wording telling you that something had written to your content. **Nothing was wrong on those sites, and nothing needed removing.** The scan now leaves this plugin's own options alone, and the first scan after the update clears the false findings by itself. One exception: anything you marked "Ignore" is kept on purpose, as any decision of yours is, so those few rows have to be dismissed by hand.

= 3.5.4 =
An authentication code can no longer be used twice.

* Fixed: A six-digit code from an authenticator app stayed usable for its whole validity window, and the plugin never recorded which code had been spent. Anyone who saw a code once — over a shoulder, through a proxy, through a phishing page that forwards the login — could sign in with it a second time within about a minute and a half. Each code is now spent on first use, and a second attempt with it is refused like a wrong one. Your own next code works as it always did. A reused code is written to the log as its own event, "2fa_replay", so it can be told apart from someone simply mistyping.

= 3.5.3 =
The login and password reset forms stop listing your accounts, and throttle mode finally reaches the client.

* Fixed: With "Hide user enumeration" switched on, the login form still answered "Unknown username" for a name nobody had registered and "the password you entered ... is incorrect" for one that exists. The difference was a free list of your accounts for anyone who cared to ask. Both cases give the same answer now. Your own lockout notice and messages from other plugins are left exactly as they were.
* Fixed: The password reset form did the same, naming every address that has no account behind it. It now answers identically whether the account exists or not. A genuinely empty field still says what is missing.
* Fixed: "Immediately lock out invalid usernames" did nothing at all. The setting existed on the screen and nowhere else, so it could be ticked, saved and reloaded without ever taking effect. It works now. It stays inactive while "Hide user enumeration" is on, because locking out only the names that do not exist gives away exactly what that setting hides — the Firewall screen says so beside the box.
* Fixed: Throttle mode never reached the client. It set "429 Too Many Requests" and then let WordPress carry on rendering, which overwrote the status, so what went out was an ordinary page with a contradictory Retry-After header beside it. No browser, crawler or CDN slows down for that, and all that was left of "throttling" was two seconds of your server's time held per request. **Requests over the limit are now refused with a real 429 and a Retry-After, and the two-second delay is gone.** Nothing is written to the block list, so a client that backs off is served again as soon as its window rolls over.
* Fixed: A full rate-limit block sent "Retry-After: 60" while the block itself ran for an hour, inviting the client back fifty-nine times for nothing.
* Removed: The firewall rule for /wp-admin/install.php. It could never fire on a request to that file, because WordPress loads no plugins while it is installing, and only ever matched that text turning up in some unrelated address. Updating removes it from installations that carry it.

= 3.5.2 =
The Vulnerability Shield now actually closes the endpoints it said it was closing.

* Fixed: Raising a shield over a plugin with a published, unpatched vulnerability did not refuse anything. The screen reported "a shield is up", and every request to that plugin's unauthenticated endpoints was let through and written to the log instead. This had been the case since the shield was introduced in 3.4.0. It refuses now, as described. **If you have a shield raised, those endpoints will stop answering after this update — which is what raising it was supposed to do.** Shields are never raised automatically, only by you, and one lifts itself as soon as the plugin is updated. The Firewall screen lists what is shielded and lifts them individually or all at once.

= 3.5.1 =
The admin menu works again, and so does setting up two-factor authentication.

* Fixed: Every entry in the Security menu except the dashboard — Firewall, DDoS Protection, Two-Factor Auth, Hardening, File Integrity, Database Integrity, Logs, Data Protection, Settings — answered "Sorry, you are not allowed to access this page". The same applied to the links in alert mails, in the weekly report, on the plugin list and in the dashboard widget, and to the licence page of Forge12 Security Pro. All of them work again, and bookmarks kept from before the rename do too.
* Fixed: The two-factor setup screen showed an empty image instead of the QR code, and the manual entry key below it stayed blank as well — so there was no way to enrol an authenticator app at all. Both are shown again. Anyone who started a setup that did not complete can simply run it again; nothing needs to be cleaned up first.

= 3.5.0 =
The plugin is now called Forge12 Security, and everything it contains is free. Four things that used to need a licence now work for everyone, and the firewall no longer keeps a copy of a WordPress authentication key on disk.

* Changed: The plugin is called **Forge12 Security**. Nothing you have configured changes, and your settings, blocked addresses, scan history and quarantine carry over untouched.
* Added: Firewall exceptions can be generated from what learning mode recorded. Everything a rule matched at least N times becomes an exception scoped to that path, that part of the request and that one rule — nothing wider.
* Added: AI crawlers can be allowed, watched or refused, per crawler or per category, and the ones you refuse are named in robots.txt as well.
* Added: The login page can be moved to an address of your choosing. wp-login.php then answers 404, and so does wp-admin for anyone not logged in. The field existed before but nothing acted on it.
* Added: Signature updates no longer need a licence. Without one, this site is served the rule set published 30 days ago; with one, the current set. It is fetched, signature-checked and applied the same way either way. The setup wizard asks before anything is fetched.
* Fixed: Extended Protection stored a WordPress authentication salt — the key session cookies are signed with — in a file on disk. It now uses a key belonging to this plugin alone. Anyone who has Extended Protection switched on should regenerate the loader, which happens automatically on update.
* Fixed: Extended Protection wrote its loader into the plugin folder, which every update replaces. It now lives under uploads and is moved there automatically.
* Fixed: "Force SSL for admin" is no longer applied on a site that is not reachable over HTTPS. Enabling it there redirected the admin area to an address that does not answer, which locked administrators out.
* Fixed: Attack payloads recorded by learning mode and written to the log are cleaned before they are stored.
* Changed: The two admin notices attach their dismiss handler through WordPress instead of printing a script tag.
* Fixed: Uninstalling left one table behind. The record of which of your plugins are abandoned or carry a published vulnerability was created on every install and never removed.
* Fixed: Live traffic history was capped at 24 hours regardless of the configured retention. The configured value is now what is kept.
* Changed: German translations now come from translate.wordpress.org rather than shipping with the plugin.

= 3.4.9 =
The rest of the timestamp work from 3.4.7. One of the tables it reaches decides which files a scan is allowed to skip, which is why it is worth reading the first entry below.

* Fixed: A file changed shortly after a scan could be skipped by the next one. The scanner recorded when it last looked at a file in your local time and then compared that against the file's own modification time, which is kept in UTC — so on a site in Germany the last scan appeared to have happened two hours in the future, and anything altered inside that window looked older than the scan. A file altered right after a scan is exactly the case that check exists for. This is the most serious thing in this release.
* Fixed: A scan that died could not be reported as stalled for two hours, for the same reason: its start time was written on one clock and measured against another, so the heartbeat sat in the future and the scan looked alive.
* Fixed: The "Events this week" chart drew a zero for the current day every evening. The seven-day window was counted back from your local time while the rows were grouped by UTC date, so the last bar was a day the data had not reached yet.
* Fixed: AI crawler statistics were filed under your local date while every query that reads them — the 30-day panel, the totals and the retention cleanup — worked in UTC. Two hours of every night landed on a day those queries looked past, and the cleanup could cut a day early.
* Fixed: REST API requests only counted against the REST limit when the route was the first thing in the query string. `/index.php?p=1&rest_route=/wp/v2/posts` is the same route and went against the general limit instead.
* Changed: The scanner, two-factor, firewall-rule, learning-mode and crawler tables now store UTC like the rest, the screens convert to your time zone, and existing rows are corrected once on update.

= 3.4.8 =
A number on the dashboard that was never right.

* Fixed: "Events Today" always read 0. It asked the database for rows whose timestamp *equals* today's date, and a timestamp is a date **and a time** — only an event written at exactly midnight could ever match. Every installation, every day, however much the firewall had refused in the meantime. Nobody reported it, and that is the point: a security plugin saying "0 events today" reads like good news.
* Fixed: The same count in the WordPress dashboard widget asked for "today" in your local time while the table keeps UTC. On a site in Germany that is two hours of events counted on the wrong day, at both ends of the day. Both places now start the day at your midnight.
* Changed: The plugin's own screen is titled "F12 Security", the name in the plugin header. It said "Forge12 Security", which is the company.

= 3.4.7 =
Real visitors were being locked out with "Too many 404 requests (21 in 60 s)". Finding out why turned up twelve more problems, including one where a single number in a settings field takes the whole site down.

* Fixed: A `0` in any of the rate limiting time windows caused a fatal error on every single request to the site — the counter divides by that number, and PHP 8 stops on a division by zero. The field now refuses values outside a sensible range, and both places that divide check first.
* Fixed: A missing image counted the same as a probe for an admin backdoor. One broken image in a menu means one 404 on every page a visitor opens, so a visitor reading five pages looked like an attacker — which is how a site locks out the people who work on it. Requests for files (images, CSS, JavaScript, fonts) are now counted separately, with their own threshold, and a path allowlist lets you exempt what you know.
* Fixed: The exception meant to spare logged-in administrators could never take effect: it asked whether the request was for a wp-admin page, which it never is for the REST API or the front end. Administrators, and anyone on the trusted IP list, are now genuinely exempt.
* Fixed: The block page took itself apart. Its HTML was run through a filter that strips a `<!DOCTYPE>` and a `<style>` block, so blocked visitors got the stylesheet printed at them as running text. There is now one block page instead of two near-copies, used by the IP block, the firewall and both paths of the rate limiter — translated, dark mode included, with the right status code and `noindex`.
* Fixed: The Firewall overview could report six figures of "Brute Force" on a site that had not seen a single failed login: one event name meant both the decision to block an address and every later request from it. They are separate now, brute force counts locked-out logins, and 404 blocks appear in the overview at all.
* Fixed: Timestamps were written in local time and compared in UTC — two hours apart on a German site, and no symptom looked like a clock: "Active blocks: 0" while addresses were being blocked, a 24-hour panel covering 26 hours, the rate limit table emptied at every cleanup, every live hit reading as "just now". These tables now store UTC, the screens convert to your time zone, and existing rows are corrected once on update.
* Fixed: The "API request limit" field was connected to nothing — what you typed there was never read.
* Changed: Repeated blocks no longer write one log line per request. A blocked address that keeps knocking produces one line that counts up ("× 400", with the time it started) instead of tens of thousands of identical rows.
* Changed: The default 404 threshold is 60 requests in 60 seconds, up from 20 — an office behind a single public address shares that budget between everyone in it. Sites that already have a value keep it.
* Changed: The Country column and the country map only appear when there is something to put in them; Live traffic says recording is off, with a switch, instead of claiming there is no data.
* New: Block duration and the failed-login threshold have fields. Both were fixed numbers in the code before.
= 3.4.6 =
Getting ready for the WordPress plugin directory, and one thing that had to change on the way there.

* Changed: The vulnerability lookup no longer runs until you say so. Until this version, activating the plugin scheduled a daily job that sent the name and version of every installed plugin and theme to wpvulnerability.net — a third party, contacted because the plugin was installed and for no other reason. It is now off by default, the setup wizard asks for it in plain words, and switching it off removes the scheduled job rather than leaving it to run and return empty. Sites that already have it on keep it on.
* New: readme.txt lists every service this plugin's server talks to, what it sends, when, and under whose privacy policy. The plugin's own Data Protection screen has done this for a while; the list a reviewer and a customer read first had not. A test now compares the two and fails the build when the code contacts a host the readme does not name.
* Changed: Quarantined files and pre-restore backups now live under uploads/f12-security/ instead of directly in wp-content/, behind the same .htaccess as the rest of the plugin's data. Anything quarantined by an earlier version stays where it is and can still be restored or deleted.
* Changed: Live traffic recording is security events with a short retention, and says so, rather than shipping the full-traffic code and refusing to run it without a licence. What the paid add-on adds now arrives through filters instead of a flag.
* Fixed: `build.sh --local` only skipped the upload when it was the first argument. `build.sh --feed --local` uploaded.
* Internal: A second build flavour for the plugin directory (`build.sh --wporg`) without the self-hosted update checker and with the admin sources included, translator comments on every string with a placeholder, and ordered placeholders where a translation needs to move them.


= 3.4.5 =
Malware signatures and firewall rules can now arrive between releases. Until this version they were a file inside the plugin, and a new one reached your site only when the whole plugin updated.

* New: Rule updates. Every set is signed before it leaves us, with a key that never touches a server, and your site checks that signature before it looks at the contents — a set that does not verify is refused and the rules you already have stay in place. The same check refuses a set that is older than the one you have, or one whose publishing date has run out: a signature proves who wrote something, not that it is still current.
* New: A "Rule updates" panel on the Scanner screen showing how many rules are in use, when they were published, when they were last checked, and which channel you are on. A free licence receives the set published 30 days ago; a paid one receives the current set — which matters most in the days right after a new kind of attack appears. The panel says so plainly rather than hiding it.
* New: A switch to stop accepting rule updates. It takes effect on the next page load and falls back to the rules shipped with the plugin. That is deliberate: a bad pattern from us would slow a site down as effectively as a forged one, and you should not have to wait for a plugin update to get out of it. For the same reason every pattern is compiled and timed before it is allowed to run — anything that will not compile, or is slow enough to be a risk on a real file, is left out and logged.
* Fixed: Five of the twelve tabs on the Firewall screen were off the edge of the window and could not be clicked — Brute Force, Rate Limiting, IP Ranges, Learning Mode and GeoIP. The tab strip could not wrap. This affected any window narrower than about 1400 px, which is most laptops with the WordPress menu expanded. The cards on the same screen were cut off on the right for a related reason and now fit.
* Fixed: Outbound traffic monitoring and the vulnerability shield were labelled PRO. Both are free and always have been — no licence check exists anywhere in them. They are also the two things neither Wordfence Free nor NinjaFirewall offers, so the label was working against the very thing it sat on: people saw it and never tried them.

= 3.4.4 =
* New: The weekly report can carry somebody else's letterhead. An agency that forwards it to fifty customers no longer has to explain a message headed with the name of a plugin those customers have never heard of. The name, logo and closing line come from Forge12 Security Pro; without it, and on a site that has not set them, the report looks exactly as it did. Nothing else moves — the figures, the dashboard link and the unsubscribe line are untouched, because that part is not decoration.

= 3.4.3 =
Two things this plugin shipped were loading from somebody else's server. Both are gone. Neither was in the plugin's own privacy screen, which is the part that stings.

* Fixed: The two-factor setup asked api.qrserver.com to draw the QR code, and handed it the full otpauth:// URI to do so. That URI contains the shared secret of the second factor in the clear, so on every single enrolment the secret was sent to a third party and left in their access log — and whoever holds that secret can produce valid codes for the account indefinitely. The code is now drawn in your browser and the secret never leaves the page. **If you set up two-factor authentication before this release, treat the secret as disclosed: remove the authenticator entry and set it up again.**
* Fixed: Every screen of this plugin loaded its two typefaces from fonts.googleapis.com, which gave Google the address of whoever opened the page and set Google's cookies for them. Inter and JetBrains Mono now ship inside the plugin. This is the same objection that removed reCAPTCHA in 3.3.44 — it applied to the administration screens all along, and nobody looked.
* Fixed: The privacy screen lists which data this plugin sends where. Neither of the above was on it. That list is written by hand, and nothing checked it against what the code actually does; a test now reads the sources and the built stylesheet before every release and fails on anything that fetches from another host.
* Note: Nothing to configure, and no site is worse off offline than before — the fonts and the QR code are files in the plugin now, not requests.

= 3.4.2 =
Four corrections to the modules added in 3.4.0. All four were found by looking at a site running 3.4.1, and none of them by the test suite, which was green the whole time.

* Fixed: The list of outgoing connections named this plugin as the cause of every request on it, including requests it never makes. Every outgoing call passes through this plugin on its way out, so the nearest name on the stack was always its own — on the site where this came to light, all nine destinations were attributed wrongly, among them a cookie plugin's licence check and another firewall's update server. The list now names the plugin, theme, or WordPress itself that actually asked. A column that says who is responsible is worse than no column at all when it is wrong.
* Fixed: Five counts in the new module screens appeared in English on translated sites — "81 are recorded in total" in the middle of a German sentence. The translations were there; the catalogue the browser reads was being built without the plural forms. Building a release now fails if any of them is missing, rather than shipping the gap.
* Fixed: Times in the outgoing connections list carried a doubled preposition in German, reading "vor vor 7 Minuten".
* Fixed: The shield screen showed each vulnerability's severity as the untranslated word from the advisory, sitting next to the scanner's own translated rating of the same plugins, and drew medium-severity items in the grey otherwise used for switched-off things.

= 3.4.1 =
* New: A vulnerability finding now says how far along an exploit for it is — whether working exploit code has been published, and whether the flaw is listed as under active exploitation. The middle one is the useful one: once exploit code is public, using the flaw needs no skill, and that arrives far earlier than any official listing. Findings are ordered by it, ahead of the severity score, because what somebody can already do outweighs what it was rated. "No exploit known" and "nothing is known" are shown as different answers — the data covers about half of all advisories, and treating silence as reassurance would be telling you there is no exploit when in truth nobody has said. None of this triggers anything: the official listing arrives once mass exploitation is established, which for the two WordPress plugins ever listed was 420 and 569 days after disclosure.

= 3.4.0 =
Four new modules. Three of them only ever report — they cannot block anything — and the fourth arrives switched off. Every one of them can be switched off on its own, and a constant in wp-config.php stops all four at once even when the database is unreadable.

* New: The endpoints on your site that anyone can call without logging in are now written down, and you are told when a new one appears. Broken access control is the most exploited class of WordPress vulnerability and a firewall cannot see it — the attack looks like an ordinary request. What can be seen is the door. The number on its own is not a finding: an ordinary site has dozens of open endpoints and nearly all of them are meant to be open, a shop needs a public cart. So this reports the change rather than the count. Measured across 24 consecutive releases of four large plugins, that is about one finding per plugin per major version — WooCommerce, Yoast and Contact Form 7 did not alter their unauthenticated surface at all over six releases each. It records what each request happens to reveal rather than scanning: a scheduled scan runs in cron context, where a good number of AJAX endpoints are not registered at all, and forcing the REST API to build costs twenty megabytes.
* New: Every outgoing request this site makes is recorded, with the plugin that asked for it. Stolen data has to leave somehow, and this is the only place it can be seen — no other security plugin looks at it. The list is short enough to be useful: five popular plugins together account for about one external host. WordPress.org, this plugin's own services and the site calling itself are never listed and never refused; a security tool that can stop its own updates has a defect, not a feature. Free shows the list and says which entries an enforcing version would have refused. Refusing them is part of Pro — and refusing is the weaker answer anyway, because it treats the symptom while the planted file stays and finds another way out. The route to the cause, malware scan and quarantine, is here in the free version.
* New: After a plugin updates, its files are compared against the checksums WordPress.org publishes for that exact version. More than 25 plugins were taken over through the supply chain in 72 hours in April 2026, so an update is an event worth checking rather than a promise. A file that differs is reported by name. A file that is merely present without being published is not a finding — plugins write into their own folders all the time, and reporting that would produce an alarm on most working sites. The exception is an extra PHP file that actually runs something. Plugins with no published checksums, which is every paid one, are marked "could not be checked" — deliberately neither clean nor changed.
* New: A shield can be held over a plugin with a published vulnerability until its update arrives, closing the endpoints anyone can reach. Half of the high-impact vulnerabilities are exploited within a day and just under half are still unpatched when they become public, so the hours in between are the point. This is the only part of the plugin that can stop a request, so it ships switched off, is raised for one plugin at a time rather than across the board, lifts itself the moment the plugin is updated, and never refuses an administrator. Enforcement is part of Pro; the free version records that somebody tried the vulnerable endpoint, which is worth knowing on its own.
* New: A safe mode. `define( 'F12_SECURITY_SAFE_MODE', true );` in wp-config.php stops all four modules, and works when the settings cannot be read — which is the situation it exists for.

= 3.3.52 =
* Fixed: Seven strings in the admin showed in English on a German site. The translation template had last been rebuilt before the interface work in 3.3.49 and 3.3.50, and a string only reaches a catalogue through that template — so the assistant allowlist heading and its explanation, and the log line for a refused REST request, had already shipped untranslated in those two releases. The four this release added were in the same position. All seven are translated now, in both the formal and the informal form of address.

= 3.3.51 =
* Fixed: The database scan judged your settings by the signatures written for PHP files. Every value in wp_options was run past all 282 of them — patterns describing source code, applied to serialised plugin configuration. Measured across 42 databases and 121,468 option rows, where every hit is by definition a false alarm: 131 before, 0 after. Among them Ultimate Member's user cache, Slider Revolution's update record, and NinjaFirewall's own stored rule list — a catalogue of attack patterns matches a scanner hunting for attack patterns. Posts and comments were never affected; they were passing the right set all along.
* Fixed: A script tag in a setting is no longer treated as an injection by itself. A consent manager, a snippet manager, a tracking-code field and a theme's option panel all store script tags because storing script tags is what they are for — Borlabs Cookie's record of the scripts it blocks before consent was reported on every scan for exactly this. The rules now ask what is inside the tag rather than whether there is one, and still catch an eval/atob payload, a fromCharCode writer, an unescape packer, a hex-escaped redirect and a plain document.location assignment. A script tag in an article is still reported: WordPress strips those from post content for anyone without unfiltered_html, so there it means something.
* Fixed: Five malware signatures matched a dangerous name where they meant a dangerous call. "exec" sits inside max_execution_time, so raising a time limit — the first thing every backup, import and migration tool does — was read as a shell; "eval" sits inside retrieval, evaluate and medieval. Two str_replace assignments in a row were read as an obfuscated backdoor, though that is how slugs are built and templates are filled, and storing the administrator's address was reported whoever did it, including the settings form made for the purpose. Measured over 218,209 files from eight ordinary installations: 70 false alarms before, 0 after, with a real payload behind each rule to show it still catches one.
* Fixed: Two signatures had fallen apart at an unbracketed alternation, which splits the whole pattern rather than the group beside it. One of them meant "a socket opened to an SMTP server" and in practice read as the bare text ":25 followed by a quote", so any stored time at twenty-five minutes past the hour was reported as mail-relay abuse; the other meant "writing a crontab" and read as the bare text "cron.d", which a comment mentioning /etc/cron.d/ satisfies.
* New: Findings in the database are in the export at last. The report had three sections and none of them covered what the database scan finds, so a site reporting a finding in an option had nothing to send but a screenshot of the screen it was looking at. The values themselves come along too, but behind their own confirmation rather than the one for files — an option or a comment can carry personal data and credentials in a way a file path does not.

= 3.3.50 =
* New: Assistants and their crawlers can be allowlisted — Anthropic and Claude, OpenAI and ChatGPT, Perplexity, DuckDuckGo. Their published address ranges have shipped with the plugin since the bot verification work; until now there was no checkbox to point at them, so a site whose own agent was refused by the REST restriction could only switch the whole restriction off. They sit in their own group on the firewall screen, apart from the infrastructure entries, because allowing one lifts rate limiting for it as well as the WAF — and a crawler is the visitor a rate limit is usually there for.
* Fixed: The allowlist screen and the list of services behind it are now checked against each other. They are two hand-written lists in two languages, and drift between them is silent in both directions: a checkbox whose key PHP does not know is dropped on save and does nothing, and a service with no checkbox cannot be reached at all. This plugin shipped the first of those once already.

= 3.3.49 =
* Fixed: The malware scanner reported eight clean files as backdoors, and every one of them had the same cause: a signature was measuring how near two things sat to each other instead of whether one fed the other. LiteSpeed Cache registering a footer hook, WP-CLI's comment describing what $_REQUEST contains, WPML calling a locally built closure with $_POST, WP Download Manager redirecting a visitor back where they came from, and a shortcode named "system_image" were all reported as backdoors. Measured against 216,105 files across eight ordinary installations, where every hit is by definition a false alarm: 181 before, 124 after.
* Fixed: Comments are no longer read as code. A signature that describes a call cannot be satisfied by a sentence, so a match that survives only inside a comment is prose. A signature that describes a name is exempt, because a name in a comment is where a webshell writes its own — and that distinction is drawn from the pattern itself rather than a list somebody has to maintain.
* Fixed: Translation files are no longer judged as code. WordPress 6.5 compiles each translation into a PHP file holding nothing but an array of human sentences, and the words a webshell prints on its control panel are also words a cookie banner prints on its own. Borlabs Cookie was reported as malware in five languages for exactly that. A file that consists only of a literal value cannot behave, so nothing describing behaviour is asked of it; the moment a variable, a call or an echo appears, it is code again and judged as code.
* Fixed: Input arriving behind both a nonce and a capability check is no longer read as attacker-controlled. Both halves are required, because a nonce without a capability check protects any logged-in subscriber and a capability check without a nonce is CSRF-able.
* Fixed: A plugin a host installs into mu-plugins can now be cleared against the hashes WordPress.org publishes for it. That lookup only knew wp-content/plugins and wp-content/themes, so anything below mu-plugins had no published original and could never be verified, however ordinary it was.
* Fixed: The REST API restriction blocked silently. It answered "401 rest_forbidden" and wrote nothing to either the security log or the audit log, which left the site owner comparing screenshots to work out whether the refusal came from this plugin, the host or WordPress. It now says so in both logs, names the setting responsible, and throttles itself so a public site's anonymous traffic cannot bury the log it is meant to fill.
* Fixed: That restriction now honours the trusted addresses and the allowlisted services, the same two lists the firewall consults, and a new filter lets a single route through for an integration that is not an address range. Until now the only way to permit anything was switching the whole setting off.
* Changed: A refused REST request answers 403 rather than 401. This plugin answers a refusal with 403 or 429 and nothing else, and a 401 without a WWW-Authenticate header invites the client into an authentication exchange that is not on offer.
* Fixed: A latent fatal in that same filter. Its signature accepted only WP_Error or null, while WordPress documents the value as WP_Error, null or true — true meaning an earlier callback already authenticated the request, which is what WordPress's own cookie and application-password checks return. It survived on the accident that this callback runs before those two; any plugin running earlier and succeeding would have taken the entire REST API down with it.

= 3.3.48 =
* Fixed: The whole brute-force screen kept nothing. Seven of its settings were missing from the defaults, and settings the defaults do not know are dropped on the way in — so the lockout threshold, the honeypot URLs, the global IP allowlist and three switches could be filled in, reported "settings saved", and were gone on the next reload. The features themselves ran on the fallback values their own code carries, which is why this went unnoticed for so long: they worked, they simply could not be configured.
* Fixed: The honeypot field showed a list of URLs as if it were set. It was placeholder text that had never been saved, so a site owner believed five decoy paths were being watched while none were. It now looks like what it is: empty, with the suggestion behind it.
* New: "Allowlisted services" works. It had twelve checkboxes, said the services would bypass rate limiting and the WAF, and no PHP anywhere read the setting — someone whose WordPress.com connection was being blocked could tick "Jetpack" and be no better off. A service is now recognised the way the AI crawlers are: by the address ranges it publishes itself. WordPress.com and Jetpack, Cloudflare, UptimeRobot, Google and Bing ship with their lists.
* Changed: The seven services that publish no such list are gone from that screen. A checkbox that cannot be verified could only ever have been decoration; their addresses belong on the trusted list, where they are entered deliberately.
* Fixed: A free site made two requests on every firewall page load and one on every dashboard load that could only answer 404 — the endpoints behind them are in Pro. The screens were already marked as Pro; the requests went out regardless and filled the browser console with errors that hide real ones.

= 3.3.47 =
* Fixed: With the Hardening module switched off, the hardening screen still showed its measures as active and reported a percentage — while not one of them was running. Every hardening extension stops at its own first line when the module is off, so those switches were settings that are remembered and never applied. The screen could read "11 of 20 measures active · 55% secured" while the user list was readable from the internet with names and login names, because "Prevent user enumeration" was on and inert. The screen now says so, in place of the percentage, and offers to switch the module on.
* Fixed: The security score counted those same measures. A site with the module off was told it was hardened by twenty switches that did nothing.
* Fixed: The Site Health checks reported "XML-RPC is disabled", "Security headers are enabled" and "File editing is disabled" from the setting alone. Remote management tools read those checks, so a monitoring dashboard would show the measure as fine while its own check from outside disagreed — and blame a CDN or firewall in between for the difference.

= 3.3.46 =
* Fixed: Genuine AI crawlers were blocked as impostors. Bot detection took the name in the User-Agent and confirmed it by reverse DNS, which works for Google, Bing, Yandex, Baidu, DuckDuckGo and Apple and for nobody else. Anthropic crawls from Google Cloud addresses whose reverse DNS reads googleusercontent.com, and OpenAI from Azure addresses with no reverse DNS at all; both publish a list of their address ranges instead. So the check could never succeed for either, and with the block on by default — as the Standard and Maximum setup profiles leave it — every real request from ClaudeBot, Claude-User or GPTBot was answered with 403 and had its address blocked for a day. Anyone who wanted an AI assistant to read their own site found it locked out with nothing in the interface to undo it.
* Changed: A bot claim now has three possible answers rather than two: proven, disproven, and not checkable. Only a disproven claim is treated as a fake bot. "We could not check" is recorded and passed through, because a plugin that cannot verify something has learned nothing about it — and that was the whole of the fault above.
* New: The address ranges Google, Microsoft, Apple, DuckDuckGo, Anthropic, OpenAI and Perplexity publish ship with the plugin, so verification works on a site that is not allowed to make a single outbound request. A visitor claiming to be Googlebot is now confirmed against Google's own list without a DNS lookup at all, which is both stricter and faster than what it replaces.
* Changed: An address list that has aged past 45 days stops being able to disprove a claim, while it still confirms one. Vendors add ranges between plugin releases, and an old list turning into "this crawler is a fake" is how a security plugin locks out the traffic it was bought to let through.
* New: A "Bots" tab on the Firewall page: which vendors can be checked and how, how old each list is, a button to refresh them, and a field to paste a User-Agent and an address from your log and see the same verdict the firewall would reach. The two bot detection switches were previously reachable only through the setup wizard — there was no way to turn the blocking off after it had locked something out.
* Fixed: WAF learning mode never ended. The duration was collected on screen, dropped by the endpoint that started it, and enforced only by the Pro extension — so a free site that switched it on had a firewall that recorded every attack and blocked none of them, indefinitely, while the dashboard still showed the firewall as on. The window is now honoured, checked on the request rather than by a scheduled event, and ends itself with a log entry.
* Fixed: The same panel showed "Days remaining: undefined" and "Recorded patterns: undefined" without Pro, because it read fields the free endpoint did not send. It also offered a "Generate Whitelist Rules" button whose endpoint only exists in Pro; that button now appears where it works, and the free tier is told plainly what it would do.
* New: The learning mode screen explains itself — what it records, where a match is stored from, and the one thing that matters: while it runs the WAF blocks nothing, so it belongs on a site you already trust rather than on one you suspect. Its state is a panel rather than three loose lines: what the firewall is doing right now, how much of the window is left, what has been recorded and when it last ran.
* Fixed: Every button rendered as a link drew dark body text on its filled background, the upgrade buttons included — an internal reset outranked the button colours. The upgrade button now carries white on a deeper green (5.7:1 rather than 4.1:1), and is large enough to be found.
* Fixed: The period and filter switches under "Top IPs Blocked" and "Login Attempts" were fully round pills inside an eight-pixel box, so the frame cut across each pill. One track, matching curve, no double outline.
* New: The list of blocked addresses works without Pro. The free version blocks addresses from three places — bot detection, rate limiting and the WAF — and the routes behind the list, the manual block and the release only existed in Pro, so the screen answered "no blocked IP addresses" however many the site had. Anyone locked out by their own firewall could not see the entry, let alone lift it. The button also refuses to block the address you are currently connected from.
* Fixed: The two-factor screen listed every user with a blank name and a blank e-mail address. The table asked for fields the endpoint did not send — on a page whose entire job is to say which account is protected.
* Changed: "IP Ranges" and "GeoIP" are marked as Pro on the firewall page and say what they do, instead of presenting a full set of controls whose endpoints answer 404 without a licence: the range list stayed empty and the country settings would not save.
* Fixed: The dashboard chart labelled its days Sun to Sat regardless of language.
* Fixed: The live traffic tabs and filters — All, Humans, Bots, Logins, 404s, Blocked — were never translatable at all.
* Fixed: Every dropdown in the plugin had lost its arrow, because the field styling overwrote the background WordPress draws it with. They looked like buttons with a word in them.
* Fixed: A disabled button faded the brand colour instead of looking switched off, which on the licence screen left the "activate" button pale green under white text.
* Fixed: The severity filter on the log screen mixed three shapes in one row — round pills, a six-pixel button and a dropdown twice their height.
* Changed: Log entries are written down as a template and its values rather than as a finished sentence, and the sentence is assembled when you read it. An event is recorded wherever it happens — in cron, in a front-end request — and used to be frozen in whatever language was loaded at that moment, so a site ended up with a history in two languages and no way back. Firewall, bot, rate limit and login events are converted; the scanner and audit entries still show the sentence as it was stored.
* Changed: The three paid alert channels share one prompt on the settings screen instead of three stacked cards with three identical buttons.
* Fixed: The GeoIP module switch on the dashboard could be turned on without a licence, where it turned on nothing: the lookup, the database and the country settings are all Pro. It is now shown as such.
* Changed: Text that was too pale to read reliably has been darkened throughout. Secondary text, the status and severity labels, the badges and the "PRO" chip all sat between 1.9 and 3.6 against their background where 4.5 is the threshold for text this size — the chip was the least readable label in the plugin, on the one word meant to catch the eye. The colours themselves are unchanged where they are fills: bars, dots, tints and charts keep the palette, and only the lettering moved. Measured across every screen afterwards, nothing is left below the line.
* New: An optional daily refresh of the published lists, off by default. Off, the site makes no outbound request for this and uses what shipped; on, each vendor list is fetched once a day so it never ages out. A refresh that fails changes nothing.

= 3.3.45 =
* New: The plugin is now available in German — the whole interface, not parts of it. Every screen, every setting, the alert emails, and the finding guide you read when something has actually happened to your site. Sites running WordPress in German get it automatically; nothing needs to be switched on.
* Fixed: Translation never worked at all before this release. The plugin has shipped a German file since 2024 that no site could read: nothing told WordPress to load it, and the administration interface — which is where nearly all the text is — had no mechanism to be translated even in principle. On top of that, two thirds of the interface used a different text domain than the plugin declares, so those strings could not have been matched to a translation even once the loading was fixed.
* Changed: The translation catalogues are compiled during the release build and the build now refuses to produce a package without them. An untranslated interface looks like a design decision rather than a fault, so it is the kind of mistake that ships unnoticed.

= 3.3.44 =
* New: A "Data Protection" page that answers the question customers keep asking by email: which personal data this plugin stores, on what basis, and when it is deleted again. It is assembled from your own configuration rather than written down in advance — a store belonging to a module you have switched off is listed as such, and every retention period shown is the one set on this site rather than the shipped default. It also produces a ready-made paragraph for your privacy policy in German or English, listing only what is actually running here.
* New: The same page separates what your server sends out by whether it carries personal data. A version lookup, a licence check or a database download tell nobody anything about any person, so they do not belong in a privacy policy at all; declaring them would announce a disclosure that is not happening. They are still shown, because it is reasonable to want to know what your server talks to.
* Removed: Google reCAPTCHA. It loaded Google's script into the browser of everybody who opened your login page, which handed Google their IP address and set Google's cookies — a transfer to the United States, on behalf of a form only your own staff ever use, that you then had to justify in your privacy policy.
* New: SilentShield protects the login, registration and password reset forms in its place. It tells a person filling in a form from a script doing it, and it is loaded on those screens only, never on the pages your visitors read. It still involves a script from the provider, and that is stated plainly on the Data Protection page — what changed is that the provider is Forge12 Interactive GmbH, a German company you can hold a processing agreement with. It talks to the European endpoint, which is served directly from Germany with no content delivery network in front of it, so nobody terminates the connection in between. Enter your API key on the Hardening page.
* Changed: If SilentShield cannot be reached, the login is allowed through rather than refused. Being locked out of your own site is the worse of the two failures. Sites that would rather fail the other way can switch it with a filter.
* New: A "Check key" button beside the field, and a link to where you get one. The check matters because of the way this fails: a wrong or expired key produces no error anywhere, so logins would carry on unprotected while you had every reason to believe they were not. The button asks the service and reports what came back, and every unhappy answer says plainly that logins are going through unchecked until it is sorted out.
* Fixed: Upgrading removes the four settings the reCAPTCHA integration left behind, one of which is your Google secret key. Left in place it would have been a credential for a service this plugin no longer speaks to, kept indefinitely, watched by nobody.

= 3.3.43 =
* New: The AI crawler page has room at the foot of it for crawlers you name yourself, which Forge12 Security Pro fills. An agent the shipped list does not recognise is not treated as a crawler at all — nothing on that page applies to it and it simply passes — so naming it is what puts it in the lists above, where it takes an allow, watch or block decision like any other. Nothing renders there without Pro.

= 3.3.42 =
* Fixed: A running scan showed the previous scan's findings. The list stood there unchanged from the moment the scan started until the moment it ended, and a finding from last week standing unmarked next to a progress bar reads as something the scan has just turned up - which is the whole reason somebody watches that screen. The list is now emptied when a scan starts and refilled as the scan reaches things. What it shows is anchored on the server's own start time rather than the browser's clock, because the two disagree by however far the site's timezone and the visitor's have drifted apart.
* Fixed: Findings were ordered by when they were first written instead of by when they were last established. Neither table writes a new row when a scan reaches the same conclusion twice - the file table matches by path, the database table by what the finding says about which record - so a finding the running scan had just confirmed kept the row number it was given months ago and sorted behind everything recorded since. On a site with any history that put the live results at the bottom.
* Fixed: Recurring database findings were hidden from the scan that had just confirmed them. A repeat updates when the finding was last seen and counts it; the date the finding was first recorded is deliberately left alone, because it is the answer to a different question. The results list read that first date, so anything found again - which is to say anything that has not been dealt with yet - was filtered out of its own scan. These are the findings most worth showing: one that stops reproducing is deleted as stale, so what survives is what is still there.
* Fixed: On the combined results tab the filter was applied to the page being displayed rather than to the results. That page is chosen worst-first, so older critical findings filled it and the live results never reached it - the screen could report nothing while the scan was reporting plenty.
* Changed: The progress display now fetches results as it goes. It only ever moved the progress bar before and asked for the findings once, at the end, so the screen said "findings appear here as the scan reaches them" from the first file to the last and then produced all of them at once. It refetches when the issue counter moves, and periodically regardless - that counter follows the file phases, so findings from the database, vulnerability and user audit phases would otherwise never appear until the scan was over.
* Changed: Persistence and File Writing moved below the results. They explain a scan that has finished rather than one that is running, and above the list they pushed the results off the screen every time.

= 3.3.41 =
* Fixed: Two kinds of data file were being read as code. A settings file that begins `<?php __halt_compiler();` is data by the strongest guarantee PHP offers - it does not merely skip what follows, it never compiles it - and All In One Security stores its firewall rules exactly that way, so scanning the contents reported the firewall's own rules back as findings. The other is a file written to be readable by two parsers at once: Matomo starts its configuration with a semicolon, the INI comment marker, and then the guard, and those two characters in front were enough to make the whole file count as code. Both are now recognised. The allowance for text before the opening tag is deliberately small and refuses anything containing markup, because that text is the one part of such a file that is actually served - everything past the guard never reaches the visitor.
* Fixed: A plain white background was reported as hidden spam text. The rule looked for `color:` and found it inside `background-color:`, so an ordinary white box counted as white-on-white. It also only ever checked one colour, although its name promises a match between two - white text on a dark button read the same as white text on white. It now requires both colours and compares them. Measured over 216,077 PHP files on eight uninfected installations: 156 files reported before, none now, and hidden text is still caught in either order of the two declarations.
* Changed: Transparent text is still reported, but not when the element it sits in has been collapsed away entirely. `color: transparent` together with `display:none`, a zero height and `visibility:hidden` is how every email template writes its preheader - the preview line a mail client shows in the inbox listing, written so that it never appears in the message itself. That one shape was the whole of what the corrected rule still reported.
* Changed: Three rules that counted an idiom have been replaced by ones that read it. A chain of `chr()` calls was reported at three links by one rule and five by another, and what they were finding is a character table: Doctrine's Inflector builds its ISO-8859-1 map out of `chr(192)` onwards, and so does the sentiment analyser inside an SEO plugin. Those decode to accented letters. A payload decodes to the word `eval`. The check that already decoded hex escapes now decodes chr() chains as well and judges both the same way. 201 files reported before, none now.
* Changed: Unpacking something is no longer treated as running it. `gzuncompress(base64_decode(...))` and `gzinflate(base64_decode(...))` are the malware idiom and also exactly what RevSlider does to a stored slider and what a backup plugin does to a stored value - the result goes to `json_decode` and `unserialize` respectively, and neither file contains an execution function at all. Both rules are gone; the ones that ask what happens to the unpacked value keep their work, and one of them now also recognises the value being passed to a function named in a variable, which is the shape that lets a payload run without the word `eval` appearing anywhere.
* New: No rule was removed on the assumption that another one covered it. Three payload shapes turned out to rest on them alone, and one of those - a superglobal spelled out in `chr()` calls and reached through a variable variable - was caught by nothing else at all. All three are now reported by the rules that read rather than count.

= 3.3.40 =
* Changed: The rule for hidden strings now reads them instead of counting them. "Hex escape sequence" reported any file with ten `\x` escapes in a row, and that number was a guess. Measured over 216,074 PHP files on eight uninfected installations it reported 255 of them, every single one wrong - and raising the count makes it worse rather than better: at fifty escapes the false reports fall to eighteen, but the rule then recognises none of the realistic payloads at all. The reason is plain in what survives at that length: a PDF library, a unicode normaliser, a cryptography polyfill. Those are data tables, and a data table is long precisely because it is data. Length was selecting for the false report and against the payload.
* New: What replaces it decodes the escapes and looks at what they say. A payload is written in hex so that nobody reading the file sees the word `eval` or `$_POST`; decode it and the words are there. A cryptographic test vector decodes to bytes that spell nothing. Both halves are needed and the measurement says why: asking only that the escapes decode to readable text still reported 58 files, because TCPDF writes its own homepage that way, its parser writes the PDF delimiter set, and phpseclib's DES writes eight at-signs. Legitimate code really does hide readable text. What it does not do is hide code. Adding that second condition took the same corpus to **zero**, with the rule proven armed against a known payload in the same run.
* New: One shape that nothing caught before is now reported: a superglobal written in hex escapes and reached through a variable variable, which is how a backdoor takes its orders without ever spelling out where it reads them from. Two more that only the counting rule carried - a payload handed straight to eval, and a dropper writing PHP into a file - are now reported for what they are rather than for how many escapes they contain.
* Changed: A URL written in hex escapes is no longer reported on that alone, and this is a deliberate trade rather than an oversight. The counter-example is in the corpus: TCPDF hex-escapes its own homepage, and nothing in such a string separates that from an attacker's. What loads code from a URL is itself code, and code is judged normally. The case is recorded as a test with its reasoning, so that a later edit reinstating it has to argue with the measurement.

= 3.3.39 =
* Fixed: A file that stops dead at its first line was still judged on what it contains. `<?php exit;` followed by text is how a great many plugins keep data in a .php file - the extension stops the web server from serving it, the guard stops PHP from running it - and NinjaFirewall writes its attack log exactly that way. Such a log naturally contains the attacks it stopped, so reading its contents reported the firewall's own record as malware. On the installation this was found on, twenty-four of those logs accounted for roughly two thirds of every signature match on the site. A file whose first and only statement is an unconditional exit is now treated as data. Both halves of that sentence carry the weight: `if (!defined('ABSPATH')) exit;` is the other convention and the exact opposite case - included from WordPress that condition is false, nothing exits, and everything below it runs - so only the first statement is read, and only a literal may sit inside the guard, because `die(cleanup())` runs cleanup() on the way out. Nothing is quietly skipped: the number of files passed over is counted and named in the completion log.
* Changed: A payload parked behind such a guard is still reported, on where it lies rather than on what it says. The rule that reports executable files among the uploads is deliberately not softened to match, so a guarded file in a data directory is a finding exactly as before.
* Fixed: Four entries in the URL blocklist described shapes that are ordinary in source code. The rule for a random-named PHP file under wp-content also covered wp-includes, so it fired on any file mentioning `/wp-includes/functions.php`. The rule for a host wearing a brand name matched `https://developers.google.com/account`, which is a Google API client library doing precisely what it exists to do. The rule for a remotely loaded `jquery.js` matched any plugin that names one, and has been removed - fake-jQuery malware works by replacing the file WordPress ships, which the core manifest already covers. The entry for throwaway top-level domains now belongs to the database scanner alone: in a stored post that shape is injected spam, in source code it is a bot list or a test fixture. Measured over 216,074 PHP files on eight uninfected installations, those four reported 94 files between them. They now report none, and the host from the incident the list grew out of is still caught.
* Fixed: Two malware rules matched their own description rather than the thing described. The rule for `preg_replace` with the /e modifier let its wildcard run past the closing quote, so it reported any file where a `preg_replace` was followed within two thousand characters by the letter e after a slash - twenty-seven files across that corpus, not one of them real. The rule for renaming a file to user input had no word boundary, so `$this->rename($_POST[...])` - a method on an object, not the PHP function - read as a webshell. Both are anchored now, and the four files the first rule still reports are genuine: `preg_replace('/([A-Z])/e', ...)` in a bundled library, dead since PHP 7.0 removed the modifier.
* Changed: A blocklist finding names the pattern it matched instead of its position in the list. "Blocklist - pattern (5)" told you nothing, and stopped being true the moment somebody removed pattern (4).

= 3.3.38 =
* Fixed: The scheduler check could tell you nothing had taken over while naming, in the same sentence, a heartbeat that had run seven hours earlier. Both cannot be true: with WP-Cron switched off in wp-config.php a page load does not start the scheduler, so any heartbeat at all is proof that something outside is calling it. Reported from a site whose scan had just finished, against a notice saying nothing this plugin schedules happens. What is wrong in that situation is the rate rather than the absence, and it now says so - and how slow it is - instead of raising an alarm the reader can see is false. A caller that really has stopped is still reported as critical, in words that say it stopped rather than that it never existed.
* New: "Export Findings" can put the reported files into the export. It opens a dialogue with a checkbox; ticked, the download is a ZIP carrying the files the report names, and a MANIFEST.txt listing every one of them with its size and hash - along with everything that was left out and why. A path and a signature name are rarely enough to tell a backdoor from a library that happens to contain the same bytes, which is what nearly every false positive turns out to be. Files are read only from inside the installation, large ones are cut short and said to be, and nothing is dropped without being counted.

= 3.3.37 =
* Fixed: One scan sent one mail per finding. Alerts have gone out as a single grouped message since 3.3.15, but a critical finding was allowed to skip the collecting window on purpose - a backdoor on disk is not news that waits five minutes. Nearly everything a scan finds is graded critical, so that exception applied to a scan meant one mail per finding, which is the pile the grouping was built to end. While a scan is walking the installation nothing goes out now, whatever the severity: the whole walk is one message, sent when the scan reports it has finished. A scan that stops early still sends what it found on the way, and a critical finding reported while no scan is running still leaves at once.
* New: Three more shapes of the same backdoor. A file that decodes or decrypts something and then runs it was only ever recognised when both halves were written as one expression - `eval(base64_decode(...))` - and putting the decoded value into a variable first, `$u = gzdecode($c); eval($u);`, walked past every one of the twenty-odd rules for it. Three of five droppers that got past the old rules had exactly that shape, two of them admin-login backdoors. The variable that is run has to be the variable the decoded value went into, and the two halves have to sit close together, so what is reported is a data flow rather than a decoder and an eval that happen to share a file. Two unpackers that had no rule at all, gzdecode and openssl_decrypt, are covered as well. Measured against 216,074 PHP files on eight uninfected installations: no false positives.
* Fixed: The check for code in directories that should only hold data reported the directory-index stub. WordPress and a good part of the plugin ecosystem write `<?php // Silence is golden.` into every directory they create, and a file holding one comment and nothing else cannot do anything - so on an ordinary installation the loudest thing this check found was a file written to be empty. It is now decided on what the file contains rather than on what it is called: an index.php with code in it is still reported, and so is a stub that turns out to be a page.

= 3.3.36 =
* Fixed: The firewall could stop this site from running its own scheduled work. WordPress fires its schedule by having the site fetch its own wp-cron.php over HTTP, and that request arrives at the firewall like any other visitor: no login, no cookies, no referrer, the same address every few minutes. Nothing exempted it - the rate limit skips only logged-in administrators - so a site could rate-limit or block its own cron and quietly stop checking for updates, stop resuming interrupted scans and stop sending reports. From the outside that looks exactly like the connection to WordPress.org being blocked, which is how it was found.
* Changed: That one call is now exempt, but only when it can prove it is genuine: either the connection comes from the server itself, read from the actual connection rather than from a header a caller can write, or it carries the cron token WordPress generated moments before - the same value wp-cron.php itself checks before it runs anything, which is what makes this work behind a proxy or CDN. Any other request for wp-cron.php is filtered exactly as before, because that script is expensive and reachable by anybody. Sites using ALTERNATE_WP_CRON are deliberately not covered: there the schedule runs on a visitor request, and a visit is filtered like a visit.

= 3.3.35 =
* New: The plugin now watches the places you cleaned up yourself. Removing a malicious file settles nothing on its own - what keeps a site infected is whatever puts the file back - and the check for that only ever watched files this plugin had quarantined or deleted itself. A real cleanup almost never looks like that: somebody opens an FTP client or the file manager, deletes the file, and the record naming it went with the next scan. So the commonest cleanup there is was the one case where a file coming back was nobody's finding. Now a reported file that has gone by the next scan is written down first, and if it ever exists again you are told, together with the list of scheduled events - because at that point those are evidence rather than trivia.
* New: How loudly that is said depends on what the file was. One that matched a malware rule and is back has no innocent explanation. One that merely belonged to nothing may have arrived with a reinstall of the plugin or theme it sat in, so that is a warning which says so, rather than an accusation aimed at somebody who has just reinstalled something.
* Fixed: Findings naming a file that is no longer on disk stayed on the results screen. That is a screen you are meant to act on, and half of it was describing work already done.

= 3.3.34 =
* Fixed: A scan that ran out of time reported itself finished. The file scan said "completed", the malware step said "completed", the log said no threats were detected - and the walk was standing at file 500 of 1,538, two thirds of wp-content never read. The dropper was in the part it never reached. The step now finishes only when the walk says it has, and otherwise pauses there and continues where it stopped.
* Fixed: 86% of the time a scan was allowed to spend went to files it had already decided to skip. Whether a file is worth opening is now asked before it is opened, instead of after it had been delivered, read and looked up one query at a time. The same scan now walks 212 files where it walked 1,538, and finishes in one round instead of four incomplete ones.
* New: Extra files inside a plugin or theme that publishes a list of what it contains. Both checks compared every published file against the disk and never looked the other way round, so a file added beside the published ones simply never came up - which is how a backdoor sits inside a plugin directory in plain sight. Only code is reported: a package that has grown a cache, a log or an index.php has merely been running.
* New: Files in the installation root and in the bundled themes that WordPress did not ship. The list WordPress publishes covers more than wp-admin and wp-includes - 431 of its 3,229 entries live under wp-content - and that half was going unused. The root has an allowlist for its legitimate strangers, so robots.txt, ownership proofs for search engines and your web server configuration are not reported.
* New: Executable code in directories that should only ever hold data - the media library, caches, the update folder. Nobody publishes a list of what a media library contains, so the location is the finding: an upload cannot create a PHP file. Where one directory holds many, it is reported once, naming the place and the count, rather than once per file.
* New: The plugin now measures whether scheduled work on this site actually runs. A scan that pauses books its own continuation as a scheduled event, so where the schedule never fires the scan waits forever - and the usual way of checking that, reading DISABLE_WP_CRON, answers nothing, because the recommended setup sets it. An hourly heartbeat of our own gives the real answer, and the scan report, the weekly mail and a notice in the admin say plainly which of the four situations this site is in, with the crontab line to fix it.
* Fixed: A finding could not say why it was one. Files reported for where they are, rather than for failing a checksum, had their explanation discarded on the way to the database - the screen showed a path, the word "added", and nothing else.
* Fixed: The signature scan had stopped opening the most suspicious files on the site. It skipped anything unchanged since the last scan that was not already marked suspicious, which included every file reported for its location - and those are written earlier in the same scan. A backdoor in the uploads directory was reported for sitting there and never examined for what it did, and no later scan would have examined it either.
* Fixed: The list of official file hashes was requested for the language the site is set to, rather than the language of the WordPress package that was installed. A site set up in English and later switched to German was compared against a list containing 214 files it never had.
* New: The list of addresses known to be used by malicious code is now applied to files as well as to the database. A configuration file naming a known command-and-control host used to pass a file scan untouched.

= 3.3.33 =
* Fixed: The free licence tier is recognised under the name the licence server actually uses. 3.3.32 shipped it under a name that does not exist there, so a free key was still refused with "No valid license type found" - the change was right and the spelling was wrong. Paid keys were never affected. Note that the free type is a prefix of every paid one, so the plugin compares them exactly, and a test now covers each paid tier against being read as free.

= 3.3.32 =
* New: "Settings" and "Check for updates" now sit next to Activate and Deactivate on the Plugins screen, where people look for them.
* New: The update check says what it found, and says when it did not look. This site is only offered releases while a licence key is stored and the server accepts it - a condition nothing on screen ever mentioned, so a fix could sit on the server for days while WordPress cheerfully reported everything up to date, however often you pressed "Check again". The check now answers plainly: the current version, a newer one, no key stored, a key that was refused, or a server that could not be reached.
* New: A free licence tier, for exactly that reason. Being told about an update is not a paid feature, and until now a site without a paid key was never offered one. A free key makes update checks work and unlocks nothing else - full traffic recording, retention, city lookup, the AI crawler decisions and the Pro screens all stay where they were. It is a licence type of its own rather than a paid one reused, which is what lets the Pro add-on refuse it.
* Fixed: Clicking "View details" on the Plugins screen said "Plugin not found." The window was filled from the update server, which answers nothing when no licence key is stored, and WordPress then asked wordpress.org - which has never hosted this plugin. The installed copy has a header and a changelog on disk, so the window is now filled from those and the server only adds to them.

= 3.3.31 =
* New: A finding now says what was done about it. The report explained at length what a backdoor is and what you ought to do, and said nothing at all about whether the file had been touched - so a finding the plugin had deliberately left alone read exactly like one it had dealt with. Every finding now carries one sentence: moved to quarantine, reviewed and left alone, or reported only - and where it is "reported only", why. Switched off under Settings, a heuristic that is a hint rather than a verdict, or a file that does not meet the conditions for automatic action are all different answers, and the report gives the one that applies.
* Changed: The setting "Quarantine files that belong to nothing" is now called "Quarantine files identified by how they are written". The old name described the wrong half of the decision: a file has to belong to nothing for either kind of automatic quarantine, so that was the condition the two share, not the one that tells them apart - which is whether the evidence is a signature or a behaviour. Anyone reading the settings screen was deciding something other than what they thought. Your existing answer is kept, whichever name it was given under, and switching it off after the change stays switched off.

= 3.3.30 =
* Fixed: A minified file could be reported as a backdoor because two harmless things in it sat far apart. 76 detection rules looked for one indicator followed by another - a write to disk followed by request data, say - without any limit on how far apart the two could be. In ordinary code a line break stops that search after a line or two. A minified file has no line breaks: a hosting provider's own management tool arrived as a single line of 164,105 characters, and the rule dutifully joined a file write near the start to a piece of request data 161,212 characters later, in an unrelated function. Reported as critical, on a file that was neither related nor malicious. Every one of those rules now carries a limit, and the twelve that describe "this call receives request data" require the two to be in the same statement rather than merely in the same file.
* Note: Measured before shipping, because a rule that finds less is only an improvement if it still finds the malware. Against 17,170 files of a real WordPress installation the results are identical - no detection gained, none lost. Every one of 64 rules still catches the attack it describes, and eight known backdoor samples are still caught by exactly as many rules as before. On the file that started this, three findings become one, and the two that disappeared were the ones spanning 161,212 and 137,939 characters.
* New: A finding now says what the file claims to be. A file that belongs to no plugin, theme or to WordPress itself was reported by path, size and hash - everything except the twenty lines at the top of the file that often answer the question outright. The file from the incident above says "Plugin Name: Installatron Worker", "Version: 1.120", "Author: Installatron", which is the hosting provider's own management tool and is the whole answer; the scanner had it in hand and discarded it. It is shown as a claim and nothing more, because a header is trivially copied and the finding stands whatever it says - but where the file is honest, it turns an evening of analysis into five seconds.

= 3.3.29 =
* Fixed: Importing a settings file put the scanner's excluded-paths list back as a single line. The list is written one path per line, and the import ran it through a check that removes line breaks, so every entry after the first was joined onto the one before it and the whole list stopped excluding anything. Saving the same list on the settings screen was never affected - only import - which made it look like the export had been incomplete. Import and save now go through the same check, so what you export is what you get back.
* Note: Groundwork for remote management in Pro. The rules that decide what a saved setting may contain now live in one place, so the settings a monitoring tool sends are checked exactly the way the ones typed into the settings screen are. The free plugin gains no remote access from this: its endpoints still require an administrator logged into this site.

= 3.3.28 =
* Fixed: Adding an image to the media library failed with "The server cannot process the image. This can happen if the server is busy or does not have enough resources to complete the task." Nothing was busy and nothing was short of resources. This plugin's upload check was written as though WordPress always tells it which file types are permitted, and for an ordinary media upload WordPress names none - so the request ended in a PHP error, and the uploader shows that same wording for any failed request whose file happens to be an image. That is why a mismatch inside the plugin read as a problem with your server. Every image was affected on every site running the default settings, and the only way around it was switching hardening off entirely. The check now accepts what WordPress actually sends and refuses dangerous file names exactly as before.
* Fixed: Some pages could come back blank instead of simply not redirecting. WordPress cancels its own address correction when it would lead to a post the visitor is not allowed to read, and says so by handing every plugin a cancelled redirect. The guard against author-name probing was written as though that could not happen and ended the request with a PHP error when it did. It now leaves a cancelled redirect alone, and still blocks the ?author= probes it exists for.
* New: Malware that hides the name of the function it calls is now found. A file can decode the word "eval" out of a piece of base64 and call it through a variable, and no rule looking for the word eval will ever see it - which is exactly how this kind of dropper works. Four lines of it were enough to pass all 283 signatures and the statistical check. The scanner now looks for the encoded spelling of the names that run or unpack code, in base64 and in hex escapes alike, since there is no innocent reason to write the word "eval" in either. Encoding it differently does not help: the rule is about the name that comes out, not the encoding it went in as.
* New: A file that loads code from a hidden or temporary path is now reported. The second infection nothing caught was one line added to a theme's functions.php, including a file out of /tmp with its errors suppressed - so that when the payload is removed the site keeps running and nothing appears in the log. A plugin including one of its own files wants the opposite of both of those things.
* Note: Both rules were measured against 26,825 PHP files across two real installations - WordPress itself, WooCommerce, Jetpack, Wordfence and the themes - before being shipped. Neither matched anything outside the malware. That is the bar every rule of this kind has to clear here, because these are the findings the plugin acts on by itself.

= 3.3.27 =
* Fixed: When a file was caught both by what its code does and by how it is written, the finding was stored as the weaker of the two. A backdoor could match seven behaviour rules - it created an administrator account, hid it from the user list and from the REST API - and also matched one signature that only observed the file used an unusual way of writing text. Because the signature was recorded last, the finding ended up filed as "Obfuscation", the mildest possible reading of the most dangerous file on the site. The behaviour verdict is now kept: a finding that names what a file does is no longer overwritten by one that only names how it is written. The signature is still recorded in the log, since a file carrying a known signature as well is worth knowing.

= 3.3.26 =
* New: A file finding now says what matched. Until now the scanner recorded that a file was suspicious and never what had said so, which meant every such finding on the screen was explained with the same two sentences - the ones written for the word "suspicious" - whichever of the 283 signatures or the behaviour rules had actually fired. You are now told the rule by name, and the explanation is the one written for that kind of thing rather than a general one.
* New: Findings say what kind of evidence they rest on, because the two kinds are not comparable. A signature describes how a file is written, which is the part an attacker can change for nothing; a behaviour rule describes what the file does, which they cannot avoid without giving up what they came for. A finding now reads "what the code does" or "how the code is written", so you can weigh it yourself instead of taking a severity on trust. The statistical check, which matches no rule at all, says that too rather than appearing as though something specific was found.
* Fixed: One category of finding had no explanation of its own and fell back to the general one - "Evasion", which covers code that hides an administrator account from the user list, withholds its output while an administrator is looking, or removes itself from the lists that would report it. That is the category where a site looks perfectly fine by design, and so the one where a general explanation helps least. A test now checks every behaviour category has its own, the same way one already did for the signature categories.
* Changed: A file that stops being a finding stops naming a rule. A cleared or repaired file used to be able to keep the accusation beside it, and a status decided by comparing checksums - where no rule was involved at all - can no longer be made to name one.

= 3.3.25 =
* New: Files that nobody publishes an original for are now checked against the hash recorded when they were installed. The existing file check compares your site against what WordPress.org publishes, which covers WordPress itself and the plugins and themes hosted there - and nothing else. A plugin you paid for, a theme written for your site, and this plugin itself had no content check at all: nobody publishes what they should contain, so there was nothing to compare them to and the scan said nothing about them. The plugin has been recording a hash for every file it sees since 3.3.22; it now reads it back. A file whose contents changed while its name stayed the same is reported.
* New: This plugin now checks itself. Every release carries a list of hashes for its own files, written when the release is built - before it leaves us, and so before anybody could reach your site to alter it. Two things are reported: one of our files that no longer matches what we released, and a PHP file inside our directory that our release never contained. This matters more than it sounds. The malware scan deliberately skips this plugin's own folder, because the attack patterns it carries as reference data are indistinguishable from actual attacks, which made that folder the one place on your site nothing was looking. On a development checkout there is no such list, and the check says so rather than guessing.
* Note: Neither check is proof against somebody who already has full control of the site - whoever can rewrite a file can usually rewrite a database row, and the code doing the checking sits in the folder it is checking. They catch what actually happens: automated infections that append themselves to every PHP file they can open, without knowing or caring what any of those files are. The plugin says which of the two found something, so you can judge the evidence rather than take a verdict.
* New: The scan step can be switched off under Settings, like every other one. It reports how many files it compared, and if more files changed than fit on the screen it says how many were left out rather than quietly showing the first fifty.

= 3.3.24 =
* Fixed: Every security event was recorded twice. One malware detection wrote one entry saying "Malware found" and a second saying "Audit security malware detected" - the same wording, the same severity, the same second, under a name assembled by the software and meant for nobody. The reason was that the audit log decided what belonged to it by reading the beginning of an event's name, so the only way to put an event in the audit trail was to write it again under a different one. The audit log now knows which events belong to it without a copy being made. What you see is unchanged: security events are still in the audit trail and still in its CSV export. There is simply one entry for one thing that happened.
* Fixed: Because of the above, a single finding could also produce two alert emails, the second announcing the same event under the assembled name. Alerts count one event once.
* Changed: Entries already recorded twice are cleared up when you update. A copy is only removed where the original is still there beside it - same event, same wording, same severity, same minute - so nothing is deleted that is the last record of anything. Summary entries that count a burst of events are left alone, since those say something no single entry does.
* Changed: The audit log's filter now lists the security events, which it never did while they were only present under their assembled names.

= 3.3.23 =
* Fixed: A database finding is now removed once a scan no longer finds it. Nothing had ever deleted one: a finding written in July was still on the screen in August, whether or not the thing it described was still there or had ever been real. So when a cause was fixed - including the article wrongly reported as a webshell, above - the accusation stayed. A scan now clears what it did not find again, and only for the areas it actually examined, so switching off comment scanning does not silently erase every comment finding. Anything you marked as ignored is left alone, and the scan log says how many were cleared.
* Changed: An administrator with a commonly attacked name such as "admin" is now reported as medium rather than high. Every administrator is checked on every scan now rather than only newly created ones, and a standing weakness reported at the same level as an active break-in, twice a day, for ever, is how a report teaches you to skim past it. A hidden administrator remains critical, and a brand-new one remains high.
* New: Findings now say what they mean and what to do about them. Until now a finding read "Pattern matched: Imported - shell_antsword" followed by one sentence that was identical for every finding on the page. That names the rule that fired, which is a fact about this plugin's internals and about nothing you own - somebody looking at their own site late at night was told a word they had never seen, a severity of "critical", and nothing that would tell them whether their shop was currently robbing its customers or a scanner had misread an article. Every finding now carries two sentences written for the person reading it: what this kind of thing does to a website if it is real, and what to do next. Where there is a record behind the finding - a post, a comment, a user - there is now a button that opens it, instead of a table name and a row number to look up by hand.
* New: A finding that has been seen before shows how often and when it was last seen, rather than only when it first appeared.
* Note: There is deliberately no button that cleans a record for you. Editing somebody's published article to remove a fragment a pattern objected to is destructive, and patterns are sometimes wrong - which is the very reason these explanations exist. You are taken to the record instead, in the editor, where the surrounding context the scanner could not see is visible and any change can be undone.

= 3.3.22 =
* New: The scanner now also judges a file by what its code does, rather than only by how it is written. This matters because how a file is written is the part an attacker can change for nothing. A backdoor of this kind was reported by a single rule, "hex escape sequence", which says only that some text was written in an unusual way - had its author written that text normally, the file would have passed as clean while continuing to do what it did: create an administrator account, mark it so it could be found again, and remove it from both the user list and the REST API so nobody would see it. Those steps are now recognised as what they are, whether or not the file is disguised, and each one is reported separately, so what you read is an account of what happened to your site rather than a remark about its punctuation. Every rule was checked against 28,362 files from two installations - WordPress itself, WooCommerce, Jetpack, Wordfence and the themes - and only those that flagged nothing outside the malware were kept.
* New: With Pro, a file identified this way is moved out of reach by itself, without waiting for anyone to read the alert. This is on from the start, which quarantining on a signature deliberately is not - a signature describes how a file is written and ordinary code is often written the same way, while these rules require several things to be true of a file at once. The file is moved, never deleted, so the decision can be reversed. It can be turned off under Settings.
* New: Must-use plugins and drop-ins are now checked on every request rather than only during a scan. These are the two places on a WordPress site where putting a file there is the same as running it: they load on every page view, they cannot be switched off from the plugins screen, and nothing lists them anywhere you would normally look. The check that was meant to catch a file appearing out of nowhere could never see them, because it looked at the file handling the request and these are never that - which is how the backdoor this work came from ran for six days on a site where that check was active the whole time. It reports rather than blocks: these files have already run by the time anything can intervene, so refusing the page afterwards would prevent nothing and break the site instead. An unchanged site costs nothing to check.
* Fixed: A must-use plugin or drop-in that appeared after your installation was last known to be in order was noticed, but only written into the scan log - read by whoever opens the scan, and there among hundreds of other lines. Must-use plugins are always active and cannot be switched off from the plugins screen, so something arriving there unannounced is exactly what wants telling. It is now reported like any other alert.
* New: The plugin now keeps a list of the files your installation contains, and a file is treated as new when nothing ever put it there. Until now that question was answered by the file's date, which was the wrong thing to ask twice over. A date can be written - copying one from a neighbouring file takes a moment and makes something dropped yesterday look as old as WordPress - and the date it was measured against was moved to the present by every plugin update, every activation and every theme switch. So anything left on your site quietly became part of it as soon as you updated something else. On the site this was found on, a backdoor arrived on the 31st and was adopted by an update on the 1st. The list is written the first time a scan finishes and grows only when something installs files; it does not move with the clock. Nothing needs doing - the next scan writes it.
* Fixed: Posts and comments are no longer checked against rules written to recognise PHP files. WordPress never runs what you write in an article as code, so those rules could only ever match an article by accident - and they did: two German articles about accessible toilets were reported as webshells, at the highest severity, while the actual backdoor on the same site was reported one level below them. The word that caused it was fixed at the time, which turned out to be no fix at all, since the same rule still reported any article mentioning "Ice Scorpion" - a scorpion, a film monster, or in this case a scanner rule that did not know the difference. Articles and comments are now checked for the things that can actually be injected into them: scripts, iframes, code blocks.
* Fixed: An administrator account that your users screen does not show you is now reported, and keeps being reported for as long as it stays hidden. This is what a backdoor does after it creates itself an account, and it is the one thing it cannot avoid doing - whatever trick it uses, the account has to be in the database and absent from the list. The plugin now asks both and says so when they disagree.
* Fixed: Administrator accounts are examined on every scan instead of only when they are new. The account that prompted all of this was reported once, on the day it appeared, and then absent from six days of daily reports while remaining an administrator the whole time. A check that answers "did anything change since yesterday" cannot answer "is anything wrong", and the second question is the one being asked.
* Fixed: The same finding is no longer written out again by every scan. Four problems could fill twenty-seven rows in a week and a hundred by the end of the month. A finding that is still there now records when it was last seen and how often, and existing duplicates are merged when you update - keeping the earliest date, because when the problem started is the useful part. If you marked something as ignored, it stays ignored.
* Fixed: Two directories are no longer read. wp-content/upgrade-temp-backup holds WordPress' own copies of plugins it is updating, so anything found there was a second report of a file already examined where it actually runs; wp-content/wflogs belongs to another scanner and holds attack patterns because that is what it is for. On the installation this was measured on, the two accounted for half of all false reports.

= 3.3.21 =
* New: How long findings are collected before they are sent as one email can be set under Settings, from one minute to an hour. It was fixed at five minutes with no way to change it.
* Fixed: The setting that used to sit in that place did nothing. "Max Alerts Per Hour" described a cap that stopped existing when findings began to be grouped - there is no longer anything to suppress, since nothing is dropped - but the field stayed on the screen, along with its description of a suppression notice that is never sent. It has been removed rather than left there looking like a control.

= 3.3.20 =
* Fixed: Your licence key was stored and displayed in clear. It was written into the download address of an available update, and that address is kept in the site's own options table - readable by every other plugin on the site, and carried into every backup and database export. WordPress also prints that address on screen while it installs the update, so the key appeared in screenshots, screen recordings and anything pasted into a support request. The key is now added only to the request that actually fetches the file, and appears nowhere else. Nothing needs doing: the next update check stores the address without it.

= 3.3.19 =
* Fixed: The weekly summary never arrived on a good many sites. It waited on WordPress's scheduler, which only runs when somebody loads a page and can be stopped outright by a firewall, by HTTP authentication, or by a staging password - and on a site with few visitors there may be nobody to set it off for days. A summary that quietly never comes is worse than none at all: silence is the very thing it exists to fill, so its absence reads as "nothing happened" rather than "nobody sent it". Whether it is due is now a question of the date, not of a scheduled job, and any page in the admin area will send an overdue one - after that page has finished loading, so nobody waits for it. The schedule is still used where it works.

= 3.3.18 =
* Fixed: "Check again" needed several goes before a new version appeared. Two things were in the way. This plugin discarded its own stored answer a moment too late - WordPress refreshes the update list on the same event, and its own handler runs first, so the refresh still used the answer that was being thrown away and only the next check saw the new version. And WordPress itself declines to look again within a minute on the updates screen, whether or not anybody asked it to, so pressing the button again in the meantime did nothing at all. The stored answer is now cleared before the refresh, and asking explicitly lifts that waiting period. One press is enough. This is worst in the hour after a security release, which is exactly when it matters.

= 3.3.17 =
* Fixed: A scan only made progress while its own screen was the tab you were looking at. Scans run in short stretches, and the thing asking for the next stretch was the timer on the scanner screen - which browsers slow to about once a minute in a background tab and stop altogether in one they have set aside. Switching to another tab therefore stopped the scan, and coming back started it again, which reads as a scan that hangs. Any page in the admin area now carries a stalled scan forward, after that page has finished loading, so it no longer matters which tab is in front. Returning to the scanner screen also brings the display straight up to date instead of waiting for its next tick.

= 3.3.16 =
* Fixed: Four malware signatures reported ordinary plugins as infected and could never have caught what they were written for. They describe settings that live in .htaccess and .user.ini files - a redirect that makes every request run an attacker's file first, or that makes uploaded images execute as code - but the malware scan reads only files ending in .php, so it never opened one of them. What it did read was plugin code that writes those settings legitimately, such as a staging tool creating a .user.ini, and reported it as critical. Signatures can now say which files they belong to, and these four are limited to the files they describe. The check that actually protects against those settings is unaffected: the scan already reads every .htaccess, php.ini and .user.ini in its own step, and that is where the finding comes from.
* Changed: A file that grants world-writable permissions is no longer reported at the standard scan level. Setting them is untidy rather than malicious, and every installer and staging tool does it, so it was putting a critical finding on healthy sites. It is still reported at the thorough level, which is the one that asks for everything.

= 3.3.15 =
* Fixed: One scan could produce twenty emails. Every finding was sent on its own, and with the Pro add-on installed each one was sent twice, in two different layouts, because both plugins were mailing about the same event. When the hourly limit was reached the site sent one more message to say that it had stopped sending - so the reader was left with ten fragments and no way of knowing how many findings there had actually been. Findings are now collected for a few minutes and sent as a single message, grouped by kind and counted: "Vulnerable plugin or theme, 9". Nothing is dropped quietly; anything not listed individually is counted out loud. Anything critical still goes out immediately, and takes what has already collected with it.
* Changed: The subject line names the product, says what happened in words, and names the site: "[Forge12 Security] Malware found and 1 other issue on example.com". It used to be the raw event key from the log, which is what the log is for.
* New: Every message carries a link to stop receiving them, so somebody who no longer has an account on the site can still take their address off the list. The link identifies itself with a token rather than carrying the address in the URL, and works once. An administrator can reverse it.
* New: A weekly summary, sent whether or not anything went wrong - because alerts only ever speak when something is broken, and a site that hears nothing cannot tell a quiet week from a plugin that has stopped working. It reports what was blocked, failed logins, malware found and files changed, and names the addresses blocked most often where the site stores them in a form that can be read back. It was previously part of the paid add-on.
* Fixed: Two figures in that summary were zero on every site, always: it counted two event names that are never written anywhere.

= 3.3.14 =
* Fixed: With the Pro add-on installed, the firewall blocked on the first rule that matched. Everything on the WAF Rules screen - the six score thresholds, the trusted address list, the per-parameter exceptions - and learning mode had no effect on those requests, because the add-on ran a second firewall of its own beside this one, over the same rules. It also used the administrator check from before 3.3.9, the one that locked administrators out of anything outside wp-admin. There is one firewall now. Updating the Pro add-on to 3.2.5 removes the second one.
* Fixed: The nineteen rules that add-on installed were stored without a score, which meant every one of them was worth exactly the amount needed to block on its own - including ones this plugin deliberately treats as a hint, such as a SQL comment or a lookup of the user list. They are removed during the update, and their coverage is part of the shipped rule set now, weighted like everything else. A rule you have edited yourself is left exactly as you set it. One of them never worked at all: it was not a valid pattern, and both firewalls quietly ignored the error.
* Fixed: A rule pointed at all request data was still shown the raw cookie header, which put the login cookie back under inspection after 3.3.9 had taken it out. A signature matching there is what returned "Access Denied" for a whole site.
* New: Signatures for shell command injection - a command, or a download, after a shell separator - for scanners that announce themselves in the user agent, for a second SQL statement after a semicolon, and for MySQL version comments, which look like a comment but are executed by the database. Each carries a score that reflects how much it proves on its own.
* New: Time-based SQL injection against SQL Server, three more HTML event handlers and two more PHP stream wrappers are recognised. The wider patterns are written into existing installations during the update, leaving rules you have edited untouched.
* Fixed: A scan could stop partway and report itself as finished. The page showed a green tick, a fresh timestamp and 100 percent while six of the nine steps had never started - the vulnerability check among them - so there was no reason to doubt a result that covered a fraction of the site. A run that stops is now marked as stopped, in red, saying plainly that the steps which did not run were not checked, and it is written to the security log. The scan that triggered this ended on every attempt because of a single date field left empty by design; no value from the database can end a scan that way any more.
* Fixed: Around seventy files reported as modified WordPress core files on every site that is not in English. They were the translation files: WordPress updates those on their own schedule, separately from the release they were published with, so they differ from it for entirely ordinary reasons. They are no longer compared against the release, which is the same rule this scan already applied to bundled themes.

= 3.3.13 =
* Fixed: Every plugin that is not in the WordPress.org directory was reported as "removed from the WordPress.org repository", at high severity, with "needs an upgrade" beside it. That covers all premium and all private plugins, so an ordinary site could collect a dozen findings with nothing real behind any of them. WordPress.org answers the same way for a plugin it never hosted as for one it took down, so the scan cannot tell them apart and no longer pretends to: such a plugin is now marked as not listed, noted as information rather than a warning, and left out of the findings and the issue count. Existing entries are corrected during the update.

= 3.3.12 =
* Fixed: An available update could be seen but not installed. WordPress showed the notice about a new version and, right beside it, "an automatic update is not available for this plugin" — there was no button to press. The update carried no download link, because the plugin looked for that address under a different name than the update server uses. Updating had to be done by hand. It works from the updates screen now.

= 3.3.11 =
* Fixed: Two firewall signatures matched ordinary traffic. One looked for a shell separator followed by a command, and one of those commands was "id" — two letters, with a single vertical bar counting as the separator. A WordPress login cookie contains exactly that. The other matched two dashes followed by a space, anywhere, in any field. Together they were enough to block a site's own visitors. Both now need the shape an attack actually has, and every attack pattern they were written for still matches.
* Fixed: Those two rules are already stored in every installation, and loading the shipped rules skips a name that is already there — so the corrected versions are written over the old ones during the update. A rule you have edited yourself is left exactly as you set it.
* New: All six firewall score thresholds can be set under Firewall > WAF Rules. Three of them were fixed at 100 with no way to change them, and none of the six was visible anywhere. A rule does not block on its own: every rule that matches adds its score to its category, and the request is blocked once a category reaches its threshold.

= 3.3.10 =
* Fixed: "Check again" on the updates screen did not reach this plugin. The answer from the update server was kept for twelve hours and nothing ever discarded it — so a site went on reporting the version it had been told about earlier, and there was no way to ask again. That is worst in the hours after a security release, which is exactly when a site should be finding out there is one. A forced check now clears it, as does entering a licence key, and as does updating this plugin — which previously kept describing the version it had just replaced.

= 3.3.9 =
* Fixed: The firewall could lock an administrator out of their own site. It skipped its checks only for pages inside wp-admin — and a REST request, or the front page, is not one of those. So an administrator's own requests were matched against every signature, including the contents of their login cookie, and a match there returned "Access Denied" for everything: the front page, the images, and the very screen that would have let them switch the firewall off. The check now recognises an administrator on any kind of request.
* Fixed: The cookies WordPress sets itself are no longer searched for attack patterns. Their contents are generated by the site — a login token, a session hash — and an attacker chooses nothing in them, so the only thing that search produced was blocked administrators. Cookies set by other plugins are still checked, because those can genuinely be read straight into a database query by faulty code.
* New: A list of trusted IP addresses, under Firewall > Trusted IPs. Requests from those addresses skip the firewall, rate limiting, bot detection and the IP block list — for your own office, or a monitoring service. There is a button that adds the address you are connecting from. The list existed before but was only honoured by the rate limiter and only editable with the Pro add-on installed.
* Note: Login protection is deliberately not part of that list. Brute-force lockout and two-factor still apply to a trusted address; they guard the login form, and an office is exactly where a stolen laptop would be.

= 3.3.8 =
* Fixed: The check for known vulnerabilities never found any. It asked the vulnerability database at an address that does not exist and answers "not found", so every lookup ended in the error branch, remembered an empty result for six hours and reported that nothing was wrong. No plugin could ever have been reported as vulnerable. Even at the right address the severity was read from the wrong field and every finding would have been rated the lowest one there is.
* New: WordPress itself is now checked for known vulnerabilities. The check was keyed by plugin and theme, and WordPress is neither — so a site could be running a WordPress release with a published, actively exploited flaw and the scan would report nothing out of the ordinary. This is how CVE-2026-63030 and CVE-2026-60137 went unnoticed here.
* New: Themes are checked for known vulnerabilities too. Until now they were only compared against the version published for them, so a theme with a known hole that was otherwise up to date was reported as current.
* New: Firewall rules against the WordPress core flaw known as wp2shell, which lets an unauthenticated visitor run code on versions 6.9.0 to 7.0.1. They stop the two halves of the attack — a batch request smuggled inside another one, and the author filter carrying SQL. They buy time; the WordPress update to 6.8.6, 6.9.5 or 7.0.2 is what actually fixes it.
* Fixed: The firewall inspected requests sent as JSON against nothing. PHP hands a plugin the fields of a form, but not the body of a JSON request — which is what the block editor and every app or integration sends. Rules can now be pointed at that body.
* Fixed: A fresh installation had a firewall with no rules in it. The rule set was only ever loaded by a button nothing in the interface offered, so the firewall was switched on, reported itself as active, and matched every request against an empty list. Rules are now installed on activation, and new ones arrive with an update.
* Improved: A finding now names the vulnerability it found, with its identifier, a link to the advisory, how severe it is and which release fixes it. Previously it said only "vulnerable".

= 3.3.7 =
* Fixed: Clicking "Scan Now" appeared to do nothing until the page was reloaded. The page waited for the request to come back before showing anything, and that request does not come back quickly: the scan does its first stretch of work while answering, and the function that would send the response early exists only under PHP-FPM. The page now switches to the scanning view immediately and lets the request finish in its own time.
* Fixed: A scan started while the previous one was still on record could be announced as finished the moment it began. The progress display polls straight away, and until the new scan had actually started it was reading the old scan's result. Starting a scan now marks it as running before answering.

= 3.3.6 =
* Fixed: Every installed plugin was reported as having a modified file, always the readme. WordPress.org builds a plugin's checksums from trunk but ships the tagged release, and authors edit the readme in trunk constantly — so the file that arrives routinely differs from the hash published for it. WP-CLI's own checksum command reaches the same conclusion and ignores it; unlike WP-CLI, this also ignores README.txt spelled with capitals. A readme is not executed, so there is nothing to be gained by editing one.
* New: php.ini and .user.ini files are now read. Nothing had ever looked inside them, because the malware scanner only opens files ending in .php — yet a single "auto_prepend_file" line in one of these loads code on every request, ahead of WordPress, and survives replacing every PHP file on the site. Files that load code are reported as critical; upload limits, memory and opcache settings are not.
* Improved: Identical copies of those files are counted once instead of reported one by one. Shared hosts apply PHP settings per directory and copy the same file into dozens of them, and forty identical findings is how the one that differs gets missed. A configuration that is not uniform is pointed out for exactly that reason.

= 3.3.5 =
* New: A File Writing card on the scanner page. It names the system account PHP runs as and the account that owns each directory, because those being different is the usual reason uploads work over FTP while WordPress, updates and backups report they cannot write. Every directory is tested by actually creating, writing and removing a file — the permission bits on their own report a file as writable even when it carries the immutable attribute, which is how a planted file is protected from being replaced, and that combination is reported as the tampering signal it is.

= 3.3.4 =
* Fixed: A false positive that reported ordinary German pages as a webshell, at critical severity. One imported signature looked for the bare word "behinder", which is the start of Behinderung, behindert and behindern. Every signature that matched inside a longer word now requires word boundaries, and a test refuses any new one that does not — these patterns are written for source code but are also run over what people wrote in their posts.
* Fixed: A database finding said only which pattern had matched, never what it had matched. There was nothing to judge it by and nothing to act on, because the plugin had discarded the text as well. Findings now quote the matched text with its surrounding sentence.
* Improved: The scan progress now moves as the scan moves. It was written only once per batch of five hundred files, and during the search for unknown files it followed the findings rather than the files — so on a healthy installation the path stood still for the whole search and a working scan looked stuck.
* Improved: The scan reports its speed in files per second, and its working time. The previous timer measured only the current stretch, so on a scan that takes several requests it restarted at zero each time.
* Fixed: On hosts where the web server ends a request sooner than PHP admits to, a scan could still stop and never continue. A scan works in timed stretches and arranges the next one before its time is up — but it was reading that time from a limit PHP-FPM overrides and does not report, so it was ended before it got the chance. Each request now takes a smaller, adjustable bite, and shrinks it further whenever a run is seen to have been cut short.
* Fixed: A paused scan waited for WP-Cron to continue it, and WP-Cron works by calling wp-cron.php over HTTP — which firewalls, staging passwords and HTTP authentication all block, on more sites than one would think. While the scan page is open it now continues the scan directly, and no longer depends on WP-Cron at all.
* Fixed: When a scan really cannot be continued, the reported reason now distinguishes between the process being ended and WP-Cron never running, instead of always suggesting the chunk size.
* New: Persistence check. Removing a malicious file settles nothing if something puts it back, so the scan now asks what would. It reports any file that was quarantined or deleted and exists again — the one finding that proves a cleanup failed — and only then treats the schedule as evidence. Scheduled events are reported when the name looks generated, the arguments carry a URL or an encoded block, or an update check has been removed; the leftovers of uninstalled plugins are counted and named but deliberately not raised as findings.
* New: Drop-ins (object-cache.php, advanced-cache.php, db.php and the rest) and must-use plugins are listed for the first time. They run on every request and cannot be switched off from the plugins screen. Being present is normal; having arrived after the installation was last in order is reported.
* New: The server's own schedule is read where the host allows it. Where it does not, the check says so plainly and is reported as unknown rather than folded into a clean result — a crontab entry outside WordPress survives reinstalling the site.
* New: Write access diagnosis, in the scan and under Diagnostics. It answers the common case of uploads working over FTP while WordPress and backup plugins report they cannot write: files put there over FTP belong to the FTP account, and PHP runs as another. It also attempts a real write instead of trusting the permission bits, which report a file as writable even when it carries the immutable attribute — a way of stopping anyone replacing a planted file, and reported as such.

= 3.3.2 =
* Fixed: On a large installation the scan could stop at "File Changes" and never move again. Past the core file check nothing looked at the remaining execution time, so the server ended the process before it could arrange to carry on — leaving a scan that reported itself as running, a progress display that never advanced, and no way to start a new one.
* Fixed: A scan that had run out of time started the file list again from the top instead of continuing where it stopped. On an installation too large to check in one go, it repeated the same first minute indefinitely and never reached the end.
* Fixed: Removed default themes were reported as missing core files. WordPress publishes checksums for the themes and plugins it ships with, and deleting those is normal - reporting it buries the findings that matter under hundreds that do not.
* Fixed: A scan whose process was ended by the server is now noticed and asked to continue; if it stays silent, it is reported as failed and names the step it stopped at, instead of appearing to run forever.
* Improved: Plugins and themes that do not come from WordPress.org no longer cost a 30-second wait each, on every scan. That there are no published checksums for them is now remembered.

= 3.3.1 =
* Fixed: With Pro active, an automatic scan could clear the results list of a scan that was still being read. Pro ran a file scan cron of its own; since its scanner became part of the free scan, that cron started a second full run which cleared previous findings before starting.
* Fixed: The "File Scan Interval" setting had no effect. The recurring scan ran twice a day regardless of what the field said; it now uses the configured interval, with 15 minutes as the shortest accepted.
* Fixed: A notice on every request under WordPress 6.7 and newer ("Translation loading for the f12-security domain was triggered too early"). The site was unaffected, but the log filled up.
* Improved: One walk of the installation instead of two. With Pro active, the file scan ran twice over the same files with separate verdicts; Pro now reads the free scan's results and adds only what it alone provides.
* Improved: The download no longer contains the test suites, documentation and CI files that the bundled third-party libraries ship with. About 40 files smaller.

= 3.3.0 =
* New: File Guard. An unexpected PHP file — one running from the uploads directory, or one that appeared after the last update and is not something its author published — is reported the moment it is executed, instead of on the next scheduled scan. Reports by default; blocking is optional.
* New: Three scan sensitivity levels, built on the severities the signature set already carries rather than a vague slider.
* New: A reviewed file can be marked as allowed. The allowlist is pinned to the file's content, so a later change brings it back into scope instead of granting a permanent exemption by path.
* New: Automatic response (Pro). An infected file with a known original is restored to it; a file that belongs to nothing installed and matches a critical signature can be quarantined. Nothing is ever deleted automatically, and a heuristic finding alone never triggers a response.
* Fixed: The malware scanner reported 48 false positives out of 49 findings on a stock site — WordPress core among them. Files that still match their published WordPress.org checksum are no longer flagged, 33 signatures no longer match across a whole file, and the heuristic no longer treats generated data or UTF-8 text as suspicious.
* Fixed: On any site large enough to hit the scan time limit, the malware scan restarted from the first file every time and could never finish. It now resumes where it stopped.
* Fixed: Every scan pass re-fetched the WordPress.org checksum manifest with a 30-second timeout, usually consuming the whole run before a file was read.
* New: AI crawler detection. Recognises crawlers from OpenAI, Anthropic, Google, Perplexity, Meta, Apple, Amazon, Common Crawl, ByteDance and others, and sorts them by purpose: model training, assistant fetch, AI search index. Detection and reporting are free; allowing, watching or blocking each crawler or category is Pro.
* New: Live Traffic is now part of the free plugin. Free records security-relevant requests and keeps 24 hours; Pro records everything, keeps it longer and adds city lookup and WHOIS.
* New: Extended Protection (pre-WordPress firewall via auto_prepend_file) moved from Pro to free.
* New: The malware scanner now runs on a daily schedule. It had always listened for the event but nothing ever scheduled it, so signature scans only ran when triggered by hand.
* Fixed: With Pro active, every password change queried the Have I Been Pwned API twice and could show the error twice — both plugins had their own implementation on the same hooks. The free implementation, which caches results, is now the only one.
* Fixed: With Pro active, /firewall/rules, /firewall/stats and the learning-mode endpoints had two handlers registered on them, and only the first ever answered. The free plugin now owns those routes.
* Fixed: With Pro active, a cron tick ran two full file integrity scans instead of one.
* Fixed: The GeoIP database was downloaded into the free plugin's own folder, so every update of the free plugin deleted it. It now lives under wp-content/uploads and an existing database is moved there automatically.
* Fixed: The free plugin scheduled a weekly GeoIP update event whose handler only exists in Pro, leaving an event with no listener on every free installation.
* Fixed: Settings showed Slack, Telegram, digest, Safe Browsing and GeoIP cards to unlicensed sites. The switches saved but nothing acted on them, and some buttons called endpoints that only exist in Pro.
* Fixed: The plugin identified itself as "Forge12 Security Pro" in the plugin list and in the update dialog.
* Improved: Update manifests are generated from the plugin header instead of hardcoded values.

= 2.0.0 =
* New: Dashboard charts for security events and traffic overview
* Improved: Redesigned sidebar navigation and settings layout
* Improved: Filter and Live Traffic button styling
* Improved: Card component layout and styling
* Improved: Toast notifications moved from React to native DOM for better performance
* Improved: CSS refinements across all admin pages
* Fixed: Vulnerability check reliability
* Fixed: Pro license request handling
* Improved: Version compatibility with Pro 3.0.0

= 1.8.0 =
* New: OpenAPI 3.0.3 REST API documentation covering all 79 endpoints (docs/api.yaml)
* New: PHPUnit test suite for models, extensions, and controllers (Free + Pro)
* New: Composer dev-dependencies for testing (phpunit/phpunit ^10.5, yoast/phpunit-polyfills ^2.0)
* Improved: Version compatibility with Pro 2.2.0

= 1.7.0 =
* New: WordPress Multisite support with network activation and per-site provisioning (Pro)
* New: Network admin pages for centralized security management (Pro)
* New: Shared IP blocklist synchronized across all network sites (Pro)
* New: Security Audit Log with tamper-proof SHA-256 hash chain (Pro)
* New: Audit Log page with integrity verification, filtering, and CSV export (Pro)
* New: 16+ WordPress events automatically logged (login, logout, plugin changes, settings, etc.)
* New: Audit Log sidebar entry with Pro gate fallback
* New: 3 new settings: audit_log_enabled, multisite_shared_blocklist, multisite_network_settings
* New: 8 new REST API endpoints for audit log and multisite management
* Improved: Multisite-aware activation (activate_single_site, on_new_site via wp_initialize_site hook)
* Improved: Pro models now loaded centrally via pro-models.php

= 1.6.0 =
* New: WAF Learning Mode - records normal request patterns and generates whitelist rules to reduce false positives (Pro)
* New: Extended Protection via auto_prepend_file - blocks threats before WordPress loads for maximum performance (Pro)
* New: WAF rule import/export as JSON for backup and sharing between sites (Pro)
* New: Whitelist (allow) action for WAF rules - whitelisted requests bypass all further WAF checks
* New: Server type auto-detection (Apache, Nginx, LiteSpeed) for Extended Protection installation
* New: Installation wizard for Extended Protection with .user.ini, .htaccess, and manual methods
* New: Automatic config regeneration when firewall data changes (IP blocks, rules, ranges)
* New: Learning Mode progress indicator with days remaining and recorded patterns count
* New: 8 new settings: waf_learning_enabled, waf_learning_started_at, waf_learning_duration_days, waf_learning_auto_start, waf_extended_protection, waf_extended_server_type, waf_extended_method, waf_extended_installed_at
* New: 13 new REST API endpoints for rule export/import, learning mode, and extended protection management
* New: Database table wp_f12sec_waf_learning for learning mode entries
* New: firewall_data_changed action hook for cross-cutting config synchronization

= 1.5.0 =
* New: Multi-channel notifications with Slack webhook integration (Pro)
* New: Telegram bot notification integration (Pro)
* New: Alert rate limiting to prevent notification flooding - configurable max alerts per hour (Pro)
* New: Daily/weekly security digest emails with event counts, top blocked IPs, scan status, and traffic summary (Pro)
* New: WHOIS/RDAP lookup for IP addresses from Firewall, Live Traffic, and Logs pages (Pro)
* New: Test buttons for Email, Slack, and Telegram notification channels
* New: REST API endpoints for notification testing and WHOIS lookups
* New: 8 new settings: notification_max_per_hour, notification_slack_enabled, notification_slack_webhook, notification_telegram_enabled, notification_telegram_bot_token, notification_telegram_chat_id, notification_digest_enabled, notification_digest_frequency

= 1.4.0 =
* New: Live Traffic monitoring with real-time request logging and 5-second polling (Pro)
* New: Live Traffic page with summary badges, filter bar, and data table
* New: Human vs. Bot classification with ~20 User-Agent patterns (search engines, social, monitoring, crawlers)
* New: City-level geolocation via GeoLite2-City database with automatic download (Pro)
* New: Block IPs directly from live traffic entries
* New: Security-only mode to log only 4xx/5xx and login events
* New: Configurable retention period for traffic data (1-168 hours)
* New: REST API endpoints for traffic data, polling, summary, blocking, and clearing
* New: Hourly cron job for automatic traffic data cleanup
* New: 4 new settings: live_traffic_enabled, live_traffic_mode, live_traffic_retention, live_traffic_city_lookup

= 1.3.0 =
* New: Vulnerability detection for plugins and themes via WPVulnerability.net API (Pro)
* New: Abandoned plugin detection - flags plugins removed from WordPress.org or not updated for 2+ years (Pro)
* New: Content scanning for posts, comments, and options - detects injected scripts, phishing URLs, and malicious iframes (Pro)
* New: Heuristic malware detection with scoring system and Shannon entropy analysis (Pro)
* New: Google Safe Browsing integration to check site reputation (Pro)
* New: Vulnerability Scanner page with plugin/theme status overview, content scan results, and Safe Browsing status
* Improved: Malware signature database expanded from 12 to 90+ signatures across 6 categories (Backdoor, Webshell, Phishing, Spam, Crypto-Miner, Obfuscation)
* Improved: File scan results now include heuristic score, malware category, and severity
* Improved: Malware signatures now include category and severity classification
* New: 8 new scanner settings for vulnerability check, abandonment detection, content scanning, heuristic analysis, and Safe Browsing

= 1.2.0 =
* New: CIDR / IP range blocking with IPv4 and IPv6 support (Pro)
* New: 404 rate limiting to detect and block vulnerability scanners (Pro)
* New: GeoIP whitelist mode - allow only selected countries instead of blocking (Pro)
* New: GeoIP bypass URLs to exempt specific paths from country blocking (Pro)
* New: Bot detection with reverse DNS verification for Googlebot, Bingbot, Yandex, Baidu, DuckDuckBot (Pro)
* Improved: IPv6 support verified across all firewall components
* Improved: DDoS config endpoint now includes 404 rate limiting and bot detection settings

= 1.1.0 =
* New: Strong password enforcement with configurable minimum length, uppercase, numbers, and special character requirements
* New: Google reCAPTCHA v3 integration for login, registration, and lost password forms with fail-open strategy
* New: Breached password detection via Have I Been Pwned API using k-Anonymity (Pro)
* New: 2FA grace period allowing users a configurable number of days to set up two-factor authentication (Pro)
* New: Password Policy card in Hardening settings UI
* Improved: Security score now includes strong password and reCAPTCHA toggles
* Security: reCAPTCHA secret key is never exposed to the frontend (filtered from window.f12Security and masked in REST API)

= 1.0.0 =
* Initial release
* Web Application Firewall with custom rules
* IP blocking and country-based blocking
* Rate limiting with separate REST API limits
* Two-factor authentication (TOTP)
* Login brute force protection
* Custom login URL
* Login and comment honeypot protection
* File integrity monitoring with SHA-256 checksums
* Malware signature scanner (12 built-in patterns)
* Database integrity monitoring
* WordPress hardening (20+ settings)
* Security headers (HSTS, CSP, X-Frame-Options, etc.)
* GDPR-compliant IP handling
* Email notifications with scan summaries
* Security event logging with CSV export
* Login activity tracking
* Settings import/export
* Security score calculation
* Modern React-based admin interface

