Skip to content

funnypot-policy

metrictower/funnypot-policy is the framework-free, PHP 7.3+ position-blind decision engine behind funnypot's WAF / honeypot extensions. It takes normalized request evidence in and returns exactly one Decision (allow / log / block / deceive) out — pure data, zero side effects. Whatever hosts it (a Laravel middleware, a WordPress hook, your own adapter) executes that Decision; the policy engine never touches the response itself.

It sits in the middle of a three-layer split: funnypot-core does the mechanism (detect an attack, render a fake), funnypot-policy owns the opinions (whether to act, what action, where), and the host adapter does the execution (normalize the request, run the Decision, persist state). Because policy owns the opinions and the host owns the effects, "honeypot vs WAF" and "before vs fallback" are a config choice, never a code change.

Who this is for

Most people never install this directly — it's consumed through the framework adapters, funnypot-laravel and funnypot-wordpress, which normalize the request, call the engine, and perform the effect. Install it directly only when you're building your own adapter for another host and want the same audited decision logic.

Install

composer require metrictower/funnypot-policy

Requires PHP 7.3 or newer and has no runtime Composer dependencies — it's plain PHP with zero framework coupling, so it drops into anything.

Usage

The engine is a pure function of its inputs: six injected ports (dependencies it calls out through) plus a PolicyConfig, then evaluate(RequestEvidence, SiteProfile) on every request.

Two of the ports — EvaluatorInterface and ReputationInterface — have no shipped default; a real deployment bridges them to funnypot-core (detection) and funnypot-mainnet-client (reputation) respectively. The other three infrastructure ports ship stock implementations (NullGeoIp, NullLogger, SystemClock), and the state-store port is the one thing every host must back with its own storage. The example below uses a minimal in-memory StateStore and trivial stand-ins for the other two so it runs standalone — swap them for the real bridges in production.

Implement the state store

use Funnypot\Policy\ActorFacts;
use Funnypot\Policy\AggScore;
use Funnypot\Policy\Pin;
use Funnypot\Policy\Port\StateStoreInterface;
use Funnypot\Policy\RuleState;

final class InMemoryStateStore implements StateStoreInterface
{
    private $pins = array();
    private $blocked = array();
    private $ruleStates = array();

    public function getPin(string $ip)
    {
        return isset($this->pins[$ip]) ? $this->pins[$ip] : null;
    }

    public function setPin(string $ip, string $action, string $seed, int $ttlSeconds)
    {
        $this->pins[$ip] = new Pin($action, $seed, time() + $ttlSeconds);
    }

    public function isBlocked(string $ip)
    {
        return isset($this->blocked[$ip]);
    }

    public function mirrorVerdict(string $ip)
    {
        return null; // no fleet reputation mirror wired up yet
    }

    public function ruleState(string $ruleId)
    {
        return isset($this->ruleStates[$ruleId]) ? $this->ruleStates[$ruleId] : new RuleState();
    }

    public function putRuleState(string $ruleId, RuleState $s)
    {
        $this->ruleStates[$ruleId] = $s;
    }

    public function bumpRuleEvaluated(string $ruleId, int $n = 1)
    {
        // no-op here — count real-route rule volume in your own storage to drive promotion
    }

    public function seenVerdict(string $dedupKey, int $ttlSeconds)
    {
        return false;
    }

    public function incrAlertCount(string $ip, int $windowSeconds)
    {
        return 1;
    }

    public function bufferReport(string $groupKey, array $report, int $ttlSeconds)
    {
        return 1;
    }

    public function takeReportBuffer()
    {
        return array();
    }

    public function aggregateScore(string $scoreKey, int $windowDays)
    {
        return new AggScore(array(), 0);
    }

    public function decayScore(string $key, int $inc, int $baseTtlSeconds, int $capTtlSeconds)
    {
        return $inc;
    }

    public function actorFacts(string $ip)
    {
        return new ActorFacts();
    }

    public function incr(string $counterKey, int $windowSeconds)
    {
        return 1;
    }
}

A real adapter backs this with the host's own persistence — WordPress transients/options, a Laravel cache store, a SQLite file — rather than a PHP array that forgets everything between requests.

Configure, wire the ports, and evaluate

