Search by

metrictower / funnypot-core

bobbymhr

funnypot core engine: turn a scanner's own nuclei detection template into a matching fake-vulnerable HTTP response. Pure-PHP runtime, inert by default.

Package info

github.com/metrictower/funnypot-core

pkg:composer/metrictower/funnypot-core

Statistics

Installs: 220

Dependents: 3

Suggesters: 0

Stars: 1

Open Issues: 0

v0.6.3 2026-08-31 10:46 UTC

README

License: MIT PHP Runtime Docs

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 enginefunnypot-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 repeated jdoe/example.com placeholders.
  • 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 RenderHtmlHelpers trait API). FP-0283 removes the fixed fp- class prefix and gives the widget trait a required bindClassPrefix()/chromeClass()/widgetCss() surface. This is a breaking change for ^0.6 consumers, so the first core tag that contains FP-0283 MUST be v0.7.0, never a v0.6.x — a v0.6.x tag would let a consumer's composer update pull 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 own fp-* 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: true install that does nothing else now serves no active reflector until it wires a reflectorAuthorizer — 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 $reflectClass argument. The standalone-app activation path (an edge-attested origin split from the operator plane) is delivered separately; do not substitute fn () => true for 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 classreflect_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; also opcache.enable_cli=1 if you construct the engine from CLI or queue workers — CLI opcache is off by default, so a worker that touches Honeypot::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_files above 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:

  1. 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.
  2. CRS-generic / attack-class (tier 2). No route matched → TemplateAttackEmulator emulates a generic attack class (hand-authored rules first, then the CRS-broadened alternation) from a hand-authored response archetype.
  3. 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-aicompile-emulatorscompile-routescompile-paramsmerge-routesbuild-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 by bin/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.com hosts, 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.