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¶
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) |
SHADOW → TUNING, 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) |
TUNING → ENFORCED, only after approve(). Returns whether it promoted. |
demoteOnProvenLegit($ruleId) |
Automatic, instant ENFORCED → SHADOW 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. |
Links¶
- Repository: github.com/metrictower/funnypot-policy
- Packagist: packagist.org/packages/metrictower/funnypot-policy