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?
- Protecting a Laravel app → funnypot-laravel
- Protecting a WordPress site → funnypot-wordpress
- Want a ready-to-run honeypot box to deploy → the honeypot app
- Just the deception/detection engine, no reporting → funnypot-core
- Only the reputation SDK (query/report IPs from your own code) → funnypot-mainnet-client
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¶
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:
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:
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.
Links¶
- Repository: github.com/metrictower/funnypot
- Packagist: packagist.org/packages/metrictower/funnypot