Skip to content

funnypot-core

The HTTP deception engine behind Funnypot. It answers a scanner's probe with the fake-vulnerable response the scanner was fishing for — the inverse of a nuclei scan: instead of sending a probe and reading the reply to decide "this host is vulnerable", it reads an incoming probe and writes the reply that satisfies the scanner's own matcher, so the scanner walks away with a full, coherent, wrong vulnerability report while you log every move.

Use this package when you want to embed the detection/deception engine directly in your own PHP or PSR-15 app. Runtime is pure PHP — no YAML, no extensions, no network, no framework dependency — and it is inert by default: a fresh install only detects, it never writes to the wire until you opt into respond mode.

Not sure this is the package you want?

Install

composer require metrictower/funnypot-core

Requires PHP 7.3 or later. The package has no runtime dependencies beyond PHP itself — everything below is optional and needed only for a specific feature:

Package Only needed for
ext-sodium Verifying signed runtime rule releases (Rules\SignatureVerifier)
symfony/yaml Compiling templates from source (bin/funnypot compile) — not needed to run the engine
psr/http-server-middleware The PSR-15 adapter, Http\HoneypotMiddleware
psr/http-message The PSR-15 request/response mappers, Http\PsrRequestMapper / Http\PsrResponseMapper
psr/http-factory Response/stream factories passed into Http\HoneypotMiddleware

opcache is a requirement, not an optimisation

The compiled detection index (~6,400 templates / ~5,200 routes) is a literal PHP array that opcache interns into shared memory once per host. With opcache on, Honeypot::default() + detect() costs roughly 0.2 ms and ~0.9 MB per worker; with it off, the index is re-materialised on every request (tens of ms, tens of MB). Enable opcache.enable=1 (and opcache.enable_cli=1 if you construct the engine from CLI/queue workers), and bind Honeypot::default() lazily so a process that never serves a request never pays for it. Check a running host with the bundled diagnostic:

php vendor/metrictower/funnypot-core/bin/funnypot doctor

Usage

Detect mode (always safe)

Detect never writes to the wire — it only tells you a request matches a known scanner probe:

use Funnypot\Core\Honeypot;
use Funnypot\Core\RequestContext;

$funnypot = Honeypot::default();  // inert: detect-only, respond gate closed

$detection = $funnypot->detect(RequestContext::fromGlobals());
if ($detection->matched) {
    logScannerProbe($detection->templateIds(), $detection->highestSeverity, $detection->tags());
}

Respond mode (opt-in, gated)

Respond mode is off until you supply a Config with mode: 'respond' and a suspicion gate — without a gate, respond mode stays closed (the default gate always returns false):

use Funnypot\Core\Config;
use Funnypot\Core\Honeypot;
use Funnypot\Core\RequestContext;
use Funnypot\Core\Http\ResponseEmitter;

$funnypot = Honeypot::default(new Config(
    mode: 'respond',
    gate: fn (RequestContext $r) => isSuspicious($r),   // your own suspicion predicate
    responseStyle: 'realistic',                          // minimal | realistic | taunt
    attackEmulation: true,                                // also reflect LFI/SQLi and friends
));

$response = $funnypot->respond(RequestContext::fromGlobals());
if ($response !== null) {
    ResponseEmitter::emit($response);   // a matched probe gets an inert fake
    exit;
}
// nothing matched: serve your normal 404

Set a per-deploy persona seed

Every fabricated identity the engine serves — company name, domain, admin credentials, visual skin — is a pure function of a seed. If you leave both deploySeed and seedSalt unset, every unconfigured install shares one identity, so a scanner can correlate two of your deploys as "both funnypot". Generate a per-install secret once, persist it yourself (the engine does no I/O), and pass it as both:

$funnypot = Honeypot::default(new Config(
    mode: 'respond',
    gate: fn ($r) => isSuspicious($r),
    deploySeed: $secret,   // per-deploy identity
    seedSalt: $secret,     // per-request render salt
));

Check what the engine sees, without changing a single served byte, via $funnypot->seedHealth().

Any PSR-15 app

Http\HoneypotMiddleware sends a matched probe an inert fake and passes everything else through, so your app serves its own 404 on a miss. It needs psr/http-server-middleware, psr/http-message, and psr/http-factory installed (any PSR-17 factory implementation works):

use Funnypot\Core\Honeypot;
use Funnypot\Core\Http\HoneypotMiddleware;

$middleware = new HoneypotMiddleware(
    Honeypot::default($config),
    $responseFactory,   // your Psr\Http\Message\ResponseFactoryInterface
    $streamFactory       // your Psr\Http\Message\StreamFactoryInterface
);

Start with respond mode off (or no gate), watch what detect() flags, then enable respond mode once you're confident in the signal.

Public API

Engine

Funnypot\Core\Honeypot (final class, implements Engine) is the entry point.

