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.

Maintainers

Package info

github.com/metrictower/funnypot-core

pkg:composer/metrictower/funnypot-core

Transparency log

Statistics

Installs: 13

Dependents: 1

Suggesters: 0

Stars: 1

Open Issues: 0

v0.1.0 2026-08-18 14:26 UTC

This package is auto-updated.

Last update: 2026-08-20 10:15:15 UTC


README

License: MIT PHP Runtime

Not sure you're in the right place?

  • Want a ready-to-run honeypot box to deploy → funnypot
  • Protecting a Laravel app → funnypot-laravel
  • Protecting a WordPress site → funnypot-wordpress
  • 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 Laravel 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.

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.
  • 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.

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\Honeypot;
use Funnypot\RequestContext;

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

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

Respond mode (opt-in, gated)

use Funnypot\Config;
use Funnypot\Honeypot;
use Funnypot\RequestContext;
use Funnypot\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

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\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. Step-by-step rollout: docs/INTEGRATION.md.

Response styles

Set at init with responseStyle:

Style What the attacker gets
minimal Just the tokens the matcher needs. Smallest. The default.
realistic A believable fake: a full .git/config, a plausible .env, a real XML-RPC methodResponse. All values inert.
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

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 (bin/funnypot compile), which CI runs weekly against the latest nuclei-templates release. See SPEC.md and docs/PERSONA-CAP.md.

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.

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

Laravel: schedule php artisan funnypot:rules-update (config block funnypot.rules). 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, 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.

Testing

composer install
vendor/bin/phpunit                 # unit + compiler suite
bash tests/acceptance/run.sh       # real nuclei (Docker) vs a php -S server (golden test)

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.