=== Kwistech Secure ===
Contributors: kwistech
Tags: security, hardening, two-factor, login-security, audit-log
Requires at least: 6.2
Tested up to: 7.1
Requires PHP: 7.4
Stable tag: 1.5.0
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html

Twelve local hardening controls with an audit trail. No account, no telemetry, no third-party requests, nothing to configure elsewhere.

== Description ==

Kwistech Secure hardens a WordPress site using only what WordPress already gives you. It is
not a firewall, not a malware scanner, and not a service — everything it does happens on your
own server, and it never contacts anybody.

**Twelve controls, each independently switchable, each logged when it changes:**

* **Two-factor sign-in** — RFC 6238 authenticator codes with one-time recovery codes. Detects
  an existing 2FA plugin and stays out of the way rather than fighting it.
* **Login rate limiting** — counts failures by IP address *and* by username, so a password
  sprayed across many accounts from one host and one account attacked from a botnet are both
  caught. Usernames are stored only as a salted hash.
* **Non-enumerating login errors** — one identical message for every failure, on the sign-in
  form and the password-reset form alike, so neither confirms which usernames exist.
* **Custom sign-in address** — moves `/wp-login.php`. Tests the new address before saving it
  and provides a break-glass constant, because the obvious way to implement this is also the
  obvious way to lock yourself out.
* **File editor disabled** — removes the most direct path from a stolen administrator session
  to arbitrary code execution.
* **XML-RPC off** — closes a long-standing brute-force amplifier that most sites never use.
* **Application password inventory** — reports how many exist per user, never their values.
  They bypass two-factor, and most people have forgotten the ones they created.
* **Version fingerprints removed** — generator tags, feed generators, `X-Powered-By`.
* **Username discovery blocked** — `?author=1` scans, the REST user list, the user sitemap
  and oEmbed author fields all stop answering anonymous callers. Author archives at their own
  address keep working, and so does everything a signed-in editor needs. The control also
  reports when an account's display name *is* its username, because when that is true the
  byline gives the game away regardless.
* **Security response headers** — `X-Content-Type-Options`, `X-Frame-Options`,
  `Referrer-Policy` and `X-Permitted-Cross-Domain-Policies`, and never overwriting one your
  server or CDN already sends. Deliberately no CSP: any policy that leaves wp-admin usable
  needs `unsafe-inline`, which does not stop the attack CSP exists to stop.
* **Enforced password policy** — a minimum length, and a refusal of passwords containing the
  username or the site name, applied server-side when a password is set or reset. WordPress
  itself only shows a strength meter next to a "confirm use of weak password" checkbox.
* **`unfiltered_html` restricted** — via a capability filter rather than the all-or-nothing
  constant, so administrators keep the workflows they need.

**An append-only activity log** records every setting change with the old value, the new
value, who made it, from what address, and when. There is no edit path and no single-row
delete; the only removal is the scheduled retention sweep, which logs itself.

= What it does not do =

Being clear about this matters more than the feature list:

* It does not scan for malware, and a clean install proves nothing about whether you are
  infected.
* It does not filter traffic. It is not a WAF and cannot stop an attack in progress.
* It does not patch anything. It reports on outdated core and plugins; updating is yours.
* It cannot protect against someone who already has your administrator password *and* your
  second factor.

= Privacy =

This plugin makes **no third-party network request of any kind**. There is no account, no
registration, no telemetry, no usage reporting, no licence check, and no remote access. It
does not phone home because there is nowhere for it to phone.

Two requests are made, both to your own website address: an HTTPS request to your home page
to check whether a security header is present, and a one-off request to a new sign-in address
to confirm it works before saving it. Neither carries anything about you.

Data stored, all locally: the activity log (400 days), failed sign-in attempts with usernames
hashed (30 days), and encrypted two-factor secrets in user meta until you turn the feature
off. Deleting the plugin removes all of it.

= Optional paid add-on =