use Funnypot\Policy\Clock\SystemClock;
use Funnypot\Policy\Decision;
use Funnypot\Policy\FakeResponse;
use Funnypot\Policy\Geo\NullGeoIp;
use Funnypot\Policy\Log\NullLogger;
use Funnypot\Policy\PolicyConfig;
use Funnypot\Policy\PolicyEngine;
use Funnypot\Policy\Port\EvaluatorInterface;
use Funnypot\Policy\Port\ReputationInterface;
use Funnypot\Policy\RequestEvidence;
use Funnypot\Policy\ReputationVerdict;
use Funnypot\Policy\SiteProfile;
use Funnypot\Policy\Verdict;

// Stand-ins for the two ports with no shipped default. In production these bridge to
// funnypot-core (classify/synthesize) and funnypot-mainnet-client (lookup) — see Public API below.
$evaluator = new class implements EvaluatorInterface {
    public function classify(RequestEvidence $request, SiteProfile $profile)
    {
        $onRealRoute = $profile->routeExists($request->path());

        return new Verdict(Verdict::SCANNER_PROBE, false, '', 0, Verdict::SEVERITY_LOW, $onRealRoute);
    }

    public function synthesize(Verdict $verdict, SiteProfile $profile, string $seed)
    {
        return new FakeResponse(404, array(), 'Not Found', 'text/plain');
    }
};

$reputation = new class implements ReputationInterface {
    public function lookup(string $ip)
    {
        return ReputationVerdict::absent(); // no reputation source wired up
    }
};

$config = PolicyConfig::fromArray(array(
    'posture'  => PolicyConfig::POSTURE_HONEYPOT, // deceive on the fallback (404) position — the default
    'self_ips' => array('203.0.113.1'),           // your own test/egress IP — always allowlisted
));

$engine = new PolicyEngine(
    $evaluator,
    $reputation,
    new InMemoryStateStore(),
    new NullGeoIp(),     // no country gate
    new SystemClock(),   // wall-clock time
    new NullLogger(),    // swallow log calls
    $config,
    'a-per-site-secret'  // siteSalt, mixed into the deterministic per-actor seed
);

$evidence = new RequestEvidence(
    'GET',
    '/wp-login.php',
    array(),                            // query params
    array('User-Agent' => 'curl/8.0'),  // headers
    array('len' => 0),                  // body-shape, never the raw body
    '198.51.100.9'                      // source IP
);

$profile = new SiteProfile('wordpress', array('/'), array('/wp-login.php'));

$decision = $engine->evaluate($evidence, $profile);

switch ($decision->action()) {
    case Decision::ALLOW:
        // pass the request through untouched
        break;
    case Decision::LOG:
        // pass through, but record the observation
        break;
    case Decision::BLOCK:
        http_response_code($decision->status());
        break;
    case Decision::DECEIVE:
        $fake = $decision->fakeHandle();
        http_response_code($fake->status());
        foreach ($fake->headers() as $name => $value) {
            header($name . ': ' . $value);
        }
        header('Content-Type: ' . $fake->contentType());
        echo $fake->body();
        break;
}

evaluate() never throws on the request path — a fault in any port degrades to Decision::allow('failsafe') rather than a 5xx (a 500 is itself a tell).

Public API

Everything lives under the Funnypot\Policy\ namespace, or a sub-namespace noted below.

Entry point: Funnypot\Policy\PolicyEngine

Member Notes
new PolicyEngine(EvaluatorInterface $evaluator, ReputationInterface $reputation, StateStoreInterface $store, GeoIpInterface $geo, Clock $clock, Logger $logger, PolicyConfig $config, $siteSalt = '', $source = 'honeypot') All six ports are required positional args. $siteSalt seeds the deterministic per-actor seed; $source is stamped on emitted reports.
evaluate(RequestEvidence $e, SiteProfile $p): Decision The single entry point. Never throws — every port fault degrades to Decision::allow.
allowlistReason(RequestEvidence $e) The step-1 allowlist/self-IP/safe-path check in isolation — a reason label on a match, else null.
seedFor(RequestEvidence $e): string The stable per-actor seed (sha1(actorId + siteSalt)) used for deception-consistency pinning.

Inputs you build

