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?
- Want a ready-to-run honeypot box to deploy? → The honeypot app
- Detection and IP reporting in one call, batteries included? → funnypot
- Protecting a Laravel app? → funnypot-laravel
- Protecting a WordPress site? → funnypot-wordpress
- Turning detections into ban/allow decisions? → funnypot-policy
- Querying/reporting the IP-reputation service? → funnypot-mainnet-client
- Embedding the engine in your own app — you're in the right place.
Install¶
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:
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. |
Links¶
- Repository: github.com/metrictower/funnypot-core
- Packagist: packagist.org/packages/metrictower/funnypot-core