A separate plugin, **Kwistech Secure — Insurance Evidence Pack**, generates a dated PDF and
JSON report of this site's security posture for an insurance broker. It is sold at
[kwistech.ca](https://kwistech.ca/wordpress-secure) and is not required for anything here.

Everything described on this page is free, works without it, and always will. This plugin
contains no locked functionality.

== Installation ==

1. Upload the plugin through **Plugins → Add New → Upload Plugin**, or install it from the
   directory.
2. Activate it. Ten of the twelve controls switch on immediately at safe defaults.
3. Open **Kwistech Secure → Hardening** and decide on the two that need a decision from you: the
   custom sign-in address and two-factor enforcement. Both default to off, deliberately —
   enabling either without preparation can lock you out of your own site.

No configuration file to edit, no keys to obtain, no account to create.

== Frequently Asked Questions ==

= Will this lock me out? =

Two controls can, and both are built to make that hard. The custom sign-in address makes a
real request to the new address before saving it, and refuses to save if it does not answer;
if the check cannot run, you must tick an acknowledgement, and that choice is recorded.
Two-factor enforcement cannot be switched on until at least one administrator has enrolled,
because requiring codes before anyone has an authenticator locks out everybody at once.

If you are locked out anyway, add `define( 'KWISTECH_SECURE_DISABLE_LOGIN_SLUG', true );` to
`wp-config.php` and `/wp-login.php` works again immediately.

= Does it conflict with my existing security plugin? =

It detects other two-factor plugins and steps aside rather than running a second
implementation. For firewall and scanning plugins there is no overlap — this one does neither.
Switch off any individual control that duplicates something you already have.

= Does it slow the site down? =

The hardening controls are filters and capability checks with no database work on a normal
page load. Nothing runs on the front end except the header changes.

= Does it work on multisite? =

The code paths exist and have never been run on a real network. Treat multisite as
unsupported rather than broken.

= What happens to my data if I uninstall it? =

Deleting the plugin removes its options, both database tables, the scheduled job, and every
user meta key it set, including two-factor secrets and recovery codes. Deactivating changes
nothing. Constants you added to `wp-config.php` by hand are left alone, because this plugin
never edits that file.

= Is the source readable? =

Yes, deliberately. No minified files, no bundled dependencies, no build step, no obfuscation.
A security plugin you cannot read is a security plugin you cannot check.

== Changelog ==

= 1.5.0 =
* Added a one-off notice on this plugin's own screens introducing the Insurance Evidence Pack
  add-on. It appears nowhere else in wp-admin, never appears if the add-on is installed, is
  dismissed permanently and per-user, is styled as information rather than as a warning, and
  loads nothing from anywhere. Developers and agencies can switch it off with the
  `kwistech_secure_show_addon_notice` filter.
* Fixed a test-isolation bug where the active-plugin cache leaked between unit tests.

= 1.4.0 =
* **Three new hardening controls, taking the total to twelve.** All three default to on; none
  of them can lock you out.
  * *Block username discovery.* Closes `?author=N` scans, `/wp-json/wp/v2/users`, the user
    sitemap and oEmbed author fields to anonymous visitors. This was a known gap, recorded in
    the threat model as accepted residual R-5. Refusing to confirm usernames at the sign-in
    form while handing the list out over REST was never a coherent position.
  * *Security response headers.* Four of them, and it will not overwrite a header your server
    or CDN already sends.
  * *Enforced password policy.* Minimum length, and no passwords containing the username or
    the site name. Separators and case are ignored on both sides of that comparison, so
    "Test-Site-2026" is caught for a site called "Test Site". Existing passwords are stored as
    hashes and cannot be assessed; the policy applies from the next time each one is set.
* Added a regression test that the hardening form and its save handler agree on the field
  list. An unchecked checkbox submits nothing, so a field rendered but not read back is
  silently switched off on every save — a bug that only surfaces as "this setting will not
  stay on", and precisely the mistake a three-control release invites.

= 1.3.0 =
* Renamed from "Kwistech WP Secure" to "Kwistech Secure". "WP" is a restricted term that
  cannot appear in a plugin name or slug, so the old name could never have been published.
  The plugin folder is now `kwistech-secure`; if you installed 1.2.0 by hand, delete the old
  `kwistech-wp-secure` folder after upgrading or WordPress will run both copies at once.
  Settings, audit log and login history are untouched -- they are keyed independently of the
  folder name and carry across.
* Extension hooks renamed to match the new slug: `kws_dashboard_cards`,
  `kws_settings_panels`, `kws_admin_notices` and `kws_admin_notice_messages` become
  `kwistech_secure_*`. The opt-out constant `KWS_DISABLE_LOGIN_SLUG` becomes
  `KWISTECH_SECURE_DISABLE_LOGIN_SLUG`. Update the add-on to 1.1.0 at the same time.
* Now requires WordPress 6.2, up from 6.0. Table names in the audit log and login-attempt
  queries are passed through `$wpdb->prepare()` as `%i` identifier placeholders rather than
  interpolated into the SQL string. The old form was safe -- the names were built from
  `$wpdb->prefix` plus a literal -- but "safe because you can read the surrounding code"
  is weaker than "cannot be otherwise", and it needed a static-analysis suppression on every
  query to say so. `%i` arrived in WordPress 6.2.

= 1.2.0 =
* Split into two plugins. The evidence pack is now a separate paid add-on distributed from
  kwistech.ca; this plugin contains no locked functionality and never will.
* Added extension points so add-ons attach through documented hooks rather than patching.
* Settings screen now explains what is stored and for how long, instead of offering a
  retention control that governed data this plugin no longer owns.
* Renamed for clarity. The plugin folder and all settings are unchanged; nothing to migrate.

= 1.1.0 =
* Removed the payment layer entirely. No webhook, no outbound third-party request, no stored
  credential.
* Every entry point is now a capability-checked, nonce-protected wp-admin request.

= 1.0.1 =
* Fixed admin styles being served from cache after an update.

= 1.0.0 =
* Initial release.

== Upgrade Notice ==

= 1.5.0 =
Adds a dismissible notice on this plugin's own screens describing the paid evidence-pack
add-on. It shows nowhere else, never shows if you already have the add-on, and stays gone once
dismissed. No change to any hardening control.

= 1.4.0 =
Three new controls, all on by default and none able to lock you out: username-discovery
blocking, security response headers, and an enforced minimum password length. Existing
settings are untouched. Review them under Kwistech Secure, Hardening.

= 1.3.0 =
Renamed. The folder is now kwistech-secure. If you installed 1.2.0 from a zip, delete the old
kwistech-wp-secure folder or WordPress runs both copies. Settings and audit log carry across.
Update the Insurance Evidence Pack too: its hook names changed.

= 1.2.0 =
Hardening is unchanged and needs no action. If you use the evidence pack, install the separate
Insurance Evidence Pack plugin from kwistech.ca — your licence key and existing packs carry
over untouched.