Class Purpose
RequestEvidence new RequestEvidence(string $method, string $path, array $query, array $headers, array $bodyShape, string $ip, $actorId = null, $asn = null). Getters: method(), path(), query(), headers(), header(string $name) (case-insensitive), bodyShape(), ip(), actorId() (falls back to ip()), asn(). bodyShape is a shape descriptor only — never the raw body.
SiteProfile new SiteProfile(string $stack, array $realRoutes = [], array $sacrificialPaths = []). stack(), routeExists(string $path): bool, isSacrificialPath(string $path): bool. The real-route/sacrificial-path sets are your FP-safety oracle — a fake must never collide with a route that actually exists.
PolicyConfig Built only via the static PolicyConfig::fromArray(array $opts). See Configuration keys below.

Output: Decision

Pure data — the adapter reads it and performs the effect.

Member Notes
Decision::allow($reason = 'allow') / ::log($reason = 'log') / ::block(int $status = 403, $reason = 'block') / ::deceive(FakeResponse $fake, $pinTtl = null, $reason = 'deceive') The four factories — the action set is closed at these four.
action() One of Decision::ALLOW | LOG | BLOCK | DECEIVE.
status() App-chosen HTTP status, or null for allow/log. Never model-chosen (no open-redirect surface).
fakeHandle() The FakeResponse to emit — present only when action() === Decision::DECEIVE.
pinTtl() Seconds this actor's treatment should replay identically, or null.
report() An optional ReportIntent the adapter may enqueue for reputation reporting.
reason() A label from a closed, non-sensitive set (e.g. allow, pin, sacrificial-path, reputation-block, shadow, failsafe, …) — constructing a Decision with anything else throws. This is what makes a detection-signature leak into a log impossible by construction.
isAllow() / isDeceive() Convenience predicates.
withReport(ReportIntent $report) / withReason($reason) Return a new Decision (the original is unchanged).

The six ports (Funnypot\Policy\Port\)

Port You implement it, or... Typically bridges to
EvaluatorInterface Always — no default ships classify() + synthesize() from funnypot-core
ReputationInterface Always — no default ships funnypot-mainnet-client's cached verdict lookup (never a live socket on the request path)
StateStoreInterface Always — the one persistence seam; no default ships Your host's own storage (cache, DB, WP transients)
GeoIpInterface Optional — ship Funnypot\Policy\Geo\NullGeoIp to disable the country gate A local GeoIP DB (DB-IP Lite / GeoLite2) — never a network call
Clock Optional — ship Funnypot\Policy\Clock\SystemClock for wall-clock time Your own testable clock, if you have one
Logger Optional — ship Funnypot\Policy\Log\NullLogger to no-op A PSR-3-shaped sink (not a psr/log dependency)

Value objects you'll construct or read

Needed when implementing EvaluatorInterface (produces Verdict, consumes it in synthesize() to produce FakeResponse) or StateStoreInterface (persists/returns the rest).

Class Shape
Verdict new Verdict($classification, $matched, $signal, $anomalyScore, $severity, $onRealRoute, $botSignals = null, $engineHandle = ''). classification() is one of Verdict::CLEAN / SUSPICIOUS / SCANNER_PROBE / ATTACK_CLASS; signal()/ruleId() is an opaque handle, never a signature string. engineHandle() round-trips unread back to your evaluator's synthesize().
FakeResponse new FakeResponse(int $status, array $headers, string $body, string $contentType), with matching getters. Opaque to the policy engine — it only flows through.
BotSignals new BotSignals($uaClass = BotSignals::UA_UNKNOWN, array $flags = [], $fingerprint = ''). weakSignalCount(), isScannerUa(), isBotShaped(). BotSignals::none() for a clean browser request.
ReputationVerdict new ReputationVerdict($verdict, $score, $source, $usageType = null). verdict() is one of unknown/clean/suspicious/malicious/critical. Factories: ::failOpen(), ::absent(). Predicates: isMalicious(), isSuspicious(), isUnknown().
Pin new Pin(string $action, string $seed, int $expiresAt) — the deception-consistency pin StateStoreInterface::getPin()/setPin() round-trip.
RuleState new RuleState($phase = RuleState::SHADOW, $since = 0, $count = 0, array $exclusions = [], $humanApproved = false). Phases: SHADOW / TUNING / ENFORCED.
ActorFacts new ActorFacts($authSession = false, $loadsAssets = false, $matches30d = 0, $firstSeen = 0) — rolling per-actor facts for the false-positive heuristic.
AggScore new AggScore(array $sources, int $total)distinctSourceCount(), total(), used by the aggregate-ban rule.
ReportIntent Built for you by the engine and attached via Decision::report(); carries no raw payload or signature string, only opaque category tokens.