Member Signature What it does
Honeypot::default() static function default(?Config $config = null, ?Observer $observer = null): self Builds against the compiled template artifact bundled with the package. No Config ⇒ inert (detect-only).
detect() function detect(RequestContext $r): Detection Always-safe signal: does this request match a known scanner probe? Never has a side effect.
respond() function respond(RequestContext $r): ?SynthesizedResponse Serves a fake only when Config::$mode === 'respond' and every safety gate passes; null on a miss or a declined gate — serve your normal 404.
seedHealth() function seedHealth(): array{identity: string, render_salt: string, ok: bool, warnings: list<string>} Non-served diagnostic reporting whether the per-deploy persona seed is configured.

Request / response value objects

Class Key members Notes
RequestContext __construct(string $method, string $path, string $query = '', array $headers = [], ?string $rawBody = null, string $host = '', string $scheme = 'https', string $httpVersion = ''), static fromGlobals(): self Framework-agnostic snapshot of an incoming request. fromGlobals() builds one from PHP superglobals for the plain-PHP path.
Detection $matched: bool, $matches: TemplateMatch[], $highestSeverity: string, $clusterKey: string, static none(): self, isEmpty(): bool, templateIds(): string[], tags(): string[] The result of detect(). Signal only — your app decides what to do with it.
TemplateMatch $id, $severity, $tags, $name One matched template, as returned inside Detection::$matches.
SynthesizedResponse $status: int, $headers: array, $body: string, $satisfies: Detection The fake response respond() built. $satisfies records which template(s) it satisfies, for your own logging.

Config

Funnypot\Core\Config is a plain constructor-configured object; every parameter is also a public property. Defaults keep an install inert. Commonly-set options:

Option Default Meaning
mode 'detect' 'off' | 'detect' | 'respond'. Respond mode is opt-in.
gate null (closed) fn(RequestContext): bool — your suspicion predicate; respond() never serves without one returning true.
responseStyle 'realistic' 'minimal' | 'realistic' | 'taunt' — see Response styles below.
attackEmulation false Also reflect generic attack classes (LFI/SQLi/command injection/etc.) on a route miss.
severityCeiling 'high' Refuse to fabricate a response stronger than this nuclei severity.
maxBodyBytes 65536 Hard cap; a larger synthesized body is refused rather than served.
exclude [] Template ids/tags to never serve (respond()); detection is unaffected.
ignoreTemplates [] Template ids/tags to never let drive a detection (detect()/classify()); serving is unaffected.
deploySeed, seedSalt null, '' Per-deploy identity + render-salt material — see the persona-seed warning above.
isolatedOrigin false true only for a standalone honeypot that owns its origin — enables decoys that reflect attacker bytes into an active response context. Leave false when embedding in a real app.
honeytokenKey null HMAC key for a tamper-evident bait cookie; a request that returns it altered is a high-signal escalation attempt.
serverHeader, poweredBy null, null Force a consistent Server / X-Powered-By on every response, so the whole site presents one coherent identity.
trustedBypass, killSwitch null, null fn(RequestContext): bool / fn(): bool escape hatches — always skip your own scanners, or un-poison the whole install.

Observer (optional)

Pass an Observer implementation as Honeypot::default($config, $observer) to hook logging, scoring, or banning into the respond() path — the engine itself stays side-effect-free.

interface Observer
{
    public function onDetection(RequestContext $r, Detection $detection): void;
    public function shouldRespond(RequestContext $r, Detection $detection): bool;
    public function onOutcome(RequestContext $r, ?SynthesizedResponse $response, string $reason): void;
}

Every call is wrapped in a try/catch on the engine side, so a throwing implementation can never turn a would-be 404 into a host 500 — to suppress a fake, return false from shouldRespond().

An Observer that also implements HealthObserver (one method, onSeedHealth(array $report): void) additionally receives the seedHealth() report once, at construction — the push counterpart to Honeypot::seedHealth().

PSR-15 adapter

Class Key members
Http\HoneypotMiddleware __construct(Engine $inverter, ResponseFactoryInterface $responseFactory, StreamFactoryInterface $streamFactory), implements Psr\Http\Server\MiddlewareInterface; attaches the Detection to the request under HoneypotMiddleware::ATTRIBUTE_DETECTION.
Http\PsrRequestMapper static map(ServerRequestInterface $request): RequestContext
Http\PsrResponseMapper static map(SynthesizedResponse $response, ResponseFactoryInterface $responseFactory, StreamFactoryInterface $streamFactory): ResponseInterface
Http\ResponseEmitter static emit(SynthesizedResponse $response): void — the plain-PHP alternative to a PSR-15 stack: writes status/headers/body with http_response_code()/header()/echo.

Response styles

Set via Config::$responseStyle (Funnypot\Core\Response\Style::MINIMAL / ::REALISTIC / ::TAUNT). Every style still satisfies the scanner's matcher — they differ only in the body around the tokens the matcher needs:

Style What the attacker gets
minimal Just the tokens the matcher needs. Smallest.
realistic A believable fake — a full .git/config, a plausible .env, a real XML-RPC methodResponse. All values inert. The default.
taunt Still satisfies the scanner, and carries a visible "honeypot, your scan was logged" marker.