Skip to content

funnypot

funnypot is the batteries-included embedder for the Funnypot family: funnypot-core detection wired to funnypot-mainnet-client IP-reputation reporting, with the request-path invariants (fail-safe config, dedup, self-IP guarding) already enforced for you. Reach for it when you have a framework-free PHP app and want scanner probes detected — and the attacker IP reported — with one composer require instead of assembling the two pieces yourself.

Not sure this is the right package?

Early days

Published and tagged, but the API is deliberately small and still moving. Pin a caret range (^0.4) rather than tracking a branch.

Install

composer require metrictower/funnypot

Requires PHP 7.3+, plus ext-pdo_sqlite (the bundled report queue) and ext-curl (delivery transport).

Usage

The whole integration is two questions — did this request look hostile, and should I act on that — answered by one pure, cheap call:

use Funnypot\Sensor\Funnypot;
use Funnypot\Core\RequestContext;

$funnypot = Funnypot::fromArray([
    'base_url'      => 'https://mainnet.example',
    'key'           => getenv('MAINNET_KEY'),
    'self_ips'      => ['203.0.113.7'],          // this host's own public addresses
    'intel_db_path' => '/var/lib/funnypot/intel.sqlite',

    // REQUIRED. Does this path resolve to a route your app actually serves?
    'own_routes'    => function ($method, $path) use ($router) {
        return $router->has($method, $path);
    },
]);

// Pure, no I/O — safe inline on the request path.
$check = $funnypot->check(RequestContext::fromGlobals());

if ($check->shouldReport()) {
    $funnypot->report($clientIp, $check);   // enqueues only, never blocks
}
if ($check->shouldBlock()) {
    http_response_code(403);
    exit;
}

Then, out of band — cron, scheduler, worker, never a web request:

$funnypot->drain();   // opens sockets; this is where reports actually get sent

That's the whole integration. There is no severity floor to tune and no anomaly threshold to pick.

own_routes is required

Without a real route oracle, your own /login and /admin look exactly like scanner probes and real visitors get reported. If you only call check() from your 404 / NotFound handler, say so explicitly instead of writing your own oracle:

'own_routes' => Funnypot::ONLY_ON_404,

Start in log-only mode

check() returns a verdict; you decide what to do with it. Reading $check->toArray() for a log line — without acting on shouldReport() or shouldBlock() — needs no extra config and is the recommended way to trial funnypot alongside whatever you already run.

The two profiles

The profile option moves what shouldReport() / shouldBlock() return — nothing else. Evidence on the Assessment (kind(), severity(), score(), signals(), …) is always populated either way.

'profile' => Funnypot::PROFILE_APP,        // default: a real site with real visitors
'profile' => Funnypot::PROFILE_HONEYPOT,   // nothing on this host is real

shouldBlock() is always false under PROFILE_HONEYPOT — a honeypot that blocks has told the attacker it detected them.

Detection only, bring your own queue

Mainnet delivery here rides a bundled SQLite queue — the right default for a framework-free app, and the wrong one for a host with multiple ephemeral workers (they'd fragment the dedup state). Use Detector when you already have a queue: same check(), same Assessment, no mainnet key or queue path needed because check() is pure.

use Funnypot\Sensor\Detector;

$detector = Detector::fromArray(['own_routes' => Funnypot::ONLY_ON_404]);

$check = $detector->check($request);
if ($check->shouldReport()) {
    MyReportJob::dispatch($clientIp, $check->toArray());   // your queue, your delivery
}

Funnypot is a Detector plus delivery — $funnypot->detector() hands back the inner one.

Silencing a false-positive template

If one template misfires on your site, silence just that template instead of turning detection off. Assessment::templateIds() lists the ids that drove a classification, so a false-positive log line hands you the exact id to list:

$detector = Detector::fromArray([
    'own_routes'       => Funnypot::ONLY_ON_404,
    'ignore_templates' => ['some-template-id', 'some-tag'],   // ids AND tags both accepted
]);

An ignored template contributes no evidence: a request whose only matching templates are listed classifies clean; a request that also matches a template you didn't list is still reported on that remaining one.

Serving fakes too

If you also want funnypot-core's deception responses, MainnetObserver implements core's Observer seam so detections on the respond() path get reported automatically:

use Funnypot\Sensor\Reporting\MainnetObserver;

$observer = new MainnetObserver($funnypot, static function () use ($clientIp) { return $clientIp; });
$engine   = \Funnypot\Core\Honeypot::default(null, $observer);

A detect-only integration doesn't need this — call check() / report() at your own call site instead, as in the example above.

Public API

Everything a consumer typically calls lives in the Funnypot\Sensor\ namespace.

Class Purpose
Funnypot The facade: detection + mainnet reporting together. Build it with Funnypot::fromArray(array $config).
Detector The judgement half alone, with no reporting attached. Build it with Detector::fromArray(array $config).
Assessment What check() returns: the verdict plus evidence for your log row.
Judge Interface to replace the built-in report/block rules wholesale.
Reporting\MainnetObserver Wires mainnet reporting into funnypot-core's Observer seam for deployments also serving fakes.

Funnypot

Member Signature
fromArray() static fromArray(array $config): self
check() check(RequestContext $r): Assessment — pure, no I/O, safe inline
report() report(string $ip, Assessment $assessment, string $comment = ''): array{queued:bool,reason:string} — enqueues only, never performs network I/O
drain() drain(int $limit = 200): array — call out of band; this is where sockets open
queuedCount() queuedCount(): int
detector() detector(): Detector — the judgement half on its own
mainnet() mainnet(): Funnypot\Mainnet\Client
engine() engine(): Funnypot\Core\Honeypot
Funnypot::PROFILE_APP / Funnypot::PROFILE_HONEYPOT the two postures
Funnypot::ONLY_ON_404 own_routes value for mounting inside a 404 / NotFound handler

fromArray() requires key, self_ips, and intel_db_path, plus own_routes unless profile is PROFILE_HONEYPOT. It throws InvalidArgumentException rather than starting half-configured. Detection keys (profile, own_routes, stack, ambient_extra, ambient_drop, ignore_templates, judge, act_on_scripting_uas) pass straight through to Detector::fromArray().

Detector

Member Signature
fromArray() static fromArray(array $config): self
check() check(RequestContext $r): Assessment
engine() engine(): Funnypot\Core\Honeypot

Assessment

Member Signature
shouldReport() shouldReport(): bool
shouldBlock() shouldBlock(): bool
kind() kind(): string — one of clean, ambient, scanner-probe, attack-class, suspicious
severity() severity(): string — highest nuclei severity across the match, '' when nothing matched
score() score(): int — graded evidence strength (+1 / +10 / +100) for a host that accumulates it
anomaly() anomaly(): int — evidence for the log row, never a gate
signals() signals(): Funnypot\Core\BotSignalSet
templateIds() templateIds(): string[]
tags() tags(): string[]
reason() reason(): string — why the two verbs came out the way they did
verdict() verdict(): Funnypot\Core\Verdict — the full core verdict, a deliberate escape hatch
toArray() toArray(): array — one log row, ready to store
Assessment::AMBIENT the kind() value for a path every site is asked for whether or not it has one

Assessment has no public properties — everything is a method, and reaching for $check->matched or $check->actionable throws a LogicException on purpose rather than letting the shape drift toward a boolean that gets used as a gate.

Judge

interface Judge
{
    /** @return array{report:bool,block:bool,reason:string} */
    public function judge(Verdict $verdict, RequestContext $request, string $profile): array;
}

Pass an implementation as 'judge' => $yourJudge in fromArray() to replace the built-in rules entirely.