Skip to content

The actor feed is now a write-only dead store — intel_for() has zero callers #62

Description

@cport1

Found by the adversarial review of PR #54 (2.3.2 safety release). Verified — the reviewer
attempted to refute this and could not.

includes/class-webdecoy-actor-feed.php:205 · severity high · lens upgrade-and-defaults

The defect

The actor feed is now a write-only dead store. record_intel() writes webdecoy_actor_feed_intel, and the only reader, intel_for() (line 291), has zero callers anywhere in the plugin — grepping intel_for across all PHP/JS returns only its own declaration. (WebDecoy_Actor_Intel at includes/class-webdecoy-actor-intel.php is an unrelated cloud-lookup class.) So the migration removes the feed's protection and replaces it with nothing, while the hourly cron still makes the HTTP request and rewrites a ~2,000-entry option. The code comment at line 202 ("kept and surfaced (actor intel, scoring input, rate-limit trigger)") and readme.txt's "the feed is now advisory intelligence" both describe behavior that does not exist. enforce_cap() (line 332) and blocker() (line 390) are now unreferenced too.

Failure scenario

A Pro site paying for the actor_feed entitlement upgrades. maybe_upgrade() deletes up to 2,000 network block rows; the next hourly sync() re-fetches the same feed, writes it to webdecoy_actor_feed_intel, and purges again. No request path ever reads that option, no score is adjusted, no rate limit is triggered, nothing is surfaced in the admin. The customer's paid feature is silently gone with no replacement and no changelog line saying so — while the readme they see on wordpress.org says the data is still being used.

Verified mechanism

Mechanism confirmed exactly. intel_for appears exactly once in the whole repo (its own declaration, includes/class-webdecoy-actor-feed.php:291) — zero callers, and no dynamic dispatch could reach it (the plugin's only call_user_func is class-webdecoy-wp-traps.php:180). So record_intel() (:262) writes webdecoy_actor_feed_intel every hour and nothing ever reads it: a write-only store. enforce_cap() (:332), blocker() (:390), the $blocker property (:77) and the EXPIRY_HOURS/HOUR constants (:65,:74) are now unreachable; select_evictions() (:505) is referenced only by tests. The comment at :202-203 names three surfaces (actor intel, scoring input, rate-limit trigger) that do not exist, and the class docblock at :5-9 ("mirrors it into the existing blocked-IPs table... denied before they ever reach the site") was never updated and is now wholly false. WebDecoy_Actor_Intel (class-webdecoy-actor-intel.php:32) is confirmed a separate live HTTP lookup against /api/v1/sdk/detections/intel, unrelated to the local option.

Two parts of the stated failure scenario are WRONG and I am not endorsing them: (1) "no changelog line saying so" — changelog.txt:14 and readme.txt:209 both explicitly disclose that the feed stops writing to the block table and that its rows are purged on upgrade; (2) "re-fetches the same feed" — the cursor advances (:211), so later syncs are deltas. The "paid feature silently gone" framing is therefore overstated, and readme.txt:209's "advisory intelligence" is soft overstatement rather than a false statement about site behavior.

The real falsehood lives in two admin notices the diff never touched, in includes/class-webdecoy-critical-moment.php (registered on admin_notices, :55). :180 (variant connected_covered = connected + known actor + actor_feed entitled) renders as notice-success (:105): "This attacker is already blocked network-wide — your actor feed already covers threats like this before they reach your site", with a "View blocked IPs" CTA pointing at the page maybe_upgrade() (webdecoy.php:422) just purged of every network row. It was true in 2.3.1 and is false in 2.3.2 — a paying Pro customer trips a CRITICAL decoy and gets a green "you're covered" notice while the address is not blocked at all (and with monitor mode now default, nothing is). :164-165 (connected_upgrade) tells a free user "on Pro it would have been blocked before its first request" — selling the exact capability this release removes.

blocksRelease=true on the stated bar ("makes the changelog/admin-notice promise false"), scoped narrowly to those two strings: a false success notice at the worst possible moment plus a paid upsell for a removed capability, on a listing where one bad review ends distribution. It is a two-string change. The dead store and unreachable methods break no site and are a follow-up.

Fix

BLOCKER (2 strings, before release) — includes/class-webdecoy-critical-moment.php:

  1. :178-184, case 'connected_covered' — stop asserting the attacker is blocked and stop sending the admin to the now-empty blocked-IPs page:
    'message' => __('This attacker is already known across the WebDecoy network — the same actor has been seen attacking other sites.', 'webdecoy'),
    'cta_label' => __('View detections', 'webdecoy'),
    'cta_url' => admin_url('admin.php?page=webdecoy-detections'),
    'type' => 'warning', // was 'success'; nothing was blocked

  2. :164-165, case 'connected_upgrade' — drop the "would have been blocked" Pro claim, e.g. "...%d days ago — Pro shows you the full cross-site history for this actor." Nothing in 2.3.2 makes Pro block a feed address.

FOLLOW-UP ISSUE (not release-blocking) — includes/class-webdecoy-actor-feed.php: the store is write-only. Either wire intel_for() into a real consumer (the natural one is build_notice() in class-webdecoy-critical-moment.php, which currently pays for a live HTTP lookup), or delete the dead path: record_intel() (:262), intel_for() (:291), INTEL_OPTION (:56), enforce_cap() (:332), blocker() (:390), $blocker (:77), EXPIRY_HOURS (:65), HOUR (:74) — and if nothing reads the option, unschedule the hourly cron rather than making an HTTP request every hour for data no code consumes. Either way correct the comment at :202-203 (claims three surfaces that do not exist) and the class docblock at :5-9 (still describes writing to the blocked-IPs table), and reconsider readme.txt:209's "advisory intelligence" once it is decided whether anything advises on it.


Blocks the 2.3.2 release. Refs #54.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1High prioritybugSomething isn't workingtech-debtCleanup / correctness debt

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions