metrictower / funnypot-core
funnypot core engine: turn a scanner's own nuclei detection template into a matching fake-vulnerable HTTP response. Pure-PHP runtime, inert by default.
Requires
- php: >=7.3
Requires (Dev)
- nyholm/psr7: ^1.8
- phpunit/phpunit: ^9.5
- psr/http-factory: ^1.1
- psr/http-message: ^2.0
- psr/http-server-middleware: ^1.0
- symfony/yaml: ^5.4
Suggests
- ext-sodium: Required only to verify signed rule releases (Rules\SignatureVerifier); the engine itself needs no extensions.
- psr/http-factory: Required only to use the PSR-15 adapter (response/stream factories passed to Http\HoneypotMiddleware).
- psr/http-message: Required only to use the PSR-15 adapter (Http\PsrRequestMapper / Http\PsrResponseMapper).
- psr/http-server-middleware: Required only to use the PSR-15 adapter (Http\HoneypotMiddleware).
- symfony/yaml: Required only to compile templates (bin/funnypot). Runtime needs PHP alone.
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-08 22:48:31 UTC
README
Not sure you're in the right place?
- Want a ready-to-run honeypot box to deploy → funnypot-app
- Protecting a Laravel app → funnypot-laravel
- Protecting a WordPress site → funnypot-wordpress
- Detection and IP reporting in any PHP app, batteries included → funnypot
- Embedding the deception/detection engine in your own PHP / PSR-15 app → funnypot-core ← you are here
- Querying / reporting to the IP-reputation service from code (the SDK) → funnypot-mainnet-client
- Building on the low-level decision/policy engine → funnypot-policy
The HTTP deception engine behind funnypot. It answers a scanner's probe with the fake-vulnerable response the scanner was fishing for. It is 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. The scanner walks away with a full, coherent, wrong vulnerability report while you log every move.
This is the reusable PHP library. Drop it into any PHP or PSR-15 app and its 404s start answering scanners with believable decoys. Runtime is pure PHP: no YAML, no extensions, no network. It is inert by default (detect only); respond mode is opt-in and gated by your own suspicion signal.
Want to run a honeypot, not embed one? The standalone app builds on this package and adds a live dashboard, a pure-PHP SSH server, a fake shell, and 18 TCP service emulators: github.com/metrictower/funnypot-app.
What it does
- Nuclei inversion. Compiles the upstream nuclei-templates
corpus and inverts each detection template into a response that satisfies its matcher. From 11,196
HTTP templates it indexes about 6,300 invertible ones into roughly 5,100
(method, path)route personas. - Attack-class emulators. Reflects LFI, SQLi, command injection, SSTI, XXE, shellshock, Struts
OGNL, open redirect, reflected XSS and cloud-IMDS probes on any path, with canned inert markers
(
root:x:0:0,uid=0(root)). - CRS-broadened coverage. Recall for the generic attack classes (SQLi/XSS/LFI/RCE) is widened
from the upstream OWASP CoreRuleSet: its portable
PL1 rules are aggregated into one broadened match per class, behind funnypot's SAME response
archetype. It never invents a per-rule response and never touches the nuclei corpus — see
docs/CRS.md. - Product and route decoys. Believable
.git/config,.env,wp-config,phpinfo,.htpasswd,server-status, SSH keys, SQL dumps, phpMyAdmin and more. Data-bearing decoys draw people and records from a shared seeded generator (Support\Fake, exposed to templates as{{fake.person.*}}directives), so rows are coherent per deployment rather than repeatedjdoe/example.complaceholders. - Anti-fingerprint. One coherent product persona per attacker (deterministic, spoof-proof seed)
instead of an impossible "vulnerable to everything" host. Consistent
X-Powered-By, tamper-evident honeytoken cookie whose name, payload vocabulary and attribute tail are seeded per deploy (Honeytoken::bait($deploySeed)), so the bait envelope is not a fleet-wide regex. - AI-API recon surface. A fake Ollama + OpenAI/Anthropic-shaped model API —
/api/tags,/api/version,/api/ps,/api/show, header-branched/v1/models— plus a buffered floor on the four chat endpoints. One shared model catalog is the single source of truth for every body.
Install
composer require metrictower/funnypot-core
Detect mode (always safe)
Detect never writes to the wire. It just tells you a request is a known scanner probe:
use Funnypot\Core\Honeypot; use Funnypot\Core\RequestContext; $funnypot = Honeypot::default(); // inert: detect-only, gate closed $detection = $funnypot->detect(RequestContext::fromGlobals()); if ($detection->matched) { logScannerProbe($detection->templateIds(), $detection->highestSeverity, $detection->tags()); }
Silencing a noisy template
When one template turns out to be noisy on a particular site, you don't have to switch the sensor
off — you can drop just that template. The id to name is the one you already log:
Detection::templateIds() gives the matching ids (and ->tags() the tags) for a flagged request, so
read a false-positive log line and you have the exact id to silence:
/telescope/requests scanner-probe ids=laravel-telescope
/robots.txt ambient ids=CVE-2023-33960,robots-txt,robots-txt-endpoint,bigcommerce-detect
Then list it (or a whole tag) under ignoreTemplates:
use Funnypot\Core\Config; use Funnypot\Core\Honeypot; $funnypot = Honeypot::default(new Config( ignoreTemplates: ['laravel-telescope', 'miscellaneous'], // ids AND tags accepted ));
An ignored template contributes no evidence to the classification. A path that matched only
ignored templates classifies CLEAN; a path that also matches a non-ignored template is still a
probe on that remaining template (drop-from-evidence). Ids and tags are both accepted, so a whole
noisy tag can go in one entry.
ignoreTemplates is not exclude. They govern opposite sides and never cross over:
| Knob | Governs | Effect |
|---|---|---|
ignoreTemplates |
detection (detect() / check()) |
the template no longer drives a classification |
exclude |
serving (respond()) |
the template's fake is never served, but it still detects |
So exclude alone keeps detecting a probe while refusing to serve it a fake; ignoreTemplates alone
stops the classification while leaving every other template's serving untouched. Reach for
ignoreTemplates when a template is a false positive on your site; reach for exclude when you want
the intel but not the decoy.
Respond mode (opt-in, gated)
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 suspicion predicate; null = closed 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
Per-deploy persona seed (avoid a fleet-constant identity)
Every fabricated identity — the company name, domain, admin credentials, fake secrets, visual skin — is
a pure function of a per-deploy 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". Set a per-install secret so each site presents a distinct, self-coherent identity:
$secret = /* a per-install secret, generated once and persisted by your app */; $funnypot = Honeypot::default(new Config( mode: 'respond', gate: fn ($r) => isSuspicious($r), deploySeed: $secret, // drives the persona IDENTITY ({{persona.*}}, visual skin, decoy session + its breached-DB table story + the bait cookie envelope) seedSalt: $secret, // drives the per-request RENDER seed ({{fake.*}}, {{pick:*}} choices) ));
Two independent conditions matter: deploySeed (identity material) and seedSalt (render salt).
Setting one persisted secret for both is the simplest safe configuration. The core never generates
or persists the secret — it does no I/O; provisioning a per-install secret is the host app's job.
The SubSeed registry reserves SubSeed::NS_APP (app) for downstream app-owned persona fields.
This registration supplies namespace ownership only: it does not define the app's field vocabulary
or mean that downstream persona adoption has shipped.
The visual skin the deploySeed drives is seeded end to end: the CSS class-name prefix word (a
neutral <word>-XXXX namespace, never the old fleet-constant fp-) and the full text palette
(including the foreground/muted greys) vary per deploy too (FP-0283), so two deploys never share one
CSS hash. Skins reach the prefix through RenderHtmlHelpers::bindClassPrefix($persona->classPrefix()),
called once at the top of render(); the widget helpers throw if used unbound (there is no
fleet-constant fallback).
Release note (FP-0283 — breaking
RenderHtmlHelperstrait API). FP-0283 removes the fixedfp-class prefix and gives the widget trait a requiredbindClassPrefix()/chromeClass()/widgetCss()surface. This is a breaking change for^0.6consumers, so the first core tag that contains FP-0283 MUST bev0.7.0, never av0.6.x— av0.6.xtag would let a consumer'scomposer updatepull the new trait and throw on every panel that renders a widget unbound. The app tier adopts it (bump to^0.7+ bind + rename its ownfp-*literals) under follow-up FP-0298.
The deploySeed also drives the decoy surface graph (the /sitemap.xml, /robots.txt, OpenAPI/
Swagger docs and REST index): its advertised endpoint set, ordering and resource nouns are a per-deploy
seeded subset ({{surface.*}}), so two deploys expose different but internally-coherent surface graphs
instead of one fleet-correlation tell — every advertised path still resolves and no linked path ever
dangles. Because it is seed-derived, upgrading the package re-rolls a deploy's surface graph once
(a returning scanner sees the site's map change), exactly as it re-rolls the persona identity; the seed
derivation itself never changes.
Ask the engine what it sees, without changing a single served byte:
$health = $funnypot->seedHealth(); // ['identity' => 'set'|'empty'|'placeholder', 'render_salt' => 'set'|'empty', 'ok' => bool, 'warnings' => [...]]
seedHealth() classifies by the material string only — it never inspects the derived seed and never
re-derives, so an unconfigured deploy keeps serving byte-for-byte what it served before; the report is a
non-served diagnostic. An Observer that also implements HealthObserver receives the same report once at
construction (push); most hosts read seedHealth() on a status page (pull).
Embedded vs. isolated origin (reflecting decoys)
A few decoys are believable only if they reflect the attacker's own request bytes into an active
response context — the reflected-XSS decoy echoes the payload into an HTML body, the open-redirect
decoy echoes the target into a Location, and the Vite /@fs/ decoy echoes the path into its body.
On a standalone honeypot that owns its origin, that is safe bait. Embedded inline in a
response-owning host (via funnypot-laravel or the funnypot embedder), the same reflection would
be a live XSS / open redirect in that host's real origin.
The engine is fail-safe by default: Config::$isolatedOrigin defaults to false, meaning "treat
this install as embedded" — reflecting decoys are withheld from serving, while detection is
untouched (the probe still classifies, so the intel is captured; only the reflection is suppressed).
Since v0.7.0 an isolated origin is necessary but not sufficient. isolatedOrigin is the
operator's coarse intent; a reflector also requires evidence about the specific request —
Config::$reflectorAuthorizer, a fn(RequestContext, string $reflectClass): bool the adapter
supplies. A reflector serves only when all of these hold, in order:
isolatedOrigin === true && class enabled && a live request is present && reflectorAuthorizer(request, class) === true
The authorizer is fail-closed exactly like gate: null (the default) suppresses every
reflector, only a literal true authorizes, and any Throwable suppresses. It must return true
only from a server-derived fact a client cannot supply or override — for example a static
FastCGI param injected by an exact deception vhost whose router has no operator-plane routes. Reading
RequestContext::$host/$scheme, Host, Forwarded, X-Forwarded-*, a cookie, a query value, or
any ordinary request header is not evidence (a client controls all of those) and must never gate
a reflector. A standalone honeypot opts in with both terms:
$funnypot = Honeypot::default(new Config( mode: 'respond', gate: fn (RequestContext $r) => isSuspicious($r), attackEmulation: true, isolatedOrigin: true, // intent: this box owns its origin // ... reflectorAuthorizer is the LAST positional arg; usually set as a property: )); $config->reflectorAuthorizer = fn (RequestContext $r, string $class) => edgeAttestsDeceptionOrigin();
v0.7.0 migration (breaking, safe-off). An
isolatedOrigin: trueinstall that does nothing else now serves no active reflector until it wires areflectorAuthorizer— the three reflect classes (xss,open-redirect,fs-read) all ride this one seam, so all three go dormant together. This is deliberate: a bare boolean was never proof that the reflection would not act in an operator origin. Re-enable per class from the authorizer's$reflectClassargument. The standalone-app activation path (an edge-attested origin split from the operator plane) is delivered separately; do not substitutefn () => truefor the missing callback in a shared-origin app. A newer rules artifact reaching an older (pre-v0.7.0) engine is also safe: the unknown{{urldecode-ascii:match.*}}slot renders empty, so no attacker bytes are reflected.
Embedded hosts (funnypot-laravel, the funnypot embedder) inherit the safe default and need no
change. A template joins this class by declaring reflects_input: true at its top level (the attack
and param compilers carry the flag into the compiled rule; the runtime reads it as data), so covering
a future reflector is a one-line template edit with no engine change.
Each reflector also declares an explicit reflect class — reflect_class: xss (reflected-XSS),
open-redirect, or fs-read (the Vite /@fs/ path echo). Config::$reflectClasses is a per-class
override map (array<string, bool>, default []) that lets an isolated-origin honeypot turn a
single class off without disabling the others:
$funnypot = Honeypot::default(new Config( mode: 'respond', attackEmulation: true, isolatedOrigin: true, // this box owns its origin reflectClasses: ['xss' => false], // ... but keep reflected-XSS bait off ));
A missing key defaults to enabled, so the default [] neither adds nor removes a class. The map
is AND-composed into the serve decision alongside the origin intent and the request-bound authorizer
(serveReflector(class, request) = isolatedOrigin && (reflectClasses[class] ?? true) && request-present && reflectorAuthorizer(request, class) === true)
and can therefore only ever subtract: setting a class to true on an embedded host
(isolatedOrigin=false) does not re-enable it — the isolatedOrigin term dominates, so an
embedded host never reflects, whatever the map says. The fail-safe default is preserved.
Parameter reactions (opt-in, isolated origin only)
Config::$paramReactivity (default false) lets an already-resolved decoy route react to a
common query-parameter intent — a file/path read, a redirect/SSRF notice, a debug view, a command
result, or a search result — by appending an inert, persona-coherent panel to its response. It never
creates a route, changes classification, performs the requested operation, weakens the route's
scanner matcher, or enters a header/redirect/I/O context; anything unsupported, ambiguous or
constraint-sensitive keeps the exact base response.
It is a reflecting decoy and rides the SAME gate as every other reflector: a reaction serves only
when paramReactivity is on and serveReflector('param-reaction', request) is true. Because that
composition AND-s in isolatedOrigin, an embedded host (funnypot-laravel, the funnypot embedder —
isolatedOrigin=false) can never echo a reaction, and the position-blind synthesize() port
(no request, no evidence) never reacts either — only the respond() facade does. An isolated origin
subtracts it like any class with reflectClasses: ['param-reaction' => false].
$funnypot = Honeypot::default(new Config( mode: 'respond', isolatedOrigin: true, // this box owns its origin ... reflectorAuthorizer: $evidence, // ... and supplies per-request evidence paramReactivity: true, // append bounded query reactions on decoy routes ));
The query is parsed by a strict, closed classifier (at most 2 KiB / 32 pairs, one percent-decode, no
parse_str), the reflected value appears only as entity- or byte-encoded display text (never in a
URL, attribute, script, header or redirect), and only append-safe text/html/text/plain responses
are decorated — every size/exclusive/regex/binary bundle and every active-header response declines to
the untouched base. 'param-reaction' is a code-defined reflect class (no compiled rule carries
it), so no compiled artifact changes.
The XSS reflection baseline (inert by charset, not by the gate)
Most XSS scanners (dalfox, nuclei) send a benign alphanumeric marker first and only escalate to
markup once it echoes; the gated reflected-XSS decoy above matches markup only, so its bait was
unreachable by the very scanners it targets. The attack-xss-baseline rule closes that gap: it owns
one synthetic search path, GET /products/quick-search, and echoes one query value — q — that is
wholly [A-Za-z0-9]{1,64}. That character class is the whitelist: any out-of-class byte
(markup, quotes, %-encoding, space, +, &, CR/LF/NUL, multibyte) makes the capture fail, so the
rule declines and echoes nothing — the reflected string can never carry a markup-forming byte. Unlike
the reflectors above it is therefore inert by construction (like the php-cgi source-disclosure
and SSTI-numeric decoys) and serves on a default embedded install, with no isolated origin
required. Full-tag reflection stays on the gated attack-xss decoy, unchanged.
Its bounded raw escalation companion attack-xss-escalation (priority 65, same owned path)
carries a scanner's confirmation stages — dalfox's batched special-character probe and generated
tags, nuclei's percent-encoded '"><N> breakout — that the alphanumeric baseline refuses. It
reflects the raw q through a single bounded slot ({{urldecode-ascii:match.value}}: one form-decode,
then 1..512 printable-ASCII bytes or nothing) behind a restrictive sandbox CSP. Being a
reflects_input/reflect_class: xss rule it obeys the full three-term gate above, so on a default
install a non-alphanumeric q on this path is suppressed to the host's own 404 (the detection is
still recorded) — no attacker bytes are ever served without an isolated origin and a
reflectorAuthorizer.
- Opt-out is ID-only for this attack-tier rule:
Config::$exclude = ['attack-xss-baseline']. A tag (exclude: ['xss']) does not disable it — tag-based exclusion applies only to route bundles, never the attack tier. - Embedding caveat: on a store miss the attack tier does not consult your
SiteProfile, and the middleware runs before your handler, so if your app genuinely serves/products/quick-search?q=…the decoy will answer it. This is the same exposure the/@fs/and/catalog/{slug}decoys accept; use the ID opt-out above if the path collides with a real route. (Follow-up: consult the profile in the attack tier for store-miss paths.)
Using Laravel?
Use funnypot-laravel
(composer require metrictower/funnypot-laravel) — the ServiceProvider + middleware drop-in. This
repo is the framework-agnostic engine.
Any other framework (PSR-15)
Wire the engine directly. A PSR-15 middleware (Funnypot\Core\Http\HoneypotMiddleware) sends matched
probes an inert fake and passes everything else through, so your app serves its own 404 on a miss.
Start detect-only, watch the logs, then set mode = respond and supply a gate.
Want detection and IP reporting without assembling it yourself? metrictower/funnypot wires this engine to the mainnet reporting SDK and enforces the request-path invariants for you.
Request and inspection ceilings
Core accepts a complete request target only when it is at most 4,096 raw bytes. The target is
never decoded or clipped into a route: an oversized RequestContext produces an empty detection and
no fake. The PSR-15 adapter checks getRequestTarget() before the injected engine, URI mapping,
headers or body, attaches an empty detection and passes the host request downstream unchanged. It
also declines when separately exposed URI path/query primitives reconstruct beyond the same limit.
Plain-PHP mapping applies the equivalent check to REQUEST_URI before headers or php://input.
Accepted adapter snapshots keep up to 65,536 bytes of canonical headers (at most 128 fields and 256 values) and the existing 65,536-byte captured-body ceiling. Attack and bot classifiers derive a smaller view: 16,384 header bytes over at most 64 fields, a 32,768-byte body contribution, at most two URL-decode passes, and a final 32,768-byte regex subject. Header values and body bytes beyond those documented inspection windows are deliberately not classified. OOB probes retain their separate 65,536-byte header-first layout, reserved 16,384-byte body tail and three decode passes. Direct contexts also cap Host at 512 bytes and cookie/session parsing at 8,192 bytes/64 pairs.
The standalone app owns the wire policy: its edge and pre-bootstrap guard return 414 for an oversized raw target. An embedded host remains in control of its response; core only declines and never aliases an oversized prefix onto a real decoy route.
Response styles
Set at init with responseStyle:
| 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. |
Rich content is validated against the matcher before use. If a richer body would not satisfy the scanner it falls back to minimal, so richness can never break the guarantee.
How it works
flowchart TD
REQ([HTTP request]) --> D{scanner probe?}
D -->|no| NULL[return null - your app serves its own 404]
D -->|yes| OWN{owns_path request-aware rule?}
OWN -->|match| SERVE
OWN -->|decline or none| T1{tier 1 · nuclei-exact + route decoy}
T1 -->|hit| SERVE
T1 -->|miss| T2{tier 2 · CRS attack-class}
T2 -->|hit| SERVE
T2 -->|miss| T3{tier 3 · LLM 404-upgrade · app-only}
T3 -->|upgrade| SERVE
T3 -->|miss| P404([plain 404])
SERVE([serve inert fake · fingerprint-safe · logged])
Loading
Templates are compiled once, at build time, into frozen PHP arrays (resources/compiled/*.php). The
app loads them into opcache and serves with a single O(1) lookup. A miss returns null so your app
serves its own 404. symfony/yaml is only needed by the compiler — it serializes the compiler's
inputs for both the in-repo bin/funnypot build (compile-ai) and the pinned full rebuild
(bin/funnypot build-corpus, see docs/CORPUS-PIPELINE.md); the runtime
loads none of it. It is pinned to an exact version because those bytes are part of the
compiled-artifact ABI (see the zero-drift law below). See SPEC.md and
docs/PERSONA-CAP.md.
Memory and opcache
opcache is an operating requirement, not an optimisation. The compiled index is a pure literal PHP array, which is what lets opcache intern it into shared memory as an immutable array — shared across workers at no per-process cost. Turn opcache off and the index is re-materialised on every request.
Measured on the shipped artifact (6,397 templates / 5,196 routes), identical on PHP 7.3, 8.0, 8.4 and 8.5:
| opcache on | opcache off | |
|---|---|---|
| process heap, per request | 0.00 MB | 20.43 MB |
| private memory, per worker | ~0.9 MB | ~42 MB |
| shared memory, once per host | ~14 MB | — |
Honeypot::default() + detect() |
0.2 ms | 52 ms |
Warm detect() is 2–20 µs. A pool of 20 workers costs roughly 35 MB total with opcache and
~840 MB without.
What to check when embedding:
opcache.enable=1; alsoopcache.enable_cli=1if you construct the engine from CLI or queue workers — CLI opcache is off by default, so a worker that touchesHoneypot::default()pays the full cost on every process boot- at least ~20 MB of opcache shared memory free above whatever your app already uses, and
opcache.max_accelerated_filesabove your app's file count - never
opcache.file_cache_only=1— it loads into process memory and silently reinstates the full cost - bind
Honeypot::default()lazily. An eager service-provider binding makes every CLI and queue process pay for an index it will never consult.
Check a host with the bundled command — exit 0 when the index is shared and the packaged
manifest.json still verifies the packaged index (sha256, size, counts, upstream pin), 1 otherwise,
so a deploy step can gate on it:
php vendor/metrictower/funnypot-core/bin/funnypot doctor
index shared : yes
reason : interned into opcache shared memory
sapi : cli
opcache free : 238.5 MB
corpus : projectdiscovery/nuclei-templates @ 2ec9141 (2ec914123864439c3618e7e9ae72d32d0eb56df7)
compiled by : core unknown / php unknown / built_at 2026-08-17T14:04:52+00:00
index : 5303 route keys / 6447 templates / 6225412 bytes / sha256 4fa6b2f1bb8ca32e…
provenance : OK — manifest.json verifies nuclei-index.full.php (sha256, size, counts, upstream pin)
Run it from the SAPI that actually serves your traffic. doctor --provenance runs only the artifact
half — the form CI's drift gate calls, since opcache sharing is a property of the host, not the repo.
PhpArrayStore::diagnose() returns the opcache verdict as an array if you would rather wire it into
a health endpoint; it never throws, and it degrades to shared => false when opcache.restrict_api
blocks the introspection calls.
Two ways to lose the interning silently: file_cache_only as above, and making the compiled
artifact non-literal (a const reference, a function call, a computed key). Both are worth catching
in review.
Note a one-shot CLI process can never show the benefit — it is a guaranteed cold cache, so measuring there reports the opcache-off numbers no matter how opcache is configured.
Response precedence
respond() decides what to serve in a fixed order — an earlier tier always wins:
- Nuclei-exact (tier 1). The request routes to a compiled nuclei template or route decoy → a byte-exact response derived from what that scanner probes for.
- CRS-generic / attack-class (tier 2). No route matched →
TemplateAttackEmulatoremulates a generic attack class (hand-authored rules first, then the CRS-broadened alternation) from a hand-authored response archetype. - LLM fake, then plain 404 (tiers 3–4, app layer).
So a request matching BOTH a nuclei template AND a CRS attack class always gets the nuclei-exact
response — nuclei-exact beats CRS-generic. CRS is a coverage multiplier for tier 2, never a
tier-1 source. Full detail, and how to regenerate (bin/funnypot compile-crs), in
docs/CRS.md.
Path ownership override. A request-blind tier-1 decoy is the right answer for most paths, but a
few paths deserve a request-aware emulator that dispatches on the request body — a WordPress
xmlrpc.php that parses the methodCall, a panel login that answers a brute-force attempt. Such a
rule declares owns_path: in its template; for those paths the request-aware attack tier is consulted
before the static tier-1 entry, and a match wins. Critically, a rule decline falls straight
through to the static decoy — so ownership only ever upgrades a served path, never removes coverage,
and it sits after the "never shadow a live host route" guard so a real endpoint is untouched. This is
how bare /xmlrpc.php and the credential oracles serve request-aware responses even though a static
decoy also keys those paths.
AI-API recon surface
The same owns_path override backs a fake AI-inference API: Ollama /api/tags, /api/version,
/api/ps, and a per-model /api/show (POST), plus a header-branched GET /v1/models — no
anthropic-version header gets the OpenAI list shape, its presence switches to the Anthropic shape.
A buffered ("non-streaming") floor answers the four chat paths (/api/chat, /api/generate,
/v1/chat/completions, /v1/messages) with a static, deliberately-wrong answer and the request's
own model echoed back; the echo is capture-bounded so a malformed model value can never break the
served JSON. /api/tags and /api/ps also carry a heavy-weighted tier-1 route decoy, so the AI
persona still wins the rare corpus-template collision even where owns_path isn't in play.
Every body is json_encode() of a projection from resources/ai/model-catalog.php, read through
Funnypot\Core\Ai\ModelCatalog — one source of truth for both the route- and attack-tier copies, so
there's nothing to hand-sync. The catalog leads with mythos (owned_by anthropic), the box's fictional
house flagship — placed first so it heads /v1/models + /api/tags and shows as the loaded model on
/api/ps, matching the identity the chat surface gives when asked "what model are you"; the real,
verified models follow as the also-available multi-model rig. bin/funnypot compile-ai regenerates the templates from the catalog
(route templates into templates/generated/, owns_path rules into templates/attack-ai/); re-run
it after a catalog change, then rebuild the in-repo artifacts with composer build — the single
funnypot build orchestrator that encodes the whole DAG in order (compile-ai → compile-emulators
→ compile-routes → compile-params → merge-routes → build-manifest; merge-routes is
synchronizing — it owns every route-* id in the index (the prefix is reserved for new_page ids:
RouteBundleSynth rejects any other id and compile skips a corpus template that squats it),
removes them all, then folds the current fragment, so a removed or changed page never survives a
rebuild). A full rebuild from the external corpus
is composer build-corpus, pinned to the recorded upstream commit — see
docs/CORPUS-PIPELINE.md.
The interactive streaming chat and the actual LLM live in the funnypot app, not here — this package only floors the buffered, non-streaming chat shapes, so those four paths still answer believably when the app's LLM is off.
Handmade decoys
The hand-authored decoys — original responses re-derived from what a scanner probes for (never vendored upstream markup). Distinct from the auto-inverted nuclei-templates corpus, which is generated at build time and not listed here. Every response stays inert (emulates output, never executes) and fingerprint-safe (never echoes a scanner's own signature strings).
| Family | Decoys | Behaviour |
|---|---|---|
| Panel login oracles | Grafana · Kibana · Jenkins · Webmin · cPanel/cpsrvd · phpPgAdmin · WP-login · D-Link HNAP | Byte-faithful login pages that never authenticate — bait the login, log the attempt, always decline |
| Mock-auth (high-interaction) | phpMyAdmin (gate + login + authed dashboard) · WordPress (wp-login mint → authed /wp-admin dashboard) |
Accepts any credential → inert decoy session → an authed decoy: phpMyAdmin's "breached DB" (6 seeded tables, one seeded with an inert CTF-style flag) or a full WordPress admin dashboard. Dormant until a signing key is set |
| WordPress | xmlrpc (base · GET · addtwo · system.multicall) · wp-login (mock-auth mint) · wp-admin (authed dashboard, else login redirect) · REST API (/wp-json index + wp/v2 users · posts · pages · comments · media · categories · tags · types · statuses · settings) |
Request-aware xmlrpc.php parses the methodCall; a plausible wp-login POST mints a signed decoy session and 302s to /wp-admin/, which renders an authed admin dashboard for that cookie and falls back to the pinned login redirect otherwise; REST endpoints serve one persona-seeded author set (5 users, index 1 = admin) that every collection references — no email/login exposed to anon, settings → 401 |
| Next.js / RSC | App-Router GET / shell + a React Server Components (Flight) responder for ?_rsc= navigations |
A persona-gated framework fingerprint for the 2025 RSC CVE family (CVE-2025-55182/-55183/-55184) — fires only on deploys where the persona lottery picked Next.js for /, so it never leaks a stray Flight response on a WordPress/nginx deploy. Inert hand-authored Flight document, no request byte reflected |
| RCE / CVE exploits | Confluence OGNL (26134) · php-cgi (1823 · 4577) · Shellshock · Struts OGNL (5638) · PHPUnit (9841) · ThinkPHP · F5 iControl (1388) · GeoServer (36401) · Laravel Ignition · ownCloud (49103) · Spring Actuator · webshell | Fake-vulnerable responses so the scanner "confirms" a hit that isn't real |
| Spring Boot Actuator | /actuator/{env,health,mappings,info,beans,loggers,threaddump,configprops} · heapdump (twelve scanner paths) · logfile |
One persona-coherent Spring identity across every endpoint. heapdump is a generated HotSpot HPROF (binary_generator: spring_hprof_v1, < 4 KB, raw bytes, no gzip/disposition) planting the datasource/AWS/admin/JWT secrets as rooted java.lang.String objects a heap analyser or strings recovers; logfile is a Logback log leaking the same values, stamped with the same seeded boot date as the heap header |
| IoT / edge exploits (CVE) | Hikvision camera (36260) · GPON router (10561) · Fiberhome router (27973) · Netgear router (6277) · Xdebug remote-debug recon · Node-RED (deploy RCE + recon) | Signal-only decoys for the highest-volume real-world IoT/edge HTTP exploit probes — path/payload-gated, inert canned reply, no request byte reflected |
| Injection & reflection | SQLi · XSS · SSTI (Twig · numeric) · command injection (unix · windows) · XXE · open-redirect · CRLF response-splitting · php-glastopf | Plausible reflected-payload behaviour, never executed |
| LFI / traversal | /etc/shadow · /etc/group · /proc/*/environ · unix · windows · SMB conf |
Bounded fake file-read, in-string only — no filesystem access |
| Network appliances (CVE) | FortiOS (40684) · Ivanti Connect Secure (21887) · Citrix Bleed (4966) | Edge-device exploit surfaces bots sweep hardest |
| Cloud / IMDS | EC2 instance-metadata tree (category listing · every leaf · placement/iam/network/block-device-mapping sub-listings) · instance-identity/document · iam/security-credentials role listing → inert STS creds |
Fully-walkable SSRF/LFI bait — every advertised child resolves (no partial tell), all values inert, seed-coherent, and consistent across the document and leaves |
| AI-API impersonation | Ollama (/api/tags · version · ps · show · chat · generate) · OpenAI chat · Anthropic messages · GET /v1/models |
Byte-exact inference-API surfaces to bait LLM/GPU scanners; buffered troll-chat floor |
| CRS attack-class engine | sqli · xss · lfi · rce | A coverage multiplier for tier 2 — broadens the generic attack-class alternation |
| Static file & config leaks (route tier) | config & secret files, cloud/AWS creds, .env, logs, API docs / Swagger, VCS metadata (.git HEAD/logs/packed-refs/refs, .svn, .hg, .bzr, CVS/Entries — coherent shas/refs/branch), CMS configs (TYPO3 typo3conf/ + localconf.php), backups, phpinfo, directory listings |
The long tail scanners crawl for — served as plausible disclosure pages |
A generated visual of this inventory + the response-precedence pipeline lives at
docs/DECOY-MAP.md(auto-generated bybin/funnypot map— planned; see the roadmap).
How a route-tier decoy is authored — the match / new_page / response DSL, the closed response
key set (headers | body | body_b64 | binary | binary_generator, exactly one body source), and the
binary_generator contract (closed built-in registry, 64 KB inclusive ceiling, decline-to-404, the
old-runtime sentinel) — is in docs/ROUTE-TEMPLATES.md.
Runtime rule updates (no composer update)
Rules move roughly weekly (nuclei-templates tags, CRS releases). Instead of a composer update
per change, a honeypot can fetch signed rule releases at runtime and hot-swap them:
funnypot rules:update --data-dir=/var/lib/funnypot/rules # fetch, verify, atomic swap funnypot rules:status --data-dir=/var/lib/funnypot/rules funnypot rules:rollback --data-dir=/var/lib/funnypot/rules # network-free, to a retained release
On Laravel, funnypot-laravel ships the scheduled
command; core itself is framework-free. With no
data_dir configured, nothing changes — the engine loads only the bundled artifacts, exactly as
before. Because the compiled artifacts are required PHP, an update is verified in depth before
anything is loaded: an ed25519 signature against a public key vendored inside this package
(never fetched), a per-file sha256 cross-check, a pure-array-literal proof of every .php
(no code can execute on load), a ReDoS budget on every regex, a fingerprint-leak re-scan of
every served string leaf in every served artifact — the attack, route and param rules and the
nuclei + flat route indexes, against the scanner/OAST-aware denylist — and an anti-blinding
coverage floor. Any failure keeps the current rules — the honeypot never
serves empty. Full mechanism, trust model, and operator runbook:
docs/RULES-UPDATE.md.
Safety
funnypot can only mislead an attacker, never help one.
- Emulate output, never execute input. No
exec/eval, no real filesystem, no outbound socket. - Reflect, never harm. No bombs, no retaliation, no outbound requests. All responses size-capped
(
maxBodyBytes, default 64 KB). - Never reflects attacker input, never deserializes a request body. Every synthesized header is CRLF/NUL-safe.
- Inert by default. A fresh install is detect-only with the gate closed. A layered gate then guards respond mode: kill switch, mode, trusted bypass, suspicion gate, severity ceiling, coherent persona, body-size cap.
- Inert fakes only.
example.comhosts, RFC-5737 IPs, obviously-fake keys. Never a real secret. - Credential oracles never authenticate. Login endpoints (Webmin, Jenkins, HNAP, and the panel logins) answer a brute-force attempt with the real "login failed" — captured only to gate the response, never reflected — and have no success path: no authenticated session, no auth cookie, no code path that could accept a password. Adversarially proven zero-exec.
Testing
composer install vendor/bin/phpunit # unit + compiler suite bash tests/acceptance/run.sh # real nuclei (Docker) vs a php -S server (golden test)
Zero-drift compiled-artifact law
The compiled artifacts under resources/compiled/ are generated from the templates and MUST be
regenerated-and-committed whenever the templates change — nothing serves them stale, but a stale
funnypot-manifest.php / orphaned manifest.json fingerprint is a real defect. Two commands:
composer build # recompile the in-repo artifacts (the funnypot build DAG) composer check # the LAW: recompile + drift gate + lint-routes + fingerprint (static + runtime render-corpus) + namespace
Run composer check before pushing. It runs the exact bytes CI's artifact-law workflow runs
(both call scripts/ci/check-drift.sh), so local and CI cannot disagree: the script rebuilds, then
git status --porcelain over resources/compiled + templates/generated + templates/attack-ai
must be empty (catching modified, untracked, and deleted outputs — a plain git diff misses new
files), and funnypot doctor --provenance must pass — manifest.json must verify
nuclei-index.full.php (sha256, size, counts, and the upstream pin embedded in the index). If it
drifts, run composer build and commit the result.
How the compile is made deterministic. The compiled index carries no wall-clock stamp — the old
built_at field made a fresh recompile never reproduce the committed bytes, so it moved to the JSON
sidecars (manifest.json / crs-manifest.json), which are refresh-workflow records, never rewritten
by the in-repo build. In its place each artifact carries a reproducible source-tree sha256
provenance stamp (SourceTreeStamp over exactly the *.yaml set the step globbed, repo-relative and
SORT_STRING-ordered), and the nuclei index stores the complete source commit in both
upstream_tag and upstream_sha. Using the full sha for the display field keeps corpus bytes
independent of local Git tags, history and abbreviation settings; CRS compilation retains its
descriptive Git tag in the separate CRS manifest. File globs sort SORT_STRING (cross-PHP-stable
for the digit-prefixed filenames) and all compile writes go through one atomic writer.
merge-routes owns the index's reproducible fields: it synchronizes the
owned route-* fold (removes every route-* template/bundle/detection, drops a key left empty, then
folds the current fragment — a removed or changed page cannot linger), recomputes source_tree,
refreshes the post-fold route_keys/templates_indexed counts (which RulesUpdater/
publish-rules-release read into coverage and signed release manifests), and refreshes the
manifest.json sha256/size so the sidecar's fingerprint actually verifies the index. A malformed
index or fragment fails the fold closed (exit 2, nothing written).
The law is defined at PHP 8.3's bytes (the version the refresh workflows build with). Line
endings are pinned to LF via .gitattributes (*.yaml/*.php text eol=lf) so a CRLF checkout
can't change a content hash and make local composer check disagree with CI. artifact-law ships as
workflow_dispatch-only for now (FP-0039's PR-CI pause stands); the law is enforced by composer check locally and manual dispatch.
The YAML serializer version is part of that ABI. compile-ai serializes the committed compiler
inputs with symfony/yaml, so the serializer version — not merely its PHP API — helps define the
generated bytes. It is pinned to an exact version in require-dev (never a caret, tilde, wildcard or
range), and a guard test (tests/YamlSerializerPinTest.php) fails the build if the constraint ever
floats. Upgrading it, including a security update, stays allowed but is a deliberate artifact
migration in one reviewable commit: bump the exact version and its guard, install from a clean solve,
run composer build, and review every changed templates/generated/templates/attack-ai byte and
the downstream index/manifest/provenance diff before release. It is never an incidental composer update — that unreviewed drift is exactly what the pin prevents.
compile-emulators prints ~23 exit-0 warning: lines about the AI owns_path templates on a clean
build — an intentional design note (those rules lean on the runtime auth-witness backstop, not full
path-regex variant coverage), not drift. Only a non-zero exit or a dirty tree is drift.
Corpus provenance and the full rebuild
composer build never touches the corpus half — it folds the committed index. The external half,
the nuclei-templates compile, is pinned: manifest.json's upstream_sha is the exact upstream
commit the committed index was built from (also embedded in the index itself), and the one full
rebuild is:
composer build-corpus # pin check -> compile <checkout>/http -> build composer build-corpus -- ../nuclei-templates --bump # move the pin deliberately (prints the route-key delta) composer verify-corpus # prove the pinned checkout reproduces the committed bytes
build-corpus refuses a checkout that is not a git clone (no recordable revision — a
nuclei -update-templates dir is scratch, not a source) or that is not at the pin. The checkout
root comes from the argument, NUCLEI_TEMPLATES_DIR, or ../nuclei-templates. The sidecar also
records which compiler produced the corpus (core_commit, php_version) and a reproducible
built_at (SOURCE_DATE_EPOCH, else the upstream commit date). The nuclei upstream_tag display
value is the same complete commit sha as the pin, so build-corpus --verify does not depend on
Git's configured abbreviation length or on which tags are present. tests/CorpusProvenanceTest.php
holds a route-key floor so a rebuild that loses coverage fails loudly instead of shipping — running
compile without the fold would drop every in-repo new-page key and still exit 0. The pipeline, the
pin, and the refresh procedure: docs/CORPUS-PIPELINE.md.
Licence
MIT, see LICENSE. Derived in part from
projectdiscovery/nuclei-templates
(MIT, © 2025 ProjectDiscovery, Inc.); the upstream notice is kept at
resources/UPSTREAM-LICENSE.md. The CRS-broadened attack
templates are derived from OWASP CoreRuleSet
(Apache-2.0); its separate notice and statement of changes are at
resources/UPSTREAM-LICENSE-CRS.md. A CI license gate
(scripts/ci/check-license.sh, SPDX allow-list) enforces this on every upstream refresh.