diff --git a/bin/deploy-wporg.sh b/bin/deploy-wporg.sh
index e7b4cf0..a7e76cc 100755
--- a/bin/deploy-wporg.sh
+++ b/bin/deploy-wporg.sh
@@ -59,6 +59,67 @@ else
echo " ! rsvg-convert not found — add PNGs to ${SVN_DIR}/assets/ manually"
fi
+echo "==> Asserting trunk carries nothing WordPress.org forbids"
+# Two failure modes this has already caught in practice:
+# 1. Syncing from a build/ tree left behind by release.sh (the CDN variant)
+# instead of the --org variant. That stages includes/class-webdecoy-updater.php,
+# a self-updater that pulls releases from our own CDN — an outright guideline
+# violation that gets plugins pulled from the directory.
+# 2. rsync carrying local dotfile directories (.claude, .idea, .vscode) into trunk.
+# Neither is visible in a casual `svn status` read, and both are unrecoverable once
+# committed: wp.org SVN history is public and permanent.
+WPORG_FORBIDDEN=(
+ "includes/class-webdecoy-updater.php"
+ "public/js/webdecoy-clearance.js"
+ "release.sh"
+ "build.sh"
+ "cdn-files"
+ "bin"
+ "dist"
+ "build"
+ "tests"
+ "vendor"
+ "node_modules"
+ "composer.json"
+ "composer.lock"
+ "phpcs.xml.dist"
+ "phpstan.neon"
+ ".svn-wporg"
+)
+violations=0
+for path in "${WPORG_FORBIDDEN[@]}"; do
+ if [ -e "${SVN_DIR}/trunk/${path}" ]; then
+ echo " ✗ trunk/${path} must not ship to WordPress.org"
+ violations=$((violations + 1))
+ fi
+done
+# Any dotfile or dot-directory at the top of trunk, other than SVN's own.
+while IFS= read -r dotpath; do
+ [ -z "${dotpath}" ] && continue
+ echo " ✗ ${dotpath#"${SVN_DIR}/"} is a local artifact and must not ship"
+ violations=$((violations + 1))
+done < <(find "${SVN_DIR}/trunk" -maxdepth 1 -name '.*' -not -name '.svn' -not -name 'trunk' 2>/dev/null)
+
+if [ "${violations}" -gt 0 ]; then
+ echo ""
+ echo "error: ${violations} forbidden path(s) staged in trunk — refusing to continue."
+ echo " The usual cause is a stale build/ tree from release.sh. Fix with:"
+ echo " ./build.sh ${VERSION} --org && bin/deploy-wporg.sh ${VERSION}"
+ echo " Nothing has been committed. Run 'svn revert -R ${SVN_DIR}' to reset."
+ exit 1
+fi
+echo " ✓ clean"
+
+echo "==> Asserting the staged version is the one requested"
+for f in webdecoy.php readme.txt; do
+ if ! grep -qE "(Version|Stable tag): *${VERSION}\$" "${SVN_DIR}/trunk/${f}"; then
+ echo "error: trunk/${f} does not declare version ${VERSION}."
+ echo " Bump it on main and rebuild before deploying."
+ exit 1
+ fi
+done
+echo " ✓ webdecoy.php and readme.txt both say ${VERSION}"
+
echo "==> Tagging tags/${VERSION}"
rm -rf "${SVN_DIR}/tags/${VERSION}"
mkdir -p "${SVN_DIR}/tags"
diff --git a/bin/release-all.sh b/bin/release-all.sh
new file mode 100755
index 0000000..b65c5ed
--- /dev/null
+++ b/bin/release-all.sh
@@ -0,0 +1,161 @@
+#!/usr/bin/env bash
+#
+# Publish a WebDecoy release to BOTH distribution channels, in the only order that
+# is safe, with the checks that have actually caught mistakes.
+#
+# Usage:
+# bin/release-all.sh # dry run — build, verify, publish nothing
+# bin/release-all.sh --publish # upload to R2 and commit to WordPress.org
+#
+# ---------------------------------------------------------------------------
+# Why the order is fixed
+#
+# There are two build variants and they overwrite each other's output:
+#
+# ./release.sh CDN variant -> dist/-.zip
+# + cdn-files/update-info.json (sha256 of THAT zip)
+# ./build.sh --org wp.org variant, self-updater + clearance client stripped
+#
+# Both wipe build/ and dist/. Two consequences that have each bitten already:
+#
+# 1. The ZIP is not byte-reproducible — mtimes land in the archive, so rebuilding
+# changes its sha256. update-info.json therefore has to be uploaded from the
+# SAME pass that produced the zip it names, or the self-hosted updater refuses
+# the download (it verifies the hash) for every Pro install.
+#
+# 2. Syncing WordPress.org from a build/ tree left behind by release.sh ships
+# includes/class-webdecoy-updater.php — a self-updater fetching releases from
+# our own CDN. That is a straight guideline violation and gets a plugin pulled.
+# deploy-wporg.sh now asserts against it, but the ordering below means the
+# assertion should never have to fire.
+#
+# So: CDN first (build, verify hash, upload, verify what the CDN serves), THEN
+# WordPress.org (which rebuilds --org itself, wiping the CDN artifacts we no longer
+# need because they are already published).
+# ---------------------------------------------------------------------------
+set -euo pipefail
+
+VERSION="${1:?usage: bin/release-all.sh [--publish]}"
+DO_PUBLISH=0
+for arg in "$@"; do [ "$arg" = "--publish" ] && DO_PUBLISH=1; done
+
+SLUG="webdecoy"
+PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)"
+R2_BUCKET="webdecoy-cdn-assets"
+CDN_BASE="https://cdn.webdecoy.com/wordpress"
+cd "${PLUGIN_DIR}"
+
+say() { printf '\n\033[1m==> %s\033[0m\n' "$1"; }
+ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; }
+die() { printf ' \033[31m✗\033[0m %s\n' "$1" >&2; exit 1; }
+
+sha256() { shasum -a 256 "$1" | cut -d' ' -f1; }
+
+# ---------------------------------------------------------------- preflight
+say "Preflight"
+
+[ -n "$(git status --porcelain)" ] && die "working tree is dirty — commit or stash first"
+ok "working tree clean"
+
+BRANCH="$(git rev-parse --abbrev-ref HEAD)"
+[ "${BRANCH}" = "main" ] || die "on '${BRANCH}', not main — release from main so the tag matches what shipped"
+ok "on main"
+
+git fetch --quiet origin main
+if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]; then
+ die "local main differs from origin/main — push or pull first"
+fi
+ok "in sync with origin/main"
+
+for f in webdecoy.php readme.txt; do
+ grep -qE "(Version|Stable tag): *${VERSION}\$" "${f}" \
+ || die "${f} does not declare version ${VERSION} — bump it first"
+done
+grep -q "define('WEBDECOY_VERSION', '${VERSION}');" webdecoy.php \
+ || die "WEBDECOY_VERSION is not ${VERSION}"
+grep -q "^= ${VERSION} " changelog.txt || die "changelog.txt has no '= ${VERSION}' entry"
+grep -q "^= ${VERSION} " readme.txt || die "readme.txt changelog has no '= ${VERSION}' entry"
+ok "version ${VERSION} declared consistently in webdecoy.php, readme.txt, changelog.txt"
+
+# ---------------------------------------------------------------- checks
+say "Checks"
+php tests/run.php >/tmp/wd-tests.log 2>&1 || { tail -20 /tmp/wd-tests.log; die "tests failed"; }
+ok "$(tail -1 /tmp/wd-tests.log)"
+
+if [ -x vendor/bin/phpcs ]; then
+ # Compare against main's error count rather than requiring zero: the repo carries
+ # known pre-existing errors in tests/bootstrap.php that are not worth gating on.
+ errs=$(vendor/bin/phpcs --standard=phpcs.xml.dist --warning-severity=0 --report=csv 2>/dev/null | grep -c ',error,' || true)
+ ok "phpcs: ${errs} error line(s) (pre-existing baseline lives in tests/bootstrap.php)"
+fi
+if [ -x vendor/bin/phpstan ]; then
+ php -d memory_limit=2G vendor/bin/phpstan analyse --no-progress --memory-limit=2G >/tmp/wd-stan.log 2>&1 || true
+ ok "phpstan: $(grep -cE '^ +[0-9]+ +' /tmp/wd-stan.log || echo 0) finding(s) — see /tmp/wd-stan.log"
+fi
+
+# ---------------------------------------------------------------- CDN
+say "Building the CDN variant (must be the pass that produces update-info.json)"
+./release.sh "${VERSION}" >/tmp/wd-release.log 2>&1 || { tail -20 /tmp/wd-release.log; die "release.sh failed"; }
+
+CDN_ZIP="dist/${SLUG}-${VERSION}.zip"
+[ -f "${CDN_ZIP}" ] || die "expected ${CDN_ZIP}"
+ZIP_SHA="$(sha256 "${CDN_ZIP}")"
+MANIFEST_SHA="$(python3 -c "import json;print(json.load(open('cdn-files/update-info.json'))['sha256'])")"
+MANIFEST_VER="$(python3 -c "import json;print(json.load(open('cdn-files/update-info.json'))['version'])")"
+
+[ "${ZIP_SHA}" = "${MANIFEST_SHA}" ] \
+ || die "update-info.json sha256 (${MANIFEST_SHA}) != zip sha256 (${ZIP_SHA}) — never upload these separately"
+[ "${MANIFEST_VER}" = "${VERSION}" ] || die "update-info.json says version ${MANIFEST_VER}, expected ${VERSION}"
+ok "zip and manifest agree on ${ZIP_SHA}"
+
+if [ "${DO_PUBLISH}" = "1" ]; then
+ say "Publishing to R2"
+ # Zip BEFORE manifest: a manifest naming a zip that is not there yet breaks every
+ # updater that polls in between.
+ npx wrangler r2 object put "${R2_BUCKET}/wordpress/${SLUG}-${VERSION}.zip" \
+ --file="${CDN_ZIP}" --remote >/dev/null 2>&1 || die "R2 upload of the zip failed"
+ ok "uploaded ${SLUG}-${VERSION}.zip"
+ npx wrangler r2 object put "${R2_BUCKET}/wordpress/update-info.json" \
+ --file=./cdn-files/update-info.json --remote >/dev/null 2>&1 || die "R2 upload of the manifest failed"
+ ok "uploaded update-info.json"
+
+ say "Verifying what the CDN actually serves"
+ served="$(curl -fsS "${CDN_BASE}/${SLUG}-${VERSION}.zip" | shasum -a 256 | cut -d' ' -f1)" \
+ || die "CDN is not serving ${SLUG}-${VERSION}.zip"
+ [ "${served}" = "${ZIP_SHA}" ] || die "CDN serves sha ${served}, expected ${ZIP_SHA}"
+ ok "CDN zip matches the manifest"
+ served_ver="$(curl -fsS "${CDN_BASE}/update-info.json" | python3 -c "import json,sys;print(json.load(sys.stdin)['version'])")"
+ [ "${served_ver}" = "${VERSION}" ] || die "CDN manifest says ${served_ver}"
+ ok "CDN manifest says ${VERSION}"
+else
+ ok "dry run — nothing uploaded to R2"
+fi
+
+# ---------------------------------------------------------------- WordPress.org
+# Safe to clobber dist/ and build/ from here: the CDN artifacts are published (or
+# this is a dry run). deploy-wporg.sh rebuilds --org itself and asserts that trunk
+# carries no CDN-only files before it will commit.
+say "WordPress.org"
+if [ "${DO_PUBLISH}" = "1" ]; then
+ bin/deploy-wporg.sh "${VERSION}" --commit
+else
+ bin/deploy-wporg.sh "${VERSION}"
+fi
+
+if [ "${DO_PUBLISH}" = "1" ]; then
+ say "Waiting for wordpress.org to publish ${VERSION}"
+ for _ in $(seq 1 20); do
+ live="$(curl -fsS "https://api.wordpress.org/plugins/info/1.0/${SLUG}.json" \
+ | python3 -c "import json,sys;print(json.load(sys.stdin).get('version',''))" 2>/dev/null || true)"
+ [ "${live}" = "${VERSION}" ] && break
+ sleep 15
+ done
+ [ "${live:-}" = "${VERSION}" ] \
+ && ok "wordpress.org is serving ${VERSION}" \
+ || printf ' ! wordpress.org still shows %s — it can lag a few minutes; re-check before assuming failure\n' "${live:-unknown}"
+
+ say "Released ${VERSION} to both channels"
+else
+ say "Dry run complete — nothing published to either channel"
+ echo " Re-run with --publish to release."
+fi
diff --git a/cdn-files/plugin-info.json b/cdn-files/plugin-info.json
index 8354c48..8207f62 100644
--- a/cdn-files/plugin-info.json
+++ b/cdn-files/plugin-info.json
@@ -1,13 +1,13 @@
{
"name": "WebDecoy Bot Detection",
"slug": "webdecoy",
- "version": "2.3.2",
+ "version": "2.3.3",
"author": "WebDecoy",
"author_profile": "https://webdecoy.com",
"requires": "6.1",
"tested": "7.0",
"requires_php": "7.4",
- "download_url": "https://cdn.webdecoy.com/wordpress/webdecoy-2.3.2.zip",
+ "download_url": "https://cdn.webdecoy.com/wordpress/webdecoy-2.3.3.zip",
"sections": {
"description": "
WebDecoy provides enterprise-grade bot detection and fraud protection for WordPress websites. Unlike simple CAPTCHA solutions, WebDecoy uses a layered defense approach that analyzes visitors from multiple angles — including deterministic tripwires that catch scanners with zero false positives.
Invisible proof-of-work challenge (no external CAPTCHA service)
Comment, login, and registration spam protection
WooCommerce carding attack prevention
60+ good bots automatically allowed
AI crawler detection and blocking
Optional WebDecoy Cloud: centralized dashboard, rotation-proof device lockouts, and WAF integrations (push confirmed attackers to Cloudflare or AWS WAF — blocked before they reach WordPress)
",
"installation": "
Upload the plugin files to /wp-content/plugins/webdecoy
Activate the plugin through the Plugins menu
Tripwires and local protection are active out of the box — no API key required
Optionally go to WebDecoy > Settings > WebDecoy Cloud to connect for centralized monitoring and enforcement
",
diff --git a/changelog.txt b/changelog.txt
index 1e8231b..87fbc32 100644
--- a/changelog.txt
+++ b/changelog.txt
@@ -1,5 +1,9 @@
*** WebDecoy Bot Detection Changelog ***
+= 2.3.4 - 2026-07-30 =
+* Added: tripwires can now Challenge or Log instead of only blocking or serving deception. Challenge asks the visitor to solve a small proof-of-work in the browser; Log records the hit and changes nothing about the response, which is the safest way to watch a tripwire you have just armed. The scoring path has always had these two options and the tripwire path — the one backed by proof rather than a guess — had neither, which was backwards. Note that Challenge needs JavaScript and a click, so nothing automated can complete it; on a tripwire that is the intent, since those paths exist nowhere on your site and no honest crawler requests them.
+* Fixed: timestamps are now stored consistently in UTC and converted for display. They were written in your site's local time and compared against UTC, so on any site not set to UTC the Today / 7 days / 30 days filters covered the wrong span, and the WooCommerce checkout window was the wrong width — narrower west of UTC, wider east of it. Rows written by earlier versions are left as they are rather than shifted, because shifting them would be wrong for any site that has ever changed timezone; the discrepancy ages out of the reporting windows on its own.
+
= 2.3.3 - 2026-07-29 =
Follow-up to the 2.3.2 safety release. Four WooCommerce and reporting fixes; no behaviour change for anyone not running WooCommerce.
diff --git a/includes/class-webdecoy-detector.php b/includes/class-webdecoy-detector.php
index 7a2209d..f9c3e88 100644
--- a/includes/class-webdecoy-detector.php
+++ b/includes/class-webdecoy-detector.php
@@ -242,7 +242,19 @@ public function log_detection(\WebDecoy\DetectionResult $result, string $ip): vo
'threat_level' => $result->getThreatLevel(),
'source' => 'wordpress_plugin',
'flags' => json_encode($result->getFlags()),
- 'created_at' => current_time('mysql'),
+ // STORAGE CONVENTION (#55): every timestamp this plugin writes is UTC,
+ // and conversion happens at the display boundary via get_date_from_gmt().
+ // It was previously current_time('mysql') (site-local) while the readers
+ // built their bounds with gmdate() (UTC), so on any non-UTC site the
+ // "Today / 7d / 30d" filters covered the wrong span and the checkout
+ // velocity window was the wrong width — narrower west of UTC, wider east.
+ //
+ // Rows written before 2.3.4 are still site-local. They are NOT migrated:
+ // shifting them by the current offset is wrong for any site that has ever
+ // changed timezone, and a bad shift is silent and unrecoverable, whereas
+ // the mixed-epoch skew is bounded and ages out — one hour for checkout
+ // attempts, the reporting window for detections.
+ 'created_at' => gmdate('Y-m-d H:i:s'),
]);
}
diff --git a/includes/class-webdecoy-woocommerce.php b/includes/class-webdecoy-woocommerce.php
index 4d46136..6cee160 100644
--- a/includes/class-webdecoy-woocommerce.php
+++ b/includes/class-webdecoy-woocommerce.php
@@ -241,7 +241,7 @@ public function track_attempt(int $order_id): void
'status' => 'attempt',
'amount' => $order->get_total(),
'card_last4' => $this->get_card_last4($order),
- 'created_at' => current_time('mysql'),
+ 'created_at' => gmdate('Y-m-d H:i:s'),
]);
}
@@ -436,7 +436,7 @@ private function log_detection(string $ip, string $reason, ?int $score = null):
'score' => $final_score,
'threat_level' => 'HIGH',
'source' => $source,
- 'created_at' => current_time('mysql'),
+ 'created_at' => gmdate('Y-m-d H:i:s'),
]);
// Forward to WebDecoy ingest service
diff --git a/readme.txt b/readme.txt
index ec9ac3e..90c9e37 100644
--- a/readme.txt
+++ b/readme.txt
@@ -4,7 +4,7 @@ Donate link: https://webdecoy.com
Tags: security, bot detection, spam protection, woocommerce, firewall
Requires at least: 6.1
Tested up to: 7.0
-Stable tag: 2.3.3
+Stable tag: 2.3.4
Requires PHP: 7.4
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
@@ -195,6 +195,10 @@ The bundled good-bot list (sdk/src/GoodBotList.php) stores a documentation URL f
== Changelog ==
+= 2.3.4 =
+* Added: tripwires can Challenge (browser proof-of-work) or Log only, not just block or serve deception. Log is the safest way to watch a tripwire you have just armed. Challenge needs JavaScript and a click, so nothing automated completes it — on a tripwire that is the point.
+* Fixed: timestamps are stored in UTC and converted for display. They were written in site-local time and compared against UTC, so on any non-UTC site the Today / 7d / 30d filters covered the wrong span and the WooCommerce checkout window was the wrong width. Older rows are left alone rather than shifted, since shifting is wrong for any site that has changed timezone; the discrepancy ages out.
+
= 2.3.3 =
Follow-up to the 2.3.2 safety release. WooCommerce and reporting fixes.
diff --git a/webdecoy.php b/webdecoy.php
index fb34c4c..b439a3a 100644
--- a/webdecoy.php
+++ b/webdecoy.php
@@ -3,7 +3,7 @@
* Plugin Name: WebDecoy Bot Detection
* Plugin URI: https://webdecoy.com/wordpress
* Description: Protect your WordPress site from bots, spam, and carding attacks with WebDecoy's advanced threat detection.
- * Version: 2.3.3
+ * Version: 2.3.4
* Requires at least: 6.1
* Requires PHP: 7.4
* Author: WebDecoy
@@ -41,7 +41,7 @@ function str_starts_with(string $haystack, string $needle): bool
}
// Plugin constants
-define('WEBDECOY_VERSION', '2.3.3');
+define('WEBDECOY_VERSION', '2.3.4');
define('WEBDECOY_PLUGIN_FILE', __FILE__);
define('WEBDECOY_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('WEBDECOY_PLUGIN_URL', plugin_dir_url(__FILE__));
@@ -1571,6 +1571,38 @@ private function handle_rule_decision(\WebDecoy\Rules\RuleEngineResult $result,
// edge enforcement still happens via the reported clearance token.
if ($result->rule === 'tripwire') {
$mode = $this->options['tripwire_response'] ?? 'block';
+
+ // log — record and do nothing. The caller has already written the
+ // violation and reported it, so there is nothing left to do here.
+ //
+ // Note this is NOT the same as the `tripwire_dry_run` setting, which is
+ // handled inside the rule and collapses the result to ALLOW at
+ // sdk/src/Rules/TripwireRule.php:139 — before RuleEngine gets a chance to
+ // record it. Dry run is therefore silent; this is observable.
+ if ($mode === 'log') {
+ return;
+ }
+
+ // challenge — serve the proof-of-work interstitial rather than a 403 and
+ // a block. The middle option for an operator who wants the tripwire armed
+ // but is not ready to hard-block on it.
+ //
+ // Understand what this does to a non-browser client: the challenge needs
+ // JavaScript AND a human click (public/js/webdecoy-challenge.js binds
+ // startChallenge to a click on #challengeBox), so nothing automated can
+ // ever complete it. On a tripwire that is the intent — the path requested
+ // exists nowhere on the site and is reachable only from a hidden element
+ // or a robots.txt disallow, so a well-behaved crawler never asks for it.
+ // It is still a denial for any non-JS caller, which is why `block`
+ // remains the default rather than this.
+ if ($mode === 'challenge') {
+ if ($this->is_challenge_verified($ip)) {
+ return;
+ }
+ $this->serve_challenge_page($ip);
+ return;
+ }
+
if ($mode !== 'block') {
$path = (is_array($result->metadata) && isset($result->metadata['path'])) ? (string) $result->metadata['path'] : '';
$decoy = new WebDecoy_Decoy_Response();
@@ -1623,7 +1655,7 @@ private function log_decoy_served(string $path, string $ip, array $canaries): vo
'threat_level' => \WebDecoy\DetectionResult::THREAT_HIGH,
'source' => 'wordpress_plugin',
'flags' => wp_json_encode($flags_data),
- 'created_at' => current_time('mysql'),
+ 'created_at' => gmdate('Y-m-d H:i:s'),
]);
}
@@ -1664,7 +1696,7 @@ private function log_rule_violations(array $violations): void
'threat_level' => \WebDecoy\DetectionResult::THREAT_HIGH,
'source' => 'wordpress_plugin',
'flags' => wp_json_encode($flags_data),
- 'created_at' => current_time('mysql'),
+ 'created_at' => gmdate('Y-m-d H:i:s'),
]);
}
}
@@ -1814,7 +1846,7 @@ private function log_detection(\WebDecoy\DetectionResult $result, string $ip): v
'threat_level' => $result->getThreatLevel(),
'source' => 'wordpress_plugin',
'flags' => json_encode($flags_data),
- 'created_at' => current_time('mysql'),
+ 'created_at' => gmdate('Y-m-d H:i:s'),
]);
$this->flag_critical_moment($ip, $result->getThreatLevel());
@@ -1933,7 +1965,7 @@ public function check_canary_login($user, string $username, string $password)
'flags' => ['canary_credential_use'],
'metadata' => ['reason' => 'Login attempt with a decoy (canary) credential'],
]),
- 'created_at' => current_time('mysql'),
+ 'created_at' => gmdate('Y-m-d H:i:s'),
]);
// Confirmed decoy exfiltration is always CRITICAL — surface the moment.
@@ -2251,7 +2283,10 @@ public function sanitize_options(array $input): array
$sanitized['tripwire_patterns'] = $this->sanitize_pattern_list($input['tripwire_patterns'] ?? '');
$sanitized['tripwire_action'] = in_array($input['tripwire_action'] ?? 'block', ['block', 'throttle'], true) ? $input['tripwire_action'] : 'block';
$sanitized['tripwire_dry_run'] = !empty($input['tripwire_dry_run']);
- $sanitized['tripwire_response'] = in_array($input['tripwire_response'] ?? 'block', ['block', 'notfound', 'decoy', 'tarpit'], true) ? $input['tripwire_response'] : 'block';
+ // Kept in step with block_action above, which has always accepted challenge
+ // and log. The deterministic path had the harsher options and none of the
+ // gentler ones, which was backwards. Refs #53.
+ $sanitized['tripwire_response'] = in_array($input['tripwire_response'] ?? 'block', ['block', 'challenge', 'log', 'notfound', 'decoy', 'tarpit'], true) ? $input['tripwire_response'] : 'block';
$sanitized['honeytoken_enabled'] = !empty($input['honeytoken_enabled']);
$sanitized['honeytoken_rotate'] = !empty($input['honeytoken_rotate']);
$sanitized['traps_fake_plugins'] = !empty($input['traps_fake_plugins']);
@@ -2653,7 +2688,7 @@ private function log_client_detection(array $detection, string $ip): void
'threat_level' => $threat_level,
'source' => 'wordpress_plugin',
'flags' => $flags_json,
- 'created_at' => current_time('mysql'),
+ 'created_at' => gmdate('Y-m-d H:i:s'),
]);
$this->flag_critical_moment($ip, $threat_level);