Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion admin/partials/detections-page.php
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,8 @@ class="button <?php echo empty($active_range) ? 'button-primary' : ''; ?>">
<input type="checkbox" class="webdecoy-select-ip" value="<?php echo esc_attr($detection['ip_address']); ?>" />
</td>
<td>
<?php echo esc_html(date_i18n(get_option('date_format') . ' ' . get_option('time_format'), strtotime($detection['created_at']))); ?>
<?php // created_at is stored UTC (#55); convert before formatting or the site shows UTC labelled as local. ?>
<?php echo esc_html(date_i18n(get_option('date_format') . ' ' . get_option('time_format'), strtotime(get_date_from_gmt($detection['created_at'])))); ?>
</td>
<td>
<code><?php echo esc_html($detection['ip_address']); ?></code>
Expand Down
9 changes: 9 additions & 0 deletions admin/partials/settings-page.php
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,12 @@
<option value="block" <?php selected($options['tripwire_response'] ?? 'block', 'block'); ?>>
<?php esc_html_e('Block — 403 Forbidden (default)', 'webdecoy'); ?>
</option>
<option value="challenge" <?php selected($options['tripwire_response'] ?? 'block', 'challenge'); ?>>
<?php esc_html_e('Challenge — ask for proof of work instead of blocking', 'webdecoy'); ?>
</option>
<option value="log" <?php selected($options['tripwire_response'] ?? 'block', 'log'); ?>>
<?php esc_html_e('Log only — record the hit and let the request through', 'webdecoy'); ?>
</option>
<option value="notfound" <?php selected($options['tripwire_response'] ?? 'block', 'notfound'); ?>>
<?php esc_html_e('Not Found — 404 (hide that anything is protected)', 'webdecoy'); ?>
</option>
Expand All @@ -261,6 +267,9 @@
<p class="description">
<?php esc_html_e('Decoy mode serves fake .env / wp-config / SQL-dump / phpinfo content seeded with unique, per-site canary credentials — a later login attempt using one is logged as a critical exfiltration detection. Decoy/Not-Found/Tarpit deliberately keep feeding the scanner (no local IP block) to gather more evidence. Tarpit ties up a PHP worker for up to 10s — use sparingly.', 'webdecoy'); ?>
</p>
<p class="description">
<?php esc_html_e('Challenge asks the visitor to solve a small proof-of-work in their browser. It needs JavaScript and a click, so nothing automated can complete it — on a tripwire that is the point, since these paths exist nowhere on your site and no honest crawler requests them. Log only records the hit and changes nothing about the response, which is the safest way to watch a tripwire you have just armed.', 'webdecoy'); ?>
</p>
</td>
</tr>
<tr>
Expand Down
61 changes: 61 additions & 0 deletions bin/deploy-wporg.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
161 changes: 161 additions & 0 deletions bin/release-all.sh
Original file line number Diff line number Diff line change
@@ -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 <version> # dry run — build, verify, publish nothing
# bin/release-all.sh <version> --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 <v> CDN variant -> dist/<slug>-<v>.zip
# + cdn-files/update-info.json (sha256 of THAT zip)
# ./build.sh <v> --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 <version> [--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
4 changes: 2 additions & 2 deletions cdn-files/plugin-info.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
{
"name": "WebDecoy Bot Detection",
"slug": "webdecoy",
"version": "2.3.2",
"version": "2.3.3",
"author": "<a href=\"https://webdecoy.com\">WebDecoy</a>",
"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": "<p>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.</p><h4>Key Features</h4><ul><li>Deterministic tripwires (hidden honeypot paths) — zero-false-positive bot blocking</li><li>Server-side and client-side bot detection</li><li>Invisible proof-of-work challenge (no external CAPTCHA service)</li><li>Comment, login, and registration spam protection</li><li>WooCommerce carding attack prevention</li><li>60+ good bots automatically allowed</li><li>AI crawler detection and blocking</li><li>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)</li></ul>",
"installation": "<ol><li>Upload the plugin files to <code>/wp-content/plugins/webdecoy</code></li><li>Activate the plugin through the Plugins menu</li><li>Tripwires and local protection are active out of the box — no API key required</li><li>Optionally go to WebDecoy &gt; Settings &gt; WebDecoy Cloud to connect for centralized monitoring and enforcement</li></ol>",
Expand Down
4 changes: 4 additions & 0 deletions changelog.txt
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
14 changes: 13 additions & 1 deletion includes/class-webdecoy-detector.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
]);
}

Expand Down
4 changes: 2 additions & 2 deletions includes/class-webdecoy-woocommerce.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
]);
}

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading