From d26eae88d3ba792105800bdc82e812ec3c95d98f Mon Sep 17 00:00:00 2001 From: Chris Portscheller Date: Wed, 29 Jul 2026 21:50:41 -0500 Subject: [PATCH] feat: tripwire challenge/log, UTC timestamps, and a release script that can't repeat my mistakes (#53 #55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #53 — tripwire_response accepts challenge and log. block_action has always accepted both; the deterministic path, the one backed by proof rather than a score, had only the harsher options. `log` records and returns — note this is NOT the tripwire_dry_run setting, which collapses to ALLOW inside the rule (TripwireRule.php:139) before RuleEngine can record it, so dry run is silent where this is observable. `challenge` serves the existing proof-of-work interstitial. Documented rather than papered over: the challenge binds startChallenge to a click and needs JavaScript, so no automated client can ever complete it. On a tripwire that is the intent — the path exists nowhere and is reachable only from a hidden element or a robots disallow — but it is still a denial for any non-JS caller, which is why block stays the default. Deliberately did NOT gate it on GoodBotList::verifyBotIP() or isGoodBot(): both return true on User-Agent alone for bots they have no verification rule for, so either would be a trivially spoofable bypass. #55 — all eight created_at writers now store UTC, and the one display site converts with get_date_from_gmt(). They were current_time('mysql') (site-local) while readers built 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. Existing rows are NOT migrated: shifting by the current offset is wrong for any site that has 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). The reasoning is recorded at the canonical writer. Release tooling. bin/deploy-wporg.sh now refuses to continue if trunk carries anything WordPress.org forbids, and asserts the staged version is the one requested. This is not hypothetical: syncing from a build/ tree left behind by release.sh stages includes/class-webdecoy-updater.php — a self-updater fetching from our own CDN, an outright guideline violation — and rsync had already carried a local .claude directory into trunk. Both were caught by eye before commit; now they are caught by the script. New bin/release-all.sh does both channels in the only safe order, because the two build variants overwrite each other and the ZIP is not byte-reproducible (mtimes land in the archive). update-info.json must therefore ship from the same pass that produced the zip it names, or the self-hosted updater rejects the download for every Pro install on a hash mismatch. CDN first (build, verify hash, upload zip before manifest, verify what the CDN serves), then wp.org, which rebuilds --org itself. Preflight refuses a dirty tree, a non-main branch, a branch out of sync with origin, or a version not declared consistently across webdecoy.php, readme.txt and changelog.txt. 80/80 tests, phpcs baseline unchanged, phpstan unchanged. --- admin/partials/detections-page.php | 3 +- admin/partials/settings-page.php | 9 ++ bin/deploy-wporg.sh | 61 +++++++++ bin/release-all.sh | 161 ++++++++++++++++++++++++ cdn-files/plugin-info.json | 4 +- changelog.txt | 4 + includes/class-webdecoy-detector.php | 14 ++- includes/class-webdecoy-woocommerce.php | 4 +- readme.txt | 6 +- webdecoy.php | 51 ++++++-- 10 files changed, 302 insertions(+), 15 deletions(-) create mode 100755 bin/release-all.sh diff --git a/admin/partials/detections-page.php b/admin/partials/detections-page.php index a2ca82d..7a32eca 100644 --- a/admin/partials/detections-page.php +++ b/admin/partials/detections-page.php @@ -275,7 +275,8 @@ class="button "> - + + diff --git a/admin/partials/settings-page.php b/admin/partials/settings-page.php index f18388c..7ffecf1 100644 --- a/admin/partials/settings-page.php +++ b/admin/partials/settings-page.php @@ -248,6 +248,12 @@ + + @@ -261,6 +267,9 @@

+

+ +

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.

Key Features

  • Deterministic tripwires (hidden honeypot paths) — zero-false-positive bot blocking
  • Server-side and client-side bot detection
  • 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": "
  1. Upload the plugin files to /wp-content/plugins/webdecoy
  2. Activate the plugin through the Plugins menu
  3. Tripwires and local protection are active out of the box — no API key required
  4. 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);