metrictower / funnypot-wordpress
WordPress plugin for funnypot: normalise the WP request, ask the funnypot-policy decision engine, execute the Decision (allow / log / block / deceive). Owns no decision logic.
Package info
github.com/metrictower/funnypot-wordpress
Type:wordpress-plugin
pkg:composer/metrictower/funnypot-wordpress
Requires
- php: >=7.3
- metrictower/funnypot-core: ^0.3
- metrictower/funnypot-mainnet-client: ^0.1
- metrictower/funnypot-policy: ^0.1
Requires (Dev)
- brain/monkey: ^2.6
- mockery/mockery: ^1.5
- phpunit/phpunit: ^9.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-20 20:52:52 UTC
README
Not sure you're in the right place?
- Want a ready-to-run honeypot box to deploy → funnypot-app
- Protecting a Laravel app → funnypot-laravel
- Protecting a WordPress site → funnypot-wordpress ← you are here
- Detection and IP reporting in any PHP app, batteries included → funnypot
- Embedding the deception/detection engine in your own PHP / PSR-15 app → funnypot-core
- Querying / reporting to the IP-reputation service from code (the SDK) → funnypot-mainnet-client
- Building on the low-level decision/policy engine → funnypot-policy
A thin WordPress adapter over the metrictower/funnypot-policy decision
engine. The plugin does not decide whether a request is an attack, whether to deceive, block, or
report — that is the shared policy engine's job. D only:
- normalizes the WP request into a neutral
RequestEvidence+ aWpSiteProfile(the real-route oracle — the one job only WordPress can do), - asks
Funnypot\Policy\PolicyEngine::evaluate()for aDecision, and - executes that
Decision:allow/log→ WordPress proceeds;deceive→ emit core's byte-exact fake and exit before the theme loads;block→ emit an honest app-chosen403.
All decision logic (cheapest-first precedence, learn-then-enforce, pin/TTL, report suppression) lives
once in funnypot-policy. The deception content is core's (metrictower/funnypot-core), unchanged.
Status
Inert by default. A fresh install decides nothing until an operator enables a posture. Reputation
checking and reporting are each off, and both require a MAINNET_KEY.
The plugin code is PHP 7.3-clean (old WP hosts run old PHP), and the bundled funnypot-core is now
PHP 7.3+ too — the 7.3 re-floor and the two-phase classify()/synthesize() split shipped in core
v0.0.1.
Install
As a downloadable zip (recommended for WP hosts)
bin/build.sh (see Build) produces build/funnypot-wordpress.zip with
vendor/metrictower/{funnypot-policy,funnypot-core,mainnet-client}/ and core's rules artifacts
bundled. Upload it under Plugins → Add New → Upload Plugin, then activate.
As a Composer package
composer require metrictower/funnypot-wordpress
On activation the plugin creates its tables, generates a per-install sensor_id, and copies a
must-use loader shim into wp-content/mu-plugins/ so the BEFORE position runs at the earliest hook. If
that directory is not writable it falls back to plugins_loaded and raises an admin notice.
Configure
Settings → Honeypot produces the policy config array. Key choices:
-
Posture:
honeypot(FALLBACK — upgrade a genuine 404, FP-free),WAF(BEFORE — block/deceive ahead of routing), orboth. -
Response mode — the primary behaviour selector, composed in the plugin from two engine seams:
stealth— capture-only. The hit is logged + reported, then WordPress serves its own plain 404 on every band (every non-allowaction band is clamped tolog, decoys are forced off). No decoy, no 403 — the lowest fingerprint, pure intel.realistic(default) — byte-exact core template fakes, versioned decoys, and the authed wp-admin skin when armed. Reproduces the plugin's prior default behaviour exactly.taunt— the troll "nice try" persona layered over the same decoy; still only ever upgrades a 404 (any engine fault degrades to a plain 404, never a 5xx).blocked— the structural inverse of stealth: every non-allowband is clamped up toblock, so instead of a decoy the plugin serves a generic, vendor-neutral "access denied" HTML 403 page. This is the posture for a deployment that should look hardened rather than lure. Tradeoff (opt-in): a block page advertises a defense — a site that 403s where a plain WordPress site would 404 signals that something is filtering, which reverses the honeypot's stay-hidden stance. It can also 403 a borderlinesuspiciousvisitor (a false-positive risk). The page carries no WAF/product branding and no scanner/CRS signature; the only per-request variation is an inert reference token.allow(clean traffic) and the relocated-loginsafe_pathsare left intact, so blocked never locks out real users or the operator. Blocked serves no decoy.
The old
response_stylefield is folded intoresponse_mode: on upgrade an install with no saved mode derives it from its legacy style (taunt→taunt,realistic/minimal→realistic). A legacyminimalinstall therefore moves torealistic, which serves richer fake bodies than core's terseminimaltokens — intentional, since coreminimalstill emits a matcher-satisfying fake and is not the capture-only stealth mode. -
Decoys —
decoy_xmlrpcanddecoy_wp_login(both off by default) toggle the xmlrpc and wp-login decoys;decoy_session_key(a per-deploy secret) arms the wp-login mock-auth authed dashboard. All are forced off in the non-luring modes (stealth and blocked). -
Login relocation (
login_relocation_enabled+login_slug, off by default): moves the real WordPress login to a secret slug (/your-slug) and inverts the vacated default — every hit on/wp-login.php(and the anon/wp-adminbounce that lands there) is now an attacker, so it serves the wp-login mock-auth decoy (auto-armed when relocation is active) + WP-native capture, with zero false positives from real users. The technique is re-derived from WPS Hide Login (no code vendored): no core files are renamed and no rewrite rules are added, so deactivating the plugin (or clearing the slug) restores/wp-login.phpimmediately. Key behaviours and caveats:- No-lockout / fail-open: the slug is allowlisted from the engine (a hard
safe-pathallow under every posture), an invalid/empty slug leaves relocation off (the real login is untouched), an authenticated operator andaction=postpass(password-protected posts) are carved out to the real login, and every hook degrades to the real login on any fault — never a lockout, never a 5xx. - Slug-leak guard (divergence from WPS Hide Login): login-URL rewriting is scoped to the slug
page itself + authenticated contexts.
login_urlis never rewritten for anonymous requests, so an anon/wp-adminbounce and front-end login links resolve to the default/wp-login.php(the decoy), never the slug. Bookmark the slug —/wp-admindeliberately does not auto-bounce to the real login (that would hand the secret to any attacker who probes/wp-admin). - Scope: relocation does not hide REST (
/wp-json) or XML-RPC (xmlrpc.php) authentication — they bypasswp-login.phpand are covered separately by WP-native capture + the xmlrpc decoy. Most effective inrealistic/taunt(stealth serves no decoy — the vacated default is capture-only and the real login stays reachable there). Single-site only in v1 (multisite is a clean no-op). A page cache in front of WordPress should exclude the slug and/wp-login.phpfrom caching.
- No-lockout / fail-open: the slug is allowlisted from the engine (a hard
-
Advanced: real-route actions / severity ceiling / attack emulation / nuclei reflection — how a fake looks and which per-band action (
allow/log/block/deceive) runs within realistic/taunt. -
Plugin/theme enumeration absorber (on by default): a real site runs ~10-30 plugins, so a request for
/wp-content/plugins/<slug>/readme.txt(or a themestyle.css) whose slug is not installed is an unambiguous enumeration probe. When on,WpSiteProfileconsults the installed set (fromget_plugins()/wp_get_themes(), cached in a transient and refreshed on (de)activation / theme switch / upgrade — never called on the request path) so an uninstalled-slug probe becomes sacrificial and the policy engine deceives + reports it; an installed slug stays a real route. A nuclei-wordfence sweep is 80k+ requests, so the burst is absorbed: per source, a 60s window collapses to one localmass_plugin_scanrollup row (with a probed-slug count + sample), not one row per probe.enum_escalate_thresholdsets the per-window escalation point;enum_auto_ban(off by default) blocks a source past it forenum_ban_ttl_secs. Fail-safe: a cold/unwarmed installed set reverts to the historical blanket behavior, so a genuine installed asset is never flagged. Turn the absorber off to restore blanket/wp-content/plugins|themes/handling. -
WP-native capture (
wp_native_capture, off by default): captures attacks that WordPress handles itself — and which therefore never reach the Interceptor — into the local hit store by hooking WP's own pipelines:wp_login_failed(credential stuffing),xmlrpc_call(system.multicallamplification,wp.getUsersBlogscredential probing,pingback.ping), and REST (rest_authentication_errors,rest_user_queryuser-enumeration). Capture-only: the REST filters return their incoming value unchanged, so login/xmlrpc/REST behaviour is byte-identical whether it is on or off. Local intel only — nothing new is sent to mainnet (it never builds a report intent or calls the reporter). It never logs a real credential: the password is never in scope (it hookswp_login_failed, notauthenticate), and a real-account failure is anonymised via ausername_exists()self-guard — no submitted username/password/XML-RPC arg/pingback URL is ever stored, only IP + a fixed opaque reason + a bounded User-Agent. Durable rows are rollup-gated per IP per channel per 60s window exactly like the enumeration absorber, so a singlesystem.multicallwith N sub-calls (Nxmlrpc_callfires) writes at most one hit row — the burst is captured as the per-IP aggregate count (velocity), not as N rows. Every callback is degrade-safe: a capture fault never breaks WP login/xmlrpc/REST. The hit-store write itself is degrade-safe too — a missing or broken plugin table makes the$wpdbwrite a silent no-op (wpdb's own error output is suppressed around every request/cron-facing write), so a raw "WordPress database error" can never be echoed onto a page as a fingerprint tell; the write is simply skipped. -
Login honeypot field (
login_honeypot_field, off by default): injects an invisible decoy<input>into WordPress's own login form via thelogin_formaction (the plugin does not override/wp-login.php), and passively flags a login POST that arrives with that field non-empty — a credential-stuffing bot that blindly fills every named input. A real human/browser never fills it (an inlinedisplay:nonewrapper plustabindex=-1,aria-hidden,autocomplete=off), so a non-empty submit is a zero-false-positive scripted-bot signal, captured (reasonlogin_honeypot_field) through the same rollup-gated local hit store as WP-native capture — no PII, no password in scope (it reads only the one decoy key onwp_login_failed, neverauthenticate), nothing sent to mainnet, and the submitted value is inspected for emptiness only, never stored or reflected. It is independent ofwp_native_capture(either can be armed alone); when both are on and the field trips, the high-confidence honeypot reason owns the durable row. The field name is fingerprint-safe: an ordinary contact-field name from the core closed list, derived per-site fromcrc32(host|salt)(server-sidehome_url()host, not the clientHostheader), so it is stable across a bot's GET render and POST submit yet differs per install — no fleet-wide constant to correlate, no honeypot/trap self-unmask token. It never blocks or alters a real login (additive markup + a passive side-effect only); both halves are try/catch-guarded. Ceiling: a bot that fills the field and submits valid credentials succeeds, sowp_login_failednever fires and that (rare, non-target) attempt is not caught — catching it would needauthenticate, which pulls the password into scope, deliberately not done. -
Bot-gated fake lockout (
login_fake_lockout, off by default): after a failed login on the real login form, serves the core fake-lockout page (a believable "too many attempts, wait N minutes" login screen — the same page as the dedicated box, from vendored core, no core change) to a suspected bot only, so the attacker thinks it tripped a rate-limiter and wastes its session. It is gated on a bot signal, never a plain failed-login count: either the invisible honeypot field above was tripped (definitive), or a conservative per-IP failed-login velocity was reached (a dedicated 60s counter, default 15/60s, clamped[5,240], on its own key so it works withwp_native_captureoff). Requiresrealisticortauntresponse mode (forced off instealth/blockedand when the plugin is disabled); the countdown minutes and visual persona are seeded from the samecrc32(host|salt)as the honeypot field, so the page is deterministic per deploy and stable on re-scan. Never locks a real user — structurally, not by tuning: it fires onwp_login_failed(only after WordPress already rejected the credentials) and registers noauthenticate/wp_signon/login_redirecthook, so the credential decision is out of scope; it writes no persistent lock (only the ephemeral counter, read solely by this cosmetic gate, never by any auth path); and the "lockout" is a per-request cosmetic lie — the next login POST re-runswp_signonfrom scratch, so a correct password always authenticates, even for an IP that tripped velocity behind a shared NAT/proxy. A real user never fills the hidden field and never reaches the velocity, so never sees the page; even a false-positive velocity trip only decorates one already-failed request. Fingerprint-safe (reuses the FP-0491-vetted page — small 1–2 digit minutes, nollar/six-digit rule-id tell), no PII (the submitted username/password/decoy value is never read into the page), and degrade-safe: any render/emit fault (or output already sent) falls through to normal WordPress — it only ever upgrades a failed login, never a 500. Ceiling: the signals are passive — a bot that neither fills the field nor sustains the velocity is not shown the lockout (by design; the alternative would risk a real user). -
XML-RPC pingback shield (
wp_pingback_shield, off by default): neutralizes the classicpingback.pingSSRF / DDoS-reflection vector on a real WordPress site. It hooks WordPress's ownpingback_ping_source_urifilter at priority 1 (before WP'swp_http_validate_url), captures a bounded, sanitized copy of the attacker-chosen source URI — the URL WordPress would fetch, i.e. the SSRF/DDoS target — into a localpingbackchannel, then returns''sopingback_ping()faults with its own canonical "A valid URL was not provided." error beforewp_safe_remote_get. Two independent no-fetch guarantees hold: no HTTP/socket primitive exists anywhere on this path (SSRF- safe by construction), and returning''makes WordPress short-circuit before its own fetch — so the operator's real site can never be coerced into an open pingback relay. Unlike WP-native capture this changes whatxmlrpc.phpreturns forpingback.ping(matching WordPress's own validation fault, so it is not a tell), which is why it has a dedicated opt-in toggle. Off/unconfirmed ⇒ the filter returns the source unchanged (native WP behaviour); on ⇒ it always short-circuits, even on a capture fault. The captured URL is local intel only — never a durable-row column (no schema change), never a report intent, never relayed to mainnet — stored as a bounded distinct sample (≤ 8 URLs, each ≤ 255 chars, control chars stripped) in the per-IP aggregate slot, rollup-gated to one durable row per IP per 60s window so a pingback flood cannot exhaust the table. -
Reputation (verdict-first):
check_enabled+block_verdicts(defaultmalicious,critical)- optional
min_block_score. Cache-first, fail-open, never a synchronous request-path call. Off by default; requiresMAINNET_KEY.
- optional
-
Country policy (optional): deny-list or allow-list; action defaults to
score-modifier(a hardblockin the honeypot posture is a tell — eyes-open opt-in). Resolved from a local GeoIP DB. -
Reporting: off by default;
mainnet_base_url(scheme+host only) +MAINNET_KEY+self_ips(the operator's own egress, never reported). -
Deception corpus auto-update (data only): off by default.
rules_autoupdate_enabled+rules_channel(stable/beta) +rules_update_interval(hourly/twicedaily/daily). A WP-cron event pulls a newer signed detection corpus (nuclei/route/attack/param artifacts, templates, fingerprints) using funnypot-core's rules-update engine — so new scanner coverage and decoys reach the site without waiting for a manual plugin update. Engine code still updates the normal way (WP plugin auto-update /composerbump); this ships DATA only. See Deception corpus auto-update below.
wp-config.php constants (override the stored settings)
define('HONEYPOT_WP_MAINNET_BASE_URL', 'https://mainnet.example'); // scheme+host only define('HONEYPOT_WP_MAINNET_KEY', '…'); // a sensor-tier key define('HONEYPOT_WP_RULES_AUTOUPDATE', true); // force corpus auto-update on/off define('HONEYPOT_WP_RULES_DIR', '/var/lib/honeypot-wp-rules'); // corpus data dir (see below)
Reporting/checking are inert without a key. The single key is a mainnet sensor-tier key
carrying both report rights and an escalation-check quota (O2).
Local intel dashboard
Settings → Honeypot Intel is a read-only view of what the honeypot has caught locally (the
Wordfence "Live Traffic" analog — the operator's reason to install). It renders the honeypot_wp_hits
store and local state: summary tiles (total events, events in the last 24h, report-queue depth,
blacklist-mirror age), a paginated recent-events table (time, IP, method, path, action, reason,
status), top attacker IPs over the last 24h, and the mass_plugin_scan rollups. When WP-native
capture is on, the login/xmlrpc/REST rows appear here too. When the pingback shield is on, a
"XML-RPC pingback SSRF targets (captured)" table lists the captured source URIs per IP — every
target is attacker-supplied and is escaped as text on output (never emitted raw or in an attribute).
manage_options-gated and read-only: it makes no state changes, so it carries no nonce (the guards are the capability gate +absint-clamped pagination). The recent-events list can be filtered by a fixed action whitelist (log/deceive/block).- No external I/O on render — only local
$wpdband state reads. It never drains the reporter or triggers a GeoIP/blacklist refresh. - Escapes every value at output. IP/path/User-Agent are attacker-controlled; they render only as escaped text-node content, never into an HTML attribute.
- Top-IP User-Agent and current-window velocity are a best-effort enrichment from the per-IP
aggregate slots (recent-window; an older IP shows "—"). The aggregate
countis a current-60s-window velocity, not a cumulative total, and is labelled as such. Country shows "—" until a local GeoIP reader is wired (none is today); it is never a network lookup.
WP-Cron caveat
WP-Cron only fires on traffic, so on a low-traffic site the report drain, the O1 blacklist-mirror pull, and the GeoIP refresh can stall between visits. For any install that enables reporting, checking, or the mirror, disable WP-Cron and use a real system cron:
define('DISABLE_WP_CRON', true); // in wp-config.php
# crontab:
*/5 * * * * wp honeypot report-drain --path=/var/www/html >/dev/null 2>&1
0 * * * * wp honeypot mirror-pull --path=/var/www/html >/dev/null 2>&1
The corpus auto-update rides the same honeypot_wp_rules_pull WP-cron event; with DISABLE_WP_CRON
a wp cron event run honeypot_wp_rules_pull line on your chosen interval keeps it firing.
Deception corpus auto-update (data only)
When rules_autoupdate_enabled is on, a WP-cron event (honeypot_wp_rules_pull, on the configured
hourly/twicedaily/daily schedule, with a per-host jittered first run) runs funnypot-core's signed
rules-update engine to fetch a newer detection corpus and hot-swap it into a local data dir the
engine reads in preference to its bundled copy. This ships DATA only — the corpus, templates and
fingerprints — never executable engine code (that stays on the plugin auto-update / composer track,
and shipping code this way is wp.org-prohibited and would lose the array-literal guard below).
- Signed + verified before load. Every fetched artifact is
required PHP, gated by the engine's full trust chain before anything goes live: an ed25519 signature (public key vendored in funnypot-core, never fetched) + per-file sha256 + a pure-array-literal validator proving each file isreturn [...]data that cannot execute, plus a fetch-time fingerprint re-scan. A failed, unsigned or tampered pull is rejected and the current corpus is kept — there is no partial or unverified load. - Exec-free. The fetch is
wp_remote_get(nocurl/exec/shell), so it works on locked-down hosts. The target host is a fixed, pinned allow-list (the GitHub release hosts); the fetch is HTTPS-only, redirects are re-validated per hop, and the response size and timeout are bounded. - Fail-safe on a read-only host. If the data dir cannot be created or written (a common shared-host case), the cron tick logs and degrades — it never fatals — and the site keeps serving the bundled corpus.
- Data dir. Defaults to
wp-uploads/honeypot-wp-rules. It holdsrequired PHP, so for least-privilege setHONEYPOT_WP_RULES_DIRto a path outside the web root that the web user cannot write. Single-site only in v1; on multisite the feature stays off (bundled corpus). - Status. Settings → Honeypot shows the live corpus source (
bundled/data-dir), version and last-applied/last-checked timestamps.
Prerequisite (not live yet): the
metrictower/funnypot-rulesdistribution repo and its published signing keys do not exist yet. Until they do, every pull fails signature verification and the corpus stays on the bundled floor — which is why this feature ships default-off. The consumer wiring is complete and tested; enabling it becomes useful once the distribution + keys are stood up.
WP-CLI
wp honeypot status # enabled?, posture, CONFIGURED position, VERIFIED mount, style, queue depth, mirror age
wp honeypot enable [--posture=honeypot|WAF|both]
wp honeypot disable
wp honeypot test <path> [--method=GET]
wp honeypot report-drain [--limit=200]
wp honeypot mirror-pull
wp honeypot geoip-refresh
wp honeypot promote <rule-id> # advance a rule SHADOW -> TUNING -> ENFORCED
wp honeypot shadow [<rule-id>|--all]
status reports the verified BEFORE mount (mu-plugin / plugins_loaded (degraded) /
not running), not the configured intent — so a wiped shim that silently demoted the BEFORE position
is visible (Wordfence gap a).
Security invariants
- Fail-safe to allow, never a 5xx. Any policy/evaluator/store fault degrades to "WordPress proceeds" — a 500 is itself a tell. The must-use loader shim is degrade-safe (SF-4): a plugin folder deleted without deactivation leaves the shim inert, never fatal.
- Only ever upgrade a not-genuine request. The FALLBACK position (hooked
template_redirect@0, before WP'sredirect_canonical@10) fires on a genuineis_404()and — for core-owned scanner paths — on a WP-preempted request that WordPress would otherwise 301/soft-200 (/phpmyadmin,/solr/admin,/actuator/*,/telescope/requests, …). A fail-safe-to-genuine oracle decides what is not-genuine: only a hard 404, or a front-page/blog-index fallthrough off root with an empty main query. Every other WP-resolved state — real pages/posts/archives,/feed,/robots.txt,/favicon.ico, search, and SEO-plugin sitemaps (which carry asitemapquery var) — is genuine and never decoyed, even though core owns decoys for some of them (ownership can only narrow an already not-genuine path, never promote a genuine one). Any doubt resolves to genuine. Serving the owned decoy is gated by the response mode (realistic/taunt only; stealth/blocked stay WP-normal + log), and theWpSiteProfilereal-route oracle keeps a fake from ever colliding with a real WP route. - Content-Type matches the request; status is app-chosen (never model-chosen — no open redirect).
- Reporting is key-gated and self-guarded: inert without
MAINNET_KEY, refuses the operator's ownself_ips, reports public-routable IPs only. The reporter enqueue arg order matches F'sFunnypot\Mainnet\Reporterexactly:enqueue($ip, $comment, $categories).
Testing
Unit suite (green here)
Pure PHPUnit + Brain Monkey — no WordPress, no DB. Every test drives either a fake PolicyEngine or
the real one wired with D's real ports; WP I/O is mocked.
composer install
vendor/bin/phpunit --testsuite unit
The suite includes an end-to-end "wired ports" test (a scanner-probe / sacrificial /.env evidence →
a deceive/block Decision through the real PolicyEngine + D's real adapters), the SF-4
shim-takedown proof, and a real-Funnypot\Honeypot integration smoke test.
Live: wp-env integration suite
Integration against a real WordPress — booted by @wordpress/env in Docker — is live. The suite
issues real HTTP requests to the booted site and asserts the plugin's actual request-time behavior:
a scanner probe for /.env is deceived (the 404 is upgraded into a fake-vulnerable 200 serving
a synthetic .env), a benign unknown path passes through as WordPress' own 404, and the homepage
is served untouched. Needs Docker. It skips cleanly when the base URL is unreachable, so it is
safe in a CI stage without Docker.
npm install && npx wp-env start && bash bin/wp-env-provision.sh
vendor/bin/phpunit --testsuite integration # or: npm run test:integration
npx wp-env stop
Full run instructions, the observed-behavior table, and the environment notes (PHP 8.2 container,
sibling-package mappings, the pinned wp-env version) are in docs/INTEGRATION.md.
Build
bash bin/build.sh # composer install --no-dev (bundle policy/core/mainnet-client) + zip
Deferred / prerequisites
C — funnypot-core to PHP 7.3 + the two-phase splitDONE (corev0.0.1): the bundled core is now PHP 7.3+, so the zip is shippable on 7.x hosts. The plugin's own glue was already 7.3-clean and CI-lint-gated.- Golden-emit parity vs the standalone app (byte-identical fake surfaces) + reporter/reputation wire
tests. The live wp-env suite (
docs/INTEGRATION.md) already covers the core deceive / passthrough behavior over real HTTP; these deeper parity checks are the remaining follow-up. - A production local GeoIP DB reader — the
WpGeoIpport + refresh cron are built; wiring a concrete DB-IP Lite MMDB reader is a data-distribution follow-up (the port fail-opens tonulluntil then). - The reserved L6 local allow/deny overlay; multisite network UI; wordpress.org SVN distribution.
- Signed corpus auto-update (FP-0502): wired, default-off. The WP-cron consumer over core's
rules-update engine ships now (see Deception corpus auto-update).
A live pull awaits the
metrictower/funnypot-rulesdistribution repo + published signing keys, which do not exist yet — until then a pull fail-safes to the bundled corpus. An admin "Check now" action andwp honeypot rules:update|statusWP-CLI subcommands are deferred follow-ups.
Try it locally (Docker)
A docker-compose.yml stands up real WordPress with this plugin live-mounted and activated, so you can
click through the Funnypot settings/intel screens and confirm it installs cleanly. It doubles as an
install smoke test.
docker compose up -d # first boot provisions WP, creates the admin, activates the plugin open http://localhost:8919/wp-admin/ # log in: admin / funnypot docker compose down # stop (add -v to also wipe the db + wp volumes)
- Non-standard host port
8919so it won't collide with other local stacks; the DB has no host port. - Dev admin
admin/funnypot— a LOCAL-ONLY convenience, not a secret, never for a real site. - Settings: Settings → Honeypot (
/wp-admin/options-general.php?page=honeypot-wp); the Intel dashboard is its submenu. - The plugin is bind-mounted from the working tree (with its vendored
funnypot-core/-policy), so edits are live — reload wp-admin to see them. If a fresh clone has novendor/, runcomposer installfirst. - Requires Docker; the run itself is developer/operator-invoked (not part of CI's PHP-only gates).