Configuration keys

Built via PolicyConfig::fromArray(array $opts) — every key is optional and has a documented default. Constants: PolicyConfig::POSTURE_HONEYPOT / POSTURE_WAF / POSTURE_BOTH, and POSITION_BEFORE / POSITION_FALLBACK.

Key Purpose
posture honeypot (default) · WAF · both — presets which position(s) run and the before-position action ceiling.
position {fallback, before} booleans — overrides the posture's preset per field.
actions Per-Verdict-band action ceiling on real routes: clean, suspicious, attack_class, scanner_probe. Defaults allow / log / block / deceive.
reputation enabled, block_verdicts, min_block_score — reputation is always a modifier, never primary (as_primary is hard-false, a truthy value is ignored).
learn shadow_days, shadow_min_reqs, baseline_excluded, kill_switch — the learn-then-enforce rule lifecycle.
country enabled, mode (deny/allow), countries, action (modifier/deceive/block) — resolved from a local GeoIP DB only.
bot_signals enabled, exempt_uas, exempt_paths, telemetry — the request-shape scrutiny modifier.
pin ttl_seconds — how long a deceived actor's treatment replays identically.
suppression Report suppression: verdict_dedup_hours, per_ip_alert_cap, per_ip_cap_window_s, buffer_ttl_s, score_gate, plus nested aggregate (min_sources, min_total_score, window_days) and decay (base_ttl_s, cap_ttl_s, inc_soft, inc_medium, inc_hard).
allowlist ips, cidrs, asns, safe_paths — a hard override that beats everything else.
self_ips Your own egress/test IPs — always exempt.

$config->actionFor($classification) and $config->ceiling($position) read back the resolved action/ceiling for a given band or position, if your adapter needs to reason about it.

Learn-then-enforce admin actions: Funnypot\Policy\Learn\StateMachine

A standalone service for an adapter's own admin UI to drive rule-phase transitions. Promotion is human-gated and slow; demotion is automatic and instant.

Method Purpose
new StateMachine(StateStoreInterface $store, Clock $clock, PolicyConfig $config, Logger $logger)
eligibleForTuning($ruleId) Returns whether a shadowed rule has cleared both shadow_days and shadow_min_reqs.
promoteToTuning($ruleId) SHADOWTUNING, if eligible. Returns whether it promoted.
isOtherwiseLegit(ActorFacts $facts, $cleanReputation) The false-positive heuristic used while tuning.
compileExclusion($ruleId, ActorFacts $facts, $cleanReputation, $pathPrefix, $param) Compiles a scoped exclusion tuple from a legit-actor flag — never a global rule disable. Returns whether a tuple was compiled.
approve($ruleId) The required human sign-off before ENFORCED.
promoteToEnforced($ruleId) TUNINGENFORCED, only after approve(). Returns whether it promoted.
demoteOnProvenLegit($ruleId) Automatic, instant ENFORCEDSHADOW demotion + a logged alert. Returns whether it demoted.
applyBaseline() Pre-excludes the shipped list of known-FP-prone rule ids.

IP helpers: Funnypot\Policy\Net

Static helpers used internally for allowlist/mirror matching — useful if your StateStoreInterface implementation needs the same semantics.

Method Purpose
Net::normaliseV6(string $ip): string IPv6 → its /64; IPv4 unchanged. The unit an actor is tracked at, so rotating within a /64 doesn't evade tracking.
Net::containment(string $cidr, string $ip): int Prefix length on containment (a bare IP acts as /32//128), else -1. Longest-prefix-wins semantics.
Net::contains(string $cidr, string $ip): bool Boolean containment.
Net::isPublic(string $ip): bool True for a public, routable IP.